|null */ final class DrawHallSnapshotBuilder { public function __construct( private readonly JackpotSummaryService $jackpotSummary, ) {} /** * Tick 未及时跑时,DB 仍为 `open` 但已到封盘时刻;对外快照与界面应对齐真实可下注态(见 DrawTickService::openToClosingOrClosed)。 * * 后台「当前大厅可见状态」预览可共用本方法。 */ public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string { $db = (string) $target->status; if ($db === DrawStatus::Pending->value) { $startUtc = $target->start_time; if ($startUtc instanceof Carbon && $startUtc <= $nowUtc) { $closeUtc = $target->close_time; if ($closeUtc === null || $closeUtc > $nowUtc) { $db = DrawStatus::Open->value; } } else { return $db; } } if ($db !== DrawStatus::Open->value) { return $db; } $closeUtc = $target->close_time; if (! $closeUtc instanceof Carbon || $closeUtc > $nowUtc) { return $db; } $drawUtc = $target->draw_time; if ($drawUtc instanceof Carbon && $drawUtc <= $nowUtc) { return DrawStatus::Closed->value; } return DrawStatus::Closing->value; } /** 与大厅 {@see effectiveHallDisplayStatus} 一致:是否仍接受 preview/place。 */ public function isBettingOpen(Draw $draw, ?Carbon $nowUtc = null): bool { $nowUtc ??= now()->utc(); return $this->effectiveHallDisplayStatus($draw, $nowUtc) === DrawStatus::Open->value; } private function showsPublishedResults(string $drawStatus): bool { return in_array($drawStatus, [ DrawStatus::Cooldown->value, DrawStatus::Settling->value, DrawStatus::Settled->value, ], true); } /** 与 {@see build()} 使用同一套「大厅指向的当期行」 */ public function resolveHallTarget(?Carbon $nowUtc = null): ?Draw { $nowUtc = ($nowUtc ?? Carbon::now())->utc(); $bettingOpen = Draw::query() ->where(function ($q) use ($nowUtc): void { $q->where('status', DrawStatus::Open->value) ->orWhere(function ($q2) use ($nowUtc): void { $q2->where('status', DrawStatus::Pending->value) ->whereNotNull('start_time') ->where('start_time', '<=', $nowUtc); }); }) ->where(function ($q) use ($nowUtc): void { $q->whereNull('close_time') ->orWhere('close_time', '>', $nowUtc); }) ->orderBy('draw_time') ->first(); if ($bettingOpen !== null) { return $bettingOpen; } $upcoming = Draw::query() ->whereNotIn('status', [ DrawStatus::Settled->value, DrawStatus::Cancelled->value, ]) ->where(function ($q) use ($nowUtc): void { $q->where(function ($q2) use ($nowUtc): void { $q2->whereNotNull('close_time') ->where('close_time', '>', $nowUtc); })->orWhere(function ($q2) use ($nowUtc): void { $q2->whereNull('close_time') ->whereNotNull('draw_time') ->where('draw_time', '>', $nowUtc); }); }) ->orderBy('draw_time') ->get(); foreach ($upcoming as $candidate) { if ($this->isStalePendingRow($candidate, $nowUtc)) { continue; } return $candidate; } $chronological = Draw::query() ->whereNotIn('status', [ DrawStatus::Settled->value, DrawStatus::Cancelled->value, ]) ->orderByDesc('draw_time') ->first(); if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) { $next = Draw::query() ->whereNotIn('status', [ DrawStatus::Settled->value, DrawStatus::Cancelled->value, ]) ->where('draw_time', '>', $chronological->draw_time) ->orderBy('draw_time') ->first(); if ($next !== null) { return $next; } } return $chronological; } /** 调度未跑时:库内仍是 pending,但封盘/开奖时刻已过,不应再作为大厅「当期」。 */ private function isStalePendingRow(Draw $draw, Carbon $nowUtc): bool { if ((string) $draw->status !== DrawStatus::Pending->value) { return false; } $closeUtc = $draw->close_time; if ($closeUtc instanceof Carbon && $closeUtc <= $nowUtc) { return true; } $drawUtc = $draw->draw_time; return $drawUtc instanceof Carbon && $drawUtc <= $nowUtc; } private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool { return (string) $draw->status === DrawStatus::Cooldown->value && $draw->cooling_end_time instanceof Carbon && $draw->cooling_end_time <= $nowUtc; } /** * {@see DrawTickService} 发 `draw.status_change` 用:按 **数据库** `draw_no`+`status`,不用展示态规范化。 * * @return array{draw_no: string, status: string}|null */ public function hallTargetFingerprint(?Carbon $nowUtc = null): ?array { $target = $this->resolveHallTarget($nowUtc); if ($target === null) { return null; } return [ 'draw_no' => (string) $target->draw_no, 'status' => (string) $target->status, ]; } /** * @return array|null */ public function build(?Carbon $nowUtc = null, ?string $currencyCode = null): ?array { $nowUtc = ($nowUtc ?? Carbon::now())->utc(); $currencyCode = $this->normalizeCurrencyCode($currencyCode); $target = $this->resolveHallTarget($nowUtc); if ($target === null) { return null; } $closeUtc = $target->close_time; $secsToClose = ($closeUtc !== null && $closeUtc > $nowUtc) ? max(0, (int) $closeUtc->getTimestamp() - (int) $nowUtc->getTimestamp()) : 0; $secsToDraw = ($target->draw_time !== null && $target->draw_time > $nowUtc) ? max(0, (int) $target->draw_time->getTimestamp() - (int) $nowUtc->getTimestamp()) : 0; $startUtc = $target->start_time; $secsToStart = ($startUtc !== null && $startUtc > $nowUtc) ? max(0, (int) $startUtc->getTimestamp() - (int) $nowUtc->getTimestamp()) : 0; $coolingRemain = null; if ( $target->cooling_end_time instanceof Carbon && $target->cooling_end_time > $nowUtc ) { $coolingRemain = max( 0, (int) $target->cooling_end_time->getTimestamp() - (int) $nowUtc->getTimestamp(), ); } $effectiveStatus = $this->effectiveHallDisplayStatus($target, $nowUtc); $scheduleTz = (string) config('lottery.draw.timezone', 'UTC'); $payload = [ 'schedule_timezone' => $scheduleTz, 'schedule_now' => $nowUtc->copy()->timezone($scheduleTz)->format('Y-m-d H:i:s'), 'draw_no' => $target->draw_no, 'business_date' => $target->business_date instanceof Carbon ? $target->business_date->format('Y-m-d') : (string) $target->business_date, 'sequence_no' => (int) $target->sequence_no, 'status' => $effectiveStatus, 'start_time' => $target->start_time?->toIso8601String(), 'close_time' => $target->close_time?->toIso8601String(), 'draw_time' => $target->draw_time?->toIso8601String(), 'seconds_to_close' => $secsToClose, 'seconds_to_start' => $secsToStart, 'seconds_to_draw' => $secsToDraw, 'cooling_end_time' => $target->cooling_end_time?->toIso8601String(), 'seconds_remaining_in_cooldown' => $coolingRemain, 'jackpot_currency_code' => $currencyCode, 'jackpot' => $this->jackpotSummary->summary($currencyCode), ]; $riskAlerts = RiskPool::query() ->where('draw_id', $target->id) ->where(function ($q): void { $q->where('sold_out_status', 1) ->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); }) ->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') ->orderByDesc('locked_amount') ->orderBy('normalized_number') ->limit(500) ->get(['normalized_number', 'sold_out_status']) ->map(fn ($row) => [ 'normalized_number' => (string) $row->normalized_number, 'status' => (int) $row->sold_out_status === 1 ? 'sold_out' : 'warning', ]) ->values() ->all(); $payload['risk_pool_alerts'] = $riskAlerts; if ($this->showsPublishedResults((string) $target->status)) { $batchId = DrawResultBatch::query() ->where('draw_id', $target->id) ->where('result_version', (int) $target->current_result_version) ->where('status', DrawResultBatchStatus::Published->value) ->value('id'); if ($batchId !== null) { $payload['result_items'] = DrawResultItem::query() ->where('result_batch_id', $batchId) ->orderBy('prize_type') ->orderBy('prize_index') ->get([ 'prize_type', 'prize_index', 'number_4d', 'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit', ]) ->map(fn ($row) => [ 'prize_type' => $row->prize_type, 'prize_index' => (int) $row->prize_index, 'number_4d' => $row->number_4d, 'suffix_3d' => $row->suffix_3d, 'suffix_2d' => $row->suffix_2d, 'head_digit' => $row->head_digit, 'tail_digit' => $row->tail_digit, ]) ->values() ->all(); } $payload['result_version'] = (int) $target->current_result_version; $payload['result_source'] = $target->result_source; } return $payload; } private function normalizeCurrencyCode(?string $currencyCode): string { $code = strtoupper(substr(trim((string) ($currencyCode ?? '')), 0, 16)); if ($code !== '') { return $code; } return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16)); } }