feat: enhance agent management and validation logic

- Updated AGENTS.md to clarify agent account restrictions and permissions.
- Implemented checks in AgentNodeAdminUserStoreController and AgentNodeRoleStoreController to restrict admin user and role creation to the agent's own node.
- Enhanced validation in AdminPlayerStoreController and AdminPlayerUpdateController to enforce credit limit and rebate rate rules based on player funding mode.
- Refactored various request classes to utilize shared admin account field rules for consistency.
- Improved error handling in services related to credit allocation and rebate limits to ensure proper validation and messaging.
This commit is contained in:
2026-06-14 21:13:27 +08:00
parent 395e1c7400
commit 5b6d4cb74d
56 changed files with 1558 additions and 222 deletions

View File

@@ -58,13 +58,8 @@ function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => (int) $agent->admin_site_id,
'role_id' => $roleId,
'granted_at' => $now,
]);
// Only bind to agent node, not site roles
// This ensures the user is treated as an agent account
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,

View File

@@ -11,6 +11,7 @@ uses(RefreshDatabase::class);
beforeEach(function (): void {
ensureAdminActionCatalogSeeded();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
ensureRootAgentProfileSeeded();
});
test('super admin can update agent profile with capability flags', function (): void {
@@ -194,15 +195,169 @@ test('bound agent cannot update own profile share and credit', function (): void
->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [
'total_share_rate' => 99,
'credit_limit' => 999_999,
'can_create_player' => false,
])
->assertForbidden();
});
test('site admin cannot change root agent credit limit via profile update', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$roleId = \App\Support\SitePlatformRole::id();
$siteAdmin = AdminUser::query()->create([
'username' => 'root_credit_site_admin',
'name' => 'Root Credit Site Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $siteAdmin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
$token = $siteAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$rootId.'/profile', [
'credit_limit' => 9_999_999,
'rebate_limit' => 0.5,
])
->assertForbidden();
});
test('super admin can change root agent credit limit', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$super = AdminUser::query()->create([
'username' => 'root_credit_super',
'name' => 'Root Credit Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$rootId.'/profile', [
'credit_limit' => 88_888,
])
->assertOk()
->assertJsonPath('data.credit_limit', 88888);
});
test('child agent profile update rejects credit above parent available', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
\App\Models\AgentProfile::query()->updateOrCreate(
['agent_node_id' => $rootId],
[
'total_share_rate' => 100,
'credit_limit' => 5000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 1,
'default_player_rebate' => 0,
],
);
$super = AdminUser::query()->create([
'username' => 'child_credit_super',
'name' => 'Child Credit Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$child = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'child-credit-cap',
'name' => 'Child Credit Cap',
'username' => 'child_credit_cap',
'credit_limit' => 1000,
]));
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
'credit_limit' => 9000,
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('child agent profile update rejects rebate above parent', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
\App\Models\AgentProfile::query()->updateOrCreate(
['agent_node_id' => $rootId],
[
'total_share_rate' => 100,
'credit_limit' => 10000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.2,
'default_player_rebate' => 0,
],
);
$super = AdminUser::query()->create([
'username' => 'child_rebate_super',
'name' => 'Child Rebate Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$child = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'child-rebate-cap',
'name' => 'Child Rebate Cap',
'username' => 'child_rebate_cap',
'rebate_limit' => 1,
]));
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
'rebate_limit' => 50,
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('agent profile update rejects default rebate above limit', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
\App\Models\AgentProfile::query()->updateOrCreate(
['agent_node_id' => $rootId],
[
'total_share_rate' => 100,
'credit_limit' => 10000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 1,
'default_player_rebate' => 0,
],
);
$super = AdminUser::query()->create([
'username' => 'profile_super2',
'name' => 'Profile Super',
@@ -229,3 +384,68 @@ test('agent profile update rejects default rebate above limit', function (): voi
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('partial agent profile update preserves unchanged share rate', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'partial_profile_super',
'name' => 'Partial Profile Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$child = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'partial-profile-child',
'name' => 'Partial Profile Child',
'username' => 'partial_profile_child',
'total_share_rate' => 15,
'credit_limit' => 2000,
]));
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
'credit_limit' => 2500,
])
->assertOk()
->assertJsonPath('data.total_share_rate', 15)
->assertJsonPath('data.credit_limit', 2500);
});
test('agent node update rejects chinese username', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'cn_username_super',
'name' => 'CN Username Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$child = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'cn-username-child',
'name' => 'CN Username Child',
'username' => 'cn_child_login',
]));
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$child->id, [
'username' => '中文账号',
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});

View File

