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.
szkp-map-service/app/Services/DashboardTicketGrabStatsSer...

466 lines
17 KiB

<?php
namespace App\Services;
use App\Models\Reservation;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\TicketGrabVenueReleaseDay;
use App\Models\Venue;
use Carbon\Carbon;
use DateTimeImmutable;
use Illuminate\Support\Collection;
class DashboardTicketGrabStatsService
{
/**
* @param \Illuminate\Support\Collection<int, int|string> $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<int, array<string, mixed>>}
*/
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<int, int|string> $scopedVenueIds
* @return array{dates: string[], date_labels: string[], rows: array<int, mixed>}|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<int, int|string> */
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();
}
}