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.

401 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Reservation;
use App\Models\User;
use App\Models\VerifyPortalCredential;
use App\Services\ReservationExpiryService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReservationVerifyController extends Controller
{
/**
* 移动端「今日」核销仅允许活动日activity_days.activity_date为当天的预约核销。
*/
private function activityDayNotTodayMessage(Reservation $reservation): ?string
{
$today = now()->toDateString();
if ((string) ($reservation->reservation_kind ?? 'activity') === Reservation::KIND_TICKET_GRAB) {
$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;
}
private function actingPortalCredential(Request $request): ?VerifyPortalCredential
{
$u = $request->user();
return $u instanceof VerifyPortalCredential ? $u : null;
}
private function assertPortalActiveOrAbort(VerifyPortalCredential $c): void
{
if (! $c->portalAcceptsVerification()) {
abort(403, '活动已结束,核销登录已失效');
}
}
private function applyPortalScope(Builder $query, VerifyPortalCredential $cred): void
{
$query->where('venue_id', $cred->venue_id);
if ($cred->portal_kind === VerifyPortalCredential::KIND_ACTIVITY) {
$query->where('activity_id', $cred->portal_id)
->where(function ($q) {
$q->whereNull('reservation_kind')
->orWhere('reservation_kind', Reservation::KIND_ACTIVITY);
});
} elseif ($cred->portal_kind === VerifyPortalCredential::KIND_TICKET_GRAB_VENUE) {
$query->where('reservation_kind', Reservation::KIND_TICKET_GRAB);
} else {
$query->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
->where('ticket_grab_event_id', $cred->portal_id);
}
}
private function ensureReservationAccess(Request $request, Reservation $reservation): void
{
$cred = $this->actingPortalCredential($request);
if ($cred !== null) {
$this->assertPortalActiveOrAbort($cred);
if ((int) $reservation->venue_id !== (int) $cred->venue_id) {
abort(403, '无权核销该预约');
}
if ($cred->portal_kind === VerifyPortalCredential::KIND_ACTIVITY) {
if ((int) ($reservation->activity_id ?? 0) !== (int) $cred->portal_id) {
abort(403, '该预约不属于本活动');
}
if ((string) ($reservation->reservation_kind ?? '') === Reservation::KIND_TICKET_GRAB) {
abort(403, '该预约不属于本活动');
}
} elseif ($cred->portal_kind === VerifyPortalCredential::KIND_TICKET_GRAB_VENUE) {
if ((string) ($reservation->reservation_kind ?? '') !== Reservation::KIND_TICKET_GRAB) {
abort(403, '该预约不属于抢票');
}
} elseif ((string) $reservation->reservation_kind !== Reservation::KIND_TICKET_GRAB
|| (int) ($reservation->ticket_grab_event_id ?? 0) !== (int) $cred->portal_id) {
abort(403, '该预约不属于本抢票活动');
}
return;
}
/** @var User $user */
$user = $request->user();
if ($user->isSuperAdmin()) {
return;
}
$allowed = $user->venues()->where('venues.id', $reservation->venue_id)->exists();
abort_unless($allowed, 403, '仅可核销已绑定场馆预约');
}
/**
* 扫码后先查询预约信息,不修改状态(用于移动端「确认后再核销」)。
*/
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->ensureReservationAccess($request, $reservation);
$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
{
$cred = $this->actingPortalCredential($request);
$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',
])
->orderByRaw("CASE status WHEN 'pending' THEN 0 WHEN 'verified' THEN 1 WHEN 'expired' THEN 2 WHEN 'cancelled' THEN 3 ELSE 4 END")
->orderByDesc('id');
if ($cred !== null) {
$this->assertPortalActiveOrAbort($cred);
$this->applyPortalScope($query, $cred);
} else {
/** @var User $user */
$user = $request->user();
$rk = (string) $request->input('reservation_kind', '');
if ($rk === 'ticket_grab') {
$query->where('reservation_kind', Reservation::KIND_TICKET_GRAB);
} elseif ($rk === 'activity') {
$query->where(function ($q) {
$q->whereNull('reservation_kind')
->orWhere('reservation_kind', 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;
$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
{
$cred = $this->actingPortalCredential($request);
$today = now()->toDateString();
$tg = Reservation::KIND_TICKET_GRAB;
if ($cred !== null) {
$this->assertPortalActiveOrAbort($cred);
if ($cred->portal_kind === VerifyPortalCredential::KIND_ACTIVITY) {
$base = Reservation::query()
->where('venue_id', $cred->venue_id)
->where('activity_id', $cred->portal_id)
->where(function ($q) {
$q->whereNull('reservation_kind')
->orWhere('reservation_kind', Reservation::KIND_ACTIVITY);
})
->whereHas('activityDay', function ($q3) use ($today) {
$q3->whereDate('activity_date', $today);
});
} elseif ($cred->portal_kind === VerifyPortalCredential::KIND_TICKET_GRAB_VENUE) {
$base = Reservation::query()
->where('venue_id', $cred->venue_id)
->where('reservation_kind', $tg)
->whereDate('entry_date', $today);
} else {
$base = Reservation::query()
->where('venue_id', $cred->venue_id)
->where('reservation_kind', $tg)
->where('ticket_grab_event_id', $cred->portal_id)
->whereDate('entry_date', $today);
}
} else {
/** @var User $user */
$user = $request->user();
$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->ensureReservationAccess($request, $reservation);
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);
}
$cred = $this->actingPortalCredential($request);
$payload = [
'status' => 'verified',
'verified_at' => now(),
];
if ($cred !== null) {
$payload['verified_by'] = null;
$payload['verify_credential_id'] = $cred->id;
} else {
/** @var User $user */
$user = $request->user();
$payload['verified_by'] = $user->id;
$payload['verify_credential_id'] = null;
}
$reservation->update($payload);
$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),
]);
}
}