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

@@ -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');
});