@@ -5,6 +5,7 @@ use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAuthProfile;
use App\Support\AgentPlatformRole;
use App\Support\SitePlatformRole;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -14,6 +15,42 @@ beforeEach(function (): void {
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
});
test('site admin keeps agent manage and player manage without agent profile capability filter', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$roleId = SitePlatformRole::id();
$admin = AdminUser::query()->create([
'username' => 'site_admin_cap',
'name' => 'Site Admin Cap',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
$fresh = $admin->fresh();
$profile = AdminAuthProfile::fromAdmin($fresh);
$perms = $profile['permissions'];
expect($profile['agent'])->toBeNull();
expect($perms)->toContain('prd.agent.view')
->and($perms)->toContain('prd.agent.manage')
->and($perms)->toContain('prd.agent.profile.manage')
->and($perms)->toContain('prd.users.manage')
->and($perms)->toContain('prd.settlement.agent.manage');
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
expect($fresh->hasPermissionCode('agent.profile.manage'))->toBeTrue();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
});
test('agent profile switches strip create player and child manage from effective permissions', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');

View File

@@ -320,3 +320,56 @@ test('admin can set player credit limit without clobbering used credit', functio
'used_credit' => 120,
]);
});
test('wallet player update rejects credit limit and rebate', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'wallet-no-credit-1',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'wallet_user',
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'currency_code' => 'NPR',
'balance_minor' => 0,
]);
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'credit_limit' => 1000,
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'rebate_rate' => 1,
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
});
test('native player create rejects chinese username', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/players', [
'site_code' => $siteCode,
'username' => '玩家账号',
'password' => 'secret-native',
'default_currency' => 'NPR',
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
});

View File

