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.
424 lines
16 KiB
424 lines
16 KiB
|
2 weeks ago
|
<?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;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
|
||
|
|
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' => [],
|
||
|
|
'hourly_matrix' => ['hours' => [], 'rows' => []],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$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)
|
||
|
|
: [];
|
||
|
|
|
||
|
|
$hourlyMatrix = $this->hourlyMatrix($eventId, $releaseRows, $scopedVenueIds, $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),
|
||
|
|
'hourly_matrix' => $hourlyMatrix,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 行:场馆;列:有抢票的小时;单元格:该小时抢票张数 / 该馆当日已抢(占比)
|
||
|
|
*/
|
||
|
|
private function hourlyMatrix(int $eventId, Collection $releaseRows, Collection $scopedVenueIds, string $dateYmd): array
|
||
|
|
{
|
||
|
|
$dayIds = $releaseRows->pluck('id')->all();
|
||
|
|
if ($dayIds === []) {
|
||
|
|
return ['hours' => [], 'rows' => []];
|
||
|
|
}
|
||
|
|
|
||
|
|
$hourExpr = DB::connection()->getDriverName() === 'sqlite'
|
||
|
|
? "CAST(strftime('%H', created_at) AS INTEGER)"
|
||
|
|
: 'HOUR(created_at)';
|
||
|
|
|
||
|
|
$raw = Reservation::query()
|
||
|
|
->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
|
||
|
|
->where('ticket_grab_event_id', $eventId)
|
||
|
|
->whereIn('ticket_grab_venue_release_day_id', $dayIds)
|
||
|
|
->where('status', '!=', 'cancelled')
|
||
|
|
->whereDate('created_at', $dateYmd)
|
||
|
|
->selectRaw('venue_id, '.$hourExpr.' as h, SUM(ticket_count) as c')
|
||
|
|
->groupBy('venue_id', 'h')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$hours = $raw->pluck('h')->unique()->sort()->values()->map(fn ($h) => (int) $h)->all();
|
||
|
|
if ($hours === []) {
|
||
|
|
return ['hours' => [], 'rows' => []];
|
||
|
|
}
|
||
|
|
|
||
|
|
$byVenueHour = [];
|
||
|
|
foreach ($raw as $r) {
|
||
|
|
$vid = (int) $r->venue_id;
|
||
|
|
$h = (int) $r->h;
|
||
|
|
$byVenueHour[$vid][$h] = (int) $r->c;
|
||
|
|
}
|
||
|
|
|
||
|
|
$venueDayTotals = Reservation::query()
|
||
|
|
->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
|
||
|
|
->where('ticket_grab_event_id', $eventId)
|
||
|
|
->whereIn('ticket_grab_venue_release_day_id', $dayIds)
|
||
|
|
->where('status', '!=', 'cancelled')
|
||
|
|
->whereDate('created_at', $dateYmd)
|
||
|
|
->selectRaw('venue_id, SUM(ticket_count) as c')
|
||
|
|
->groupBy('venue_id')
|
||
|
|
->pluck('c', 'venue_id');
|
||
|
|
|
||
|
|
$eventDayTotal = (int) $venueDayTotals->sum();
|
||
|
|
|
||
|
|
$rows = [];
|
||
|
|
foreach ($releaseRows->sortBy('venue_id') as $rr) {
|
||
|
|
$vid = (int) $rr->venue_id;
|
||
|
|
$name = $rr->venue?->name ?? (Venue::query()->whereKey($vid)->value('name') ?? '场馆 #'.$vid);
|
||
|
|
$dayTot = (int) ($venueDayTotals[$vid] ?? 0);
|
||
|
|
$cells = [];
|
||
|
|
foreach ($hours as $h) {
|
||
|
|
$g = (int) ($byVenueHour[$vid][$h] ?? 0);
|
||
|
|
$pct = $dayTot > 0 ? round(100 * $g / $dayTot, 2) : 0.0;
|
||
|
|
$cells[] = [
|
||
|
|
'hour' => $h,
|
||
|
|
'grabbed' => $g,
|
||
|
|
'display' => $dayTot > 0 ? $g.'/'.$dayTot.' ('.$pct.'%)' : (string) $g,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
$rowPct = $eventDayTotal > 0 ? round(100 * $dayTot / $eventDayTotal, 2) : 0.0;
|
||
|
|
$rows[] = [
|
||
|
|
'venue_id' => $vid,
|
||
|
|
'venue_name' => $name,
|
||
|
|
'cells' => $cells,
|
||
|
|
'day_total' => $dayTot,
|
||
|
|
'day_share_display' => $eventDayTotal > 0 ? $dayTot.'/'.$eventDayTotal.' ('.$rowPct.'%)' : (string) $dayTot,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'hours' => $hours,
|
||
|
|
'rows' => $rows,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @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();
|
||
|
|
}
|
||
|
|
}
|