You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

421 lines
17 KiB

1 week ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\TicketGrabVenueReleaseDay;
use App\Models\Reservation;
use App\Services\TicketGrabReleaseDayService;
use App\Support\CalendarDateFormat;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TicketGrabEventController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = TicketGrabEvent::query()
->with('venues:id,name')
->orderByDesc('id');
$this->restrictByEventVenues($request, $query);
if ($request->filled('keyword')) {
$keyword = trim((string) $request->input('keyword'));
$query->where('title', 'like', "%{$keyword}%");
}
if ($request->filled('is_active')) {
$query->where('is_active', (bool) $request->boolean('is_active'));
}
if ($request->filled('schedule_status')) {
$st = (string) $request->string('schedule_status');
if (in_array($st, ['not_started', 'ongoing', 'ended'], true)) {
$query->whereComputedScheduleStatus($st);
}
}
$pageSize = max(1, min(100, (int) $request->input('page_size', 10)));
$page = $query->paginate($pageSize);
$page->getCollection()->transform(function (TicketGrabEvent $e) {
$e->setAttribute(
'schedule_status',
TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at)
);
return $e;
});
return response()->json($page);
}
public function store(Request $request): JsonResponse
{
$data = $this->validated($request, true);
$this->assertVenueIdsAllowed($request, $data['venue_ids'] ?? []);
$audit = $request->user()?->isSuperAdmin() ? TicketGrabEvent::AUDIT_APPROVED : TicketGrabEvent::AUDIT_PENDING;
$ctrl = $this;
$e = DB::transaction(function () use ($data, $audit, $ctrl) {
$e = new TicketGrabEvent;
$e->fill($ctrl->mapMainFields($data, true, true));
$e->audit_status = $audit;
$e->total_quota = 0;
$e->registered_count = 0;
$e->schedule_status = TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at);
$e->save();
$ctrl->syncPivots($e->id, $data['venues'] ?? [], true);
return $e;
});
if ($e->booking_start_at && $e->booking_end_at && (count($data['venues'] ?? [])) > 0) {
TicketGrabReleaseDayService::rebuildAllReleaseDaysForEvent($e->id);
}
return response()->json($e->fresh()->load('venues:id,name,address', 'eventVenuePivots'), 201);
}
public function show(Request $request, TicketGrabEvent $ticketGrabEvent): JsonResponse
{
$this->assertEventAccessible($request, $ticketGrabEvent);
$ticketGrabEvent->load('venues', 'eventVenuePivots.venue', 'releaseDays');
return response()->json($ticketGrabEvent);
}
public function update(Request $request, TicketGrabEvent $ticketGrabEvent): JsonResponse
{
$this->assertEventAccessible($request, $ticketGrabEvent);
$data = $this->validated($request, false);
if (isset($data['venue_ids'])) {
$this->assertVenueIdsAllowed($request, $data['venue_ids'] ?? []);
}
$needRebuild = false;
if (array_key_exists('booking_start_at', $data) || array_key_exists('booking_end_at', $data)
|| array_key_exists('venues', $data)) {
$needRebuild = true;
}
DB::transaction(function () use ($data, $ticketGrabEvent, $request) {
$m = $this->mapMainFields($data, true, true);
if ($m !== []) {
$ticketGrabEvent->fill($m);
}
if (! $request->user()?->isSuperAdmin()) {
$ticketGrabEvent->audit_status = TicketGrabEvent::AUDIT_PENDING;
} else {
$ticketGrabEvent->audit_status = TicketGrabEvent::AUDIT_APPROVED;
}
$ticketGrabEvent->schedule_status = TicketGrabEvent::computeScheduleStatusFromBounds(
$ticketGrabEvent->start_at,
$ticketGrabEvent->end_at
);
$ticketGrabEvent->save();
if (array_key_exists('venues', $data) && is_array($data['venues'])) {
$this->syncPivots($ticketGrabEvent->id, $data['venues'], true);
}
});
if ($needRebuild) {
$hasPiv = TicketGrabEventVenue::query()->where('ticket_grab_event_id', $ticketGrabEvent->id)->exists();
if ($ticketGrabEvent->fresh()->booking_start_at && $ticketGrabEvent->booking_end_at && $hasPiv) {
$hasR = Reservation::query()
->where('ticket_grab_event_id', $ticketGrabEvent->id)
->where('reservation_kind', 'ticket_grab')
->where('status', '!=', 'cancelled')
->exists();
if (! $hasR) {
TicketGrabReleaseDayService::rebuildAllReleaseDaysForEvent($ticketGrabEvent->id);
}
}
}
return response()->json($ticketGrabEvent->fresh()->load('venues', 'eventVenuePivots'));
}
public function toggle(Request $request, TicketGrabEvent $ticketGrabEvent): JsonResponse
{
$this->assertEventAccessible($request, $ticketGrabEvent);
$ticketGrabEvent->is_active = ! $ticketGrabEvent->is_active;
$ticketGrabEvent->save();
return response()->json($ticketGrabEvent);
}
public function releaseConfig(Request $request, TicketGrabEvent $ticketGrabEvent): JsonResponse
{
$this->assertEventAccessible($request, $ticketGrabEvent);
$ticketGrabEvent->load('venues', 'eventVenuePivots');
// 打开弹窗前按「今天」重算滚入并写回库,否则界面仍显示历史累积的 carry_in
TicketGrabReleaseDayService::syncCarryInChain($ticketGrabEvent->id);
$byVenue = [];
$days = TicketGrabVenueReleaseDay::query()
->where('ticket_grab_event_id', $ticketGrabEvent->id)
->orderBy('venue_id')
->orderBy('release_date')
->get();
foreach ($days as $d) {
$byVenue[$d->venue_id][] = [
'release_date' => $d->release_date->toDateString(),
'day_quota' => (int) $d->day_quota,
'booked_count' => (int) $d->booked_count,
'carry_in' => (int) $d->carry_in,
'total_day_pool' => $d->totalDayPool(),
'current_remaining' => $d->remainingForDisplay(),
];
}
$entryStats = $this->entryDateStats($ticketGrabEvent->id, $ticketGrabEvent->start_at, $ticketGrabEvent->end_at);
return response()->json([
'event' => [
'id' => $ticketGrabEvent->id,
'title' => $ticketGrabEvent->title,
'booking_start_at' => CalendarDateFormat::ymdFromDateValue($ticketGrabEvent->booking_start_at),
'booking_end_at' => CalendarDateFormat::ymdFromDateValue($ticketGrabEvent->booking_end_at),
'start_at' => CalendarDateFormat::ymdFromDatetime($ticketGrabEvent->start_at),
'end_at' => CalendarDateFormat::ymdFromDatetime($ticketGrabEvent->end_at),
'daily_release_start_time' => $ticketGrabEvent->daily_release_start_time,
'daily_release_end_time' => $ticketGrabEvent->daily_release_end_time,
],
'venues' => $ticketGrabEvent->eventVenuePivots->map(function ($p) use ($byVenue) {
return [
'id' => $p->id,
'venue_id' => $p->venue_id,
'venue_total_quota' => (int) $p->venue_total_quota,
'release_days' => $byVenue[$p->venue_id] ?? [],
];
}),
'entry_date_stats' => $entryStats,
]);
}
public function updateReleaseConfig(Request $request, TicketGrabEvent $ticketGrabEvent): JsonResponse
{
$this->assertEventAccessible($request, $ticketGrabEvent);
$data = $request->validate([
'venue_day_quotas' => ['required', 'array', 'min:1'],
'venue_day_quotas.*.venue_id' => ['required', 'integer', 'exists:venues,id'],
'venue_day_quotas.*.days' => ['required', 'array'],
'venue_day_quotas.*.days.*.date' => ['required', 'date'],
'venue_day_quotas.*.days.*.day_quota' => ['required', 'integer', 'min:0'],
]);
$this->assertVenueIdsAllowed(
$request,
array_map('intval', array_column($data['venue_day_quotas'], 'venue_id'))
);
DB::transaction(function () use ($data, $ticketGrabEvent) {
foreach ($data['venue_day_quotas'] as $block) {
$vid = (int) $block['venue_id'];
$map = [];
foreach ($block['days'] as $r) {
$map[Carbon::parse($r['date'])->toDateString()] = (int) $r['day_quota'];
}
TicketGrabReleaseDayService::updateDayQuotasFromAdmin($ticketGrabEvent->id, $vid, $map);
}
});
return $this->releaseConfig($request, $ticketGrabEvent);
}
private function entryDateStats(int $eventId, $start, $end): array
{
if (! $start || ! $end) {
return [];
}
$s = $start->copy()->startOfDay();
$e = $end->copy()->startOfDay();
if ($e->lt($s)) {
return [];
}
$counts = Reservation::query()
->where('ticket_grab_event_id', $eventId)
->where('reservation_kind', 'ticket_grab')
->where('status', '!=', 'cancelled')
->selectRaw('entry_date, sum(ticket_count) as t')
->whereNotNull('entry_date')
->groupBy('entry_date')
->pluck('t', 'entry_date');
$out = [];
for ($d = $s->copy(); $d->lte($e); $d->addDay()) {
$k = $d->toDateString();
$out[] = [
'entry_date' => $k,
'reservation_count' => (int) ($counts->get($k) ?? 0),
];
}
return $out;
}
private function assertEventAccessible(Request $request, TicketGrabEvent $e): void
{
$pivots = TicketGrabEventVenue::query()
->where('ticket_grab_event_id', $e->id)
->pluck('venue_id')
->all();
if ($pivots === []) {
$this->assertVenueIdsAllowed($request, []);
return;
}
$this->assertVenueIdsAllowed($request, $pivots);
}
private function assertVenueIdsAllowed(Request $request, array $venueIds): void
{
$user = $request->user();
if (! $user || $user->isSuperAdmin()) {
return;
}
$allow = $user->venues()->pluck('venues.id');
foreach (array_unique(array_map('intval', $venueIds)) as $id) {
if ($id > 0 && ! $allow->contains($id)) {
abort(403, '仅可操作已绑定场馆');
}
}
}
private function restrictByEventVenues(Request $request, $query): void
{
$user = $request->user();
if (! $user?->isSuperAdmin()) {
$ids = $user->venues()->pluck('venues.id');
if ($ids->isEmpty()) {
$query->whereRaw('1=0');
} else {
$query->where(function ($q) use ($ids) {
foreach ($ids as $i => $venueId) {
$q->{$i === 0 ? 'whereExists' : 'orWhereExists'}(function ($s) use ($venueId) {
$s->selectRaw('1')
->from('ticket_grab_event_venue as tgev')
->whereColumn('tgev.ticket_grab_event_id', 'ticket_grab_events.id')
->where('tgev.venue_id', $venueId);
});
}
});
}
}
}
/**
* @param bool $stripNonModel 去掉 venues 等非模型字段
*/
private function mapMainFields(array $data, bool $isCreate, bool $stripNonModel): array
{
$keys = [
'title', 'summary', 'start_at', 'end_at', 'booking_start_at', 'booking_end_at',
'daily_release_start_time', 'daily_release_end_time',
'booking_audience', 'age_limit_start', 'age_limit_end',
'address', 'detail_html', 'reservation_notice', 'cover_image', 'gallery_media', 'tags',
'sort', 'is_active', 'audit_remark', 'audit_status', 'last_approved_snapshot',
];
$row = array_intersect_key($data, array_flip($keys));
if (isset($row['tags']) && is_array($row['tags'])) {
$row['tags'] = array_values($row['tags']);
}
$tz = (string) config('app.timezone');
if (array_key_exists('start_at', $row) && $row['start_at'] !== null && $row['start_at'] !== '') {
$row['start_at'] = Carbon::parse($row['start_at'], $tz)->startOfDay();
}
if (array_key_exists('end_at', $row) && $row['end_at'] !== null && $row['end_at'] !== '') {
$row['end_at'] = Carbon::parse($row['end_at'], $tz)->endOfDay();
}
if (array_key_exists('booking_start_at', $row) && $row['booking_start_at']) {
$row['booking_start_at'] = Carbon::parse($row['booking_start_at'], $tz)->toDateString();
}
if (array_key_exists('booking_end_at', $row) && $row['booking_end_at']) {
$row['booking_end_at'] = Carbon::parse($row['booking_end_at'], $tz)->toDateString();
}
if (array_key_exists('age_limit_start', $row) && $row['age_limit_start']) {
$row['age_limit_start'] = Carbon::parse($row['age_limit_start'], $tz)->toDateString();
}
if (array_key_exists('age_limit_end', $row) && $row['age_limit_end']) {
$row['age_limit_end'] = Carbon::parse($row['age_limit_end'], $tz)->toDateString();
}
if (isset($row['start_at'], $row['end_at']) && $row['end_at'] && $row['start_at']
&& $row['end_at']->lt($row['start_at'])) {
throw ValidationException::withMessages(['end_at' => ['结束不能早于开始']]);
}
if ($stripNonModel) {
unset($row['venues']);
}
return $row;
}
private function validated(Request $request, bool $isCreate = true): array
{
$rules = [
'title' => [$isCreate ? 'required' : 'sometimes', 'string', 'max:150'],
'summary' => ['nullable', 'string', 'max:255'],
'start_at' => ['nullable', 'date'],
'end_at' => ['nullable', 'date'],
'booking_start_at' => ['nullable', 'date'],
'booking_end_at' => ['nullable', 'date'],
'daily_release_start_time' => ['nullable', 'regex:/^\d{1,2}:\d{2}$/'],
'daily_release_end_time' => ['nullable', 'regex:/^\d{1,2}:\d{2}$/'],
'booking_audience' => ['nullable', 'in:all,school_age'],
'age_limit_start' => ['nullable', 'date'],
'age_limit_end' => ['nullable', 'date'],
'address' => ['nullable', 'string', 'max:255'],
'detail_html' => ['nullable', 'string'],
'reservation_notice' => ['nullable', 'string'],
'cover_image' => ['nullable', 'string', 'max:255'],
'gallery_media' => ['nullable', 'array'],
'tags' => ['nullable', 'array'],
'sort' => ['nullable', 'integer', 'min:0'],
'is_active' => ['boolean'],
'venues' => [$isCreate ? 'required' : 'sometimes', 'array', 'min:1'],
'venues.*.venue_id' => ['required', 'integer', 'exists:venues,id'],
'venues.*.venue_total_quota' => ['required', 'integer', 'min:0'],
];
if (! $isCreate) {
$rules['venues'] = ['sometimes', 'array', 'min:1'];
}
if (! $request->user()?->isSuperAdmin()) {
$rules['sort'] = ['nullable', 'prohibited'];
}
return $request->validate($rules) + [
'venue_ids' => $request->input('venues') ? array_map('intval', array_column($request->input('venues', []), 'venue_id')) : [],
];
}
private function syncPivots(int $eventId, array $venues, bool $allowReplace): void
{
if (! $allowReplace) {
return;
}
$seen = [];
foreach ($venues as $v) {
$vid = (int) $v['venue_id'];
$seen[] = $vid;
TicketGrabEventVenue::query()->updateOrCreate(
['ticket_grab_event_id' => $eventId, 'venue_id' => $vid],
['venue_total_quota' => (int) $v['venue_total_quota']],
);
}
if ($seen) {
TicketGrabEventVenue::query()
->where('ticket_grab_event_id', $eventId)
->whereNotIn('venue_id', $seen)
->delete();
}
$sum = (int) TicketGrabEventVenue::query()
->where('ticket_grab_event_id', $eventId)
->sum('venue_total_quota');
TicketGrabEvent::query()->where('id', $eventId)->update(['total_quota' => $sum]);
}
}