feat: 扩展奖池、风控与报表能力,新增对账补偿、广播和人工操作接口

This commit is contained in:
2026-05-18 15:09:10 +08:00
parent 9157dcb6a1
commit 6ef41cee76
46 changed files with 1889 additions and 98 deletions

View File

@@ -62,6 +62,68 @@ test('report job create list show and audit log index work for super admin', fun
expect(AuditLog::query()->where('module_code', 'report_jobs')->exists())->toBeTrue();
});
test('report jobs support module 13 report types and downloadable csv with bom', function (): void {
$token = phase15SuperToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'daily_profit_summary',
'export_format' => 'csv',
'parameters' => [
'date_from' => '2026-05-01',
'date_to' => '2026-05-07',
],
]);
$create->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.export_format', 'csv');
$id = (int) $create->json('data.id');
expect($id)->toBeGreaterThan(0);
$row = ReportJob::query()->whereKey($id)->firstOrFail();
expect($row->output_path)->toContain('每日盈亏汇总_2026-05-01_2026-05-07')
->and($row->output_path)->toEndWith('.csv');
$download = $this->withHeader('Authorization', 'Bearer '.$token)
->get('/api/v1/admin/report-jobs/'.$id.'/download');
$download->assertOk()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
$content = $download->streamedContent();
expect(substr($content, 0, 3))->toBe("\xEF\xBB\xBF");
});
test('report jobs support xlsx export filename convention', function (): void {
$token = phase15SuperToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
'export_format' => 'xlsx',
'parameters' => [
'date_from' => '2026-05-01',
'date_to' => '2026-05-31',
],
]);
$create->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.export_format', 'xlsx');
$id = (int) $create->json('data.id');
$row = ReportJob::query()->whereKey($id)->firstOrFail();
expect($row->output_path)->toContain('后台操作审计报表_2026-05-01_2026-05-31')
->and($row->output_path)->toEndWith('.xlsx');
$this->withHeader('Authorization', 'Bearer '.$token)
->get('/api/v1/admin/report-jobs/'.$id.'/download')
->assertOk()
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
});
test('reconcile job create with items and nested items index', function (): void {
$token = phase15SuperToken();

View File

@@ -0,0 +1,67 @@
<?php
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function playerManageAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'player_manage_admin',
'name' => 'Player Manage Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin can freeze and unfreeze player with audit log', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'freeze-1',
'username' => 'freeze_user',
'nickname' => 'Freeze',
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 1_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/players/'.$player->id.'/freeze')
->assertOk()
->assertJsonPath('data.status', 1);
$this->assertDatabaseHas('audit_logs', [
'module_code' => 'player_manage',
'action_code' => 'freeze',
'target_type' => 'player',
'target_id' => (string) $player->id,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/players/'.$player->id.'/unfreeze')
->assertOk()
->assertJsonPath('data.status', 0);
expect(AuditLog::query()->where('module_code', 'player_manage')->count())->toBe(2);
});

View File

@@ -5,7 +5,9 @@ use App\Models\RiskPool;
use App\Models\AdminUser;
use App\Models\RiskPoolLockLog;
use Illuminate\Support\Facades\Hash;
use App\Services\Ticket\RiskPoolService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Exceptions\TicketOperationException;
uses(RefreshDatabase::class);
@@ -75,6 +77,154 @@ test('admin risk pools index returns rows for draw', function (): void {
->assertJsonPath('data.items.0.is_sold_out', true);
});
test('admin risk pools index filters by number and high risk usage', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-004',
'business_date' => '2026-05-12',
'sequence_no' => 4,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1288',
'total_cap_amount' => 1_000,
'locked_amount' => 850,
'remaining_amount' => 150,
'sold_out_status' => 0,
'version' => 1,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '5678',
'total_cap_amount' => 1_000,
'locked_amount' => 100,
'remaining_amount' => 900,
'sold_out_status' => 0,
'version' => 1,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?high_risk_only=1')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.normalized_number', '1288');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?normalized_number=67')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.normalized_number', '5678');
});
test('admin can manually close and recover a risk pool number', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-005',
'business_date' => '2026-05-12',
'sequence_no' => 5,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '2468',
'total_cap_amount' => 1_000,
'locked_amount' => 300,
'remaining_amount' => 700,
'sold_out_status' => 0,
'version' => 1,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/2468/manual-close')
->assertOk()
->assertJsonPath('data.normalized_number', '2468')
->assertJsonPath('data.is_sold_out', true)
->assertJsonPath('data.version', 2);
$this->assertDatabaseHas('risk_pool_lock_logs', [
'draw_id' => $draw->id,
'normalized_number' => '2468',
'action_type' => 'close',
'amount' => 0,
'source_reason' => 'admin_manual_close',
]);
expect(fn () => app(RiskPoolService::class)->preview($draw->id, [['number_4d' => '2468', 'amount' => 1]]))
->toThrow(TicketOperationException::class, 'risk_sold_out');
expect(fn () => app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]]))
->toThrow(TicketOperationException::class, 'risk_sold_out');
});
test('admin can recover a manually closed risk pool number', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-006',
'business_date' => '2026-05-12',
'sequence_no' => 6,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '2468',
'total_cap_amount' => 1_000,
'locked_amount' => 300,
'remaining_amount' => 700,
'sold_out_status' => 1,
'version' => 2,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/2468/recover')
->assertOk()
->assertJsonPath('data.normalized_number', '2468')
->assertJsonPath('data.is_sold_out', false)
->assertJsonPath('data.version', 3);
$this->assertDatabaseHas('risk_pool_lock_logs', [
'draw_id' => $draw->id,
'normalized_number' => '2468',
'action_type' => 'recover',
'amount' => 0,
'source_reason' => 'admin_manual_recover',
]);
expect(app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]]))
->toBe(1);
});
test('admin risk pool lock logs include ticket_no when linked', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-002',

View File

@@ -1,6 +1,8 @@
<?php
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -42,5 +44,53 @@ test('admin jackpot pools index returns rows', function (): void {
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/jackpot/pools')
->assertOk()
->assertJsonPath('data.items.0.currency_code', 'NPR');
->assertJsonPath('data.items.0.currency_code', 'NPR')
->assertJsonPath('data.items.0.combo_trigger_play_codes', []);
});
test('admin can update jackpot combo trigger and manually burst pool', function (): void {
$pool = JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 1000,
'contribution_rate' => '0.01',
'trigger_threshold' => 1000,
'payout_rate' => '0.5',
'force_trigger_draw_gap' => 10,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$draw = Draw::query()->create([
'draw_no' => '20260518-001',
'business_date' => '2026-05-18',
'sequence_no' => 1,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(2),
'close_time' => now()->subHour(),
'draw_time' => now()->subHour(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 1,
'is_reopened' => false,
]);
$token = mintSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
'combo_trigger_play_codes' => ['straight', 'ibox'],
])
->assertOk()
->assertJsonPath('data.combo_trigger_play_codes.0', 'straight')
->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox');
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'draw_id' => $draw->id,
'amount' => 400,
])
->assertOk()
->assertJsonPath('data.burst_amount', 400)
->assertJsonPath('data.current_amount', 600);
});

