feat: 扩展奖池、风控与报表能力,新增对账补偿、广播和人工操作接口
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
67
tests/Feature/AdminPlayerManageApiTest.php
Normal file
67
tests/Feature/AdminPlayerManageApiTest.php
Normal 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user