seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function ticketItemsPlayer(): Player { $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'items-p-'.$uniq, 'username' => 'ti_'.$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 ticketItemsPublishAndSettle(Draw $draw, string $firstNumber): void { $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => 'items-'.(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(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $admin = AdminUser::query()->create([ 'username' => 'ticket_items_settle_'.bin2hex(random_bytes(3)), 'name' => 'Ticket Items Settle', '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()); } test('jackpot summary is public', function (): void { // 迁移 seed_default_jackpot_pools 已插入 NPR 池,须 upsert 避免 UNIQUE(currency_code) JackpotPool::query()->updateOrCreate( ['currency_code' => 'NPR'], [ 'current_amount' => 1_234_000, 'contribution_rate' => '0.0100', 'trigger_threshold' => 0, 'payout_rate' => '0.5000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => null, ], ); $this->getJson('/api/v1/jackpot/summary?currency_code=NPR') ->assertOk() ->assertJsonPath('data.enabled', true) ->assertJsonPath('data.current_amount_minor', 1_234_000); }); test('ticket items index returns placed ticket for player', function (): void { $player = ticketItemsPlayer(); $draw = Draw::query()->create([ 'draw_no' => '20260511-777', 'business_date' => '2026-05-11', 'sequence_no' => 777, '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-777', 'currency_code' => 'NPR', 'client_trace_id' => 'items-trace-1', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.draw_no', '20260511-777') ->assertJsonPath('data.items.0.play_code', 'big'); $ticketNo = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items') ->json('data.items.0.ticket_no'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) ->assertOk() ->assertJsonPath('data.ticket_no', $ticketNo) ->assertJsonPath('data.combinations.0.number_4d', '1234'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777')) ->assertOk() ->assertJsonPath('data.total', 1); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-000')) ->assertOk() ->assertJsonPath('data.total', 0); }); test('ticket items index filters by status number and date range', function (): void { $player = ticketItemsPlayer(); $draw1 = Draw::query()->create([ 'draw_no' => '20260511-779', 'business_date' => '2026-05-11', 'sequence_no' => 779, '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, ]); $draw2 = Draw::query()->create([ 'draw_no' => '20260512-780', 'business_date' => '2026-05-12', 'sequence_no' => 780, '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' => $draw1->draw_no, 'currency_code' => 'NPR', 'client_trace_id' => 'items-filter-1', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $draw2->draw_no, 'currency_code' => 'NPR', 'client_trace_id' => 'items-filter-2', 'lines' => [ ['number' => '4321', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); TicketOrder::query()->where('draw_id', $draw1->id)->update([ 'created_at' => '2026-05-01 10:00:00', 'updated_at' => '2026-05-01 10:00:00', ]); TicketOrder::query()->where('draw_id', $draw2->id)->update([ 'created_at' => '2026-05-10 10:00:00', 'updated_at' => '2026-05-10 10:00:00', ]); ticketItemsPublishAndSettle($draw2, '4321'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?status[]=settled_win') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.draw_no', '20260512-780'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?status=settled_win') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.draw_no', '20260512-780'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?number=1234') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.original_number', '1234'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?start_date=2026-05-09&end_date=2026-05-11') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.draw_no', '20260512-780'); }); test('ticket item show returns match result and timeline', function (): void { $player = ticketItemsPlayer(); $draw = Draw::query()->create([ 'draw_no' => '20260513-781', 'business_date' => '2026-05-13', 'sequence_no' => 781, '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' => $draw->draw_no, 'currency_code' => 'NPR', 'client_trace_id' => 'items-detail-1', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); ticketItemsPublishAndSettle($draw, '1234'); $ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) ->assertOk() ->assertJsonPath('data.match_result.matched', true) ->assertJsonPath('data.match_result.matched_prize_tier', 'first') ->assertJsonPath('data.timeline.0.code', 'placed') ->assertJsonPath('data.timeline.1.code', 'deducted') ->assertJsonPath('data.timeline.2.code', 'draw_published') ->assertJsonPath('data.timeline.3.code', 'settlement_started') ->assertJsonPath('data.timeline.4.code', 'settled'); }); test('my-match returns hit numbers when draw settled with winning ticket', function (): void { $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'match-p-'.$uniq, 'username' => 'tm_'.$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-778', 'business_date' => '2026-05-11', 'sequence_no' => 778, '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-778', 'currency_code' => 'NPR', 'client_trace_id' => 'match-trace-1', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); ticketItemsPublishAndSettle($draw, '1234'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/draws/20260511-778/my-match') ->assertOk() ->assertJsonPath('data.has_bets', true) ->assertJsonPath('data.winning_ticket_count', 1) ->assertJsonPath('data.hit_numbers_4d', ['1234']); }); test('my-match only highlights settled winning tickets', function (): void { $player = ticketItemsPlayer(); $draw = Draw::query()->create([ 'draw_no' => '20260514-779', 'business_date' => '2026-05-14', 'sequence_no' => 779, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->subMinutes(20), 'close_time' => now()->subMinutes(10), 'draw_time' => now()->subMinutes(5), 'cooling_end_time' => now()->addMinutes(5), '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' => 'pending-match', 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (DrawPrizeLayout::slots() as $slot) { DrawResultItem::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $batch->id, 'prize_type' => $slot['prize_type'], 'prize_index' => $slot['prize_index'], 'number_4d' => '1234', 'suffix_3d' => '234', 'suffix_2d' => '34', 'head_digit' => 1, 'tail_digit' => 4, ]); } $order = TicketOrder::query()->create([ 'order_no' => 'ORD-PENDING-MATCH', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 10_000, 'total_rebate_amount' => 0, 'total_actual_deduct' => 10_000, 'total_estimated_payout' => 20_000, 'status' => 'placed', 'submit_source' => 'h5', 'client_trace_id' => 'pending-match', ]); $item = \App\Models\TicketItem::query()->create([ 'ticket_no' => 'TKPENDINGMATCH', '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' => 'single', 'unit_bet_amount' => 10_000, 'total_bet_amount' => 10_000, 'rebate_rate_snapshot' => '0.0000', 'commission_rate_snapshot' => '0.0000', 'actual_deduct_amount' => 10_000, 'odds_snapshot_json' => [], 'rule_snapshot_json' => [], 'combination_count' => 1, 'estimated_max_payout' => 20_000, 'risk_locked_amount' => 20_000, 'status' => 'pending_draw', 'win_amount' => 0, 'jackpot_win_amount' => 0, ]); \App\Models\TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 0, 'number_4d' => '1234', 'bet_amount' => 10_000, 'estimated_payout' => 20_000, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/draws/20260514-779/my-match') ->assertOk() ->assertJsonPath('data.has_bets', true) ->assertJsonPath('data.winning_ticket_count', 0) ->assertJsonPath('data.hit_numbers_4d', []); });