feat(draw): 支持多玩法provider及本地化结算时区功能
- Draw相关控制器添加provider_code和provider_name字段支持 - 允许手动录入开奖批次时指定provider_code - RNG开奖批次生成支持多provider,分别生成对应批次数据 - DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程 - DrawResultViewService调整,支持按provider汇总及筛选开奖结果 - Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算 - 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理 - 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持 - 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账 - Ticket明细返回接口添加结算时区信息,提升用户时间体验 - 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
This commit is contained in:
@@ -397,6 +397,54 @@ test('period close succeeds with no share ledger rows in window', function (): v
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('period close fails when tickets in the window are still unsettled', function (): void {
|
||||
['site_id' => $siteId, 'site_code' => $siteCode, 'root_id' => $rootId] = createSiteWithRoot('unsettled-close');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'p-unsettled-close',
|
||||
'username' => 'unsettled_close',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'funding_mode' => 'credit',
|
||||
'auth_source' => 'lottery_native',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$ticketItemId = createTicketItemForPlayer($player, 'UNSETTLED-CLOSE');
|
||||
DB::table('ticket_items')->where('id', $ticketItemId)->update([
|
||||
'status' => 'pending_draw',
|
||||
'settled_at' => null,
|
||||
'agent_settled_at' => null,
|
||||
'created_at' => '2026-06-03 12:00:00',
|
||||
'updated_at' => '2026-06-03 12:00:00',
|
||||
]);
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => '2026-06-01 00:00:00',
|
||||
'period_end' => '2026-06-07 23:59:59',
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$caught = null;
|
||||
try {
|
||||
app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$caught = $e;
|
||||
}
|
||||
|
||||
expect($caught)->toBeInstanceOf(\Illuminate\Validation\ValidationException::class);
|
||||
expect($caught?->errors()['period'][0] ?? null)->toBe('period_has_unsettled_tickets');
|
||||
expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status'))
|
||||
->toBe('open');
|
||||
expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('period close does not dispatch rebates from other sites', function (): void {
|
||||
$siteA = createSiteWithRoot('rebate-site-a');
|
||||
$siteB = createSiteWithRoot('rebate-site-b');
|
||||
|
||||
@@ -190,8 +190,116 @@ test('settlement period open hints does not suggest range overlapping occupied p
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.suggested_start', '')
|
||||
->assertJsonPath('data.suggested_end', '')
|
||||
->assertJsonPath('data.suggested_start', '2026-07-01')
|
||||
->assertJsonPath('data.suggested_end', '2026-07-08')
|
||||
->assertJsonFragment(['2026-06-01'])
|
||||
->assertJsonFragment(['2026-06-30']);
|
||||
});
|
||||
|
||||
test('settlement period open hints uses site timezone for orphan activity dates', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
DB::table('admin_sites')->where('id', $siteId)->update([
|
||||
'settlement_timezone' => 'Asia/Kathmandu',
|
||||
]);
|
||||
|
||||
DB::table('settlement_periods')->insert([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => '2026-07-08 18:15:00',
|
||||
'period_end' => '2026-07-09 18:14:59',
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'hints-orphan-player',
|
||||
'username' => 'hints_orphan_player',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => 'DRAW-HINTS-ORPHAN',
|
||||
'business_date' => '2026-07-09',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-HINTS-ORPHAN',
|
||||
'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' => 0,
|
||||
'status' => 'confirmed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$itemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-HINTS-ORPHAN',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => null,
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 2,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => null,
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => null,
|
||||
'rule_snapshot_json' => null,
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled_lose',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $itemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $rootId,
|
||||
'agent_path' => json_encode([$rootId]),
|
||||
'share_snapshot' => json_encode(['total_shares' => [$siteCode => 100]]),
|
||||
'game_win_loss' => 100,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 100,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => '2026-07-09 18:00:00',
|
||||
'settlement_period_id' => null,
|
||||
'created_at' => '2026-07-09 18:00:00',
|
||||
'updated_at' => '2026-07-09 18:00:00',
|
||||
]);
|
||||
|
||||
$hints = app(\App\Services\AgentSettlement\SettlementPeriodOpenHintsService::class)->hints($siteId);
|
||||
|
||||
expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu')
|
||||
->and($hints['occupied_period_dates'])->toContain('2026-07-09')
|
||||
->and($hints['occupied_period_dates'])->not->toContain('2026-07-08')
|
||||
->and($hints['pending_activity_dates'])->toContain('2026-07-09')
|
||||
->and($hints['orphan_activity_dates'])->toContain('2026-07-09');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\RiskPool;
|
||||
@@ -466,6 +467,60 @@ test('admin can manually trigger rng for closed draw', function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('rng creates independent result batches for every enabled provider', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:22:00', 'UTC'));
|
||||
|
||||
foreach ([['SG', 'Singapore', 10], ['MY', 'Malaysia', 20], ['TH', 'Thailand', 30]] as [$code, $name, $sort]) {
|
||||
BetProvider::query()->updateOrCreate(
|
||||
['code' => $code],
|
||||
['name' => $name, 'is_enabled' => true, 'sort_order' => $sort],
|
||||
);
|
||||
}
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-123',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 123,
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(5),
|
||||
'draw_time' => now()->copy()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_rng_provider_admin',
|
||||
'name' => 'Draw Rng Provider Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
|
||||
->assertOk()
|
||||
->assertJsonCount(3, 'data.batches');
|
||||
|
||||
$batches = DrawResultBatch::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->with('items')
|
||||
->orderBy('provider_code')
|
||||
->get();
|
||||
|
||||
expect($batches->pluck('provider_code')->all())->toBe(['MY', 'SG', 'TH']);
|
||||
expect($batches->pluck('rng_seed_hash')->unique()->count())->toBe(3);
|
||||
expect($batches->map(fn (DrawResultBatch $batch): ?string => $batch->items->firstWhere('prize_type', 'first')?->number_4d)->unique()->count())->toBe(3);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
|
||||
|
||||
@@ -500,6 +555,8 @@ test('draw tick moves open draw to closing when close_time passed before draw_ti
|
||||
test('draw tick rng publishes result when manual review disabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => false]);
|
||||
LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
@@ -527,9 +584,11 @@ test('draw tick rng publishes result when manual review disabled', function ():
|
||||
expect($draw->current_result_version)->toBe(1);
|
||||
expect($draw->cooling_end_time)->not->toBeNull();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($batch->items()->count())->toBe(23);
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $draw->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
expect($batches->pluck('provider_code')->all())->toBe(['MY', 'SG']);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::Published->value))->toBeTrue();
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->items()->count() === 23))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -537,6 +596,8 @@ test('draw tick rng publishes result when manual review disabled', function ():
|
||||
test('draw tick rng awaits manual publish when review enabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
LotterySettings::put('draw.require_manual_review', true, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
@@ -562,8 +623,9 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi
|
||||
$drawRow->refresh();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Review->value);
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value);
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $drawRow->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::PendingReview->value))->toBeTrue();
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_auditor',
|
||||
@@ -575,14 +637,23 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$sgBatch = $batches->firstWhere('provider_code', 'SG');
|
||||
$myBatch = $batches->firstWhere('provider_code', 'MY');
|
||||
expect($sgBatch)->not->toBeNull()
|
||||
->and($myBatch)->not->toBeNull();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish")
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$sgBatch->id}/publish")
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$myBatch->id}/publish")
|
||||
->assertOk();
|
||||
|
||||
$drawRow->refresh();
|
||||
$batch->refresh();
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $drawRow->id)->get();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Cooldown->value);
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::Published->value))->toBeTrue();
|
||||
expect($drawRow->current_result_version)->toBe(1);
|
||||
expect($drawRow->cooling_end_time)->not->toBeNull();
|
||||
|
||||
@@ -644,6 +715,73 @@ test('admin can create manual result batch with 23 numbers for review', function
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can create and publish provider specific manual result batches for one draw', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:22:00', 'UTC'));
|
||||
BetProvider::query()->create(['code' => 'MY', 'name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-222',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 222,
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(2),
|
||||
'draw_time' => now()->copy()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'provider_manual_draw_admin',
|
||||
'name' => 'Provider Manual Draw Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$items = [];
|
||||
foreach (array_values(App\Services\Draw\DrawPrizeLayout::slots()) as $i => $slot) {
|
||||
$items[] = [
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => str_pad((string) ($i + 101), 4, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
$sgBatchId = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['provider_code' => 'SG', 'items' => $items])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.batch.provider_code', 'SG')
|
||||
->json('data.batch.id');
|
||||
|
||||
$myBatchId = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['provider_code' => 'MY', 'items' => $items])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.batch.provider_code', 'MY')
|
||||
->json('data.batch.id');
|
||||
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('status', DrawResultBatchStatus::PendingReview->value)->count())->toBe(2);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$sgBatchId}/publish")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'SG');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$myBatchId}/publish")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'MY');
|
||||
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('status', DrawResultBatchStatus::Published->value)->count())->toBe(2);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can discard pending manual result batch and draw returns to closed', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:25:00', 'UTC'));
|
||||
|
||||
@@ -707,7 +845,11 @@ test('admin can discard pending manual result batch and draw returns to closed',
|
||||
});
|
||||
|
||||
test('admin can reopen cooldown draw for a replacement result batch', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => false]);
|
||||
LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC'));
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-230',
|
||||
@@ -782,7 +924,7 @@ test('admin can reopen cooldown draw for a replacement result batch', function (
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->current_result_version)->toBe(2);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(2);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(3);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -132,3 +133,61 @@ test('draw result show includes neighbor draw numbers', function (): void {
|
||||
->assertJsonPath('data.previous_draw_no', '20260509-100')
|
||||
->assertJsonPath('data.next_draw_no', '20260509-102');
|
||||
});
|
||||
|
||||
test('draw results include all published provider batches for the same draw', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-201',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 201,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(45),
|
||||
'draw_time' => now()->subMinutes(30),
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
foreach ([['SG', 'Singapore', '1111'], ['MY', 'Malaysia', '2222']] as [$providerCode, $providerName, $firstNumber]) {
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerName,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => 'first',
|
||||
'prize_index' => 0,
|
||||
'number_4d' => $firstNumber,
|
||||
'suffix_3d' => substr($firstNumber, -3),
|
||||
'suffix_2d' => substr($firstNumber, -2),
|
||||
'head_digit' => (int) substr($firstNumber, 0, 1),
|
||||
'tail_digit' => (int) substr($firstNumber, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->getJson('/api/v1/draw/results/20260509-201')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'SG')
|
||||
->assertJsonPath('data.results.1st', '1111')
|
||||
->assertJsonCount(2, 'data.provider_results')
|
||||
->assertJsonPath('data.provider_results.0.provider_code', 'SG')
|
||||
->assertJsonPath('data.provider_results.0.results.1st', '1111')
|
||||
->assertJsonPath('data.provider_results.1.provider_code', 'MY')
|
||||
->assertJsonPath('data.provider_results.1.results.1st', '2222');
|
||||
});
|
||||
|
||||
@@ -28,9 +28,11 @@ test('player me returns profile with dev bearer', function () {
|
||||
->assertJsonPath('data.id', $player->id)
|
||||
->assertJsonPath('data.site_player_id', 'uid-42')
|
||||
->assertJsonPath('data.username', 'alice')
|
||||
->assertJsonPath('data.settlement_timezone', 'UTC')
|
||||
->assertJsonPath('data.locale', 'zh')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'settlement_timezone',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -33,6 +34,8 @@ test('rng seed encrypt decrypt roundtrip preserves hex', function (): void {
|
||||
|
||||
test('admin rng run stores encrypted seed and passes batch audit verification', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-rng-audit',
|
||||
@@ -63,7 +66,9 @@ test('admin rng run stores encrypted seed and passes batch audit verification',
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $draw->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
$batch = $batches->firstOrFail();
|
||||
|
||||
expect($batch->source_type)->toBe('rng')
|
||||
->and($batch->rng_seed_hash)->not->toBeEmpty()
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -145,6 +147,147 @@ test('settlement pays big winner and marks ticket settled', function (): void {
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('settlement matches ticket items against their provider result batch', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->create(['code' => 'MY', 'name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'provider-settle-p',
|
||||
'username' => 'provider_settle_p',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-901',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 901,
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'start_time' => now()->subMinutes(10),
|
||||
'close_time' => now()->subMinutes(2),
|
||||
'draw_time' => now()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'provider-settle-order',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 20_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 20_000,
|
||||
'total_estimated_payout' => 200_000,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'test',
|
||||
'client_trace_id' => 'provider-settle-trace',
|
||||
]);
|
||||
|
||||
$snapshot = [['prize_scope' => 'first', 'odds_value' => 100_000]];
|
||||
$sgItem = TicketItem::query()->create([
|
||||
'ticket_no' => 'PROVIDER-SG-1',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'SG',
|
||||
'provider_name' => 'Singapore',
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'pos_4a',
|
||||
'bet_mode' => 'unit',
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => $snapshot,
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 100_000,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'pending_draw',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
$myItem = TicketItem::query()->create([
|
||||
'ticket_no' => 'PROVIDER-MY-1',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'MY',
|
||||
'provider_name' => 'Malaysia',
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'pos_4a',
|
||||
'bet_mode' => 'unit',
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => $snapshot,
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 100_000,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'pending_draw',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
|
||||
foreach ([$sgItem, $myItem] as $item) {
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 10_000,
|
||||
'estimated_payout' => 100_000,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([['SG', '9999'], ['MY', '1234']] as [$providerCode, $firstNumber]) {
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerCode === 'MY' ? 'Malaysia' : 'Singapore',
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
expect($sgItem->fresh()->status)->toBe('settled_lose');
|
||||
expect((int) $sgItem->fresh()->win_amount)->toBe(0);
|
||||
expect($myItem->fresh()->status)->toBe('pending_payout');
|
||||
expect((int) $myItem->fresh()->win_amount)->toBe(100_000);
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('admin settlement requires review before payout and can export report', function (): void {
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
|
||||
Reference in New Issue
Block a user