$scopedVenueIds 已按权限收窄的场馆 id */ public function build(int $eventId, string $dateYmd, Collection $scopedVenueIds): array { $event = TicketGrabEvent::query()->find($eventId); if (! $event) { return ['error' => '抢票活动不存在']; } $releaseRows = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->whereDate('release_date', $dateYmd) ->whereIn('venue_id', $scopedVenueIds) ->with('venue:id,name') ->get(); if ($releaseRows->isEmpty()) { return [ 'event' => ['id' => $event->id, 'title' => $event->title, 'booking_audience' => $event->booking_audience], 'date' => $dateYmd, 'overview' => [ 'total_released' => 0, 'total_grabbed' => 0, 'total_remaining' => 0, 'sellout_duration_label' => '-', 'remaining_badge' => '尚余0张', ], 'highlights' => [ 'fastest_sellout' => null, 'max_release_venue' => null, 'summary' => '当日暂无放票计划或无权限范围内的场馆数据。', ], 'ticket_types' => [], 'age_groups' => [], 'venues' => [], 'daily_verify_matrix' => $this->dailyVerifyMatrix($event, $eventId, $scopedVenueIds), ]; } $releaseDayIdList = $releaseRows->pluck('id')->all(); $totalReleased = 0; $totalGrabbed = 0; $totalRemaining = 0; $venueStats = []; foreach ($releaseRows as $row) { $pool = (int) $row->carry_in + (int) $row->day_quota; $booked = (int) $row->booked_count; $remaining = max(0, $pool - $booked); $totalReleased += $pool; $totalGrabbed += $booked; $totalRemaining += $remaining; $venueName = $row->venue?->name ?? (Venue::query()->whereKey($row->venue_id)->value('name') ?? '场馆 #'.$row->venue_id); $firstAt = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $eventId) ->where('ticket_grab_venue_release_day_id', $row->id) ->where('status', '!=', 'cancelled') ->min('created_at'); $lastAt = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $eventId) ->where('ticket_grab_venue_release_day_id', $row->id) ->where('status', '!=', 'cancelled') ->max('created_at'); $soldOut = $pool > 0 && $remaining === 0; $durationLabel = '未抢完'; $seconds = null; if ($soldOut && $firstAt && $lastAt) { $a = Carbon::parse($firstAt); $b = Carbon::parse($lastAt); $seconds = max(0, $b->diffInSeconds($a)); $durationLabel = $this->formatDurationCn($seconds); } elseif (! $soldOut) { $durationLabel = '未抢完'; } $venueStats[] = [ 'venue_id' => (int) $row->venue_id, 'venue_name' => $venueName, 'released' => $pool, 'grabbed' => $booked, 'remaining' => $remaining, 'duration_label' => $durationLabel, 'duration_seconds' => $seconds, 'status' => $soldOut ? '抢完' : '未抢完', 'release_day_id' => (int) $row->id, ]; } $overallSelloutLabel = $totalRemaining > 0 ? '未抢完' : $this->overallSelloutDurationLabel($eventId, $releaseDayIdList); $fastest = collect($venueStats) ->filter(fn ($v) => ($v['duration_seconds'] ?? null) !== null) ->sortBy('duration_seconds') ->first(); $maxVenue = collect($venueStats)->sortByDesc('released')->first(); $highlights = [ 'fastest_sellout' => $fastest ? ['venue_name' => $fastest['venue_name'], 'duration_label' => $fastest['duration_label']] : null, 'max_release_venue' => $maxVenue && $maxVenue['released'] > 0 ? ['venue_name' => $maxVenue['venue_name'], 'released' => $maxVenue['released']] : null, 'summary' => $this->buildSummaryText($totalRemaining, $totalReleased, $totalGrabbed), ]; $ticketTypes = $this->ticketTypeBreakdown($event, $releaseDayIdList); $ageGroups = $event->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE ? $this->ageGroupBreakdown($releaseDayIdList, $dateYmd) : []; return [ 'event' => [ 'id' => $event->id, 'title' => $event->title, 'booking_audience' => $event->booking_audience, ], 'date' => $dateYmd, 'overview' => [ 'total_released' => $totalReleased, 'total_grabbed' => $totalGrabbed, 'total_remaining' => $totalRemaining, 'sellout_duration_label' => $overallSelloutLabel, 'remaining_badge' => '尚余'.$totalRemaining.'张', ], 'highlights' => $highlights, 'ticket_types' => $ticketTypes, 'age_groups' => $ageGroups, 'venues' => array_map(function ($v) { return [ 'venue_id' => $v['venue_id'], 'venue_name' => $v['venue_name'], 'released' => $v['released'], 'grabbed' => $v['grabbed'], 'remaining' => $v['remaining'], 'duration_label' => $v['duration_label'], 'status' => $v['status'], ]; }, $venueStats), 'daily_verify_matrix' => $this->dailyVerifyMatrix($event, $eventId, $scopedVenueIds), ]; } private function overallSelloutDurationLabel(int $eventId, array $releaseDayIds): string { if ($releaseDayIds === []) { return '-'; } $first = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $eventId) ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) ->where('status', '!=', 'cancelled') ->min('created_at'); $last = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $eventId) ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) ->where('status', '!=', 'cancelled') ->max('created_at'); if (! $first || ! $last) { return '-'; } $a = Carbon::parse($first); $b = Carbon::parse($last); return $this->formatDurationCn(max(0, $b->diffInSeconds($a))); } private function formatDurationCn(int $seconds): string { if ($seconds <= 0) { return '不足1分钟'; } $h = intdiv($seconds, 3600); $m = intdiv($seconds % 3600, 60); if ($h > 0) { return $h.'小时'.($m > 0 ? $m.'分钟' : ''); } if ($m > 0) { return $m.'分钟'; } return '不足1分钟'; } private function buildSummaryText(int $remaining, int $released, int $grabbed): string { if ($released <= 0) { return '当日无放票。'; } if ($remaining > 0) { return '整体结果:尚余 '.$remaining.' 张,未全部抢完(已抢 '.$grabbed.' / '.$released.')。'; } return '整体结果:当日票已全部抢完('.$grabbed.' / '.$released.')。'; } private function ticketTypeBreakdown(TicketGrabEvent $event, array $releaseDayIds): array { if ($releaseDayIds === []) { return []; } $rows = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $event->id) ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) ->where('status', '!=', 'cancelled') ->selectRaw('ticket_mode, SUM(ticket_count) as people') ->groupBy('ticket_mode') ->get(); $out = []; foreach ($rows as $r) { $mode = $r->ticket_mode; $people = (int) $r->people; if ($people <= 0) { continue; } if ($mode === 'pair') { $label = '1大1小'; } elseif ($mode === 'single') { $label = $event->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE ? '1张学生票' : '单人预约'; } else { $label = '其他'; } $out[] = ['label' => $label, 'people_count' => $people]; } return $out; } private function ageGroupBreakdown(array $releaseDayIds, string $refDateYmd): array { if ($releaseDayIds === []) { return []; } $reservations = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) ->where('status', '!=', 'cancelled') ->whereNotNull('id_card') ->get(['id_card', 'ticket_count']); $buckets = [ '1-3年级' => 0, '4-6年级' => 0, '7-9年级' => 0, '其他年龄段' => 0, ]; foreach ($reservations as $res) { $raw = (string) $res->id_card; $n = max(1, (int) $res->ticket_count); $age = $this->ageAtDateFromIdCard($raw, $refDateYmd); if ($age === null) { $buckets['其他年龄段'] += $n; continue; } if ($age >= 6 && $age <= 8) { $buckets['1-3年级'] += $n; } elseif ($age >= 9 && $age <= 11) { $buckets['4-6年级'] += $n; } elseif ($age >= 12 && $age <= 14) { $buckets['7-9年级'] += $n; } else { $buckets['其他年龄段'] += $n; } } return collect($buckets) ->map(fn ($n, $label) => ['label' => $label, 'people_count' => (int) $n]) ->values() ->filter(fn ($x) => $x['people_count'] > 0) ->values() ->all(); } private function ageAtDateFromIdCard(string $id, string $dateYmd): ?int { $id = strtoupper(trim($id)); if (strlen($id) !== 18 || ! preg_match('/^\d{17}[\dX]$/', $id)) { return null; } $y = (int) substr($id, 6, 4); $m = (int) substr($id, 10, 2); $d = (int) substr($id, 12, 2); if ($y < 1900 || $m < 1 || $m > 12 || $d < 1 || $d > 31) { return null; } try { $birth = new DateTimeImmutable(sprintf('%04d-%02d-%02d', $y, $m, $d)); $ref = new DateTimeImmutable($dateYmd); } catch (\Throwable) { return null; } return $birth->diff($ref)->y; } /** * 每日核销统计:行=参与场馆,列=活动举办日期范围内每一天;单元格=已核销票数/预约票数(非取消)及占比。 * * @return array{dates: string[], date_labels: string[], rows: array>} */ private function dailyVerifyMatrix(TicketGrabEvent $event, int $eventId, Collection $scopedVenueIds): array { $tz = (string) config('app.timezone'); $start = $event->start_at ? $event->start_at->copy()->timezone($tz)->startOfDay() : null; $end = $event->end_at ? $event->end_at->copy()->timezone($tz)->startOfDay() : null; if (! $start || ! $end || $end->lt($start)) { return ['dates' => [], 'date_labels' => [], 'rows' => []]; } $dates = []; $cur = $start->copy(); $maxDays = 62; while ($cur->lte($end) && count($dates) < $maxDays) { $dates[] = $cur->toDateString(); $cur->addDay(); } if ($dates === []) { return ['dates' => [], 'date_labels' => [], 'rows' => []]; } $dateMin = $dates[0]; $dateMax = $dates[count($dates) - 1]; $aggregates = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->where('ticket_grab_event_id', $eventId) ->whereIn('venue_id', $scopedVenueIds) ->where('status', '!=', 'cancelled') ->whereNotNull('entry_date') ->whereDate('entry_date', '>=', $dateMin) ->whereDate('entry_date', '<=', $dateMax) ->selectRaw( 'venue_id, entry_date, SUM(CASE WHEN status = ? THEN COALESCE(ticket_count, 1) ELSE 0 END) as v_sum, SUM(COALESCE(ticket_count, 1)) as t_sum', ['verified'] ) ->groupBy('venue_id', 'entry_date') ->get(); $map = []; foreach ($aggregates as $row) { $vid = (int) $row->venue_id; $d = Carbon::parse($row->entry_date)->toDateString(); $map[$vid][$d] = [ 'v' => (int) $row->v_sum, 't' => (int) $row->t_sum, ]; } $venuePivots = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->whereIn('venue_id', $scopedVenueIds) ->with('venue:id,name') ->orderBy('venue_id') ->get(); $dateLabels = array_map(function (string $ymd) { $c = Carbon::parse($ymd); return $c->month.'.'.$c->day; }, $dates); $outRows = []; foreach ($venuePivots as $piv) { $vid = (int) $piv->venue_id; $name = $piv->venue?->name ?? (Venue::query()->whereKey($vid)->value('name') ?? '场馆 #'.$vid); $cells = []; $sumV = 0; $sumT = 0; foreach ($dates as $d) { $v = (int) ($map[$vid][$d]['v'] ?? 0); $t = (int) ($map[$vid][$d]['t'] ?? 0); $sumV += $v; $sumT += $t; $pct = $t > 0 ? round(100 * $v / $t, 2) : 0.0; $cells[] = [ 'date' => $d, 'verified' => $v, 'total' => $t, 'pct' => $pct, 'display' => $t > 0 ? $v.'/'.$t.' ('.$pct.'%)' : '0/0 (0%)', ]; } $totPct = $sumT > 0 ? round(100 * $sumV / $sumT, 2) : 0.0; $outRows[] = [ 'venue_id' => $vid, 'venue_name' => $name, 'cells' => $cells, 'total_cell' => [ 'verified' => $sumV, 'total' => $sumT, 'pct' => $totPct, 'display' => $sumT > 0 ? $sumV.'/'.$sumT.' ('.$totPct.'%)' : '0/0 (0%)', ], ]; } return [ 'dates' => $dates, 'date_labels' => $dateLabels, 'rows' => $outRows, ]; } /** * @param \Illuminate\Support\Collection $scopedVenueIds * @return array{dates: string[], date_labels: string[], rows: array}|array{error: string} */ public function buildDailyVerifyMatrixPublic(int $eventId, Collection $scopedVenueIds): array { $event = TicketGrabEvent::query()->find($eventId); if (! $event) { return ['error' => '抢票活动不存在']; } return $this->dailyVerifyMatrix($event, $eventId, $scopedVenueIds); } /** @return \Illuminate\Support\Collection */ public static function scopedVenueIdsForEvent(int $eventId, Collection $allowedVenueIds): Collection { $pivot = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->pluck('venue_id') ->map(fn ($id) => (int) $id) ->unique() ->values(); if ($pivot->isEmpty()) { return collect(); } $allow = $allowedVenueIds->map(fn ($id) => (int) $id); return $pivot->filter(fn ($id) => $allow->contains((int) $id))->values(); } }