|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::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; } 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('status', DrawStatus::Open->value) ->where(function ($q) use ($nowUtc): void { $q->whereNull('close_time') ->orWhere('close_time', '>', $nowUtc); }) ->orderBy('draw_time') ->first(); $chronological = Draw::query() ->whereNotIn('status', [ DrawStatus::Settled->value, DrawStatus::Cancelled->value, ]) ->orderBy('draw_time') ->first(); return $bettingOpen ?? $chronological; } /** * {@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): ?array { $nowUtc = ($nowUtc ?? Carbon::now())->utc(); $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; $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); $payload = [ '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_draw' => $secsToDraw, 'cooling_end_time' => $target->cooling_end_time?->toIso8601String(), 'seconds_remaining_in_cooldown' => $coolingRemain, 'jackpot' => $this->jackpotSummary->summary('NPR'), ]; $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', 'total_cap_amount', 'locked_amount', 'remaining_amount', 'sold_out_status']) ->map(fn ($row) => [ 'normalized_number' => (string) $row->normalized_number, 'total_cap_amount' => (int) $row->total_cap_amount, 'locked_amount' => (int) $row->locked_amount, 'remaining_amount' => (int) $row->remaining_amount, 'sold_out_status' => (int) $row->sold_out_status, 'is_sold_out' => (int) $row->sold_out_status === 1, 'usage_ratio' => (int) $row->total_cap_amount > 0 ? round(((int) $row->locked_amount) / (int) $row->total_cap_amount, 6) : null, ]) ->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; } }