Files
lotteryLaravel/tests/Feature/TicketBettingApiTest.php
kang e27a00f260 feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。
- 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。
- 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
2026-05-25 14:34:24 +08:00

1046 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\RiskPool;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\JackpotPool;
use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function ticketPlayerWithWallet(int $balance = 200_000): Player
{
$uniq = bin2hex(random_bytes(5));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ticket-player-'.$uniq,
'username' => 'tp_'.$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;
}
function ticketOpenDraw(string $drawNo = '20260511-001'): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => 1,
'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,
]);
}
function ticketPreviewPayload(string $drawNo = '20260511-001'): array
{
return [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'trace-001',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
[
'number' => '1234',
'play_code' => 'ibox',
'amount' => 100,
],
],
];
}
test('ticket preview returns computed summary for open draw', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400)
->assertJsonPath('data.config_versions.play_config_version_no', 1)
->assertJsonPath('data.config_versions.odds_version_no', 1)
->assertJsonPath('data.config_versions.risk_cap_version_no', 1)
->assertJsonCount(2, 'data.lines');
});
test('module 6 box family expands combinations and computes amount semantics', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-box',
'lines' => [
['number' => '1234', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1123', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1122', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1112', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1111', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
],
];
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $payload)
->assertOk();
$lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
expect($lines[1]['combination_count'])->toBe(24)
->and($lines[2]['combination_count'])->toBe(12)
->and($lines[3]['combination_count'])->toBe(6)
->and($lines[4]['combination_count'])->toBe(4)
->and($lines[5]['combination_count'])->toBe(1)
->and($lines[6]['combination_count'])->toBe(6)
->and($lines[6]['total_bet_amount'])->toBe(600)
->and($lines[7]['combination_count'])->toBe(24)
->and($lines[7]['total_bet_amount'])->toBe(9_984)
->and($lines[7]['actual_deduct_amount'])->toBe(9_984)
->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
});
test('module 6 mbox remainder splits amount evenly across preview and place', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$cases = [
[
'number' => '1234',
'amount' => 10_001,
'combination_count' => 24,
'unit_bet_amount' => 416,
'total_bet_amount' => 9984,
'rounding_refund_amount' => 17,
],
[
'number' => '1122',
'amount' => 601,
'combination_count' => 6,
'unit_bet_amount' => 100,
'total_bet_amount' => 600,
'rounding_refund_amount' => 1,
],
];
foreach ($cases as $index => $case) {
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-remainder-'.$index,
'lines' => [
['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']],
],
])
->assertOk();
$line = $resp->json('data.lines.0');
expect($line['combination_count'])->toBe($case['combination_count'])
->and($line['total_bet_amount'])->toBe($case['total_bet_amount'])
->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount'])
->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount'])
->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']);
}
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-place-remainder',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
],
])
->assertOk()
->assertJsonPath('data.summary.total_bet_amount', 9984)
->assertJsonPath('data.summary.total_actual_deduct', 9984);
$item = TicketItem::query()->where('play_code', 'mbox')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->unit_bet_amount)->toBe(416)
->and((int) $item->total_bet_amount)->toBe(9984)
->and((int) $item->actual_deduct_amount)->toBe(9984)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17);
$comboAmounts = TicketCombination::query()
->where('ticket_item_id', $item->id)
->pluck('bet_amount')
->map(fn ($amount) => (int) $amount)
->unique()
->values()
->all();
expect($comboAmounts)->toBe([416]);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 9984);
});
test('module 6 roll expands each R position and charges per expanded combination', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-roll',
'lines' => [
['number' => 'R234', 'play_code' => 'roll', 'amount' => 100],
['number' => 'RR34', 'play_code' => 'roll', 'amount' => 100],
],
])
->assertOk();
$lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
expect($lines[1]['combination_count'])->toBe(10)
->and($lines[1]['total_bet_amount'])->toBe(1_000)
->and($lines[2]['combination_count'])->toBe(100)
->and($lines[2]['total_bet_amount'])->toBe(10_000);
});
test('module 6 reserved and phase two plays are not available for betting or public entry', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-half-box',
'lines' => [
['number' => '1234', 'play_code' => 'half_box', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-5d',
'lines' => [
['number' => '12345', 'play_code' => '5d', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'half_box')['config']['is_enabled'])->toBeFalse()
->and($plays->contains('play_code', '5d'))->toBeFalse()
->and($plays->contains('play_code', '6d'))->toBeFalse();
});
test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketItem::query()->count())->toBe(2);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
expect(RiskPool::query()->count())->toBeGreaterThan(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('ticket place is idempotent by player draw and client trace id', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => 'same-submit-once']);
$first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->json('data');
$second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->json('data');
expect($second['order_no'])->toBe($first['order_no'])
->and(TicketOrder::query()->count())->toBe(1)
->and(TicketItem::query()->count())->toBe(2)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'combo-payout-sum',
'lines' => [
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
],
])
->assertOk()
->json('data');
$item = TicketItem::query()->firstOrFail();
$combinationSum = TicketCombination::query()
->where('ticket_item_id', $item->id)
->sum('estimated_payout');
expect((int) $response['summary']['total_estimated_payout'])->toBe((int) $combinationSum)
->and((int) $item->estimated_max_payout)->toBe((int) $combinationSum)
->and((int) $item->risk_locked_amount)->toBe((int) $combinationSum);
});
test('ticket place rejects closed draw', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['status' => DrawStatus::Closed->value]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket preview and place reject open draw after server close time', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['close_time' => now()->subSecond()]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects insufficient balance', function (): void {
$player = ticketPlayerWithWallet(1_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket place succeeds when expected_config_versions matches preview', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect(TicketOrder::query()->count())->toBe(1);
$order = TicketOrder::query()->firstOrFail();
expect((int) $order->play_config_version_no)->toBe((int) $versions['play_config_version_no'])
->and((int) $order->odds_version_no)->toBe((int) $versions['odds_version_no'])
->and((int) $order->risk_cap_version_no)->toBe((int) $versions['risk_cap_version_no']);
});
test('ticket place rejects stale expected_config_versions', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]);
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetConfigStale->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects sold out risk pool', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-002',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place can return mixed success and failed risk results', function (): void {
$player = ticketPlayerWithWallet(500_000);
$draw = ticketOpenDraw();
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,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '5678',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-mixed-risk',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '5678', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1)
->assertJsonPath('data.items.0.status', 'pending_draw')
->assertJsonPath('data.items.1.status', 'failed')
->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value);
$order = TicketOrder::query()->firstOrFail();
expect($order->status)->toBe('partial_failed')
->and((int) $order->total_actual_deduct)->toBe(120)
->and(TicketItem::query()->where('status', 'pending_draw')->count())->toBe(1)
->and(TicketItem::query()->where('status', 'failed')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 120);
});
test('ticket place rejects disabled play from active catalog', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versionId = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($versionId)->not->toBeNull();
PlayConfigItem::query()
->where('version_id', $versionId)
->where('play_code', 'big')
->update(['is_enabled' => false]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-disabled-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview returns cleanup hint when draft contains closed play', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versionId = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($versionId)->not->toBeNull();
PlayConfigItem::query()
->where('version_id', $versionId)
->where('play_code', 'big')
->update(['is_enabled' => false]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-closed-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
});
test('ticket place rejects bet amount below configured minimum', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-min-bet',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 50],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletAmountExceedsLimit->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 4000,
'locked_amount' => 0,
'remaining_amount' => 4000,
'sold_out_status' => 0,
'version' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-risk-warning',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 160],
],
])
->assertOk()
->assertJsonPath('data.lines.0.risk_status', 'ok')
->assertJsonPath('data.warnings.0.number_4d', '1234');
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000)
->and((int) $pool->locked_amount)->toBe(0)
->and((int) $pool->remaining_amount)->toBe(4000)
->and(TicketOrder::query()->count())->toBe(0)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket preview validates digit size dimension and slot rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$base = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-validation',
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'client_trace_id' => 'trace-digit-invalid-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place persists valid 2d digit size slot snapshot', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-d2-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3],
],
])
->assertOk()
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.items.0.status', 'pending_draw');
$item = TicketItem::query()->where('play_code', 'digit_big')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->dimension)->toBe(2)
->and((int) $item->digit_slot)->toBe(3)
->and((int) $item->combination_count)->toBe(5000)
->and($ruleSnapshot['dimension'] ?? null)->toBe('D2')
->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3)
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue()
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse();
});
test('ticket preview rejects invalid line amount per validation rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-invalid-amt',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 0],
],
])
->assertStatus(422);
});
/**
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。
* big + amount 120 → estimated_payout 3000maxOdds 250000 / 10000 = 25
*/
test('ticket place records partial failed when mid-order acquire fails', function (): void {
$player = ticketPlayerWithWallet(500_000);
$draw = ticketOpenDraw();
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,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-rollback',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1);
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);
});
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
test('ticket place sold out for second player after first consumes shared pool', function (): void {
$draw = ticketOpenDraw();
$playerA = ticketPlayerWithWallet();
$playerB = ticketPlayerWithWallet();
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,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-race-a',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-a']))
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
$this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-b']))
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
expect(TicketOrder::query()->count())->toBe(1);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000);
});
test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void {
$draw = ticketOpenDraw();
$player = ticketPlayerWithWallet();
$order = TicketOrder::query()->create([
'order_no' => 'TO-PENDING-001',
'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' => 'pending_confirm',
'submit_source' => 'h5',
'client_trace_id' => 'pending-confirm-missing-wallet',
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-PENDING-001',
'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_confirm',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
'created_at' => now()->subMinutes(20),
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 3000,
'remaining_amount' => 2000,
'sold_out_status' => 0,
'version' => 1,
]);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->expectsOutputToContain('refunded: 1')
->assertExitCode(0);
expect($order->fresh()->status)->toBe('refunded')
->and($item->fresh()->status)->toBe('refunded')
->and($item->fresh()->fail_reason_text)->toBe('pending_confirm_timeout_refund');
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
expect((int) $pool->locked_amount)->toBe(0)
->and((int) $pool->remaining_amount)->toBe(5000)
->and(WalletTxn::query()->where('biz_no', 'TO-PENDING-001')->count())->toBe(0);
});
test('ticket pending confirmation reconcile confirms order when wallet deduction exists', function (): void {
$draw = ticketOpenDraw();
$player = ticketPlayerWithWallet(10_000);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$order = TicketOrder::query()->create([
'order_no' => 'TO-PENDING-002',
'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' => 'pending_confirm',
'submit_source' => 'h5',
'client_trace_id' => 'pending-confirm-with-wallet',
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-PENDING-002',
'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_confirm',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
'created_at' => now()->subMinutes(20),
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 3000,
'remaining_amount' => 2000,
'sold_out_status' => 0,
'version' => 1,
]);
WalletTxn::query()->create([
'txn_no' => 'WL-PENDING-002',
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'bet_deduct',
'biz_no' => 'TO-PENDING-002',
'direction' => 2,
'amount' => 100,
'balance_before' => 10_000,
'balance_after' => 9_900,
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => 'pending-confirm-with-wallet',
'remark' => null,
]);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->expectsOutputToContain('confirmed: 1')
->assertExitCode(0);
expect($order->fresh()->status)->toBe('placed')
->and($item->fresh()->status)->toBe('pending_draw')
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000);
});
test('ticket place reverses wallet and releases risk when post deduction confirmation fails', function (): void {
$player = ticketPlayerWithWallet(20_000);
$draw = ticketOpenDraw();
JackpotPool::query()->updateOrCreate(
['currency_code' => 'NPR'],
[
'current_amount' => 0,
'contribution_rate' => 1,
'trigger_threshold' => 0,
'payout_rate' => 0,
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
],
);
DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'wallet-reverse-on-confirm-fail',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
])
->assertStatus(500);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$order = TicketOrder::query()->where('client_trace_id', 'wallet-reverse-on-confirm-fail')->firstOrFail();
expect((int) $wallet->balance)->toBe(20_000)
->and($order->status)->toBe('refunded')
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_deduct')->count())->toBe(1)
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});