seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function p145_player(int $balance = 5_000_000): Player { $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'p145-'.$uniq, 'username' => 'p145_'.$uniq, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => $balance, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); return $player; } /** 路由 `draw_no` 约束为 `YYYYMMDD-NNN`(序数三位)。 */ function p145_next_draw_no(): string { static $i = 0; $i++; return sprintf('20260511-%03d', 400 + ($i % 500)); } function p145_draw(string $drawNo, int $sequenceNo): Draw { return Draw::query()->create([ 'draw_no' => $drawNo, 'business_date' => '2026-05-11', 'sequence_no' => $sequenceNo, '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, ]); } /** * @param Closure(string $prizeType, int $prizeIndex): string $numberFor */ function p145_publish_board(Draw $draw, Closure $numberFor): void { $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => 'p145', 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (DrawPrizeLayout::slots() as $slot) { $num = $numberFor($slot['prize_type'], (int) $slot['prize_index']); 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), ]); } } /** 23 格不含 8888,用于 Big 未中奖。 */ function p145_board_without_8888(string $prizeType, int $prizeIndex): string { return match ($prizeType) { 'first' => '1111', 'second' => '2222', 'third' => '3333', 'starter' => sprintf('41%02d', $prizeIndex), 'consolation' => sprintf('52%02d', $prizeIndex), }; } function p145_approve_and_payout(Draw $draw): void { $batch = SettlementBatch::query()->where('draw_id', $draw->id)->latest('id')->firstOrFail(); $admin = AdminUser::query()->create([ 'username' => 'p145_settle_'.bin2hex(random_bytes(3)), 'name' => 'P145 Settlement', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $workflow = app(SettlementBatchWorkflowService::class); $workflow->approve($batch, $admin); $workflow->payout($batch->fresh()); } test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void { $player = p145_player(); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-lose-1', 'lines' => [ ['number' => '8888', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $deduct = (int) $item->actual_deduct_amount; p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_lose') ->and((int) $item->win_amount)->toBe(0) ->and((int) $item->jackpot_win_amount)->toBe(0); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(5_000_000 - $deduct); expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0); expect(TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->count())->toBe(1); $ticketNo = $item->ticket_no; $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) ->assertOk() ->assertJsonPath('data.status', 'settled_lose') ->assertJsonPath('data.win_amount', 0); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/draws/'.$drawNo.'/my-match') ->assertOk() ->assertJsonPath('data.has_bets', true) ->assertJsonPath('data.hit_numbers_4d', []) ->assertJsonPath('data.total_win_minor', 0); }); test('§14.5 small hits second tier only', function (): void { $player = p145_player(); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-small-1', 'lines' => [ ['number' => '8888', 'play_code' => 'small', 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $deduct = (int) $item->actual_deduct_amount; $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000); p145_publish_board($draw, function (string $t, int $i): string { return match ($t) { 'first' => '1001', 'second' => '8888', 'third' => '2002', 'starter' => sprintf('30%02d', $i), 'consolation' => sprintf('40%02d', $i), }; }); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win') ->and((int) $item->win_amount)->toBe($expectedWin); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin); expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); }); test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', function (): void { $cases = [ [ 'play' => 'pos_4b', 'number' => '7777', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1111', 'second' => '7777', 'third' => '3333', 'starter' => sprintf('51%02d', $i), 'consolation' => sprintf('62%02d', $i), }, 'scope' => 'second', ], [ 'play' => 'pos_3a', 'number' => '234', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1234', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', ], [ 'play' => 'pos_2a', 'number' => '34', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1234', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', ], [ 'play' => 'pos_4e', 'number' => '7777', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1111', 'second' => '2222', 'third' => '3333', 'starter' => sprintf('51%02d', $i), 'consolation' => $i === 4 ? '7777' : sprintf('62%02d', $i), }, 'scope' => 'consolation', ], ]; foreach ($cases as $case) { $player = p145_player(); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-'.$case['play'].'-'.uniqid('', true), 'lines' => [ ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $deduct = (int) $item->actual_deduct_amount; $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; $perComboWin = (int) floor(10_000 * $odds / 10_000); $expectedWin = $perComboWin; p145_publish_board($draw, $case['board']); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win', $case['play']) ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin, $case['play']); } }); test('module 6 suffix plays settle once per ticket item instead of once per expanded prefix', function (): void { $cases = [ [ 'play' => 'pos_3a', 'number' => '234', 'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i), 'scope' => 'first', ], [ 'play' => 'pos_2a', 'number' => '34', 'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i), 'scope' => 'first', ], [ 'play' => 'pos_3abc', 'number' => '567', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '4567', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', ], [ 'play' => 'pos_2abc', 'number' => '99', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '8899', 'second' => '2299', 'third' => '1199', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', ], ]; foreach ($cases as $case) { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'module6-suffix-'.$case['play'].'-'.uniqid('', true), 'lines' => [ ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->combination_count)->toBeIn([10, 100]); $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']] / 10_000); p145_publish_board($draw, $case['board']); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win', $case['play']) ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); } }); test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void { $cases = [ [ 'play' => 'pos_3abc', 'number' => '234', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1234', 'second' => '5234', 'third' => '9234', default => p145_board_without_8888($t, $i), }, 'expected_tier' => 'first', ], [ 'play' => 'pos_3abc', 'number' => '234', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1567', 'second' => '5234', 'third' => '8234', default => p145_board_without_8888($t, $i), }, 'expected_tier' => 'second', ], [ 'play' => 'pos_2abc', 'number' => '99', 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '8899', 'second' => '2299', 'third' => '1199', default => p145_board_without_8888($t, $i), }, 'expected_tier' => 'first', ], ]; foreach ($cases as $case) { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true), 'lines' => [ ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000); p145_publish_board($draw, $case['board']); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail(); expect($item->status)->toBe('settled_win', $case['play']) ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']) ->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']); } }); test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'module6-ibox-multi-tier', 'lines' => [ ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $deduct = (int) $item->actual_deduct_amount; expect($deduct)->toBe(600) ->and((int) $item->combination_count)->toBe(6); $unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); $unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000); $expectedWin = $unitWinFirst + $unitWinStarter; p145_publish_board($draw, function (string $t, int $i): string { return match ($t) { 'first' => '1212', 'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i), default => p145_board_without_8888($t, $i), }; }); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail(); $matchLines = is_array($detail->match_detail_json) ? ($detail->match_detail_json['lines'] ?? []) : []; expect($item->status)->toBe('settled_win') ->and((int) $item->win_amount)->toBe($expectedWin) ->and($detail->matched_prize_tier)->toBe('first') ->and(count($matchLines))->toBe(2); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin); }); test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $rawAmount = 10_001; $comboCount = 24; $unitBet = intdiv($rawAmount, $comboCount); $expectedDeduct = $unitBet * $comboCount; $expectedRemainder = $rawAmount - $expectedDeduct; $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'module6-mbox-remainder-win', 'lines' => [ ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : []; expect((int) $item->combination_count)->toBe($comboCount) ->and((int) $item->unit_bet_amount)->toBe($unitBet) ->and((int) $item->total_bet_amount)->toBe($expectedDeduct) ->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct) ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder); $expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win') ->and((int) $item->win_amount)->toBe($expectedWin); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin); }); test('module 6 mbox remainder is not refunded on losing settlement', function (): void { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $rawAmount = 10_001; $expectedDeduct = intdiv($rawAmount, 24) * 24; $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'module6-mbox-remainder-lose', 'lines' => [ ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct); p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_lose') ->and((int) $item->win_amount)->toBe(0); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct); expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0); }); test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void { JackpotPool::query()->updateOrCreate( ['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, ], ); $player = p145_player(); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-jp-keep', 'lines' => [ ['number' => '8888', 'play_code' => 'small', '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); p145_publish_board($draw, function (string $t, int $i): string { return match ($t) { 'first' => '1001', 'second' => '8888', 'third' => '2002', 'starter' => sprintf('30%02d', $i), 'consolation' => sprintf('40%02d', $i), }; }); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); expect(JackpotPayoutLog::query()->count())->toBe(0); $poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); expect((int) $poolAfter->current_amount)->toBe(1_000); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->jackpot_win_amount)->toBe(0); }); test('§14.5 placement partial failure only deducts successful lines when mid-order risk acquire fails', function (): void { $player = p145_player(500_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 0, 'remaining_amount' => 5000, 'sold_out_status' => 0, 'version' => 0, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-rollback', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ], ]) ->assertOk() ->assertJsonPath('data.summary.success_count', 1) ->assertJsonPath('data.summary.failure_count', 1) ->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value); expect(TicketOrder::query()->count())->toBe(1); expect(TicketOrder::query()->firstOrFail()->status)->toBe('partial_failed'); expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(500_000 - 120); $pool = RiskPool::query() ->where('draw_id', $draw->id) ->where('normalized_number', '1234') ->firstOrFail(); expect((int) $pool->remaining_amount)->toBe(2000); expect((int) $pool->locked_amount)->toBe(3000); }); test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-odds-snapshot-1', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : []) ->firstWhere('prize_scope', 'first'); expect($snapshotOdds)->not->toBeNull(); // 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。 OddsItem::query() ->where('play_code', 'big') ->where('prize_scope', 'first') ->where('currency_code', 'NPR') ->update(['odds_value' => 10_000]); p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); $expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000)); expect($item->status)->toBe('settled_win') ->and((int) $item->win_amount)->toBe($expectedWinBySnapshot); }); test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); // 下注一单,确保产生风险池占用。 $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-risk-release-1', 'lines' => [ ['number' => '8888', 'play_code' => 'big', 'amount' => 120], ], ]) ->assertOk(); $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail(); $cap = (int) $pool->total_cap_amount; expect((int) $pool->locked_amount)->toBeGreaterThan(0) ->and((int) $pool->remaining_amount)->toBeLessThan($cap); // 先走未中奖结算,验证释放。 p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); p145_approve_and_payout($draw); $poolAfterLose = $pool->fresh(); expect((int) $poolAfterLose->locked_amount)->toBe(0) ->and((int) $poolAfterLose->remaining_amount)->toBe($cap); // 再开一盘中奖结算,验证同样释放。 $drawNo2 = p145_next_draw_no(); $draw2 = p145_draw($drawNo2, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo2, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-risk-release-2', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ], ]) ->assertOk(); $pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail(); $cap2 = (int) $pool2->total_cap_amount; expect((int) $pool2->locked_amount)->toBeGreaterThan(0); p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); $draw2->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue(); p145_approve_and_payout($draw2); $poolAfterWin = $pool2->fresh(); expect((int) $poolAfterWin->locked_amount)->toBe(0) ->and((int) $poolAfterWin->remaining_amount)->toBe($cap2); }); /** * 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。 * `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。 */ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants settle win', function (): void { $cases = [ [ 'play' => 'straight', 'line' => ['number' => '8881', 'play_code' => 'straight', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '8881' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'roll', 'line' => ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100], 'board' => fn (string $t, int $i): string => $t === 'first' ? '5234' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'box', 'line' => ['number' => '1357', 'play_code' => 'box', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '7135' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'ibox', 'line' => ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], 'board' => fn (string $t, int $i): string => $t === 'first' ? '1212' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'mbox', 'line' => ['number' => '2468', 'play_code' => 'mbox', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '8642' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'head', 'line' => ['number' => '6', 'play_code' => 'head', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '6781' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'tail', 'line' => ['number' => '2', 'play_code' => 'tail', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '2342' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'odd', 'line' => ['number' => '1', 'play_code' => 'odd', 'amount' => 10_000, 'dimension' => 'D4'], 'first_combo_board' => true, 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'even', 'line' => ['number' => '0', 'play_code' => 'even', 'amount' => 10_000, 'dimension' => 'D4'], 'first_combo_board' => true, 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'digit_big', 'line' => ['number' => '9', 'play_code' => 'digit_big', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 2], 'board' => fn (string $t, int $i): string => $t === 'first' ? '1299' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'digit_small', 'line' => ['number' => '1', 'play_code' => 'digit_small', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 1], 'board' => fn (string $t, int $i): string => $t === 'first' ? '3142' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'pos_4a', 'line' => ['number' => '6006', 'play_code' => 'pos_4a', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => $t === 'first' ? '6006' : p145_board_without_8888($t, $i), 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'pos_4c', 'line' => ['number' => '4004', 'play_code' => 'pos_4c', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'third' => '4004', default => p145_board_without_8888($t, $i), }, 'scope' => 'third', 'comboMultiplier' => 1, ], [ 'play' => 'pos_4d', 'line' => ['number' => '5555', 'play_code' => 'pos_4d', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'starter' => $i === 3 ? '5555' : sprintf('71%02d', $i), default => p145_board_without_8888($t, $i), }, 'scope' => 'starter', 'comboMultiplier' => 1, ], [ 'play' => 'pos_3b', 'line' => ['number' => '949', 'play_code' => 'pos_3b', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'second' => '2949', default => p145_board_without_8888($t, $i), }, 'scope' => 'second', 'comboMultiplier' => 1, ], [ 'play' => 'pos_3c', 'line' => ['number' => '678', 'play_code' => 'pos_3c', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'third' => '9678', default => p145_board_without_8888($t, $i), }, 'scope' => 'third', 'comboMultiplier' => 1, ], [ 'play' => 'pos_3abc', 'line' => ['number' => '567', 'play_code' => 'pos_3abc', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '4567', 'second' => '8123', 'third' => '9234', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', 'comboMultiplier' => 1, ], [ 'play' => 'pos_2b', 'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '1111', 'second' => '7856', default => p145_board_without_8888($t, $i), }, 'scope' => 'second', 'comboMultiplier' => 1, ], [ 'play' => 'pos_2c', 'line' => ['number' => '30', 'play_code' => 'pos_2c', 'amount' => 10_000], 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '9999', 'second' => '8888', 'third' => '7830', default => p145_board_without_8888($t, $i), }, 'scope' => 'third', 'comboMultiplier' => 1, ], [ 'play' => 'pos_2abc', 'line' => ['number' => '99', 'play_code' => 'pos_2abc', 'amount' => 100], 'board' => fn (string $t, int $i): string => match ($t) { 'first' => '8899', 'second' => '2299', 'third' => '1199', default => p145_board_without_8888($t, $i), }, 'scope' => 'first', 'comboMultiplier' => 1, ], ]; foreach ($cases as $case) { $player = p145_player(80_000_000); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-all-'.$case['play'].'-'.uniqid('', true), 'lines' => [$case['line']], ]) ->assertOk(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $deduct = (int) $item->actual_deduct_amount; $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; $unitOnTicket = (int) $item->unit_bet_amount; $perComboWin = (int) floor($unitOnTicket * $odds / 10_000); $expectedWin = $perComboWin * (int) $case['comboMultiplier']; $board = $case['board'] ?? null; if ($case['first_combo_board'] ?? false) { $target = (string) TicketCombination::query() ->where('ticket_item_id', $item->id) ->orderBy('combination_no') ->value('number_4d'); $board = fn (string $t, int $i): string => $t === 'first' ? $target : p145_board_without_8888($t, $i); } expect($board)->toBeInstanceOf(Closure::class, $case['play']); p145_publish_board($draw, $board); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win', $case['play']) ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin, $case['play']); } }); test('§14.6 ticket detail shows settlement tier after win', function (): void { $player = p145_player(); $drawNo = p145_next_draw_no(); $draw = p145_draw($drawNo, random_int(1, 99_999)); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'p145-detail', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertOk(); $ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); expect($ticketNo)->not->toBeEmpty(); p145_publish_board($draw, function (string $t, int $i): string { $num = $t === 'first' ? '1234' : '5678'; return $num; }); $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); p145_approve_and_payout($draw); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) ->assertOk() ->assertJsonPath('data.status', 'settled_win') ->assertJsonPath('data.settlement.matched_prize_tier', 'first'); });