seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function jackpotTestPlayer(string $prefix = 'jp'): Player { $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => $prefix.'-p-'.$uniq, 'username' => $prefix.'_'.$uniq, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 5_000_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); return $player; } function jackpotOpenDraw(string $drawNo): Draw { return Draw::query()->create([ 'draw_no' => $drawNo, 'business_date' => '2026-05-11', 'sequence_no' => (int) substr($drawNo, -3), 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(2), 'close_time' => now()->addMinutes(5), 'draw_time' => now()->addMinutes(6), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); } /** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */ function jackpotUpsertPool(array $attrs): JackpotPool { $currencyCode = (string) ($attrs['currency_code'] ?? 'NPR'); return JackpotPool::query()->updateOrCreate( ['currency_code' => $currencyCode], $attrs, ); } function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void { $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => 'test-'.(string) $draw->draw_no, 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (DrawPrizeLayout::slots() as $slot) { $num = $slot['prize_type'] === 'first' ? $firstNumber : '5678'; 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), ]); } $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); } test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void { jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 0, 'contribution_rate' => '0.1000', 'trigger_threshold' => 1, 'payout_rate' => '1.0000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => null, ]); $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'jp-p-'.$uniq, 'username' => 'jp_'.$uniq, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 5_000_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $draw = Draw::query()->create([ 'draw_no' => '20260511-901', 'business_date' => '2026-05-11', 'sequence_no' => 901, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(2), 'close_time' => now()->addMinutes(5), 'draw_time' => now()->addMinutes(6), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-901', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-trace-1', 'lines' => [ ['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000], ], ]) ->assertOk(); expect(JackpotContribution::query()->count())->toBe(1); $poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); expect((int) $poolAfterBet->current_amount)->toBe(1_000); $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => 'test', 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (DrawPrizeLayout::slots() as $slot) { $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; $suffix3 = substr($num, -3); $suffix2 = substr($num, -2); 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' => $suffix3, 'suffix_2d' => $suffix2, 'head_digit' => (int) substr($num, 0, 1), 'tail_digit' => (int) substr($num, 3, 1), ]); } $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); $ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); expect($ran)->toBeTrue(); $admin = AdminUser::query()->create([ 'username' => 'jp_settle_admin', 'name' => 'JP Settle Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->win_amount)->toBe(250_000); expect((int) $item->jackpot_win_amount)->toBe(1_000); $poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); expect((int) $poolAfterSettle->current_amount)->toBe(0); expect(JackpotPayoutLog::query()->count())->toBe(1); $order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail(); expect($order->status)->toBe('settled'); }); test('jackpot contribution respects switch and minimum bet threshold', function (): void { jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 0, 'contribution_rate' => '0.1000', 'trigger_threshold' => 1, 'payout_rate' => '1.0000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 20_000, 'status' => 1, 'last_trigger_draw_id' => null, ]); $player = jackpotTestPlayer('jpmin'); jackpotOpenDraw('20260511-902'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-902', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-min-1', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); expect(JackpotContribution::query()->count())->toBe(0); JackpotPool::query()->where('currency_code', 'NPR')->update([ 'min_bet_amount' => 0, 'status' => 0, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-902', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-off-1', 'lines' => [['number' => '2234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); expect(JackpotContribution::query()->count())->toBe(0); }); test('jackpot bursts by configured play combination trigger before threshold', function (): void { Event::fake([JackpotBurstBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 50_000, 'contribution_rate' => '0.0000', 'trigger_threshold' => 999_999_999, 'payout_rate' => '1.0000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => null, 'combo_trigger_play_codes' => ['straight'], ]); $player = jackpotTestPlayer('jpcombo'); $draw = jackpotOpenDraw('20260511-903'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-903', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-combo-1', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); jackpotPublishResults($draw, '1234'); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->jackpot_win_amount)->toBe(50_000) ->and(JackpotPayoutLog::query()->firstOrFail()->trigger_type)->toBe('play_combo') ->and((int) JackpotPool::query()->where('currency_code', 'NPR')->value('current_amount'))->toBe(0); Event::assertDispatched( JackpotBurstBroadcast::class, fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id && $event->drawNo === '20260511-903' && $event->firstPrizeNumber === '1234' && $event->currencyCode === 'NPR' && $event->totalPayoutAmount === 50_000 && $event->winnerCount === 1 && $event->triggerType === 'play_combo' && $event->poolAmountAfter === 0, ); }); test('jackpot splits burst payout between multiple winners by bet amount', function (): void { jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 90_000, 'contribution_rate' => '0.0000', 'trigger_threshold' => 1, 'payout_rate' => '1.0000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => null, ]); $playerA = jackpotTestPlayer('jpa'); $playerB = jackpotTestPlayer('jpb'); $draw = jackpotOpenDraw('20260511-904'); $this->withHeader('Authorization', 'Bearer dev:'.$playerA->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-904', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-split-a', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); $this->withHeader('Authorization', 'Bearer dev:'.$playerB->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-904', 'currency_code' => 'NPR', 'client_trace_id' => 'jp-split-b', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 20_000]], ]) ->assertOk(); jackpotPublishResults($draw, '1234'); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $amounts = TicketItem::query() ->where('draw_id', $draw->id) ->orderBy('total_bet_amount') ->pluck('jackpot_win_amount') ->map(fn ($v) => (int) $v) ->all(); expect($amounts)->toBe([30_000, 60_000]); }); test('jackpot summary and result payload expose pool amount and draw gap', function (): void { $last = Draw::query()->create([ 'draw_no' => '20260511-800', 'business_date' => '2026-05-11', 'sequence_no' => 800, 'status' => DrawStatus::Settled->value, 'start_time' => now()->subHours(3), 'close_time' => now()->subHours(2), 'draw_time' => now()->subHours(2), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 1, 'is_reopened' => false, ]); Draw::query()->create([ 'draw_no' => '20260511-801', 'business_date' => '2026-05-11', 'sequence_no' => 801, 'status' => DrawStatus::Settled->value, 'start_time' => now()->subHours(2), 'close_time' => now()->subHour(), 'draw_time' => now()->subHour(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 1, 'is_reopened' => false, ]); jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 123_456, 'contribution_rate' => '0.0100', 'trigger_threshold' => 1_000_000, 'payout_rate' => '0.5000', 'force_trigger_draw_gap' => 10, 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => $last->id, ]); $draw = jackpotOpenDraw('20260511-905'); jackpotPublishResults($draw, '1234'); $draw->forceFill(['status' => DrawStatus::Cooldown->value])->save(); $this->getJson('/api/v1/jackpot/summary?currency_code=NPR') ->assertOk() ->assertJsonPath('data.current_amount_minor', 123_456) ->assertJsonPath('data.draws_since_last_burst', 1); $this->getJson('/api/v1/draw/results/20260511-905') ->assertOk() ->assertJsonPath('data.jackpot.current_amount_minor', 123_456) ->assertJsonPath('data.jackpot.draws_since_last_burst', 1); $summary = app(DrawResultViewService::class)->summarizeDraw($draw->fresh()); expect($summary['jackpot']['current_amount_minor'] ?? null)->toBe(123_456); });