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]); } }