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

@@ -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'],

View File

@@ -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'],

View File

@@ -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',

View File

@@ -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]);

View File

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

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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();

View File

@@ -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}";
}
}

View File

@@ -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' => [],
];
}
}

View File

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