feat: enhance agent settlement features and improve data access controls

- Added new section in AGENTS.md detailing learned workspace facts for better understanding of settlement processes.
- Updated AgentNodeDestroyController to remove unnecessary checks for admin users.
- Enhanced AgentSettlement controllers to assert permissions for finance adjustments and bill operations.
- Improved query scopes in AgentSettlement services to ensure proper data access based on admin roles.
- Refactored methods in SettlementPartyEnrichment for better bill row enrichment and data handling.
- Introduced new methods in AdminAgentSettlementScope for managing agent node visibility and finance adjustments.
This commit is contained in:
2026-06-12 15:59:05 +08:00
parent e14b7b4569
commit 980f3c9593
47 changed files with 2403 additions and 187 deletions

View File

@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
final class AgentSettlementAdjustmentIndexController extends Controller
@@ -55,6 +56,19 @@ final class AgentSettlementAdjustmentIndexController extends Controller
}
}
$actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
if ($actorId !== null) {
$query->where(function (Builder $outer) use ($admin): void {
$outer->whereNull('sa.original_bill_id')
->orWhereExists(function (Builder $exists) use ($admin): void {
$exists->selectRaw('1')
->from('settlement_bills as sb')
->whereColumn('sb.id', 'sa.original_bill_id');
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($exists, $admin, 'sb');
});
});
}
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);

View File

@@ -22,6 +22,7 @@ final class AgentSettlementBillAdjustmentController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);

View File

@@ -22,6 +22,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);

View File

@@ -22,7 +22,7 @@ final class AgentSettlementBillConfirmController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404);

View File

@@ -81,7 +81,7 @@ final class AgentSettlementBillIndexController extends Controller
$items = collect($paginator->items());
return ApiResponse::success([
'items' => $this->enrichBillRows($items),
'items' => $this->partyEnrichment->enrichBillRows($items),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
@@ -138,112 +138,4 @@ final class AgentSettlementBillIndexController extends Controller
});
});
}
/**
* @param Collection<int, object> $items
* @return list<array<string, mixed>>
*/
private function enrichBillRows(Collection $items): array
{
if ($items->isEmpty()) {
return [];
}
$playerIds = [];
$agentIds = [];
foreach ($items as $row) {
if ((string) $row->owner_type === 'player') {
$playerIds[] = (int) $row->owner_id;
} elseif ((string) $row->owner_type === 'agent') {
$agentIds[] = (int) $row->owner_id;
}
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
$agentIds[] = (int) $row->counterparty_id;
}
}
$players = $playerIds !== []
? DB::table('players')
->whereIn('id', array_unique($playerIds))
->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source'])
->get()
->keyBy('id')
: collect();
foreach ($players as $player) {
$aid = (int) ($player->agent_node_id ?? 0);
if ($aid > 0) {
$agentIds[] = $aid;
}
}
$agents = $this->partyEnrichment->loadAgents($agentIds);
$out = [];
foreach ($items as $row) {
$item = (array) $row;
$ownerType = (string) $row->owner_type;
$counterType = (string) $row->counterparty_type;
$counterId = (int) $row->counterparty_id;
$item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents);
$item['counterparty_label'] = $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
$item['player_username'] = null;
$item['player_site_player_id'] = null;
$item['player_id_display'] = null;
$item['direct_agent_label'] = null;
$item['superior_agent_label'] = null;
$item['owner_party_label'] = null;
if ($ownerType === 'player') {
$player = $players->get((int) $row->owner_id);
$item['player_username'] = $this->partyEnrichment->formatPlayerUsername($player);
$item['player_site_player_id'] = $this->partyEnrichment->formatPlayerSiteId($player);
$item['player_id_display'] = (int) $row->owner_id;
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
$directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0);
$line = $this->partyEnrichment->agentLineLabels($directId > 0 ? $directId : null, $agents);
$item['direct_agent_label'] = $line['direct_agent_label'];
$item['superior_agent_label'] = $line['parent_agent_label'];
} elseif ($ownerType === 'agent') {
$ownerAgentId = (int) $row->owner_id;
$item['owner_party_label'] = $this->partyEnrichment->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
$item['superior_agent_label'] = $counterType === 'platform'
? 'platform'
: $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
}
$out[] = $item;
}
return $out;
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
*/
private function legacyOwnerLabel(
string $type,
int $id,
Collection $players,
Collection $agents,
): string {
if ($type === 'player') {
$player = $players->get($id);
return $player !== null
? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}")
: "player#{$id}";
}
if ($type === 'agent') {
return $this->partyEnrichment->formatAgent($agents->get($id), $id);
}
return "{$type}#{$id}";
}
}

View File

@@ -21,7 +21,7 @@ final class AgentSettlementBillPaymentController extends Controller
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementBillDownlineShareBuilder;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
@@ -14,6 +15,7 @@ final class AgentSettlementBillShowController extends Controller
{
public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly SettlementBillDownlineShareBuilder $downlineShareBuilder,
) {}
public function __invoke(Request $request, int $settlement_bill): JsonResponse
@@ -77,11 +79,12 @@ final class AgentSettlementBillShowController extends Controller
}
return ApiResponse::success([
'bill' => $bill,
'bill' => $this->partyEnrichment->enrichBillRow($bill),
'payments' => $payments,
'rebate_allocations' => $rebateAllocations,
'adjustments' => $adjustments,
'tier_edge' => $tierSettlements,
'downline_shares' => $this->downlineShareBuilder->forBill($bill),
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ final class AgentSettlementPaymentIndexController extends Controller
}
}
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb');
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementPeriodOpenHintsService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/settlement-periods/open-hints */
final class AgentSettlementPeriodOpenHintsController extends Controller
{
public function __invoke(
Request $request,
SettlementPeriodOpenHintsService $hintsService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$siteId = (int) $request->query('admin_site_id', 0);
abort_if($siteId <= 0, 422);
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $siteId), 404);
return ApiResponse::success($hintsService->hints($siteId));
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\AdminAgentScope;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
@@ -31,6 +32,10 @@ final class AgentSettlementReportShowController extends Controller
$type = (string) $request->query('type', 'summary');
abort_unless(in_array($type, self::TYPES, true), 404);
if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) {
abort(403, 'agent_cannot_view_platform_pnl');
}
$periodId = (int) $request->query('settlement_period_id', 0);
$period = $this->resolvePeriod($periodId, $request);