diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index a0bff9a..3e3c688 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -243,24 +243,33 @@ final class AdminDashboardSnapshotBuilder ]; } - /** @return 'd4'|'d3'|'d2'|'special'|'other' */ + /** + * 与仪表盘前端维度一致:先按「提取数字位数」分 4D/3D/2D;含字母且不足 3 位有效数字的视为特别号。 + * + * @return 'd4'|'d3'|'d2'|'special'|'other' + */ private function soldOutBucketKey(string $normalizedNumber): string { $raw = trim($normalizedNumber); - if (preg_match('/[A-Za-z]/', $raw) === 1) { + $digits = preg_replace('/\D/', '', $raw) ?? ''; + $digitLen = strlen($digits); + $hasLetter = preg_match('/[A-Za-z]/', $raw) === 1; + + if ($hasLetter && $digitLen < 3) { return 'special'; } - $digits = preg_replace('/\D/', '', $raw) ?? ''; - $len = strlen($digits) > 0 ? strlen($digits) : strlen($raw); - if ($len >= 4) { + if ($digitLen >= 4) { return 'd4'; } - if ($len === 3) { + if ($digitLen === 3) { return 'd3'; } - if ($len === 2) { + if ($digitLen === 2) { return 'd2'; } + if ($hasLetter) { + return 'special'; + } return 'other'; } diff --git a/database/seeders/DashboardHallFixtureSeeder.php b/database/seeders/DashboardHallFixtureSeeder.php new file mode 100644 index 0000000..6b9880a --- /dev/null +++ b/database/seeders/DashboardHallFixtureSeeder.php @@ -0,0 +1,321 @@ +environment('production')) { + return; + } + + $hall = app(DrawHallSnapshotBuilder::class); + $draw = $hall->resolveHallTarget(); + if ($draw === null) { + $draw = $this->createFallbackOpenDraw(); + } + + $player = Player::query() + ->where('site_code', 'demo') + ->where('site_player_id', 'demo-player-001') + ->first() + ?? Player::query()->orderBy('id')->first(); + + if ($player === null) { + $this->command?->warn('DashboardHallFixtureSeeder: 无 players 行,请先执行 DevPlayerAndWalletSeeder。'); + + return; + } + + DB::transaction(function () use ($draw, $player): void { + $this->seedRiskPools($draw); + $this->seedTicketOrders($draw, $player); + $this->seedPendingReviewBatch($draw); + $this->seedAbnormalTransfers($player); + }); + + $this->command?->info(sprintf( + 'DashboardHallFixtureSeeder: 已写入 hall 当期 draw_no=%s id=%d', + $draw->draw_no, + $draw->id, + )); + } + + private function createFallbackOpenDraw(): Draw + { + $now = Carbon::now()->utc(); + $biz = $now->format('Y-m-d'); + $ymd = str_replace('-', '', $biz); + + return Draw::query()->updateOrCreate( + ['draw_no' => $ymd.'-997'], + [ + 'business_date' => $biz, + 'sequence_no' => 997, + 'status' => DrawStatus::Open->value, + 'start_time' => $now->copy()->subMinutes(5), + 'close_time' => $now->copy()->addHours(2), + 'draw_time' => $now->copy()->addHours(2)->addSeconds(30), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ], + ); + } + + private function seedRiskPools(Draw $draw): void + { + $rows = [ + // 4D 纯数字 + ['1234', 5_000_000, 3_200_000, 0], + ['2387', 2_000_000, 1_800_000, 0], + ['4752', 1_500_000, 1_200_000, 0], + ['8899', 800_000, 720_000, 0], + ['5678', 3_000_000, 900_000, 0], + ['3456', 1_200_000, 600_000, 0], + ['1111', 2_000_000, 400_000, 0], + ['2222', 1_000_000, 250_000, 0], + ['3333', 900_000, 450_000, 0], + ['4444', 700_000, 350_000, 0], + ['0999', 300_000, 180_000, 0], + ['0099', 200_000, 50_000, 0], + // 3D:占位字母去掉后剩 3 位数字(如 909) + ['90X9', 1_200_000, 960_000, 0], + ['80Y0', 900_000, 720_000, 0], + ['Z999', 700_000, 560_000, 0], + // 2D:剩 2 位数字 + ['9X9X', 650_000, 520_000, 0], + ['8Y8Z', 550_000, 330_000, 0], + // 特别号:含字母且有效数字不足 3 位 + ['12AB', 600_000, 120_000, 0], + ['ABCD', 420_000, 210_000, 0], + ['1A2B', 380_000, 95_000, 0], + // 售罄 —— 覆盖 4D / 3D / 2D / 特别 + ['0001', 500_000, 500_000, 1], + ['9999', 400_000, 400_000, 1], + ['9Y90', 350_000, 350_000, 1], + ['9A9B', 280_000, 280_000, 1], + ['AB12', 220_000, 220_000, 1], + ]; + + foreach ($rows as [$num, $cap, $locked, $sold]) { + $remaining = max(0, $cap - $locked); + RiskPool::query()->updateOrCreate( + ['draw_id' => $draw->id, 'normalized_number' => $num], + [ + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => $remaining, + 'sold_out_status' => $sold, + 'version' => 1, + ], + ); + } + } + + private function seedTicketOrders(Draw $draw, Player $player): void + { + $suffix = (string) $draw->id; + + $o1 = TicketOrder::query()->firstOrCreate( + ['order_no' => 'ORD-HALL-'.$suffix.'-A'], + [ + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => strtoupper((string) ($player->default_currency ?? 'NPR')), + 'total_bet_amount' => 280_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 280_000, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => 'hall-fixture-a', + ], + ); + + TicketItem::query()->firstOrCreate( + ['ticket_no' => 'TK-HALL-'.$suffix.'-001'], + [ + 'order_id' => $o1->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'straight', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 200_000, + 'total_bet_amount' => 200_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 200_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'success', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 15_000, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + ], + ); + + TicketItem::query()->firstOrCreate( + ['ticket_no' => 'TK-HALL-'.$suffix.'-002'], + [ + 'order_id' => $o1->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => 'straight', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 80_000, + 'total_bet_amount' => 80_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 80_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'success', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 5_000, + 'settled_at' => null, + ], + ); + + $o2 = TicketOrder::query()->firstOrCreate( + ['order_no' => 'ORD-HALL-'.$suffix.'-B'], + [ + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => strtoupper((string) ($player->default_currency ?? 'NPR')), + 'total_bet_amount' => 120_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 120_000, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => 'hall-fixture-b', + ], + ); + + TicketItem::query()->firstOrCreate( + ['ticket_no' => 'TK-HALL-'.$suffix.'-003'], + [ + 'order_id' => $o2->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '2387', + 'normalized_number' => '2387', + 'play_code' => 'straight', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 120_000, + 'total_bet_amount' => 120_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 120_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'success', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + ], + ); + } + + private function seedPendingReviewBatch(Draw $draw): void + { + $max = (int) DrawResultBatch::query()->where('draw_id', $draw->id)->max('result_version'); + $next = max(1, $max + 1); + + DrawResultBatch::query()->firstOrCreate( + [ + 'draw_id' => $draw->id, + 'result_version' => $next, + ], + [ + 'source_type' => 'manual', + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::PendingReview->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => null, + ], + ); + } + + private function seedAbnormalTransfers(Player $player): void + { + $rows = [ + ['TR-HALL-DEMO-F1', 'failed', 'idem-hall-f1'], + ['TR-HALL-DEMO-P1', 'processing', 'idem-hall-p1'], + ['TR-HALL-DEMO-R1', 'pending_reconcile', 'idem-hall-r1'], + ]; + + foreach ($rows as [$no, $status, $idem]) { + TransferOrder::query()->firstOrCreate( + ['transfer_no' => $no], + [ + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => strtoupper((string) ($player->default_currency ?? 'NPR')), + 'amount' => 10_000, + 'idempotent_key' => $idem, + 'status' => $status, + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => $status === 'failed' ? 'demo seed' : null, + 'finished_at' => $status === 'failed' ? now() : null, + ], + ); + } + } +} diff --git a/database/seeders/DashboardSecondaryScenariosSeeder.php b/database/seeders/DashboardSecondaryScenariosSeeder.php new file mode 100644 index 0000000..ced10ad --- /dev/null +++ b/database/seeders/DashboardSecondaryScenariosSeeder.php @@ -0,0 +1,165 @@ +environment('production')) { + return; + } + + $now = Carbon::now()->utc(); + $biz = $now->format('Y-m-d'); + $ymd = str_replace('-', '', $biz); + + DB::transaction(function () use ($biz, $ymd, $now): void { + $this->seedSettledShowcase($biz, $ymd, $now); + $this->seedPendingFuture($biz, $ymd, $now); + }); + + $this->command?->info('DashboardSecondaryScenariosSeeder: 已写入 -996 settled 与 -995 pending 演示期。'); + } + + private function seedSettledShowcase(string $biz, string $ymd, Carbon $now): void + { + $drawTime = $now->copy()->subHours(6); + + $draw = Draw::query()->updateOrCreate( + ['draw_no' => $ymd.'-996'], + [ + 'business_date' => $biz, + 'sequence_no' => 996, + 'status' => DrawStatus::Settled->value, + 'start_time' => $drawTime->copy()->subMinutes(30), + 'close_time' => $drawTime->copy()->subSeconds(30), + 'draw_time' => $drawTime, + 'cooling_end_time' => $drawTime->copy()->addMinutes(10), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ], + ); + + $batch = DrawResultBatch::query()->updateOrCreate( + [ + 'draw_id' => $draw->id, + 'result_version' => 1, + ], + [ + 'source_type' => 'rng', + 'rng_seed_hash' => hash('sha256', 'dashboard-secondary-996'), + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => $drawTime, + ], + ); + + DrawResultItem::query()->where('result_batch_id', $batch->id)->delete(); + + foreach (DrawPrizeLayout::slots() as $i => $slot) { + $n = (($i + 7) * 401) % 10_000; + $num = str_pad((string) $n, 4, '0', STR_PAD_LEFT); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + SettlementBatch::query()->updateOrCreate( + [ + 'draw_id' => $draw->id, + 'settle_version' => 1, + ], + [ + 'result_batch_id' => $batch->id, + 'status' => SettlementBatchStatus::Completed->value, + 'total_ticket_count' => 12, + 'total_win_count' => 3, + 'total_payout_amount' => 450_000, + 'total_jackpot_payout_amount' => 20_000, + 'started_at' => $drawTime->copy()->addMinutes(2), + 'finished_at' => $drawTime->copy()->addMinutes(5), + ], + ); + + $pools = [ + ['2468', 1_000_000, 400_000, 0], + ['1357', 800_000, 200_000, 0], + ['90X9', 600_000, 540_000, 0], + ['9X9X', 400_000, 160_000, 0], + ['12AB', 300_000, 90_000, 0], + ['8888', 200_000, 200_000, 1], + ]; + foreach ($pools as [$num, $cap, $locked, $sold]) { + RiskPool::query()->updateOrCreate( + ['draw_id' => $draw->id, 'normalized_number' => $num], + [ + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => max(0, $cap - $locked), + 'sold_out_status' => $sold, + 'version' => 1, + ], + ); + } + } + + private function seedPendingFuture(string $biz, string $ymd, Carbon $now): void + { + $start = $now->copy()->addHours(8); + $close = $start->copy()->addMinutes(10); + $drawT = $close->copy()->addSeconds(30); + + Draw::query()->updateOrCreate( + ['draw_no' => $ymd.'-995'], + [ + 'business_date' => $biz, + 'sequence_no' => 995, + 'status' => DrawStatus::Pending->value, + 'start_time' => $start, + 'close_time' => $close, + 'draw_time' => $drawT, + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ], + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 89c6238..17885ce 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -26,6 +26,9 @@ class DatabaseSeeder extends Seeder AdminRbacAndUserSeeder::class, DevPlayerAndWalletSeeder::class, DrawDemoSeeder::class, + // 仪表盘:对齐「大厅 resolve 当期」+ 多场景演示期(-996 settled / -995 pending) + DashboardHallFixtureSeeder::class, + DashboardSecondaryScenariosSeeder::class, ]); } }