View File

@@ -12,6 +12,9 @@ use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch;
use Illuminate\Support\Facades\Event;
use App\Events\JackpotBurstBroadcast;
use App\Services\Draw\DrawResultViewService;
use App\Models\JackpotContribution;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\CurrencySeeder;
@@ -33,6 +36,84 @@ beforeEach(function (): void {
$this->seed(LotterySettingsSeeder::class);
});
function jackpotTestPlayer(string $prefix = 'jp'): Player
{
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => $prefix.'-p-'.$uniq,
'username' => $prefix.'_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 5_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function jackpotOpenDraw(string $drawNo): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => (int) substr($drawNo, -3),
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
}
function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
{
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => 'test-'.(string) $draw->draw_no,
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = $slot['prize_type'] === 'first' ? $firstNumber : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $num,
'suffix_3d' => substr($num, -3),
'suffix_2d' => substr($num, -2),
'head_digit' => (int) substr($num, 0, 1),
'tail_digit' => (int) substr($num, 3, 1),
]);
}
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
}
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
JackpotPool::query()->create([
'currency_code' => 'NPR',
@@ -155,3 +236,209 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
$order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail();
expect($order->status)->toBe('settled');
});
test('jackpot contribution respects switch and minimum bet threshold', function (): void {
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 0,
'contribution_rate' => '0.1000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 20_000,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$player = jackpotTestPlayer('jpmin');
jackpotOpenDraw('20260511-902');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-902',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-min-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
expect(JackpotContribution::query()->count())->toBe(0);
JackpotPool::query()->where('currency_code', 'NPR')->update([
'min_bet_amount' => 0,
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-902',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-off-1',
'lines' => [['number' => '2234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
expect(JackpotContribution::query()->count())->toBe(0);
});
test('jackpot bursts by configured play combination trigger before threshold', function (): void {
Event::fake([JackpotBurstBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 50_000,
'contribution_rate' => '0.0000',
'trigger_threshold' => 999_999_999,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
'combo_trigger_play_codes' => ['straight'],
]);
$player = jackpotTestPlayer('jpcombo');
$draw = jackpotOpenDraw('20260511-903');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-903',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-combo-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
jackpotPublishResults($draw, '1234');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->jackpot_win_amount)->toBe(50_000)
->and(JackpotPayoutLog::query()->firstOrFail()->trigger_type)->toBe('play_combo')
->and((int) JackpotPool::query()->where('currency_code', 'NPR')->value('current_amount'))->toBe(0);
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id
&& $event->drawNo === '20260511-903'
&& $event->firstPrizeNumber === '1234'
&& $event->currencyCode === 'NPR'
&& $event->totalPayoutAmount === 50_000
&& $event->winnerCount === 1
&& $event->triggerType === 'play_combo'
&& $event->poolAmountAfter === 0,
);
});
test('jackpot splits burst payout between multiple winners by bet amount', function (): void {
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 90_000,
'contribution_rate' => '0.0000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$playerA = jackpotTestPlayer('jpa');
$playerB = jackpotTestPlayer('jpb');
$draw = jackpotOpenDraw('20260511-904');
$this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-904',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-split-a',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-904',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-split-b',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 20_000]],
])
->assertOk();
jackpotPublishResults($draw, '1234');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$amounts = TicketItem::query()
->where('draw_id', $draw->id)
->orderBy('total_bet_amount')
->pluck('jackpot_win_amount')
->map(fn ($v) => (int) $v)
->all();
expect($amounts)->toBe([30_000, 60_000]);
});
test('jackpot summary and result payload expose pool amount and draw gap', function (): void {
$last = Draw::query()->create([
'draw_no' => '20260511-800',
'business_date' => '2026-05-11',
'sequence_no' => 800,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 1,
'is_reopened' => false,
]);
Draw::query()->create([
'draw_no' => '20260511-801',
'business_date' => '2026-05-11',
'sequence_no' => 801,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(2),
'close_time' => now()->subHour(),
'draw_time' => now()->subHour(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 1,
'is_reopened' => false,
]);
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 123_456,
'contribution_rate' => '0.0100',
'trigger_threshold' => 1_000_000,
'payout_rate' => '0.5000',
'force_trigger_draw_gap' => 10,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => $last->id,
]);
$draw = jackpotOpenDraw('20260511-905');
jackpotPublishResults($draw, '1234');
$draw->forceFill(['status' => DrawStatus::Cooldown->value])->save();
$this->getJson('/api/v1/jackpot/summary?currency_code=NPR')
->assertOk()
->assertJsonPath('data.current_amount_minor', 123_456)
->assertJsonPath('data.draws_since_last_burst', 1);
$this->getJson('/api/v1/draw/results/20260511-905')
->assertOk()
->assertJsonPath('data.jackpot.current_amount_minor', 123_456)
->assertJsonPath('data.jackpot.draws_since_last_burst', 1);
$summary = app(DrawResultViewService::class)->summarizeDraw($draw->fresh());
expect($summary['jackpot']['current_amount_minor'] ?? null)->toBe(123_456);
});

