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:
@@ -47,10 +47,6 @@ final class AgentNodeDestroyController extends Controller
|
||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
if (DB::table('admin_user_agents')->where('agent_node_id', $agent_node->id)->exists()) {
|
||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_admin_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
if ($service->hasBlockingCustomRoles($agent_node)) {
|
||||
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -409,7 +409,11 @@ final class AdminUser extends Authenticatable
|
||||
|
||||
$codes = array_keys($merged);
|
||||
|
||||
return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile());
|
||||
return AgentProfileCapabilityFilter::applyToMenuActionCodes(
|
||||
$codes,
|
||||
$this->primaryAgentProfile(),
|
||||
$this->primaryAgentNode(),
|
||||
);
|
||||
}
|
||||
|
||||
private function primaryAgentProfile(): ?AgentProfile
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
|
||||
use App\Support\AdminScopeContextResolver;
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ final class AdminDashboardAnalyticsBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminReportQueryService $reportQuery,
|
||||
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,27 @@ final class AdminDashboardAnalyticsBuilder
|
||||
$dateTo = $range['date_to'];
|
||||
|
||||
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope);
|
||||
$summary = $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope);
|
||||
$dailySeries = $trend['series'];
|
||||
$profitScope = 'house_gross';
|
||||
|
||||
if ($admin->primaryAgentNode() !== null) {
|
||||
$profitScope = 'share_profit';
|
||||
$shareByDate = $this->shareProfitAggregator->shareProfitByBusinessDate($admin, $dateFrom, $dateTo);
|
||||
$summary['approx_house_gross_minor'] = $this->shareProfitAggregator->sumShareProfitForAdmin(
|
||||
$admin,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
$dailySeries = array_map(
|
||||
static function (array $row) use ($shareByDate): array {
|
||||
$row['approx_house_gross_minor'] = $shareByDate[(string) $row['business_date']] ?? 0;
|
||||
|
||||
return $row;
|
||||
},
|
||||
$dailySeries,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
@@ -60,9 +83,10 @@ final class AdminDashboardAnalyticsBuilder
|
||||
'play_code' => $playCode,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'profit_scope' => $profitScope,
|
||||
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope),
|
||||
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope),
|
||||
'daily_series' => $trend['series'],
|
||||
'summary' => $summary,
|
||||
'daily_series' => $dailySeries,
|
||||
'chart_meta' => [
|
||||
'chart_date_from' => $trend['chart_date_from'],
|
||||
'chart_date_to' => $trend['chart_date_to'],
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Support\AdminScopeContext;
|
||||
use App\Support\AdminScopeContextResolver;
|
||||
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,6 +19,7 @@ final class AgentDashboardOverviewBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminReportQueryService $reportQuery,
|
||||
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,8 @@ final class AgentDashboardOverviewBuilder
|
||||
$sevenDayFrom = now()->subDays(6)->toDateString();
|
||||
$todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope);
|
||||
$sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope);
|
||||
$todayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $today, $today);
|
||||
$sevenDayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $sevenDayFrom, $today);
|
||||
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope)
|
||||
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope);
|
||||
$teamPlayerStats = $this->teamPlayerStats($subtreeIds);
|
||||
@@ -87,10 +91,11 @@ final class AgentDashboardOverviewBuilder
|
||||
'bet_order_count_today' => $todayActivityStats['order_count'],
|
||||
'today_bet_minor' => $todayTotals['total_bet_minor'],
|
||||
'today_payout_minor' => $todayTotals['total_payout_minor'],
|
||||
'today_profit_minor' => $todayTotals['approx_house_gross_minor'],
|
||||
'today_profit_minor' => $todayShareProfit,
|
||||
'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'],
|
||||
'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'],
|
||||
'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'],
|
||||
'seven_day_profit_minor' => $sevenDayShareProfit,
|
||||
'profit_scope' => 'share_profit',
|
||||
'currency_code' => $currencyCode,
|
||||
'pending_bill_count' => $pendingBillStats['count'],
|
||||
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],
|
||||
|
||||
@@ -31,6 +31,7 @@ final class AgentPeriodAggregator
|
||||
$rows = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereNull('sl.settlement_period_id')
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->select([
|
||||
'sl.player_id',
|
||||
|
||||
@@ -79,6 +79,7 @@ final class AgentSettlementPeriodCloseService
|
||||
->from('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereNull('sl.settlement_period_id')
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]);
|
||||
})
|
||||
->update(['settlement_period_id' => $periodId]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Support\AgentSettlementPeriodWindow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -14,8 +15,10 @@ final class AgentSettlementPeriodOpenService
|
||||
public function open(array $data): object
|
||||
{
|
||||
$siteId = (int) $data['admin_site_id'];
|
||||
$start = (string) $data['period_start'];
|
||||
$end = (string) $data['period_end'];
|
||||
[$start, $end] = AgentSettlementPeriodWindow::normalizeInputBounds(
|
||||
(string) $data['period_start'],
|
||||
(string) $data['period_end'],
|
||||
);
|
||||
|
||||
$existingSameRange = DB::table('settlement_periods')
|
||||
->where('admin_site_id', $siteId)
|
||||
@@ -43,6 +46,12 @@ final class AgentSettlementPeriodOpenService
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->overlapsExistingPeriod($siteId, $start, $end)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period_start' => ['period_overlaps_existing'],
|
||||
]);
|
||||
}
|
||||
|
||||
$id = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => $start,
|
||||
@@ -59,4 +68,13 @@ final class AgentSettlementPeriodOpenService
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function overlapsExistingPeriod(int $siteId, string $start, string $end): bool
|
||||
{
|
||||
return DB::table('settlement_periods')
|
||||
->where('admin_site_id', $siteId)
|
||||
->where('period_start', '<=', $end)
|
||||
->where('period_end', '>=', $start)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\AgentSettlementPeriodWindow;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -71,17 +71,18 @@ final class AgentSettlementPeriodPipelineService
|
||||
->whereBetween('cl.created_at', [$start, $end]);
|
||||
|
||||
if ($admin !== null) {
|
||||
AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($creditQuery, $admin, 'p');
|
||||
}
|
||||
|
||||
$shareQuery = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereNull('sl.settlement_period_id')
|
||||
->whereBetween('sl.settled_at', [$start, $end])
|
||||
->whereNull('sl.reversal_of_id');
|
||||
|
||||
if ($admin !== null) {
|
||||
AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($shareQuery, $admin, 'p');
|
||||
}
|
||||
|
||||
$shareAgg = (clone $shareQuery)
|
||||
|
||||
@@ -332,23 +332,17 @@ final class AgentSettlementReportQueryService
|
||||
|
||||
private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void
|
||||
{
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, $alias);
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, $alias);
|
||||
}
|
||||
|
||||
private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void
|
||||
{
|
||||
$subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin);
|
||||
if ($subtreeIds === null) {
|
||||
$actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($subtreeIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereIn($agentNodeColumn, $subtreeIds);
|
||||
$query->where($agentNodeColumn, $actorId);
|
||||
}
|
||||
|
||||
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 代理账单:汇总下级代理在本期保留的占成。 */
|
||||
final class SettlementBillDownlineShareBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettlementPartyEnrichment $partyEnrichment,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total: int,
|
||||
* items: list<array{owner_id: int, owner_label: string, share_profit: int}>
|
||||
* }
|
||||
*/
|
||||
public function forBill(object $bill): array
|
||||
{
|
||||
if ((string) $bill->bill_type !== 'agent' || (string) $bill->owner_type !== 'agent') {
|
||||
return ['total' => 0, 'items' => []];
|
||||
}
|
||||
|
||||
$ownerId = (int) $bill->owner_id;
|
||||
$periodId = (int) $bill->settlement_period_id;
|
||||
if ($ownerId <= 0 || $periodId <= 0) {
|
||||
return ['total' => 0, 'items' => []];
|
||||
}
|
||||
|
||||
$owner = AgentNode::query()->find($ownerId);
|
||||
if ($owner === null) {
|
||||
return ['total' => 0, 'items' => []];
|
||||
}
|
||||
|
||||
$descendantIds = AgentNode::query()
|
||||
->where('admin_site_id', (int) $owner->admin_site_id)
|
||||
->where('id', '!=', $ownerId)
|
||||
->where('path', 'like', $owner->path.'%')
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($descendantIds === []) {
|
||||
return ['total' => 0, 'items' => []];
|
||||
}
|
||||
|
||||
$rows = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'agent')
|
||||
->where('owner_type', 'agent')
|
||||
->whereIn('owner_id', $descendantIds)
|
||||
->orderBy('owner_id')
|
||||
->get(['owner_id', 'meta_json']);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return ['total' => 0, 'items' => []];
|
||||
}
|
||||
|
||||
$agentIds = $rows->pluck('owner_id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$agents = $this->partyEnrichment->loadAgents($agentIds);
|
||||
|
||||
$items = [];
|
||||
$total = 0;
|
||||
foreach ($rows as $row) {
|
||||
$shareProfit = $this->shareProfitFromMeta($row->meta_json ?? null);
|
||||
if ($shareProfit === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$agentId = (int) $row->owner_id;
|
||||
$items[] = [
|
||||
'owner_id' => $agentId,
|
||||
'owner_label' => $this->partyEnrichment->formatAgent($agents->get($agentId), $agentId),
|
||||
'share_profit' => $shareProfit,
|
||||
];
|
||||
$total += $shareProfit;
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b): int => $b['share_profit'] <=> $a['share_profit']
|
||||
?: $a['owner_label'] <=> $b['owner_label']);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
private function shareProfitFromMeta(mixed $metaJson): int
|
||||
{
|
||||
if ($metaJson === null || $metaJson === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$decoded = is_string($metaJson) ? json_decode($metaJson, true) : $metaJson;
|
||||
|
||||
return is_array($decoded) ? (int) ($decoded['share_profit'] ?? 0) : 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\AgentSettlementPeriodWindow;
|
||||
use App\Support\CurrencyFormatter;
|
||||
@@ -246,7 +245,7 @@ final class SettlementCenterLedgerService
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||
|
||||
if ($range !== null) {
|
||||
@@ -276,7 +275,7 @@ final class SettlementCenterLedgerService
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||
|
||||
if ($range !== null) {
|
||||
@@ -408,7 +407,7 @@ final class SettlementCenterLedgerService
|
||||
$outer->whereNull('p.id')
|
||||
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
|
||||
$scoped->where('p.site_code', $siteCode);
|
||||
AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
|
||||
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
|
||||
});
|
||||
});
|
||||
@@ -767,7 +766,7 @@ final class SettlementCenterLedgerService
|
||||
'sla.name as share_agent_name',
|
||||
]);
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
return $query->get()->all();
|
||||
}
|
||||
@@ -840,7 +839,7 @@ final class SettlementCenterLedgerService
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
$map = [];
|
||||
foreach ($query->limit(500)->get() as $bill) {
|
||||
@@ -907,7 +906,7 @@ final class SettlementCenterLedgerService
|
||||
])
|
||||
->orderByDesc('cl.id');
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
@@ -969,7 +968,7 @@ final class SettlementCenterLedgerService
|
||||
])
|
||||
->orderByDesc('cl.id');
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
$this->applyLedgerPlayerFilters($query, 'p', $filters);
|
||||
|
||||
if ($range !== null) {
|
||||
@@ -1023,7 +1022,7 @@ final class SettlementCenterLedgerService
|
||||
'pa.name as parent_agent_name',
|
||||
]);
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
return $query->get()->all();
|
||||
}
|
||||
@@ -1082,7 +1081,7 @@ final class SettlementCenterLedgerService
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
@@ -1212,7 +1211,7 @@ final class SettlementCenterLedgerService
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
@@ -1272,7 +1271,7 @@ final class SettlementCenterLedgerService
|
||||
'pa.name as parent_agent_name',
|
||||
]);
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
$this->applyLedgerSiteScope($query, $admin, 'sp');
|
||||
|
||||
return $query->get()->all();
|
||||
|
||||
@@ -100,6 +100,7 @@ final class SettlementPartyEnrichment
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** 结算展示:仅代理名称;编号为内部标识,无名称时才回退 code。 */
|
||||
public function formatAgent(?object $agent, int $fallbackId): string
|
||||
{
|
||||
if ($agent === null) {
|
||||
@@ -107,13 +108,13 @@ final class SettlementPartyEnrichment
|
||||
}
|
||||
|
||||
$name = trim((string) ($agent->name ?? ''));
|
||||
$code = trim((string) ($agent->code ?? ''));
|
||||
|
||||
if ($name !== '' && $code !== '') {
|
||||
return "{$name} ({$code})";
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}");
|
||||
$code = trim((string) ($agent->code ?? ''));
|
||||
|
||||
return $code !== '' ? $code : "agent#{$fallbackId}";
|
||||
}
|
||||
|
||||
public function formatPlayerUsername(?object $player): ?string
|
||||
@@ -150,4 +151,129 @@ final class SettlementPartyEnrichment
|
||||
|
||||
return "{$type}#{$id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<int, object> $rows
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function enrichBillRows(iterable $rows): array
|
||||
{
|
||||
$items = collect($rows);
|
||||
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->loadAgents($agentIds);
|
||||
|
||||
$out = [];
|
||||
foreach ($items as $row) {
|
||||
$out[] = $this->enrichBillRowWithLookups($row, $players, $agents);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function enrichBillRow(object $row): array
|
||||
{
|
||||
return $this->enrichBillRows([$row])[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $players
|
||||
* @param Collection<int, object> $agents
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichBillRowWithLookups(object $row, Collection $players, Collection $agents): array
|
||||
{
|
||||
$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->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->formatPlayerUsername($player);
|
||||
$item['player_site_player_id'] = $this->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->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->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
|
||||
$item['superior_agent_label'] = $counterType === 'platform'
|
||||
? 'platform'
|
||||
: $this->formatCounterpartyLabel($counterType, $counterId, $agents);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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->formatAgent($agents->get($id), $id);
|
||||
}
|
||||
|
||||
return "{$type}#{$id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清)。 */
|
||||
final class SettlementPeriodOpenHintsService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* suggested_start: string,
|
||||
* suggested_end: string,
|
||||
* occupied_period_dates: list<string>,
|
||||
* pending_activity_dates: list<string>,
|
||||
* unpaid_bill_dates: list<string>
|
||||
* }
|
||||
*/
|
||||
public function hints(int $adminSiteId): array
|
||||
{
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
if ($siteCode === '') {
|
||||
return $this->emptyHints();
|
||||
}
|
||||
|
||||
$periodRows = DB::table('settlement_periods')
|
||||
->where('admin_site_id', $adminSiteId)
|
||||
->orderBy('period_start')
|
||||
->get(['period_start', 'period_end', 'status']);
|
||||
|
||||
$occupiedPeriodDates = [];
|
||||
foreach ($periodRows as $row) {
|
||||
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
|
||||
$occupiedPeriodDates[$day] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$lastPeriod = DB::table('settlement_periods')
|
||||
->where('admin_site_id', $adminSiteId)
|
||||
->whereIn('status', ['closed', 'completed'])
|
||||
->orderByDesc('period_end')
|
||||
->first();
|
||||
|
||||
$pendingActivityDates = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereNull('sl.settlement_period_id')
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->selectRaw('DATE(sl.settled_at) as activity_day')
|
||||
->groupBy('activity_day')
|
||||
->orderBy('activity_day')
|
||||
->pluck('activity_day')
|
||||
->map(static fn ($day): string => (string) $day)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$unpaidPeriodRows = DB::table('settlement_periods as sp')
|
||||
->where('sp.admin_site_id', $adminSiteId)
|
||||
->whereIn('sp.status', ['closed', 'completed'])
|
||||
->whereExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
->from('settlement_bills as sb')
|
||||
->whereColumn('sb.settlement_period_id', 'sp.id')
|
||||
->where('sb.unpaid_amount', '>', 0)
|
||||
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
|
||||
})
|
||||
->orderBy('sp.period_start')
|
||||
->get(['sp.period_start', 'sp.period_end']);
|
||||
|
||||
$unpaidBillDates = [];
|
||||
foreach ($unpaidPeriodRows as $row) {
|
||||
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
|
||||
$unpaidBillDates[$day] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates);
|
||||
|
||||
return [
|
||||
'suggested_start' => $suggested['start'],
|
||||
'suggested_end' => $suggested['end'],
|
||||
'occupied_period_dates' => array_keys($occupiedPeriodDates),
|
||||
'pending_activity_dates' => $pendingActivityDates,
|
||||
'unpaid_bill_dates' => array_keys($unpaidBillDates),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $pendingActivityDates UTC `Y-m-d`
|
||||
* @param array<string, true> $occupiedPeriodDates
|
||||
* @return array{start: string, end: string}
|
||||
*/
|
||||
private function suggestRange(?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates): array
|
||||
{
|
||||
$lastEndDay = $lastPeriod !== null
|
||||
? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay()
|
||||
: null;
|
||||
|
||||
$freePending = array_values(array_filter(
|
||||
$pendingActivityDates,
|
||||
static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]),
|
||||
));
|
||||
|
||||
if ($freePending !== []) {
|
||||
$minDay = Carbon::parse($freePending[0])->utc()->startOfDay();
|
||||
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->startOfDay();
|
||||
$startDay = $lastEndDay !== null
|
||||
? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay)
|
||||
: $minDay;
|
||||
|
||||
$candidate = [
|
||||
'start' => $startDay->format('Y-m-d'),
|
||||
'end' => $maxDay->format('Y-m-d'),
|
||||
];
|
||||
|
||||
return $this->withoutOccupiedOverlap($candidate, $occupiedPeriodDates);
|
||||
}
|
||||
|
||||
if ($lastEndDay !== null) {
|
||||
$startDay = $lastEndDay->copy()->addDay();
|
||||
$endDay = Carbon::now('UTC')->subDay()->startOfDay();
|
||||
if ($endDay->lessThan($startDay)) {
|
||||
return ['start' => '', 'end' => ''];
|
||||
}
|
||||
|
||||
return $this->withoutOccupiedOverlap([
|
||||
'start' => $startDay->format('Y-m-d'),
|
||||
'end' => $endDay->format('Y-m-d'),
|
||||
], $occupiedPeriodDates);
|
||||
}
|
||||
|
||||
return ['start' => '', 'end' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{start: string, end: string} $candidate
|
||||
* @param array<string, true> $occupiedPeriodDates
|
||||
* @return array{start: string, end: string}
|
||||
*/
|
||||
private function withoutOccupiedOverlap(array $candidate, array $occupiedPeriodDates): array
|
||||
{
|
||||
if ($candidate['start'] === '' || $candidate['end'] === '') {
|
||||
return ['start' => '', 'end' => ''];
|
||||
}
|
||||
|
||||
if ($this->rangeOverlapsOccupied($candidate['start'], $candidate['end'], $occupiedPeriodDates)) {
|
||||
return ['start' => '', 'end' => ''];
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $occupiedPeriodDates
|
||||
*/
|
||||
private function rangeOverlapsOccupied(string $startYmd, string $endYmd, array $occupiedPeriodDates): bool
|
||||
{
|
||||
$cursor = Carbon::parse($startYmd)->utc()->startOfDay();
|
||||
$end = Carbon::parse($endYmd)->utc()->startOfDay();
|
||||
|
||||
while ($cursor->lessThanOrEqualTo($end)) {
|
||||
if (isset($occupiedPeriodDates[$cursor->format('Y-m-d')])) {
|
||||
return true;
|
||||
}
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return list<string> 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */
|
||||
private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$dates = [];
|
||||
$tz = 'Asia/Shanghai';
|
||||
$cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay();
|
||||
$end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay();
|
||||
|
||||
while ($cursor->lessThanOrEqualTo($end)) {
|
||||
$dates[] = $cursor->format('Y-m-d');
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
|
||||
private function emptyHints(): array
|
||||
{
|
||||
return [
|
||||
'suggested_start' => '',
|
||||
'suggested_end' => '',
|
||||
'occupied_period_dates' => [],
|
||||
'pending_activity_dates' => [],
|
||||
'unpaid_bill_dates' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminAgentScope;
|
||||
use App\Support\AdminDataScope;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */
|
||||
final class ShareLedgerScopedProfitAggregator
|
||||
@@ -54,6 +56,56 @@ final class ShareLedgerScopedProfitAggregator
|
||||
return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0);
|
||||
}
|
||||
|
||||
/** 绑定代理账号:区间内本级占成收益合计(minor)。 */
|
||||
public function sumShareProfitForAdmin(AdminUser $admin, string $dateFrom, string $dateTo): int
|
||||
{
|
||||
$viewer = $this->resolveViewer($admin);
|
||||
if ($viewer['scope'] !== 'agent') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->sumForShareQuery(
|
||||
$this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo),
|
||||
$viewer['key'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int> business_date (Y-m-d) => share_profit_minor
|
||||
*/
|
||||
public function shareProfitByBusinessDate(AdminUser $admin, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$viewer = $this->resolveViewer($admin);
|
||||
if ($viewer['scope'] !== 'agent') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo)
|
||||
->selectRaw('DATE(sl.settled_at) as business_date')
|
||||
->addSelect(['sl.allocations_json', 'sl.game_win_loss', 'sl.basic_rebate', 'sl.share_snapshot'])
|
||||
->get();
|
||||
|
||||
$byDate = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) $row->business_date;
|
||||
$byDate[$date] = ($byDate[$date] ?? 0) + $this->profitFromRow($row, $viewer['key']);
|
||||
}
|
||||
|
||||
return $byDate;
|
||||
}
|
||||
|
||||
private function shareLedgerBaseQuery(AdminUser $admin, string $dateFrom, string $dateTo): Builder
|
||||
{
|
||||
$query = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->whereDate('sl.settled_at', '>=', $dateFrom)
|
||||
->whereDate('sl.settled_at', '<=', $dateTo);
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function profitFromRow(object $row, string $profitKey): int
|
||||
{
|
||||
$allocations = $this->decodeJsonObject($row->allocations_json ?? null);
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/** 代理账单按管理员可访问站点 + 代理子树过滤。 */
|
||||
/** 结算中心账单:站点范围 + 绑定代理仅见直属边(玩家↔直属代理、代理↔直接上下级)。 */
|
||||
final class AdminAgentSettlementScope
|
||||
{
|
||||
/**
|
||||
@@ -32,6 +32,17 @@ final class AdminAgentSettlementScope
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function boundAgentNodeId(AdminUser $admin): ?int
|
||||
{
|
||||
if ($admin->isSuperAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||
|
||||
return $actor !== null ? (int) $actor->id : null;
|
||||
}
|
||||
|
||||
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
@@ -52,7 +63,7 @@ final class AdminAgentSettlementScope
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
|
||||
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -70,35 +81,72 @@ final class AdminAgentSettlementScope
|
||||
->whereIn('settlement_periods.admin_site_id', $siteIds);
|
||||
});
|
||||
|
||||
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
|
||||
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||
}
|
||||
|
||||
/** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */
|
||||
/** @deprecated 使用 {@see applyDirectEdgeScopeToBillsQuery} */
|
||||
public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
||||
{
|
||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
||||
if ($subtreeIds === null) {
|
||||
self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定代理:
|
||||
* - 玩家账单:仅直属玩家(players.agent_node_id = 本节点)
|
||||
* - 代理账单:owner=本节点(向上)或 counterparty=本节点(下级向我结)
|
||||
*/
|
||||
/**
|
||||
* 结算中心玩家维度:绑定代理仅见直属玩家;站点财务/超管见全站(由调用方再限 site_code)。
|
||||
*/
|
||||
public static function applyDirectPlayersToAlias(Builder $query, AdminUser $admin, string $alias = 'p'): void
|
||||
{
|
||||
if ($admin->isSuperAdmin() || self::canManageSitePeriods($admin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($subtreeIds === []) {
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void {
|
||||
$outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void {
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where($alias.'.agent_node_id', $actorId);
|
||||
}
|
||||
|
||||
public static function applyDirectEdgeScopeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
||||
{
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $outer) use ($billsAlias, $actorId): void {
|
||||
$outer->where(function (Builder $player) use ($billsAlias, $actorId): void {
|
||||
$player->where($billsAlias.'.owner_type', 'player')
|
||||
->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void {
|
||||
->whereExists(function (Builder $exists) use ($billsAlias, $actorId): void {
|
||||
$exists->selectRaw('1')
|
||||
->from('players')
|
||||
->whereColumn('players.id', $billsAlias.'.owner_id')
|
||||
->whereIn('players.agent_node_id', $subtreeIds);
|
||||
->where('players.agent_node_id', $actorId);
|
||||
});
|
||||
})->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void {
|
||||
})->orWhere(function (Builder $agent) use ($billsAlias, $actorId): void {
|
||||
$agent->where($billsAlias.'.owner_type', 'agent')
|
||||
->whereIn($billsAlias.'.owner_id', $subtreeIds);
|
||||
->where(function (Builder $edge) use ($billsAlias, $actorId): void {
|
||||
$edge->where($billsAlias.'.owner_id', $actorId)
|
||||
->orWhere(function (Builder $incoming) use ($billsAlias, $actorId): void {
|
||||
$incoming->where($billsAlias.'.counterparty_type', 'agent')
|
||||
->where($billsAlias.'.counterparty_id', $actorId);
|
||||
})
|
||||
->orWhere(function (Builder $platform) use ($billsAlias, $actorId): void {
|
||||
$platform->where($billsAlias.'.counterparty_type', 'platform')
|
||||
->where($billsAlias.'.owner_id', $actorId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -147,6 +195,19 @@ final class AdminAgentSettlementScope
|
||||
}
|
||||
}
|
||||
|
||||
/** 坏账核销 / 补差冲正仅站点财务或超管(绑定代理不可操作)。 */
|
||||
public static function canPerformFinanceAdjustments(AdminUser $admin): bool
|
||||
{
|
||||
return self::canManageSitePeriods($admin);
|
||||
}
|
||||
|
||||
public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void
|
||||
{
|
||||
if (! self::canPerformFinanceAdjustments($admin)) {
|
||||
abort(403, 'agent_bound_cannot_finance_adjust');
|
||||
}
|
||||
}
|
||||
|
||||
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
@@ -157,7 +218,13 @@ final class AdminAgentSettlementScope
|
||||
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->where('sb.id', $settlementBillId)
|
||||
->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id'])
|
||||
->select([
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.counterparty_type',
|
||||
'sb.counterparty_id',
|
||||
'sp.admin_site_id',
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($bill === null) {
|
||||
@@ -168,23 +235,127 @@ final class AdminAgentSettlementScope
|
||||
return false;
|
||||
}
|
||||
|
||||
$subtreeIds = self::subtreeAgentNodeIds($admin);
|
||||
if ($subtreeIds === null) {
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::billMatchesDirectEdgeScope($actorId, $bill);
|
||||
}
|
||||
|
||||
public static function canOperateBill(AdminUser $admin, int $settlementBillId): bool
|
||||
{
|
||||
if (! self::billAccessible($admin, $settlementBillId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::canManageSitePeriods($admin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
|
||||
->where('id', $settlementBillId)
|
||||
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
|
||||
->first();
|
||||
|
||||
if ($bill === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
if ($actorId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::billOperableByBoundAgent($actorId, $bill);
|
||||
}
|
||||
|
||||
public static function assertCanOperateBill(AdminUser $admin, int $settlementBillId): void
|
||||
{
|
||||
abort_if(! self::billAccessible($admin, $settlementBillId), 404);
|
||||
|
||||
if (self::canManageSitePeriods($admin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
|
||||
->where('id', $settlementBillId)
|
||||
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
|
||||
->first();
|
||||
|
||||
abort_if($bill === null, 404);
|
||||
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
abort_if($actorId === null, 403, 'agent_cannot_operate_bill');
|
||||
|
||||
abort_if(
|
||||
! self::billOperableByBoundAgent($actorId, $bill),
|
||||
403,
|
||||
(string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill',
|
||||
);
|
||||
}
|
||||
|
||||
private static function billMatchesDirectEdgeScope(int $actorId, object $bill): bool
|
||||
{
|
||||
if ((string) $bill->owner_type === 'player') {
|
||||
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
|
||||
->where('id', (int) $bill->owner_id)
|
||||
->value('agent_node_id') ?? 0);
|
||||
|
||||
return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true);
|
||||
return $agentNodeId === $actorId;
|
||||
}
|
||||
|
||||
if ((string) $bill->owner_type === 'agent') {
|
||||
return in_array((int) $bill->owner_id, $subtreeIds, true);
|
||||
return self::agentBillOnDirectEdge($actorId, $bill);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function billOperableByBoundAgent(int $actorId, object $bill): bool
|
||||
{
|
||||
if ((string) $bill->owner_type === 'player') {
|
||||
return (string) $bill->counterparty_type === 'agent'
|
||||
&& (int) $bill->counterparty_id === $actorId;
|
||||
}
|
||||
|
||||
if ((string) $bill->owner_type === 'agent') {
|
||||
if (! self::agentBillOnDirectEdge($actorId, $bill)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$payeeType, $payeeId] = self::billPayeeParty($bill);
|
||||
|
||||
return $payeeType === 'agent' && $payeeId === $actorId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** net>0:counterparty 为收款方;net<0:owner 为收款方。 */
|
||||
private static function billPayeeParty(object $bill): array
|
||||
{
|
||||
if ((int) $bill->net_amount < 0) {
|
||||
return [(string) $bill->owner_type, (int) $bill->owner_id];
|
||||
}
|
||||
|
||||
return [(string) $bill->counterparty_type, (int) $bill->counterparty_id];
|
||||
}
|
||||
|
||||
private static function agentBillOnDirectEdge(int $actorId, object $bill): bool
|
||||
{
|
||||
$ownerId = (int) $bill->owner_id;
|
||||
$counterType = (string) $bill->counterparty_type;
|
||||
$counterId = (int) $bill->counterparty_id;
|
||||
|
||||
if ($ownerId === $actorId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($counterType === 'agent' && $counterId === $actorId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $counterType === 'platform' && $ownerId === $actorId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ final class AdminAuthProfile
|
||||
* agent: ?array{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* admin_site_name: string,
|
||||
* site_code: string,
|
||||
* path: string,
|
||||
* code: string,
|
||||
@@ -73,6 +74,7 @@ final class AdminAuthProfile
|
||||
* @return array{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* admin_site_name: string,
|
||||
* site_code: string,
|
||||
* path: string,
|
||||
* code: string,
|
||||
@@ -93,13 +95,18 @@ final class AdminAuthProfile
|
||||
return null;
|
||||
}
|
||||
|
||||
$siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code');
|
||||
$site = AdminSite::query()
|
||||
->where('id', (int) $node->admin_site_id)
|
||||
->first(['code', 'name']);
|
||||
$siteCode = is_string($site?->code) ? $site->code : '';
|
||||
$siteName = is_string($site?->name) ? $site->name : '';
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
|
||||
return [
|
||||
'id' => (int) $node->id,
|
||||
'admin_site_id' => (int) $node->admin_site_id,
|
||||
'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '',
|
||||
'admin_site_name' => $siteName,
|
||||
'site_code' => $siteCode !== '' ? $siteCode : '',
|
||||
'path' => (string) $node->path,
|
||||
'code' => (string) $node->code,
|
||||
'name' => (string) $node->name,
|
||||
|
||||
@@ -447,6 +447,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
|
||||
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.open-hints', 'module_code' => 'settlement', 'name' => '开账建议与日历标记', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods/open-hints', 'route_name' => 'api.v1.admin.settlement-periods.open-hints', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
|
||||
@@ -21,7 +21,6 @@ final class AgentDefaultRolePermissions
|
||||
'prd.agent.role.view',
|
||||
'prd.agent.user.view',
|
||||
'prd.tickets.view',
|
||||
'prd.report.view',
|
||||
'prd.settlement.agent.view',
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
|
||||
/**
|
||||
@@ -36,13 +37,25 @@ final class AgentProfileCapabilityFilter
|
||||
'prd.player_freeze.manage',
|
||||
];
|
||||
|
||||
private const SETTLEMENT_AGENT_MANAGE_CODE = 'settlement.agent.manage';
|
||||
|
||||
/** 绑定代理主账号均可登记/确认与本节点直属边相关的账单(玩家↔直属代理、代理↔直接上下级)。 */
|
||||
public static function qualifiesForSettlementAgentManage(?AgentNode $node): bool
|
||||
{
|
||||
return $node !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。
|
||||
*
|
||||
* @param list<string> $permissionCodes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
||||
public static function applyToMenuActionCodes(
|
||||
array $permissionCodes,
|
||||
?AgentProfile $profile,
|
||||
?AgentNode $node = null,
|
||||
): array
|
||||
{
|
||||
$set = [];
|
||||
foreach ($permissionCodes as $code) {
|
||||
@@ -71,6 +84,10 @@ final class AgentProfileCapabilityFilter
|
||||
}
|
||||
}
|
||||
|
||||
if (self::qualifiesForSettlementAgentManage($node)) {
|
||||
$set[self::SETTLEMENT_AGENT_MANAGE_CODE] = true;
|
||||
}
|
||||
|
||||
$out = array_keys($set);
|
||||
sort($out);
|
||||
|
||||
@@ -81,9 +98,12 @@ final class AgentProfileCapabilityFilter
|
||||
* @param list<string> $permissionCodes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
||||
{
|
||||
return self::applyToMenuActionCodes($permissionCodes, $profile);
|
||||
public static function filterMenuActionCodes(
|
||||
array $permissionCodes,
|
||||
?AgentProfile $profile,
|
||||
?AgentNode $node = null,
|
||||
): array {
|
||||
return self::applyToMenuActionCodes($permissionCodes, $profile, $node);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 账期起止统一为日界(startOfDay / endOfDay),供聚合、流水、关账回填共用。 */
|
||||
/** 账期起止边界:开账时规范化写入,关账/聚合/流水筛选共用同一对 UTC 时刻。 */
|
||||
final class AgentSettlementPeriodWindow
|
||||
{
|
||||
/**
|
||||
@@ -13,8 +14,8 @@ final class AgentSettlementPeriodWindow
|
||||
public static function bounds(string $periodStart, string $periodEnd): array
|
||||
{
|
||||
return [
|
||||
Carbon::parse($periodStart)->startOfDay(),
|
||||
Carbon::parse($periodEnd)->endOfDay(),
|
||||
Carbon::parse($periodStart)->utc(),
|
||||
Carbon::parse($periodEnd)->utc(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,4 +28,42 @@ final class AgentSettlementPeriodWindow
|
||||
|
||||
return [$start->toDateTimeString(), $end->toDateTimeString()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 开账 API:支持 `Y-m-d` 或带时刻字符串;前者按 UTC 自然日扩界,后者按 UTC 解释。
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
public static function normalizeInputBounds(string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$startRaw = trim($periodStart);
|
||||
$endRaw = trim($periodEnd);
|
||||
|
||||
if ($startRaw === '' || $endRaw === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'period_start' => ['required'],
|
||||
]);
|
||||
}
|
||||
|
||||
$startAt = self::isDateOnly($startRaw)
|
||||
? Carbon::parse($startRaw.' 00:00:00', 'UTC')
|
||||
: Carbon::parse($startRaw)->utc();
|
||||
|
||||
$endAt = self::isDateOnly($endRaw)
|
||||
? Carbon::parse($endRaw.' 23:59:59', 'UTC')
|
||||
: Carbon::parse($endRaw)->utc();
|
||||
|
||||
if ($endAt->lessThan($startAt)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period_end' => ['after:period_start'],
|
||||
]);
|
||||
}
|
||||
|
||||
return [$startAt->toDateTimeString(), $endAt->toDateTimeString()];
|
||||
}
|
||||
|
||||
private static function isDateOnly(string $value): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user