feat: enhance admin role management and reconciliation features

- Updated AGENTS.md to clarify site admin roles and their associated permissions.
- Refactored reconciliation controllers to include admin user validation and improved access control based on admin roles.
- Enhanced AdminReconcileJobService to support player-specific reconciliation and site-based job creation.
- Removed deprecated rebate commission report functionality from the API and related services.
- Improved dashboard overview builders to accommodate new site operator roles and their specific functionalities.
This commit is contained in:
2026-06-16 16:04:01 +08:00
parent 3a59cc3f42
commit 4e370a79dc
35 changed files with 1230 additions and 180 deletions

View File

@@ -15,6 +15,8 @@ test('admin ping requires authentication', function () {
});
test('admin auth me returns current admin profile', function () {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([
'username' => 'admin_me',
'name' => '管理员本人',
@@ -26,13 +28,21 @@ test('admin auth me returns current admin profile', function () {
$token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
$resp = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.admin.username', 'admin_me')
->assertJsonPath('data.admin.account_kind', 'super_admin')
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]);
$permissions = $resp->json('data.admin.permissions');
$operational = $resp->json('data.admin.operational_permissions');
expect($permissions)->toBeArray()->not->toBeEmpty()
->and($operational)->toBeArray()->not->toBeEmpty()
->and($permissions[0])->toStartWith('prd.')
->and($operational[0])->not->toStartWith('prd.');
});
test('admin login returns bearer token when captcha passes validation', function () {

View File

@@ -0,0 +1,211 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\ReconcileJob;
use App\Models\TransferOrder;
use App\Support\SitePlatformRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function reconcileScopeSuperToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'reconcile_scope_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('reconcile job list is scoped to site admin site', function (): void {
$superToken = reconcileScopeSuperToken();
$siteA = $this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'reconcile-a',
'name' => 'Reconcile A',
'admin_account' => [
'username' => 'reconcile_a_admin',
'nickname' => 'A Admin',
'password' => 'secret-strong',
],
])
->assertCreated()
->json('data');
$siteB = $this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'reconcile-b',
'name' => 'Reconcile B',
'admin_account' => [
'username' => 'reconcile_b_admin',
'nickname' => 'B Admin',
'password' => 'secret-strong',
],
])
->assertCreated()
->json('data');
$playerA = Player::query()->create([
'site_code' => 'reconcile-a',
'site_player_id' => 'scope-a-1',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$playerB = Player::query()->create([
'site_code' => 'reconcile-b',
'site_player_id' => 'scope-b-1',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach ([
['TO_scope_a', $playerA->id, (int) $siteA['id']],
['TO_scope_b', $playerB->id, (int) $siteB['id']],
] as [$transferNo, $playerId, $siteId]) {
TransferOrder::query()->create([
'transfer_no' => $transferNo,
'player_id' => $playerId,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 100,
'idempotent_key' => $transferNo.'-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
'created_at' => now()->subHours(2),
]);
$this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/reconcile-jobs', [
'reconcile_type' => 'wallet_transfer',
'date_from' => now()->subDay()->toDateString(),
'date_to' => now()->toDateString(),
'player_id' => $playerId,
])
->assertOk();
}
$jobA = ReconcileJob::query()->where('admin_site_id', (int) $siteA['id'])->latest('id')->first();
$jobB = ReconcileJob::query()->where('admin_site_id', (int) $siteB['id'])->latest('id')->first();
expect($jobA)->not->toBeNull()
->and($jobB)->not->toBeNull()
->and((int) $jobA->admin_site_id)->toBe((int) $siteA['id'])
->and((int) $jobB->admin_site_id)->toBe((int) $siteB['id']);
$siteAdminA = AdminUser::query()->where('username', 'reconcile_a_admin')->firstOrFail();
$siteAdminB = AdminUser::query()->where('username', 'reconcile_b_admin')->firstOrFail();
expect(SitePlatformRole::userHasSiteAdminRole($siteAdminA))->toBeTrue()
->and($siteAdminA->isSuperAdmin())->toBeFalse()
->and($siteAdminA->accessibleAdminSiteIds())->toBe([(int) $siteA['id']])
->and($siteAdminA->primaryAgentNode())->toBeNull()
->and($siteAdminB->accessibleAdminSiteIds())->toBe([(int) $siteB['id']]);
Sanctum::actingAs($siteAdminA, ['*']);
$idsA = collect($this->getJson('/api/v1/admin/reconcile-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
Sanctum::actingAs($siteAdminB, ['*']);
$idsB = collect($this->getJson('/api/v1/admin/reconcile-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($idsA)->toEqual([(int) $jobA->id])
->and($idsB)->toEqual([(int) $jobB->id]);
Sanctum::actingAs($siteAdminA, ['*']);
$this->getJson('/api/v1/admin/reconcile-jobs/'.$jobB->id.'/items')
->assertForbidden();
});
test('site admin reconcile scan only includes own site transfer orders', function (): void {
$superToken = reconcileScopeSuperToken();
$this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'scan-scope-a',
'name' => 'Scan Scope A',
'admin_account' => [
'username' => 'scan_scope_a_admin',
'nickname' => 'Scan A',
'password' => 'secret-strong',
],
])
->assertCreated();
$defaultSiteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code');
$ownPlayer = Player::query()->create([
'site_code' => 'scan-scope-a',
'site_player_id' => 'scan-own',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$otherPlayer = Player::query()->create([
'site_code' => $defaultSiteCode,
'site_player_id' => 'scan-other',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach ([['TO_scan_own', $ownPlayer->id], ['TO_scan_other', $otherPlayer->id]] as [$transferNo, $playerId]) {
TransferOrder::query()->create([
'transfer_no' => $transferNo,
'player_id' => $playerId,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 200,
'idempotent_key' => $transferNo.'-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
'created_at' => now()->subHours(2),
]);
}
$siteAdmin = AdminUser::query()->where('username', 'scan_scope_a_admin')->firstOrFail();
Sanctum::actingAs($siteAdmin, ['*']);
$this->postJson('/api/v1/admin/reconcile-jobs', [
'reconcile_type' => 'wallet_transfer',
'date_from' => now()->subDay()->toDateString(),
'date_to' => now()->toDateString(),
])
->assertOk()
->assertJsonPath('data.item_count', 1);
$job = ReconcileJob::query()->latest('id')->firstOrFail();
expect($job->items()->value('side_a_ref'))->toBe('TO_scan_own');
});

View File

@@ -49,7 +49,7 @@ test('platform role index lists built-in roles and custom system roles', functio
->pluck('slug')
->all();
expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops');
expect($slugs)->toContain('super_admin', 'site_admin', 'site_finance', 'site_cs', 'agent', 'legacy_custom_ops');
});
test('admin can create custom platform role but not reserved slugs', function (): void {

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use App\Support\SiteOperatorRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function dashboardSiteOperatorToken(string $username, string $roleSlug): string
{
PlatformSystemRoles::ensureAll();
$user = AdminUser::query()->create([
'username' => $username,
'name' => ucfirst(str_replace('_', ' ', $username)),
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $user->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
return $user->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('site finance dashboard returns finance overview without site admin overview', function (): void {
$token = dashboardSiteOperatorToken('dash_site_finance', SiteOperatorRoles::SLUG_SITE_FINANCE);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_overview', null)
->assertJsonPath('data.site_cs_overview', null)
->assertJsonPath('data.site_finance_overview.site_code', fn ($code) => is_string($code) && $code !== '')
->assertJsonStructure([
'data' => [
'site_finance_overview' => [
'wallet_player_count',
'credit_player_count',
'pending_confirm_bill_count',
'payable_bill_count',
'payable_unpaid_minor',
'abnormal_transfer_count',
],
],
]);
});
test('site cs dashboard returns cs overview without finance or admin overview', function (): void {
$token = dashboardSiteOperatorToken('dash_site_cs', SiteOperatorRoles::SLUG_SITE_CS);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_overview', null)
->assertJsonPath('data.site_finance_overview', null)
->assertJsonPath('data.site_cs_overview.site_code', fn ($code) => is_string($code) && $code !== '')
->assertJsonStructure([
'data' => [
'site_cs_overview' => [
'player_count',
'ticket_order_count_today',
'active_player_count_today',
],
],
]);
});
test('site admin dashboard still returns site overview only', function (): void {
$token = dashboardSiteOperatorToken('dash_site_admin', SiteOperatorRoles::SLUG_SITE_ADMIN);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_finance_overview', null)
->assertJsonPath('data.site_cs_overview', null)
->assertJsonPath('data.site_overview.site_code', fn ($code) => is_string($code) && $code !== '');
});

View File

@@ -0,0 +1,75 @@
<?php
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use App\Support\SiteOperatorRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function bindSiteOperator(AdminUser $user, string $roleSlug): void
{
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $user->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
}
test('auth me exposes site role specific account_kind for site operators', function (): void {
PlatformSystemRoles::ensureAll();
$finance = AdminUser::query()->create([
'username' => 'site_finance_ops',
'name' => 'Site Finance',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
bindSiteOperator($finance, SiteOperatorRoles::SLUG_SITE_FINANCE);
$token = $finance->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.account_kind', 'site_finance')
->assertJsonPath('data.admin.site.code', fn ($code) => is_string($code) && $code !== '');
expect($finance->fresh()->adminPermissionSlugs())
->toContain('prd.report.export')
->not->toContain('prd.agent.manage');
});
test('site cs auth me has cs account kind without report permissions', function (): void {
PlatformSystemRoles::ensureAll();
$cs = AdminUser::query()->create([
'username' => 'site_cs_ops',
'name' => 'Site CS',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
bindSiteOperator($cs, SiteOperatorRoles::SLUG_SITE_CS);
$token = $cs->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.account_kind', 'site_cs');
expect($cs->fresh()->adminPermissionSlugs())
->toContain('prd.users.view_cs')
->not->toContain('prd.report.view');
});

View File

@@ -1,13 +1,44 @@
<?php
use App\Support\SiteAdminDefaultRolePermissions;
use App\Support\SiteCsDefaultRolePermissions;
use App\Support\SiteFinanceDefaultRolePermissions;
test('site admin template includes dashboard and settlement manage', function (): void {
test('site admin template includes draw view export and excludes integration and wallet adjust', function (): void {
$slugs = SiteAdminDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.agent.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.report.view');
->toContain('prd.report.view')
->toContain('prd.report.export')
->toContain('prd.draw_result.view')
->toContain('prd.wallet_reconcile.manage')
->not->toContain('prd.integration.view')
->not->toContain('prd.wallet_adjust.manage');
});
test('site finance template focuses on reconcile reports and settlement without agent manage', function (): void {
$slugs = SiteFinanceDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.report.export')
->toContain('prd.wallet_adjust.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.draw_result.view')
->not->toContain('prd.agent.manage')
->not->toContain('prd.users.manage');
});
test('site cs template is single-player scoped without global reports', function (): void {
$slugs = SiteCsDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.users.view_cs')
->toContain('prd.tickets.view')
->toContain('prd.wallet_reconcile.view_cs')
->not->toContain('prd.report.view')
->not->toContain('prd.agent.view');
});