feat: 更新玩法配置管理,简化字段并增强功能

- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。
- 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。
- 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
2026-05-25 14:34:24 +08:00
parent 270d2e9af1
commit e27a00f260
74 changed files with 4469 additions and 280 deletions

View File

@@ -0,0 +1,45 @@
<?php
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('admin api audit middleware records draw reopen', function (): void {
$admin = AdminUser::query()->create([
'username' => 'audit_reopen_admin',
'name' => 'Audit Reopen',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$draw = Draw::query()->create([
'draw_no' => '20260525-099',
'business_date' => '2026-05-25',
'sequence_no' => 99,
'status' => DrawStatus::Cooldown->value,
'cooling_end_time' => now()->addMinutes(10),
'settle_version' => 0,
]);
$before = AuditLog::query()->count();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'audit test'])
->assertOk();
expect(AuditLog::query()->count())->toBe($before + 1);
/** @var AuditLog $row */
$row = AuditLog::query()->latest('id')->first();
expect($row->module_code)->toBe('draw')
->and($row->action_code)->toBe('reopen')
->and($row->operator_id)->toBe($admin->id);
});

View File

@@ -90,9 +90,7 @@ test('enabling currency as bettable bootstraps odds items and jackpot pool', fun
'category' => '4d',
'dimension' => 4,
'bet_mode' => 'single',
'display_name_zh' => '直选',
'display_name_en' => 'Straight',
'display_name_ne' => 'सिधा',
'display_name' => '直选',
'is_enabled' => true,
'sort_order' => 10,
'supports_multi_number' => false,

View File

@@ -0,0 +1,105 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('dashboard analytics returns summary trend and play breakdown for period', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'da-p1',
'username' => 'da_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260510-001',
'business_date' => '2026-05-10',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay(),
'draw_time' => now()->subDay(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$order = TicketOrder::query()->create([
'order_no' => 'ORD-DA-1',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 8_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 8_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketItem::query()->create([
'ticket_no' => 'TK-DA-1',
'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' => null,
'unit_bet_amount' => 8_000,
'total_bet_amount' => 8_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 8_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'won',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 2_000,
'jackpot_win_amount' => 0,
'settled_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'dash_analytics_admin',
'name' => 'Dash Analytics QA',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard/analytics?period=last_30_days&metric=overview')
->assertOk()
->assertJsonPath('data.summary.total_bet_minor', 8_000)
->assertJsonPath('data.summary.total_payout_minor', 2_000)
->assertJsonPath('data.summary.approx_house_gross_minor', 6_000)
->assertJsonStructure([
'data' => [
'daily_series',
'play_breakdown',
'chart_meta' => ['truncated', 'span_days'],
],
]);
});

View File

@@ -0,0 +1,103 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketOrder;
use App\Services\Admin\AdminReportQueryService;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('resolve dashboard period lifetime returns associative bounds without error', function (): void {
$service = app(AdminReportQueryService::class);
$empty = $service->resolveDashboardPeriod('lifetime', null, null);
expect($empty)->toHaveKeys(['date_from', 'date_to'])
->and($empty['date_from'])->toBeString()
->and($empty['date_to'])->toBeString();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'lt-p1',
'username' => 'lt_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
Draw::query()->create([
'draw_no' => '20260101-001',
'business_date' => '2026-01-01',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subMonths(2),
'close_time' => now()->subMonths(2),
'draw_time' => now()->subMonths(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$drawLate = Draw::query()->create([
'draw_no' => '20260520-001',
'business_date' => '2026-05-20',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay(),
'draw_time' => now()->subDay(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
TicketOrder::query()->create([
'order_no' => 'ORD-LT-1',
'player_id' => $player->id,
'draw_id' => $drawLate->id,
'currency_code' => 'NPR',
'total_bet_amount' => 1_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 1_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
$range = $service->resolveDashboardPeriod('lifetime', null, null);
expect($range['date_from'])->toBe('2026-05-20')
->and($range['date_to'])->toBe('2026-05-20');
});
test('dashboard analytics lifetime period returns ok via http', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([
'username' => 'lt_dash_admin',
'name' => 'LT Dash',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard/analytics?period=lifetime&metric=overview')
->assertOk()
->assertJsonStructure([
'data' => [
'date_from',
'date_to',
'summary',
'daily_series',
],
]);
});

View File

@@ -61,6 +61,8 @@ test('admin dashboard aggregates hall finance and risk for super admin', functio
->assertJsonPath('data.resolved_draw.id', $draw->id)
->assertJsonPath('data.capabilities.draw_finance_risk', true)
->assertJsonPath('data.capabilities.wallet_transfer_view', true)
->assertJsonPath('data.today_finance.business_date', now()->toDateString())
->assertJsonPath('data.today_finance.total_bet_minor', 0)
->assertJsonPath('data.finance.draw_id', $draw->id)
->assertJsonPath('data.draw.result_batch_counts.total', 0)
->assertJsonPath('data.risk.locked_amount', 200_100)

View File

@@ -0,0 +1,75 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Support\AdminAuthorizationRegistry;
use Database\Seeders\AdminRbacAndUserSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('config mutate resources require domain manage slugs only', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$playStore = resourceLegacySlugs('admin.config.play-versions.store');
expect($playStore)->toBe(['prd.play_switch.manage']);
$oddsStore = resourceLegacySlugs('admin.config.odds-versions.store');
expect($oddsStore)->toContain('prd.odds.manage')
->and($oddsStore)->not->toContain('prd.play_switch.manage');
$riskCapStore = resourceLegacySlugs('admin.config.risk-cap-versions.store');
expect($riskCapStore)->toBe(['prd.risk_cap.manage']);
});
test('user with report view only cannot create report export job', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([
'username' => 'report_view_only',
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create(['slug' => 'report_view_only', 'name' => 'Report view only']);
$role->syncLegacyPermissionSlugs(['prd.report.view', 'prd.dashboard.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/reports/daily-profit')
->assertOk();
$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',
],
])
->assertForbidden();
});
/** @return list<string> */
function resourceLegacySlugs(string $code): array
{
$resource = collect(AdminAuthorizationRegistry::resourceDefinitions())
->firstWhere('code', $code);
expect($resource)->not->toBeNull();
return $resource['legacy_permission_slugs'] ?? [];
}

View File

@@ -52,7 +52,7 @@ test('finance role with report legacy can access report jobs after rbac seed', f
->assertOk();
});
test('report api resources only bind service.report.view', function (): void {
test('report read api resources bind service.report.view only', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
@@ -63,13 +63,28 @@ test('report api resources only bind service.report.view', function (): void {
];
foreach ($codes as $code) {
$bindings = DB::table('admin_api_resources as ar')
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
->where('ar.code', $code)
->pluck('ma.permission_code')
->all();
$bindings = bindingsForResource($code);
expect($bindings)->toBe(['service.report.view']);
}
});
test('report export api resources bind service.report.export', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
expect(bindingsForResource('admin.report-jobs.download'))->toBe(['service.report.export']);
expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']);
});
/** @return list<string> */
function bindingsForResource(string $code): array
{
return DB::table('admin_api_resources as ar')
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
->where('ar.code', $code)
->orderBy('ma.permission_code')
->pluck('ma.permission_code')
->all();
}

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Services\Admin\AdminReportQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('platform lifetime totals aggregate all draws with daily profit口径', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'plt-p1',
'username' => 'plt_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$drawA = Draw::query()->create([
'draw_no' => '20260501-001',
'business_date' => '2026-05-01',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDays(2),
'close_time' => now()->subDays(2)->addHour(),
'draw_time' => now()->subDays(2)->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$drawB = Draw::query()->create([
'draw_no' => '20260502-001',
'business_date' => '2026-05-02',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay()->addHour(),
'draw_time' => now()->subDay()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$orderA = TicketOrder::query()->create([
'order_no' => 'ORD-A',
'player_id' => $player->id,
'draw_id' => $drawA->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketOrder::query()->create([
'order_no' => 'ORD-B',
'player_id' => $player->id,
'draw_id' => $drawB->id,
'currency_code' => 'NPR',
'total_bet_amount' => 5_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 5_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketItem::query()->create([
'ticket_no' => 'TK-PLT-1',
'order_id' => $orderA->id,
'player_id' => $player->id,
'draw_id' => $drawA->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'won',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 3_000,
'jackpot_win_amount' => 0,
'settled_at' => now(),
]);
$totals = app(AdminReportQueryService::class)->platformLifetimeTotals();
expect($totals['total_bet_minor'])->toBe(15_000)
->and($totals['total_payout_minor'])->toBe(3_000)
->and($totals['approx_house_gross_minor'])->toBe(12_000)
->and($totals['draw_count'])->toBe(2)
->and($totals['business_day_count'])->toBe(2)
->and($totals['date_from'])->toBe('2026-05-01')
->and($totals['date_to'])->toBe('2026-05-02')
->and($totals['currency_code'])->toBe('NPR');
});

View File

@@ -3,12 +3,39 @@
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use App\Models\JackpotPool;
use App\Models\JackpotPayoutLog;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use App\Events\JackpotBurstBroadcast;
use App\Events\DrawStatusChangeBroadcast;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function mintSettlementAdminToken(): string
{
$admin = AdminUser::query()->create([
@@ -23,6 +50,44 @@ function mintSettlementAdminToken(): string
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function mintRiskOperatorToken(): string
{
$now = now();
DB::table('admin_roles')->updateOrInsert(
['slug' => 'risk_operator'],
['name' => 'Risk', 'code' => 'risk_operator', 'created_at' => $now, 'updated_at' => $now],
);
$roleId = (int) DB::table('admin_roles')->where('slug', 'risk_operator')->value('id');
$admin = AdminUser::query()->create([
'username' => 'risk_jp_admin',
'name' => 'Risk JP',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
$manageMenuActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'config.jackpot.manage')
->value('id');
if ($manageMenuActionId > 0) {
DB::table('admin_role_menu_actions')->updateOrInsert(
['role_id' => $roleId, 'menu_action_id' => $manageMenuActionId],
[],
);
}
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin settlement batches index is authenticated', function (): void {
$this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized();
});
@@ -42,22 +107,27 @@ test('admin jackpot pools index returns rows', function (): void {
->assertJsonPath('data.items.0.combo_trigger_play_codes', []);
});
test('admin can update jackpot combo trigger and manually burst pool', function (): void {
test('admin can update jackpot combo trigger', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill([
'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,
])->save();
$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');
});
test('risk operator cannot manually burst jackpot', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 1000, 'status' => 1])->save();
$draw = Draw::query()->create([
'draw_no' => '20260518-001',
'draw_no' => '20260518-099',
'business_date' => '2026-05-18',
'sequence_no' => 1,
'sequence_no' => 99,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(2),
'close_time' => now()->subHour(),
@@ -69,22 +139,263 @@ test('admin can update jackpot combo trigger and manually burst pool', function
'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');
$token = mintRiskOperatorToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'draw_id' => $draw->id,
])
->assertForbidden();
});
test('super admin manual burst allocates jackpot to first prize winners after settlement', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill([
'current_amount' => 10_000,
'contribution_rate' => '0',
'trigger_threshold' => 999_999_999,
'payout_rate' => '0.5000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
])->save();
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'manual-burst-p1',
'username' => 'manual_burst_p1',
'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,
]);
$draw = Draw::query()->create([
'draw_no' => '20260518-010',
'business_date' => '2026-05-18',
'sequence_no' => 10,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'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,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => (string) $draw->draw_no,
'currency_code' => 'NPR',
'client_trace_id' => 'manual-burst-bet-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'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' ? '1234' : '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();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->jackpot_win_amount)->toBe(0);
$admin = AdminUser::query()->where('username', 'settlement_admin')->first();
if ($admin === null) {
mintSettlementAdminToken();
$admin = AdminUser::query()->where('username', 'settlement_admin')->firstOrFail();
} else {
grantSuperAdminRole($admin);
}
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken;
$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);
->assertJsonPath('data.burst_amount', 5000)
->assertJsonPath('data.current_amount', 5000)
->assertJsonPath('data.winner_count', 1);
$item->refresh();
expect((int) $item->jackpot_win_amount)->toBe(5000);
$log = JackpotPayoutLog::query()->where('draw_id', $draw->id)->firstOrFail();
expect($log->trigger_type)->toBe('manual')
->and($log->winner_count)->toBe(1)
->and((int) $log->total_payout_amount)->toBe(5000);
expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1);
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id
&& $event->triggerType === 'manual'
&& $event->totalPayoutAmount === 5000
&& $event->winnerCount === 1
&& $event->firstPrizeNumber === '1234',
);
});
test('manual burst broadcast includes published first prize number', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save();
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'manual-burst-p2',
'username' => 'manual_burst_p2',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 1_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260518-002',
'business_date' => '2026-05-18',
'sequence_no' => 2,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'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,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => (string) $draw->draw_no,
'currency_code' => 'NPR',
'client_trace_id' => 'manual-burst-bet-2',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$resultBatch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'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' ? '1234' : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $resultBatch->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,
'result_source' => 'rng',
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$admin = AdminUser::query()->create([
'username' => 'manual_burst_admin2',
'name' => 'Burst Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'draw_id' => $draw->id,
])
->assertOk();
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->firstPrizeNumber === '1234',
);
});

View File

@@ -16,6 +16,7 @@ use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawTickService;
use App\Events\DrawStatusChangeBroadcast;
use App\Services\Draw\DrawPlannerService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -747,9 +748,70 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect
'broadcasting.connections.reverb.driver' => 'reverb',
]);
Draw::query()->create([
'draw_no' => '20260509-001',
'business_date' => '2026-05-09',
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'close_time' => now()->addMinutes(20),
'draw_time' => now()->addMinutes(25),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->artisan('lottery:hall-countdown')->assertSuccessful();
Event::assertDispatched(DrawCountdownBroadcast::class);
Event::assertDispatched(
DrawCountdownBroadcast::class,
fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']),
);
});
test('hall snapshot switches to next bettable draw when cooldown ended', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC'));
Draw::query()->create([
'draw_no' => '20260510-001',
'business_date' => '2026-05-10',
'sequence_no' => 1,
'status' => DrawStatus::Cooldown->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(2),
'cooling_end_time' => now()->subMinutes(5),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
$next = Draw::query()->create([
'draw_no' => '20260510-002',
'business_date' => '2026-05-10',
'sequence_no' => 2,
'status' => DrawStatus::Pending->value,
'start_time' => now()->subMinutes(1),
'close_time' => now()->addMinutes(20),
'draw_time' => now()->addMinutes(25),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc());
expect($target)->not->toBeNull()
->and($target->draw_no)->toBe('20260510-002');
$payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc());
expect($payload['status'] ?? null)->toBe('open');
Carbon::setTestNow();
});
test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void {

View File

@@ -79,6 +79,17 @@ function jackpotOpenDraw(string $drawNo): Draw
]);
}
/** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */
function jackpotUpsertPool(array $attrs): JackpotPool
{
$currencyCode = (string) ($attrs['currency_code'] ?? 'NPR');
return JackpotPool::query()->updateOrCreate(
['currency_code' => $currencyCode],
$attrs,
);
}
function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
{
$batch = DrawResultBatch::query()->create([
@@ -115,7 +126,7 @@ function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
}
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
JackpotPool::query()->create([
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 0,
'contribution_rate' => '0.1000',
@@ -238,7 +249,7 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
});
test('jackpot contribution respects switch and minimum bet threshold', function (): void {
JackpotPool::query()->create([
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 0,
'contribution_rate' => '0.1000',
@@ -288,7 +299,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
'broadcasting.connections.reverb.driver' => 'reverb',
]);
JackpotPool::query()->create([
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 50_000,
'contribution_rate' => '0.0000',
@@ -336,7 +347,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
});
test('jackpot splits burst payout between multiple winners by bet amount', function (): void {
JackpotPool::query()->create([
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 90_000,
'contribution_rate' => '0.0000',
@@ -413,7 +424,7 @@ test('jackpot summary and result payload expose pool amount and draw gap', funct
'is_reopened' => false,
]);
JackpotPool::query()->create([
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 123_456,
'contribution_rate' => '0.0100',

View File

@@ -80,9 +80,7 @@ test('§12.6 published play limits are visible on public effective catalog witho
'category' => $t->category,
'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'display_name' => $t->display_name ?? $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 777,
'max_bet_amount' => 400_000_000,
@@ -271,9 +269,7 @@ test('§12.6 published play config controls master_enabled on public catalog wit
'category' => $r['category'],
'dimension' => $r['dimension'],
'bet_mode' => $r['bet_mode'],
'display_name_zh' => $r['display_name_zh'],
'display_name_en' => $r['display_name_en'],
'display_name_ne' => $r['display_name_ne'],
'display_name' => $r['display_name'],
'is_enabled' => $r['is_enabled'],
'min_bet_amount' => (int) $r['min_bet_amount'],
'max_bet_amount' => (int) $r['max_bet_amount'],
@@ -381,9 +377,7 @@ test('§5 play_config publish is audited', function (): void {
'category' => $t->category,
'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'display_name' => $t->display_name ?? $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
@@ -404,6 +398,35 @@ test('§5 play_config publish is audited', function (): void {
)->toBeTrue();
});
test('play type patch toggles active config and broadcasts instantly', function (): void {
Event::fake([PlayToggleBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)
->assertOk()
->assertJsonPath('data.play_code', 'big')
->assertJsonPath('data.is_enabled', false);
Event::assertDispatched(
PlayToggleBroadcast::class,
fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
);
expect(
AuditLog::query()
->where('module_code', 'play_config')
->where('action_code', 'toggle_active')
->where('target_id', 'big')
->exists(),
)->toBeTrue();
});
test('§9 play_config publish broadcasts changed play toggles', function (): void {
Event::fake([PlayToggleBroadcast::class]);
config([

View File

@@ -78,9 +78,7 @@ test('admin play config draft publish flow', function (): void {
'category' => $t->category,
'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'display_name' => $t->display_name ?? $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 200,
'max_bet_amount' => 400_000_000,

View File

@@ -0,0 +1,133 @@
<?php
/**
* PRD §17.2 可自动化子集(不含 k6 与万级结算压测)。
*/
use App\Models\Draw;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Lottery\DrawStatus;
use App\Models\PlayerWallet;
use Carbon\Carbon;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use App\Services\Draw\DrawPlannerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('draw planner schedules five minute draw_time gaps', function (): void {
config([
'lottery.draw.timezone' => 'UTC',
'lottery.draw.interval_minutes' => 5,
'lottery.draw.buffer_draws_ahead' => 12,
]);
$fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC');
app(DrawPlannerService::class)->ensureBuffer($fixed);
$times = Draw::query()
->whereNotNull('draw_time')
->orderBy('draw_time')
->limit(13)
->pluck('draw_time')
->map(fn ($t) => Carbon::parse($t)->utc())
->all();
expect(count($times))->toBeGreaterThanOrEqual(2);
for ($i = 1; $i < count($times); $i++) {
$delta = (int) $times[$i]->diffInSeconds($times[$i - 1], absolute: true);
expect($delta)->toBe(300);
}
});
test('ticket place rejects bet when draw is closing', function (): void {
$player = perfPlayer();
Draw::query()->create([
'draw_no' => '20260525-001',
'business_date' => '2026-05-25',
'sequence_no' => 1,
'status' => DrawStatus::Closing->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subMinute(),
'draw_time' => now()->addMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260525-001',
'currency_code' => 'NPR',
'client_trace_id' => 'perf-sealed',
'lines' => [['number' => '1234', 'play_code' => 'big', 'amount' => 10]],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket place rejects bet when close_time passed but status still open', function (): void {
$player = perfPlayer();
Draw::query()->create([
'draw_no' => '20260525-002',
'business_date' => '2026-05-25',
'sequence_no' => 2,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subSecond(),
'draw_time' => now()->addMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260525-002',
'currency_code' => 'NPR',
'client_trace_id' => 'perf-close-time',
'lines' => [['number' => '5678', 'play_code' => 'big', 'amount' => 10]],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
function perfPlayer(): Player
{
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'perf-'.$uniq,
'username' => 'perf_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 1_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}

View File

@@ -70,10 +70,12 @@ test('player me works with main site jwt when dev bypass is off', function () {
'status' => 0,
]);
$now = time();
$jwt = JWT::encode([
'site_code' => 'main',
'site_player_id' => 'jwt-user-1',
'exp' => time() + 3600,
'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt)
@@ -88,10 +90,12 @@ test('jwt first successful login auto-registers player mapping', function () {
expect(Player::query()->count())->toBe(0);
$now = time();
$jwt = JWT::encode([
'site_code' => 'main',
'site_player_id' => 'brand-new-sso-1',
'exp' => time() + 3600,
'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt)

View File

@@ -0,0 +1,76 @@
<?php
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\AdminUser;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\Hash;
use App\Services\Draw\DrawRngSeedDerivation;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('rng seed derivation is deterministic for fixed seed and draw', function (): void {
$seedHex = str_repeat('ab', 32);
$drawId = 42;
$a = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
$b = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
$c = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 1);
expect($a)->toBe($b)
->and(strlen($a))->toBe(4)
->and($a)->not->toBe($c);
});
test('rng seed encrypt decrypt roundtrip preserves hex', function (): void {
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$encrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
expect(DrawRngSeedDerivation::decryptSeedHex($encrypted))->toBe($seedHex)
->and(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe(hash('sha256', $seedHex));
});
test('admin rng run stores encrypted seed and passes batch audit verification', function (): void {
config(['lottery.draw.require_manual_review' => true]);
$draw = Draw::query()->create([
'draw_no' => '20260525-rng-audit',
'business_date' => '2026-05-25',
'sequence_no' => 901,
'status' => DrawStatus::Closed->value,
'start_time' => now()->subMinutes(20),
'close_time' => now()->subMinutes(5),
'draw_time' => now()->subMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$admin = AdminUser::query()->create([
'username' => 'rng_audit_admin',
'name' => 'RNG Audit',
'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();
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect($batch->source_type)->toBe('rng')
->and($batch->rng_seed_hash)->not->toBeEmpty()
->and($batch->raw_seed_encrypted)->not->toBeEmpty()
->and($batch->items()->count())->toBe(23);
$seedHex = DrawRngSeedDerivation::decryptSeedHex((string) $batch->raw_seed_encrypted);
expect(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe($batch->rng_seed_hash)
->and(DrawRngSeedDerivation::verifyBatchAudit($batch->fresh(['items']), $draw->fresh()))->toBeTrue();
});

View File

@@ -14,6 +14,7 @@ use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\OddsItem;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -413,18 +414,238 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
}
});
test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void {
$cases = [
[
'play' => 'pos_3abc',
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1234',
'second' => '5234',
'third' => '9234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
[
'play' => 'pos_3abc',
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1567',
'second' => '5234',
'third' => '8234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'second',
],
[
'play' => 'pos_2abc',
'number' => '99',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '8899',
'second' => '2299',
'third' => '1199',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
];
foreach ($cases as $case) {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
'lines' => [
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);
p145_publish_board($draw, $case['board']);
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
expect($item->status)->toBe('settled_win', $case['play'])
->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
}
});
test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-ibox-multi-tier',
'lines' => [
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount;
expect($deduct)->toBe(600)
->and((int) $item->combination_count)->toBe(6);
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
$expectedWin = $unitWinFirst + $unitWinStarter;
p145_publish_board($draw, function (string $t, int $i): string {
return match ($t) {
'first' => '1212',
'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
default => p145_board_without_8888($t, $i),
};
});
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
$matchLines = is_array($detail->match_detail_json)
? ($detail->match_detail_json['lines'] ?? [])
: [];
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin)
->and($detail->matched_prize_tier)->toBe('first')
->and(count($matchLines))->toBe(2);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
});
test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$comboCount = 24;
$unitBet = intdiv($rawAmount, $comboCount);
$expectedDeduct = $unitBet * $comboCount;
$expectedRemainder = $rawAmount - $expectedDeduct;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-win',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->combination_count)->toBe($comboCount)
->and((int) $item->unit_bet_amount)->toBe($unitBet)
->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin);
});
test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$expectedDeduct = intdiv($rawAmount, 24) * 24;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-lose',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_lose')
->and((int) $item->win_amount)->toBe(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
});
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
JackpotPool::query()->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' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
JackpotPool::query()->updateOrCreate(
['currency_code' => 'NPR'],
[
'current_amount' => 0,
'contribution_rate' => '0.1000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
],
);
$player = p145_player();
$drawNo = p145_next_draw_no();
@@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
expect((int) $pool->locked_amount)->toBe(3000);
});
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-odds-snapshot-1',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
->firstWhere('prize_scope', 'first');
expect($snapshotOdds)->not->toBeNull();
// 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。
OddsItem::query()
->where('play_code', 'big')
->where('prize_scope', 'first')
->where('currency_code', 'NPR')
->update(['odds_value' => 10_000]);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
});
test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
// 下注一单,确保产生风险池占用。
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-1',
'lines' => [
['number' => '8888', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
$cap = (int) $pool->total_cap_amount;
expect((int) $pool->locked_amount)->toBeGreaterThan(0)
->and((int) $pool->remaining_amount)->toBeLessThan($cap);
// 先走未中奖结算,验证释放。
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$poolAfterLose = $pool->fresh();
expect((int) $poolAfterLose->locked_amount)->toBe(0)
->and((int) $poolAfterLose->remaining_amount)->toBe($cap);
// 再开一盘中奖结算,验证同样释放。
$drawNo2 = p145_next_draw_no();
$draw2 = p145_draw($drawNo2, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo2,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-2',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
$cap2 = (int) $pool2->total_cap_amount;
expect((int) $pool2->locked_amount)->toBeGreaterThan(0);
p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw2->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
p145_approve_and_payout($draw2);
$poolAfterWin = $pool2->fresh();
expect((int) $poolAfterWin->locked_amount)->toBe(0)
->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
});
/**
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。

View File

@@ -150,6 +150,84 @@ test('module 6 box family expands combinations and computes amount semantics', f
->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
});
test('module 6 mbox remainder splits amount evenly across preview and place', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$cases = [
[
'number' => '1234',
'amount' => 10_001,
'combination_count' => 24,
'unit_bet_amount' => 416,
'total_bet_amount' => 9984,
'rounding_refund_amount' => 17,
],
[
'number' => '1122',
'amount' => 601,
'combination_count' => 6,
'unit_bet_amount' => 100,
'total_bet_amount' => 600,
'rounding_refund_amount' => 1,
],
];
foreach ($cases as $index => $case) {
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-remainder-'.$index,
'lines' => [
['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']],
],
])
->assertOk();
$line = $resp->json('data.lines.0');
expect($line['combination_count'])->toBe($case['combination_count'])
->and($line['total_bet_amount'])->toBe($case['total_bet_amount'])
->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount'])
->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount'])
->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']);
}
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-place-remainder',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
],
])
->assertOk()
->assertJsonPath('data.summary.total_bet_amount', 9984)
->assertJsonPath('data.summary.total_actual_deduct', 9984);
$item = TicketItem::query()->where('play_code', 'mbox')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->unit_bet_amount)->toBe(416)
->and((int) $item->total_bet_amount)->toBe(9984)
->and((int) $item->actual_deduct_amount)->toBe(9984)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17);
$comboAmounts = TicketCombination::query()
->where('ticket_item_id', $item->id)
->pluck('bet_amount')
->map(fn ($amount) => (int) $amount)
->unique()
->values()
->all();
expect($comboAmounts)->toBe([416]);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 9984);
});
test('module 6 roll expands each R position and charges per expanded combination', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
@@ -537,6 +615,104 @@ test('ticket place rejects bet amount below configured minimum', function (): vo
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 4000,
'locked_amount' => 0,
'remaining_amount' => 4000,
'sold_out_status' => 0,
'version' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-risk-warning',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 160],
],
])
->assertOk()
->assertJsonPath('data.lines.0.risk_status', 'ok')
->assertJsonPath('data.warnings.0.number_4d', '1234');
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000)
->and((int) $pool->locked_amount)->toBe(0)
->and((int) $pool->remaining_amount)->toBe(4000)
->and(TicketOrder::query()->count())->toBe(0)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket preview validates digit size dimension and slot rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$base = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-validation',
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'client_trace_id' => 'trace-digit-invalid-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place persists valid 2d digit size slot snapshot', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-d2-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3],
],
])
->assertOk()
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.items.0.status', 'pending_draw');
$item = TicketItem::query()->where('play_code', 'digit_big')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->dimension)->toBe(2)
->and((int) $item->digit_slot)->toBe(3)
->and((int) $item->combination_count)->toBe(5000)
->and($ruleSnapshot['dimension'] ?? null)->toBe('D2')
->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3)
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue()
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse();
});
test('ticket preview rejects invalid line amount per validation rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
@@ -833,16 +1009,18 @@ test('ticket place reverses wallet and releases risk when post deduction confirm
$player = ticketPlayerWithWallet(20_000);
$draw = ticketOpenDraw();
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 0,
'contribution_rate' => 1,
'trigger_threshold' => 0,
'payout_rate' => 0,
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
]);
JackpotPool::query()->updateOrCreate(
['currency_code' => 'NPR'],
[
'current_amount' => 0,
'contribution_rate' => 1,
'trigger_threshold' => 0,
'payout_rate' => 0,
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
],
);
DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)