View File

@@ -18,7 +18,11 @@ use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\RiskCapVersion;
use App\Services\Ticket\PlayCatalogResolver;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
@@ -48,7 +52,7 @@ function acceptanceMintAdminToken(): string
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function oddsPutPayloadFromDetail(array $items): array
function acceptanceOddsPutPayloadFromDetail(array $items): array
{
return collect($items)->map(fn (array $r) => [
'play_code' => $r['play_code'],
@@ -104,7 +108,7 @@ test('§12.6 published odds are visible on public effective catalog without code
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = oddsPutPayloadFromDetail($detail);
$payload = acceptanceOddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 333_333;
@@ -132,7 +136,7 @@ test('§5 odds publish archives prior version lists history and writes audit log
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$this->putJson(
'/api/v1/admin/config/odds-versions/'.$draftId.'/items',
['items' => oddsPutPayloadFromDetail($detail)],
['items' => acceptanceOddsPutPayloadFromDetail($detail)],
$auth,
)->assertOk();
@@ -235,7 +239,7 @@ test('§5 existing ticket_items odds snapshot row is not mutated when new odds v
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = oddsPutPayloadFromDetail($detail);
$payload = acceptanceOddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 9_999_999;
@@ -330,6 +334,38 @@ test('§5 risk cap publish is audited and version history exists', function ():
expect(count($list))->toBeGreaterThanOrEqual(2);
});
test('§10 default risk cap template applies to unconfigured numbers', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'default risk cap'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', [
'items' => [
[
'draw_id' => null,
'normalized_number' => '0000',
'cap_amount' => 12_345,
'cap_type' => 'default',
],
[
'draw_id' => null,
'normalized_number' => '1234',
'cap_amount' => 777,
'cap_type' => 'per_number',
],
],
], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();
$resolver = app(PlayCatalogResolver::class);
expect($resolver->resolveCapAmount(9999, '5678'))->toBe(12_345)
->and($resolver->resolveCapAmount(9999, '1234'))->toBe(777);
});
test('§5 play_config publish is audited', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
@@ -367,3 +403,66 @@ test('§5 play_config publish is audited', function (): void {
->exists(),
)->toBeTrue();
});
test('§9 play_config publish broadcasts changed play toggles', function (): void {
Event::fake([PlayToggleBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'toggle broadcast'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$itemPayload = $create->json('data.items');
foreach ($itemPayload as &$row) {
if ($row['play_code'] === 'big') {
$row['is_enabled'] = false;
}
}
unset($row);
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
Event::assertDispatched(
PlayToggleBroadcast::class,
fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
);
});
test('§9 odds publish broadcasts odds update', function (): void {
Event::fake([OddsUpdateBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'odds broadcast'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = acceptanceOddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 444_444;
}
}
unset($row);
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
Event::assertDispatched(
OddsUpdateBroadcast::class,
fn (OddsUpdateBroadcast $event): bool => $event->versionId === $draftId,
);
});

View File

@@ -10,6 +10,7 @@ use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus;
@@ -586,3 +587,185 @@ test('ticket place sold out for second player after first consumes shared pool',
->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('success')
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000);
});