'UTC', 'lottery.draw.interval_minutes' => 60, 'lottery.draw.buffer_draws_ahead' => 3, 'lottery.draw.betting_window_seconds' => 270, 'lottery.draw.close_before_draw_seconds' => 30, 'lottery.draw.require_manual_review' => false, 'lottery.draw.cooldown_minutes' => 15, ]); }); test('draw planner fills buffer rows with ordered draw_no', function (): void { $fixed = Carbon::parse('2026-05-09 12:00:00', 'UTC')->utc(); /** @var DrawPlannerService $planner */ $planner = app(DrawPlannerService::class); $report = $planner->ensureBuffer($fixed); expect($report['created'])->toBeGreaterThan(0); expect(Draw::query()->count())->toBe($report['upcoming']); $drawNos = Draw::query()->orderBy('draw_time')->pluck('draw_no')->all(); $sorted = $drawNos; sort($sorted); expect($drawNos)->toEqual($sorted); }); test('admin can batch generate draw schedule buffer', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC')); $admin = AdminUser::query()->create([ 'username' => 'draw_plan_admin', 'name' => 'Draw Plan Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/draws/generate-plan') ->assertOk() ->assertJsonPath('data.buffer_target', 3); expect(Draw::query()->count())->toBeGreaterThanOrEqual(3); Carbon::setTestNow(); }); test('draw planner schedules after last draw_time not midnight slot', function (): void { config([ 'lottery.draw.interval_minutes' => 5, 'lottery.draw.buffer_draws_ahead' => 2, ]); $fixed = Carbon::parse('2026-05-25 11:00:00', 'UTC'); $lastId = Draw::query()->create([ 'draw_no' => '20260525-120', 'business_date' => '2026-05-25', 'sequence_no' => 120, 'status' => DrawStatus::Settled->value, 'start_time' => Carbon::parse('2026-05-25 11:54:30', 'UTC'), 'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'), 'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 1, 'settle_version' => 1, 'is_reopened' => false, ])->id; app(DrawPlannerService::class)->ensureBuffer($fixed); $next = Draw::query()->where('id', '>', $lastId)->orderBy('draw_time')->first(); expect($next)->not->toBeNull(); expect($next->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:05:00'); expect($next->sequence_no)->toBe(121); }); test('admin can manually create draw with custom timeline', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC')); $admin = AdminUser::query()->create([ 'username' => 'draw_create_admin', 'name' => 'Draw Create Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/draws', [ 'draw_time' => '2026-05-25 12:00:00', 'start_time' => '2026-05-25 11:55:00', 'close_time' => '2026-05-25 11:59:30', 'draw_no' => '20260525-901', ]) ->assertCreated() ->assertJsonPath('data.draw_no', '20260525-901') ->assertJsonPath('data.status', DrawStatus::Pending->value); $draw = Draw::query()->where('draw_no', '20260525-901')->first(); expect($draw)->not->toBeNull(); expect($draw->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:00:00'); Carbon::setTestNow(); }); test('admin can update pending draw timeline', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC')); $admin = AdminUser::query()->create([ 'username' => 'draw_update_admin', 'name' => 'Draw Update Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $draw = Draw::query()->create([ 'draw_no' => '20260525-902', 'business_date' => '2026-05-25', 'sequence_no' => 902, 'status' => DrawStatus::Pending->value, 'start_time' => Carbon::parse('2026-05-25 11:55:00', 'UTC'), 'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'), 'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/draws/'.$draw->id, [ 'draw_time' => '2026-05-25 13:00:00', 'start_time' => '2026-05-25 12:55:00', 'close_time' => '2026-05-25 12:59:30', ]) ->assertOk() ->assertJsonPath('data.draw_time', fn ($v) => str_contains((string) $v, '13:00')); Carbon::setTestNow(); }); test('admin can destroy pending draw without bets', function (): void { $admin = AdminUser::query()->create([ 'username' => 'draw_destroy_admin', 'name' => 'Draw Destroy Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $draw = Draw::query()->create([ 'draw_no' => '20260525-903', 'business_date' => '2026-05-25', 'sequence_no' => 903, 'status' => DrawStatus::Pending->value, 'start_time' => now()->addHour(), 'close_time' => now()->addHours(2), 'draw_time' => now()->addHours(3), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->deleteJson('/api/v1/admin/draws/'.$draw->id) ->assertOk() ->assertJsonPath('data.deleted', true); expect(Draw::query()->find($draw->id))->toBeNull(); }); test('admin can manually close open draw', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-120', 'business_date' => '2026-05-09', 'sequence_no' => 120, 'status' => DrawStatus::Open->value, 'start_time' => now()->copy()->subMinute(), 'close_time' => now()->copy()->addMinutes(10), 'draw_time' => now()->copy()->addMinutes(15), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_close_admin', 'name' => 'Draw Close Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/manual-close") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Closing->value); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Closing->value); expect($draw->close_time?->timestamp)->toBe(now()->timestamp); Carbon::setTestNow(); }); test('admin can cancel draw before results exist', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:15:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-121', 'business_date' => '2026-05-09', 'sequence_no' => 121, 'status' => DrawStatus::Pending->value, 'start_time' => now()->copy()->addMinute(), 'close_time' => now()->copy()->addMinutes(10), 'draw_time' => now()->copy()->addMinutes(15), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_cancel_admin', 'name' => 'Draw Cancel Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/cancel") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Cancelled->value); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Cancelled->value); Carbon::setTestNow(); }); test('admin cancel draw refunds open bets and releases risk', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:16:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-121b', 'business_date' => '2026-05-09', 'sequence_no' => 121, 'status' => DrawStatus::Open->value, 'start_time' => now()->copy()->subMinute(), 'close_time' => now()->copy()->addMinutes(10), 'draw_time' => now()->copy()->addMinutes(15), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'cancel-refund-player', 'username' => 'cancel_refund', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 49_900, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $order = TicketOrder::query()->create([ 'order_no' => 'TO-CANCEL-REFUND', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 3000, 'status' => 'placed', 'submit_source' => 'h5', 'client_trace_id' => 'cancel-refund-trace', ]); $item = TicketItem::query()->create([ 'ticket_no' => 'TK-CANCEL-REFUND', 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => '1234', 'normalized_number' => '1234', 'play_code' => 'big', 'dimension' => 4, 'digit_slot' => null, 'bet_mode' => 'straight', 'unit_bet_amount' => 100, 'total_bet_amount' => 100, 'rebate_rate_snapshot' => 0, 'commission_rate_snapshot' => 0, 'actual_deduct_amount' => 100, 'odds_snapshot_json' => [], 'rule_snapshot_json' => [], 'combination_count' => 1, 'estimated_max_payout' => 3000, 'risk_locked_amount' => 3000, 'status' => 'pending_draw', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, ]); TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 1, 'number_4d' => '1234', 'bet_amount' => 100, 'estimated_payout' => 3000, ]); WalletTxn::query()->create([ 'txn_no' => 'WT-CANCEL-REFUND', 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'bet_deduct', 'biz_no' => $order->order_no, 'direction' => 2, 'amount' => 100, 'balance_before' => 50_000, 'balance_after' => 49_900, 'status' => 'posted', 'external_ref_no' => null, 'idempotent_key' => 'bet:'.$order->order_no, 'remark' => null, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_cancel_refund_admin', 'name' => 'Draw Cancel Refund Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/cancel") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Cancelled->value); expect($order->fresh()->status)->toBe('refunded') ->and($item->fresh()->status)->toBe('refunded') ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(50_000); Carbon::setTestNow(); }); test('admin can manually trigger rng for closed draw', function (): void { config(['lottery.draw.require_manual_review' => true]); Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-122', 'business_date' => '2026-05-09', 'sequence_no' => 122, 'status' => DrawStatus::Closed->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(5), 'draw_time' => now()->copy()->subMinute(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_rng_admin', 'name' => 'Draw Rng Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/rng") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Review->value) ->assertJsonPath('data.batch.source_type', 'rng') ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Review->value); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); Carbon::setTestNow(); }); test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC')); $drawTime = now()->copy()->addMinutes(30); $closeTime = now()->copy()->subMinute(); Draw::query()->create([ 'draw_no' => '20260509-099', 'business_date' => '2026-05-09', 'sequence_no' => 99, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(50), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-099')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Closing->value); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(0); Carbon::setTestNow(); }); test('draw tick rng publishes result when manual review disabled', function (): void { config(['lottery.draw.require_manual_review' => false]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-200', 'business_date' => '2026-05-09', 'sequence_no' => 200, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-200')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); expect($draw->current_result_version)->toBe(1); expect($draw->cooling_end_time)->not->toBeNull(); $batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); expect($batch->items()->count())->toBe(23); Carbon::setTestNow(); }); test('draw tick rng awaits manual publish when review enabled', function (): void { config(['lottery.draw.require_manual_review' => true]); LotterySettings::put('draw.require_manual_review', true, 'draw', 'RNG 开奖后是否必须进入人工审核'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); $drawRow = Draw::query()->create([ 'draw_no' => '20260509-201', 'business_date' => '2026-05-09', 'sequence_no' => 201, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $drawRow->refresh(); expect($drawRow->status)->toBe(DrawStatus::Review->value); $batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value); $admin = AdminUser::query()->create([ 'username' => 'draw_auditor', 'name' => 'Auditor', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish") ->assertOk(); $drawRow->refresh(); $batch->refresh(); expect($drawRow->status)->toBe(DrawStatus::Cooldown->value); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); expect($drawRow->current_result_version)->toBe(1); expect($drawRow->cooling_end_time)->not->toBeNull(); Carbon::setTestNow(); }); test('admin can create manual result batch with 23 numbers for review', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:20:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-220', 'business_date' => '2026-05-09', 'sequence_no' => 220, 'status' => DrawStatus::Closed->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(2), 'draw_time' => now()->copy()->subMinute(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'manual_draw_admin', 'name' => 'Manual Draw Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $items = []; foreach (array_values(App\Services\Draw\DrawPrizeLayout::slots()) as $i => $slot) { $items[] = [ 'prize_type' => $slot['prize_type'], 'prize_index' => $slot['prize_index'], 'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT), ]; } $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) ->assertOk() ->assertJsonPath('data.draw_no', '20260509-220') ->assertJsonPath('data.status', DrawStatus::Review->value) ->assertJsonPath('data.batch.status', DrawResultBatchStatus::PendingReview->value) ->assertJsonPath('data.batch.source_type', 'manual') ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Review->value); expect($draw->result_source)->toBe('manual'); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('source_type', 'manual')->count())->toBe(1); expect(DrawResultItem::query()->where('draw_id', $draw->id)->count())->toBe(23); Carbon::setTestNow(); }); test('admin can discard pending manual result batch and draw returns to closed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:25:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-221', 'business_date' => '2026-05-09', 'sequence_no' => 221, 'status' => DrawStatus::Closed->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(2), 'draw_time' => now()->copy()->subMinute(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'discard_batch_admin', 'name' => 'Discard Batch Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $items = []; foreach (array_values(App\Services\Draw\DrawPrizeLayout::slots()) as $i => $slot) { $items[] = [ 'prize_type' => $slot['prize_type'], 'prize_index' => $slot['prize_index'], 'number_4d' => str_pad((string) ($i + 11), 4, '0', STR_PAD_LEFT), ]; } $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) ->assertOk(); $batchId = (int) DrawResultBatch::query()->where('draw_id', $draw->id)->value('id'); expect($batchId)->toBeGreaterThan(0); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Review->value); $this->withHeader('Authorization', 'Bearer '.$token) ->deleteJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$batchId}") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Closed->value) ->assertJsonPath('data.deleted_batch_id', $batchId); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Closed->value); expect($draw->result_source)->toBeNull(); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(0); expect(DrawResultItem::query()->where('draw_id', $draw->id)->count())->toBe(0); Carbon::setTestNow(); }); test('admin can reopen cooldown draw for a replacement result batch', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-230', 'business_date' => '2026-05-09', 'sequence_no' => 230, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(3), 'draw_time' => now()->copy()->subMinutes(2), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => hash('sha256', 'seed'), 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (App\Services\Draw\DrawPrizeLayout::slots() as $i => $slot) { $number = str_pad((string) ($i + 100), 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' => $number, 'suffix_3d' => substr($number, -3), 'suffix_2d' => substr($number, -2), 'head_digit' => (int) substr($number, 0, 1), 'tail_digit' => (int) substr($number, 3, 1), ]); } $admin = AdminUser::query()->create([ 'username' => 'reopen_draw_admin', 'name' => 'Reopen Draw Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'wrong result']) ->assertOk() ->assertJsonPath('data.draw_no', '20260509-230') ->assertJsonPath('data.status', DrawStatus::Closed->value) ->assertJsonPath('data.is_reopened', true) ->assertJsonPath('data.current_result_version', 1); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Closed->value); expect($draw->is_reopened)->toBeTrue(); expect($draw->cooling_end_time)->toBeNull(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/rng") ->assertOk() ->assertJsonPath('data.batch.result_version', 2) ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->current_result_version)->toBe(2); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(2); Carbon::setTestNow(); }); test('non super admin cannot reopen cooldown draw', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:35:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-231', 'business_date' => '2026-05-09', 'sequence_no' => 231, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(3), 'draw_time' => now()->copy()->subMinutes(2), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $role = AdminRole::query()->create([ 'slug' => 'draw_manager_test', 'name' => 'Draw Manager Test', ]); $ids = DB::table('admin_menu_actions') ->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage')) ->where('status', 1) ->pluck('id'); foreach ($ids as $mid) { DB::table('admin_role_menu_actions')->insert([ 'role_id' => $role->id, 'menu_action_id' => (int) $mid, ]); } $admin = AdminUser::query()->create([ 'username' => 'draw_manager_only', 'name' => 'Draw Manager Only', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $admin->roles()->sync([ (int) $role->id => [ 'site_id' => AdminUser::defaultAdminSiteId(), 'granted_at' => now(), ], ]); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/reopen") ->assertStatus(403); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); expect($draw->is_reopened)->toBeFalse(); Carbon::setTestNow(); }); test('cooldown expiry tick moves draw to settling', function (): void { config([ 'lottery.draw.require_manual_review' => false, 'lottery.draw.cooldown_minutes' => 15, ]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); LotterySettings::put('draw.cooldown_minutes', 15, 'draw', '开奖结果发布后的冷静期分钟数'); LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', '本用例仅验证进入结算态,不自动审核派彩'); LotterySettings::put('settlement.auto_payout_on_tick', false, 'settlement', '本用例仅验证进入结算态,不自动派彩'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-777', 'business_date' => '2026-05-09', 'sequence_no' => 777, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-777')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:01', 'UTC')->addMinutes(16)); app(DrawTickService::class)->tick(now()->utc()); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Settling->value); expect((int) $draw->settle_version)->toBe(1); expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'pending_review')->count())->toBe(1); Carbon::setTestNow(); }); test('draw tick uses lottery settings to bypass manual review and cooldown', function (): void { config([ 'lottery.draw.require_manual_review' => true, 'lottery.draw.cooldown_minutes' => 15, ]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); LotterySettings::put('draw.cooldown_minutes', 0, 'draw', '开奖结果发布后的冷静期分钟数'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:08:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-778', 'business_date' => '2026-05-09', 'sequence_no' => 778, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-778')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Settling->value); expect($draw->cooling_end_time)->toBeNull(); $batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); Carbon::setTestNow(); }); test('GET draw current returns open draw with seconds to close', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC')); $drawTime = now()->copy()->addHour(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-300', 'business_date' => '2026-05-09', 'sequence_no' => 300, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.schedule_timezone', 'UTC') ->assertJsonPath('data.data.draw_no', '20260509-300') ->assertJsonPath('data.data.status', DrawStatus::Open->value) ->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30) ->assertJsonPath('data.data.seconds_to_draw', 3600); Carbon::setTestNow(); }); test('GET draw current only exposes coarse risk alert status', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-301', 'business_date' => '2026-05-09', 'sequence_no' => 301, 'status' => DrawStatus::Open->value, 'start_time' => now()->copy()->subMinutes(5), 'close_time' => now()->copy()->addMinutes(30), 'draw_time' => now()->copy()->addHour(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 1_000, 'locked_amount' => 850, 'remaining_amount' => 150, 'sold_out_status' => 0, ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '5678', 'total_cap_amount' => 100, 'locked_amount' => 100, 'remaining_amount' => 0, 'sold_out_status' => 1, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-301') ->assertJsonPath('data.data.risk_pool_alerts.0.normalized_number', '5678') ->assertJsonPath('data.data.risk_pool_alerts.0.status', 'sold_out') ->assertJsonPath('data.data.risk_pool_alerts.1.normalized_number', '1234') ->assertJsonPath('data.data.risk_pool_alerts.1.status', 'warning') ->assertJsonMissingPath('data.data.risk_pool_alerts.0.total_cap_amount') ->assertJsonMissingPath('data.data.risk_pool_alerts.0.locked_amount') ->assertJsonMissingPath('data.data.risk_pool_alerts.0.remaining_amount') ->assertJsonMissingPath('data.data.risk_pool_alerts.0.sold_out_status') ->assertJsonMissingPath('data.data.risk_pool_alerts.0.usage_ratio'); Carbon::setTestNow(); }); test('GET draw current exposes closing when row is open in DB but close_time has passed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC'); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-310', 'business_date' => '2026-05-09', 'sequence_no' => 310, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-310') ->assertJsonPath('data.data.status', DrawStatus::Closing->value) ->assertJsonPath('data.data.seconds_to_close', 0) ->assertJsonPath('data.data.seconds_to_draw', 20); Carbon::setTestNow(); }); test('GET draw current exposes closed when row is open in DB but draw_time has passed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:31:00', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC'); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-311', 'business_date' => '2026-05-09', 'sequence_no' => 311, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-311') ->assertJsonPath('data.data.status', DrawStatus::Closed->value) ->assertJsonPath('data.data.seconds_to_close', 0) ->assertJsonPath('data.data.seconds_to_draw', 0); Carbon::setTestNow(); }); test('GET draw current includes result_items when cooldown', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC')); $drawRow = Draw::query()->create([ 'draw_no' => '20260509-400', 'business_date' => '2026-05-09', 'sequence_no' => 400, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subHour(), 'close_time' => now()->copy()->subMinutes(30), 'draw_time' => now()->copy()->subMinutes(20), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $batch = DrawResultBatch::query()->create([ 'draw_id' => $drawRow->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => hash('sha256', 'fixture'), 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); DrawResultItem::query()->create([ 'draw_id' => $drawRow->id, 'result_batch_id' => $batch->id, 'prize_type' => 'first', 'prize_index' => 0, 'number_4d' => '1234', 'suffix_3d' => '234', 'suffix_2d' => '34', 'head_digit' => 1, 'tail_digit' => 4, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.status', DrawStatus::Cooldown->value) ->assertJsonPath('data.data.result_items.0.number_4d', '1234'); Carbon::setTestNow(); }); test('lottery draw-tick command runs successfully', function (): void { Carbon::setTestNow(Carbon::parse('2030-06-01 12:00:00', 'UTC')); $this->artisan('lottery:draw-tick')->assertSuccessful(); Carbon::setTestNow(); }); test('lottery hall-countdown dispatches draw.countdown when using reverb connection', function (): void { Event::fake([DrawCountdownBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); Draw::query()->create([ 'draw_no' => '20260509-001', 'business_date' => '2026-05-09', 'sequence_no' => 1, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(5), 'close_time' => now()->addMinutes(20), 'draw_time' => now()->addMinutes(25), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->artisan('lottery:hall-countdown')->assertSuccessful(); Event::assertDispatched( DrawCountdownBroadcast::class, fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']), ); }); test('hall snapshot skips stale pending draw and picks next upcoming row', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-25 18:00:00', 'UTC')); Draw::query()->create([ 'draw_no' => '20260525-999', 'business_date' => '2026-05-25', 'sequence_no' => 999, 'status' => DrawStatus::Pending->value, 'start_time' => Carbon::parse('2026-05-25 17:32:00', 'UTC'), 'close_time' => Carbon::parse('2026-05-25 17:36:30', 'UTC'), 'draw_time' => Carbon::parse('2026-05-25 17:37:00', 'UTC'), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); Draw::query()->create([ 'draw_no' => '20260525-1006', 'business_date' => '2026-05-25', 'sequence_no' => 1006, 'status' => DrawStatus::Pending->value, 'start_time' => Carbon::parse('2026-05-25 18:07:00', 'UTC'), 'close_time' => Carbon::parse('2026-05-25 18:11:30', 'UTC'), 'draw_time' => Carbon::parse('2026-05-25 18:12:00', 'UTC'), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc()); expect($target)->not->toBeNull() ->and($target->draw_no)->toBe('20260525-1006'); $payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc()); expect($payload['draw_no'])->toBe('20260525-1006') ->and($payload['seconds_to_start'])->toBeGreaterThan(0); Carbon::setTestNow(); }); test('hall snapshot switches to next bettable draw when cooldown ended', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC')); Draw::query()->create([ 'draw_no' => '20260510-001', 'business_date' => '2026-05-10', 'sequence_no' => 1, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->subHours(3), 'close_time' => now()->subHours(2), 'draw_time' => now()->subHours(2), 'cooling_end_time' => now()->subMinutes(5), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $next = Draw::query()->create([ 'draw_no' => '20260510-002', 'business_date' => '2026-05-10', 'sequence_no' => 2, 'status' => DrawStatus::Pending->value, 'start_time' => now()->subMinutes(1), 'close_time' => now()->addMinutes(20), 'draw_time' => now()->addMinutes(25), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc()); expect($target)->not->toBeNull() ->and($target->draw_no)->toBe('20260510-002'); $payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc()); expect($payload['status'] ?? null)->toBe('open'); Carbon::setTestNow(); }); test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void { Event::fake([DrawStatusChangeBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC')); $drawTime = now()->copy()->addMinutes(30); $closeTime = now()->copy()->subMinute(); Draw::query()->create([ 'draw_no' => '20260509-099', 'business_date' => '2026-05-09', 'sequence_no' => 99, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(50), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); Event::assertDispatched(DrawStatusChangeBroadcast::class); Carbon::setTestNow(); });