toDateString(); if ((string) ($reservation->reservation_kind ?? 'activity') === \App\Models\Reservation::KIND_TICKET_GRAB) { // entry_date 是 reservations 表的字段(cast 为 date),不是 relation $ed = $reservation->entry_date; $dateStr = $ed instanceof CarbonInterface ? $ed->format('Y-m-d') : ($ed ? (string) $ed : ''); if ($dateStr === '') { return '该抢票预约未填写入馆日,无法核销'; } if ($dateStr !== $today) { return '仅可核销入馆日为今日的预约。该预约入馆日为:'.$dateStr; } return null; } $reservation->loadMissing('activityDay'); if (!$reservation->activity_day_id || !$reservation->activityDay) { return '该预约未关联活动场次,仅支持核销「活动日为今日」的预约'; } $ad = $reservation->activityDay->activity_date; $dateStr = $ad instanceof CarbonInterface ? $ad->format('Y-m-d') : (string) $ad; if ($dateStr !== $today) { return '仅可核销活动日为今日的预约。该预约活动日为:'.$dateStr; } return null; } /** * 扫码后先查询预约信息,不修改状态(用于移动端「确认后再核销」)。 */ public function preview(Request $request, ReservationExpiryService $reservationExpiryService): JsonResponse { $reservationExpiryService->expireStalePendingReservations(); $data = $request->validate([ 'qr_token' => ['required', 'string'], ]); $reservation = Reservation::with([ 'venue:id,name,address,lat,lng,reservation_notice', 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'ticketGrabEvent:id,title,summary,cover_image,address,start_at,end_at,tags', 'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at', ])->where('qr_token', $data['qr_token'])->first(); if (! $reservation) { return response()->json(['message' => '无效的二维码或预约不存在'], 404); } $this->ensureVenuePermission($request, $reservation->venue_id); $blockReason = null; if ($reservation->status === 'verified') { $blockReason = '该预约已核销'; } elseif ($reservation->status === 'cancelled') { $blockReason = '该预约已取消,不能核销'; } elseif ($reservation->status === 'expired') { $blockReason = '该预约已过期(活动日未核销),不能核销'; } $canVerify = $reservation->status === 'pending'; if ($canVerify) { $dayMsg = $this->activityDayNotTodayMessage($reservation); if ($dayMsg !== null) { $canVerify = false; $blockReason = $dayMsg; } } $h5 = app(H5ReservationController::class); return response()->json([ 'reservation' => $h5->reservationToH5Array($reservation), 'can_verify' => $canVerify, 'verify_block_reason' => $blockReason, ]); } public function index(Request $request): JsonResponse { $user = $request->user(); $query = Reservation::with([ 'venue:id,name,address,lat,lng,reservation_notice', 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'ticketGrabEvent:id,title,summary,cover_image,address,start_at,end_at,tags', 'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at', ]) // 核销端列表:待核销在前,已过期/已取消置后,同状态内按 id 倒序 ->orderByRaw("CASE status WHEN 'pending' THEN 0 WHEN 'verified' THEN 1 WHEN 'expired' THEN 2 WHEN 'cancelled' THEN 3 ELSE 4 END") ->orderByDesc('id'); $rk = (string) $request->input('reservation_kind', ''); if ($rk === 'ticket_grab') { $query->where('reservation_kind', \App\Models\Reservation::KIND_TICKET_GRAB); } elseif ($rk === 'activity') { $query->where(function ($q) { $q->whereNull('reservation_kind') ->orWhere('reservation_kind', \App\Models\Reservation::KIND_ACTIVITY); }); } if (! $user->isSuperAdmin()) { $venueIds = $user->venues()->pluck('venues.id'); $query->whereIn('venue_id', $venueIds); } if ($request->filled('status') && $request->string('status')->toString() !== 'all') { $query->where('status', (string) $request->string('status')); } if ($request->filled('keyword')) { $keyword = trim((string) $request->input('keyword')); $query->where(function ($q) use ($keyword) { $q->where('visitor_name', 'like', "%{$keyword}%") ->orWhere('visitor_phone', 'like', "%{$keyword}%") ->orWhere('qr_token', 'like', "%{$keyword}%") ->orWhere('id_card', 'like', "%{$keyword}%"); }); } $dateField = (string) $request->input('date_field', 'created_at'); if ($request->filled('start_date') || $request->filled('end_date')) { if ($dateField === 'activity_day') { $start = $request->filled('start_date') ? (string) $request->input('start_date') : null; $end = $request->filled('end_date') ? (string) $request->input('end_date') : $start; // 普通活动按 activity_days.activity_date;抢票无 activity_day,按入馆日 entry_date $query->where(function ($q) use ($start, $end) { $q->where(function ($q2) use ($start, $end) { $q2->where(function ($q3) { $q3->whereNull('reservation_kind') ->orWhere('reservation_kind', '!=', Reservation::KIND_TICKET_GRAB); })->whereHas('activityDay', function ($q4) use ($start, $end) { if ($start !== null && $start !== '') { $q4->whereDate('activity_date', '>=', $start); } if ($end !== null && $end !== '') { $q4->whereDate('activity_date', '<=', $end); } }); })->orWhere(function ($q2) use ($start, $end) { $q2->where('reservation_kind', Reservation::KIND_TICKET_GRAB); if ($start !== null && $start !== '') { $q2->whereDate('entry_date', '>=', $start); } if ($end !== null && $end !== '') { $q2->whereDate('entry_date', '<=', $end); } }); }); } elseif ($dateField === 'entry_date') { $start = $request->filled('start_date') ? (string) $request->input('start_date') : null; $end = $request->filled('end_date') ? (string) $request->input('end_date') : $start; if ($start !== null && $start !== '') { $query->whereDate('entry_date', '>=', $start); } if ($end !== null && $end !== '') { $query->whereDate('entry_date', '<=', $end); } } else { if ($request->filled('start_date')) { $query->whereDate('created_at', '>=', (string) $request->input('start_date')); } if ($request->filled('end_date')) { $query->whereDate('created_at', '<=', (string) $request->input('end_date')); } } } $h5 = app(H5ReservationController::class); return response()->json( $query->limit(500)->get()->map(fn (Reservation $r) => $h5->reservationToH5Array($r))->values() ); } /** * 今日(活动日为当天)预约单数、已核销单数,与移动端「今日报名」同权限与场馆范围。 * 一大一小按 1 单计算(按预约订单数统计)。 */ public function todaySummary(Request $request): JsonResponse { $user = $request->user(); $today = now()->toDateString(); $tg = \App\Models\Reservation::KIND_TICKET_GRAB; $base = Reservation::query() ->where(function ($q) use ($today, $tg) { $q->where('reservation_kind', $tg) ->whereDate('entry_date', $today); })->orWhere(function ($q) use ($today, $tg) { $q->where(function ($q2) use ($tg) { $q2->whereNull('reservation_kind') ->orWhere('reservation_kind', '!=', $tg); })->whereHas('activityDay', function ($q3) use ($today) { $q3->whereDate('activity_date', $today); }); }); if (!$user->isSuperAdmin()) { $venueIds = $user->venues()->pluck('venues.id'); $base->whereIn('venue_id', $venueIds); } $totalOrders = (clone $base)->whereNotIn('status', ['cancelled'])->count(); $verifiedOrders = (clone $base)->where('status', 'verified')->count(); return response()->json([ 'total_orders' => (int) $totalOrders, 'verified_orders' => (int) $verifiedOrders, ]); } public function verifyByToken(Request $request, ReservationExpiryService $reservationExpiryService): JsonResponse { $reservationExpiryService->expireStalePendingReservations(); $data = $request->validate([ 'qr_token' => ['required', 'string'], ]); $reservation = Reservation::with([ 'venue:id,name,address,lat,lng,reservation_notice', 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'ticketGrabEvent:id,title,summary,cover_image,address,start_at,end_at,tags', 'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at', ])->where('qr_token', $data['qr_token'])->firstOrFail(); $this->ensureVenuePermission($request, $reservation->venue_id); if ($reservation->status === 'verified') { return response()->json(['message' => '该预约已核销'], 422); } if ($reservation->status === 'cancelled') { return response()->json(['message' => '该预约已取消,不能核销'], 422); } if ($reservation->status === 'expired') { return response()->json(['message' => '该预约已过期(活动日未核销),不能核销'], 422); } $dayBlock = $this->activityDayNotTodayMessage($reservation); if ($dayBlock !== null) { return response()->json(['message' => $dayBlock], 422); } $reservation->update([ 'status' => 'verified', 'verified_by' => $request->user()->id, 'verified_at' => now(), ]); $fresh = $reservation->fresh()->load([ 'venue:id,name,address,lat,lng,reservation_notice', 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'ticketGrabEvent:id,title,summary,cover_image,address,start_at,end_at,tags', 'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at', 'verifier:id,name,username', ]); $h5 = app(H5ReservationController::class); return response()->json([ 'message' => '核销成功', 'reservation' => $h5->reservationToH5Array($fresh), ]); } private function ensureVenuePermission(Request $request, int $venueId): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $allowed = $user->venues()->where('venues.id', $venueId)->exists(); abort_unless($allowed, 403, '仅可核销已绑定场馆预约'); } }