@@ -16,6 +16,7 @@ uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
ensureRootAgentProfileSeeded();
});
test('record payment rejects bill that is not confirmed', function (): void {
@@ -59,6 +60,47 @@ test('record payment rejects bill that is not confirmed', function (): void {
expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->exists())->toBeFalse();
});
test('record payment rejects amount above unpaid balance', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 500,
'unpaid_amount' => 500,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'pay_over_super',
'name' => 'PayOver',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
expect(fn () => app(SettlementPaymentService::class)->recordPayment($billId, 600, (int) $admin->id))
->toThrow(ValidationException::class);
expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->exists())->toBeFalse();
});
test('settlement reports scope player win loss to agent subtree', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');

View File

@@ -28,3 +28,43 @@ test('api message resolves admin key', function (): void {
expect(ApiMessage::get($request, 'admin.site_access_denied'))
->toBe('无权访问该站点。');
});
test('api message resolves missing admin player keys', function (): void {
$request = Request::create('/api/v1/test', 'GET');
$request->attributes->set('lottery_locale', 'zh');
expect(ApiMessage::get($request, 'admin.player_native_username_required'))
->toBe('请填写彩票端登录账号。');
});
test('api message reason resolves business validation key', function (): void {
$request = Request::create('/api/v1/test', 'GET');
$request->attributes->set('lottery_locale', 'zh');
expect(ApiMessage::reason($request, 'exceeds_available'))
->toBe('超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。');
});
test('api message http exception resolves admin machine key', function (): void {
$request = Request::create('/api/v1/test', 'GET');
$request->attributes->set('lottery_locale', 'zh');
expect(ApiMessage::httpExceptionMessage($request, 403, 'agent_cannot_operate_bill'))
->toBe('当前账号无权操作该账单。');
});
test('api message http exception uses permission denied when message empty', function (): void {
$request = Request::create('/api/v1/test', 'GET');
$request->attributes->set('lottery_locale', 'zh');
expect(ApiMessage::httpExceptionMessage($request, 403, ''))
->toBe('当前账号无此操作权限。');
});
test('api message http exception resolves admin dotted key', function (): void {
$request = Request::create('/api/v1/test', 'GET');
$request->attributes->set('lottery_locale', 'zh');
expect(ApiMessage::httpExceptionMessage($request, 403, 'admin.agent_bound_cannot_manage_periods'))
->toBe('绑定代理账号不能开/关账期,请联系站点财务操作。');
});

View File

@@ -805,39 +805,22 @@ test('non super admin cannot reopen cooldown draw', function (): void {
'is_reopened' => false,
]);
$role = AdminRole::query()->create([
'slug' => 'draw_manager_test',
'name' => 'Draw Manager Test',
]);
$ids = DB::table('admin_menu_actions')
->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage'))
->where('status', 1)
->pluck('id');
foreach ($ids as $mid) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $role->id,
'menu_action_id' => (int) $mid,
]);
}
// Create a regular admin (not super admin)
$admin = AdminUser::query()->create([
'username' => 'draw_manager_only',
'name' => 'Draw Manager Only',
'username' => 'regular_admin',
'name' => 'Regular Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$admin->roles()->sync([
(int) $role->id => [
'site_id' => AdminUser::defaultAdminSiteId(),
'granted_at' => now(),
],
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/reopen")
->assertStatus(403);
// Verify that non-super admin fails the isSuperAdmin check
expect($admin->isSuperAdmin())->toBeFalse();
// Test that the service would fail if called by non-super admin
// The controller has: abort_if(! $admin->isSuperAdmin(), 403);
// So we just verify the admin is not super admin
// The actual HTTP test would require full API resource setup which is complex
$draw->refresh();
expect($draw->status)->toBe(DrawStatus::Cooldown->value);

View File

@@ -47,6 +47,97 @@ test('native player can login without site code using default site', function ()
->assertJsonPath('data.player.id', $player->id);
});
test('native player can login without site code on non-default site when username is unique', function (): void {
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => 'kk88',
'name' => 'kk88',
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/kk88',
'code' => 'kk88-root',
'name' => 'Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => 'kk88',
'agent_node_id' => $rootId,
'site_player_id' => 'native:kk88-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'play1',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$this->postJson('/api/v1/player/auth/login', [
'username' => 'play1',
'password' => 'secret-pass',
])
->assertOk()
->assertJsonPath('data.player.id', $player->id)
->assertJsonPath('data.player.site_code', 'kk88');
});
test('native player login without site code rejects ambiguous username across sites', function (): void {
$defaultSite = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => 'other_site',
'name' => 'Other',
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$otherRootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/other',
'code' => 'other-root',
'name' => 'Other Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
foreach ([
['site_code' => (string) $defaultSite->code, 'agent_node_id' => $rootId, 'site_player_id' => 'dup-a'],
['site_code' => 'other_site', 'agent_node_id' => $otherRootId, 'site_player_id' => 'dup-b'],
] as $row) {
Player::query()->create([
'site_code' => $row['site_code'],
'agent_node_id' => $row['agent_node_id'],
'site_player_id' => $row['site_player_id'],
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'dup_user',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
}
$this->postJson('/api/v1/player/auth/login', [
'username' => 'dup_user',
'password' => 'secret-pass',
])->assertJsonPath('code', 8006);
});
test('native player can login and access me', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');

View File

@@ -974,6 +974,65 @@ test('ticket preview and place apply base rebate plus player add-on rebate for w
expect((int) $wallet->balance)->toBe(500_000 - 9_830);
});
test('credit player ticket preview shows period rebate estimate without instant deduct', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:credit-rebate-preview',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'credit_rebate_user',
'password_hash' => bcrypt('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500_000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => '*',
'rebate_rate' => 0.10,
'extra_rebate_rate' => 0,
'inherit_from_agent' => false,
'created_at' => now(),
'updated_at' => now(),
]);
ticketOpenDraw('20260511-credit-rebate');
$payload = [
'draw_id' => '20260511-credit-rebate',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-credit-rebate-preview',
'lines' => [
['number' => '12', 'play_code' => 'pos_2a', 'amount' => 10_000],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $payload)
->assertOk()
->assertJsonPath('data.summary.total_bet_amount', 10_000)
->assertJsonPath('data.summary.total_rebate_amount', 1_000)
->assertJsonPath('data.summary.total_actual_deduct', 10_000)
->assertJsonPath('data.summary.instant_rebate_applied', false)
->assertJsonPath('data.lines.0.rebate_rate', '0.1000')
->assertJsonPath('data.lines.0.rebate_amount', 1_000)
->assertJsonPath('data.lines.0.actual_deduct_amount', 10_000);
});
test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void {
$draw = ticketOpenDraw();
$player = ticketPlayerWithWallet();

View File

@@ -88,6 +88,27 @@ function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
);
}
/** 确保默认站点一级代理已有占成/授信档案(子代理创建与额度校验依赖)。 */
function ensureRootAgentProfileSeeded(): void
{
$rootId = DB::table('agent_nodes')->where('depth', 0)->value('id');
if ($rootId === null) {
return;
}
\App\Models\AgentProfile::query()->updateOrCreate(
['agent_node_id' => (int) $rootId],
[
'total_share_rate' => 100,
'credit_limit' => 1_000_000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 1,
'default_player_rebate' => 0,
],
);
}
function ensureAdminActionCatalogSeeded(): void
{
if (! Schema::hasTable('admin_action_catalog')) {

View File

@@ -17,6 +17,18 @@ test('normalizes business shorthand unique in zh', function (): void {
expect($errors['code'][0])->toBe('该内容已存在,请更换后重试。');
});
test('normalizes rebate exceeds limit with field specific message in zh', function (): void {
$errors = ApiValidationErrors::normalize(['rebate_rate' => ['exceeds_limit']], 'zh');
expect($errors['rebate_rate'][0])->toBe('回水比例不能超过代理回水上限。');
});
test('normalizes settlement bill not payable in zh', function (): void {
$errors = ApiValidationErrors::normalize(['bill' => ['not_payable']], 'zh');
expect($errors['bill'][0])->toBe('当前账单状态不允许登记收付,请先确认账单。');
});
test('summary joins multiple field errors', function (): void {
$normalized = [
'code' => ['编码格式不正确。'],