Files
lotteryLaravel/app/Services/AgentSettlement/SettlementCenterLedgerService.php
kang 980f3c9593 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.
2026-06-12 15:59:05 +08:00

1538 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 结算中心统一账务流水credit_ledger + 收付 + 调账)。 */
final class SettlementCenterLedgerService
{
private const CREDIT_BIZ_TYPES = [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'settlement_confirm',
'settlement_payout',
];
private const ADJUSTMENT_BIZ_TYPES = [
'adjustment',
'reversal',
'bad_debt',
];
private const SHARE_BIZ_TYPE = 'share_ledger';
public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listUnified(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
): array {
$periodId = $filters->settlementPeriodId;
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
$settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
$stubQueries = [];
if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
$creditStub = $this->creditStubQuery($admin, $siteCode, $range, $filters);
if ($creditStub !== null) {
$stubQueries[] = $creditStub;
}
}
if ($this->shouldIncludeLedgerStub($filters, 'payment')) {
$paymentStub = $this->paymentStubQuery($admin, $siteCode, $periodId, $filters);
if ($paymentStub !== null) {
$stubQueries[] = $paymentStub;
}
}
if ($this->shouldIncludeLedgerStub($filters, 'adjustment')) {
$adjustmentStub = $this->adjustmentStubQuery($admin, $siteCode, $periodId, $filters);
if ($adjustmentStub !== null) {
$stubQueries[] = $adjustmentStub;
}
}
if ($this->shouldIncludeLedgerStub($filters, 'share')) {
$shareStub = $this->shareStubQuery($admin, $siteCode, $settledRange, $filters);
if ($shareStub !== null) {
$stubQueries[] = $shareStub;
}
}
if ($stubQueries === []) {
return [
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
];
}
$offset = max(0, ($page - 1) * $perPage);
if (count($stubQueries) === 1) {
$base = $stubQueries[0];
$total = (int) (clone $base)->count();
$stubs = (clone $base)
->orderByDesc('sort_at')
->orderByDesc('entry_id')
->offset($offset)
->limit($perPage)
->get();
} else {
$union = null;
foreach ($stubQueries as $stubQuery) {
$union = $union === null ? $stubQuery : $union->unionAll($stubQuery);
}
$wrapped = DB::query()->fromSub($union, 'ledger_page');
$total = (int) (clone $wrapped)->count();
$stubs = $wrapped
->orderByDesc('sort_at')
->orderByDesc('entry_id')
->offset($offset)
->limit($perPage)
->get();
}
$items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
$items = $this->applyFilters($items, $filters);
$filteredCount = count($items);
if ($filteredCount !== count($stubs)) {
$total = $filteredCount;
}
return [
'items' => array_values($items),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
];
}
/**
* 下注流水简化展示:下注占用 + 按注单合并的开奖结算。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listBetFlowSimplified(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
): array {
$periodId = $filters->settlementPeriodId;
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
$rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
$ticketIds = [];
foreach ($rows as $row) {
if ((string) ($row->ref_type ?? '') === 'ticket_item') {
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$ticketIds[] = $ticketId;
}
}
}
$ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds)));
$items = $this->betFlowPresenter->simplifyCreditRows(
$rows,
$ticketRefs,
function (object $row) use ($playerBills, $ticketRefs): array {
$pid = (int) ($row->player_id ?? 0);
return $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
},
function (object $row) use ($playerBills, $ticketRefs): array {
$pid = (int) ($row->player_id ?? 0);
$formatted = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$formatted['txn_no'] = 'CLS-T'.$ticketId;
$formatted['row_key'] = 'settlement-'.$ticketId;
}
return $formatted;
},
);
if ($filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD
|| $filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT) {
$items = array_values(array_filter(
$items,
static fn (array $item): bool => ($item['biz_type'] ?? '') === $filters->bizType,
));
}
$total = count($items);
$offset = max(0, ($page - 1) * $perPage);
$pageItems = array_slice($items, $offset, $perPage);
return [
'items' => $pageItems,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'credit_ledger',
];
}
private function shouldIncludeLedgerStub(SettlementLedgerListFilters $filters, string $kind): bool
{
if (! $this->includeEntryKind($filters, $kind)) {
return false;
}
if ($filters->bizType === null || $filters->bizType === '') {
return true;
}
return match ($kind) {
'credit' => in_array($filters->bizType, self::CREDIT_BIZ_TYPES, true),
'payment' => $filters->bizType === 'payment_record',
'adjustment' => in_array($filters->bizType, self::ADJUSTMENT_BIZ_TYPES, true),
'share' => $filters->bizType === self::SHARE_BIZ_TYPE
|| ($filters->entryKind === 'share' && ($filters->bizType === null || $filters->bizType === '')),
default => false,
};
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
*/
private function shareStubQuery(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereNull('sl.reversal_of_id')
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('sl.settled_at', $range);
}
$this->applyTxnNoStubFilter($query, 'sl.id', 'SL', $filters->txnNo);
return $query;
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
*/
private function creditStubQuery(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
if ($filters->bizType !== null && $filters->bizType !== '') {
$query->where('cl.reason', $filters->bizType);
} elseif ($filters->betFlowOnly) {
$query->whereIn('cl.reason', [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
]);
}
$this->applyTxnNoStubFilter($query, 'cl.id', 'CL', $filters->txnNo);
return $query;
}
private function paymentStubQuery(
AdminUser $admin,
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
}
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->selectRaw("'payment' as entry_kind, pr.id as entry_id, pr.created_at as sort_at");
if ($periodId !== null && $periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
$this->applyLedgerSiteScope($query, $admin, 'sp');
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
$this->applyPaymentPlayerFilters($query, $filters);
if ($filters->billStatus !== null && $filters->billStatus !== '') {
$query->where('sb.status', $filters->billStatus);
}
$this->applyTxnNoStubFilter($query, 'pr.id', 'PAY', $filters->txnNo);
return $query;
}
private function applyPaymentPlayerFilters(
\Illuminate\Database\Query\Builder $query,
SettlementLedgerListFilters $filters,
): void {
if ($filters->playerId !== null && $filters->playerId > 0) {
$query->where('sb.owner_type', 'player')
->where('sb.owner_id', $filters->playerId);
}
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
$query->where('sb.owner_type', 'player');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
}
}
private function adjustmentStubQuery(
AdminUser $admin,
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
}
$query = DB::table('settlement_adjustments as sa')
->join('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->selectRaw("'adjustment' as entry_kind, sa.id as entry_id, sa.created_at as sort_at");
if ($periodId !== null && $periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($filters->badDebtOnly) {
$query->where('sa.adjustment_type', 'bad_debt');
} elseif ($filters->entryKind === 'adjustment') {
$query->where('sa.adjustment_type', '!=', 'bad_debt');
}
$this->applyLedgerSiteScope($query, $admin, 'sp');
$this->applyAdjustmentPlayerScope($query, $admin, $siteCode, $filters);
if ($filters->billStatus !== null && $filters->billStatus !== '') {
$query->where('sb.status', $filters->billStatus);
}
if ($filters->bizType !== null && $filters->bizType !== '') {
$query->where('sa.adjustment_type', $filters->bizType);
}
$this->applyTxnNoStubFilter($query, 'sa.id', 'ADJ', $filters->txnNo);
return $query;
}
private function applyAdjustmentPlayerScope(
\Illuminate\Database\Query\Builder $query,
AdminUser $admin,
string $siteCode,
SettlementLedgerListFilters $filters,
): void {
$query->where(function (\Illuminate\Database\Query\Builder $outer) use ($admin, $siteCode, $filters): void {
$outer->whereNull('p.id')
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
$scoped->where('p.site_code', $siteCode);
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
});
});
}
private function applyTxnNoStubFilter(
\Illuminate\Database\Query\Builder $query,
string $idColumn,
string $prefix,
?string $txnNo,
): void {
if ($txnNo === null || $txnNo === '') {
return;
}
$needle = strtolower(trim($txnNo));
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($idColumn, $prefix, $needle): void {
if (ctype_digit($needle)) {
$match->where($idColumn, (int) $needle);
}
$match->orWhereRaw(
'LOWER(CONCAT(?, \'-\', '.$idColumn.')) LIKE ?',
[$prefix, '%'.$needle.'%'],
);
});
}
private function applyLedgerSiteScope(\Illuminate\Database\Query\Builder $query, AdminUser $admin, string $periodsAlias): void
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return;
}
if ($siteIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($periodsAlias.'.admin_site_id', $siteIds);
}
private function applyLedgerPlayerFilters(
\Illuminate\Database\Query\Builder $query,
string $playerAlias,
SettlementLedgerListFilters $filters,
): void {
if ($filters->playerId !== null && $filters->playerId > 0) {
$query->where("{$playerAlias}.id", $filters->playerId);
}
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
$like = '%'.addcslashes($filters->playerAccount, '%_\\').'%';
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($playerAlias, $like): void {
$match->where("{$playerAlias}.username", 'like', $like)
->orWhere("{$playerAlias}.site_player_id", 'like', $like)
->orWhere("{$playerAlias}.nickname", 'like', $like);
});
}
}
/**
* @param \Illuminate\Support\Collection<int, object> $stubs
* @param array<int, object> $playerBills
* @return list<array<string, mixed>>
*/
private function hydrateLedgerStubs(
AdminUser $admin,
string $siteCode,
\Illuminate\Support\Collection $stubs,
array $playerBills,
): array {
if ($stubs->isEmpty()) {
return [];
}
$creditIds = [];
$paymentIds = [];
$adjustmentIds = [];
$shareIds = [];
foreach ($stubs as $stub) {
$kind = (string) $stub->entry_kind;
$id = (int) $stub->entry_id;
if ($kind === 'credit') {
$creditIds[] = $id;
} elseif ($kind === 'payment') {
$paymentIds[] = $id;
} elseif ($kind === 'adjustment') {
$adjustmentIds[] = $id;
} elseif ($kind === 'share') {
$shareIds[] = $id;
}
}
$creditById = [];
if ($creditIds !== []) {
foreach ($this->fetchCreditRowsByIds($admin, $siteCode, $creditIds) as $row) {
$creditById[(int) $row->id] = $row;
}
}
$ticketRefs = $this->partyEnrichment->loadTicketRefs(
array_values(array_filter(array_map(
static fn (object $row): int => (string) ($row->ref_type ?? '') === 'ticket_item'
? (int) ($row->ref_id ?? 0)
: 0,
array_values($creditById),
), static fn (int $id): bool => $id > 0)),
);
$creditBillRefs = $this->settlementBillsByIds(
$admin,
array_values(array_filter(array_map(
static fn (object $row): int => (string) ($row->ref_type ?? '') === 'settlement_bill'
? (int) ($row->ref_id ?? 0)
: 0,
array_values($creditById),
), static fn (int $id): bool => $id > 0)),
);
$paymentById = [];
if ($paymentIds !== []) {
foreach ($this->fetchPaymentRowsByIds($admin, $siteCode, $paymentIds) as $row) {
$paymentById[(int) $row->id] = $row;
}
}
$adjustmentById = [];
if ($adjustmentIds !== []) {
foreach ($this->fetchAdjustmentRowsByIds($admin, $siteCode, $adjustmentIds) as $row) {
$adjustmentById[(int) $row->id] = $row;
}
}
$shareById = [];
if ($shareIds !== []) {
foreach ($this->fetchShareRowsByIds($admin, $siteCode, $shareIds) as $row) {
$shareById[(int) $row->id] = $row;
}
}
$shareTicketRefs = $this->partyEnrichment->loadTicketRefs(
array_values(array_filter(array_map(
static fn (object $row): int => (int) ($row->ticket_item_id ?? 0),
array_values($shareById),
), static fn (int $id): bool => $id > 0)),
);
$items = [];
foreach ($stubs as $stub) {
$kind = (string) $stub->entry_kind;
$id = (int) $stub->entry_id;
if ($kind === 'credit' && isset($creditById[$id])) {
$row = $creditById[$id];
$pid = (int) $row->player_id;
$bill = (string) ($row->ref_type ?? '') === 'settlement_bill'
? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null)
: ($playerBills[$pid] ?? null);
$items[] = $this->formatCreditEntry($row, $bill, $ticketRefs);
} elseif ($kind === 'payment' && isset($paymentById[$id])) {
$items[] = $this->formatPaymentEntry($paymentById[$id]);
} elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) {
$items[] = $this->formatAdjustmentEntry($adjustmentById[$id]);
} elseif ($kind === 'share' && isset($shareById[$id])) {
$items[] = $this->formatShareEntry($shareById[$id], $shareTicketRefs);
}
}
return $items;
}
/**
* @param list<int> $ids
* @return array<int, object>
*/
private function settlementBillsByIds(AdminUser $admin, array $ids): array
{
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return [];
}
$query = DB::table('settlement_bills as sb')
->whereIn('sb.id', $ids)
->select([
'sb.id',
'sb.owner_id as player_id',
'sb.status',
'sb.bill_type',
'sb.net_amount',
'sb.unpaid_amount',
'sb.paid_amount',
'sb.settlement_period_id',
]);
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
$map = [];
foreach ($query->get() as $bill) {
$map[(int) $bill->id] = $bill;
}
return $map;
}
/**
* @return array{0: Carbon|null, 1: Carbon|null}
*/
private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool
{
if ($filters->badDebtOnly) {
return $kind === 'adjustment';
}
$selected = $filters->entryKind;
if ($selected === null || $selected === '' || $selected === 'all') {
return true;
}
return $selected === $kind;
}
/**
* @param list<array<string, mixed>> $items
* @return list<array<string, mixed>>
*/
private function applyFilters(array $items, SettlementLedgerListFilters $filters): array
{
return array_values(array_filter($items, function (array $row) use ($filters): bool {
if ($filters->badDebtOnly) {
if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') {
return false;
}
} elseif ($filters->entryKind === 'adjustment') {
if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') {
return false;
}
}
if ($filters->actionableOnly) {
$actions = $row['available_actions'] ?? [];
$operational = array_filter(
$actions,
static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true),
);
if ($operational === []) {
return false;
}
}
return true;
}));
}
private function resolveCreatedRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolveSettledRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolvePeriodRange(
?int $settlementPeriodId,
?string $rangeFrom,
?string $rangeTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
if ($period === null) {
return null;
}
return AgentSettlementPeriodWindow::bounds(
(string) $period->period_start,
(string) $period->period_end,
);
}
$from = $rangeFrom !== null && $rangeFrom !== ''
? Carbon::parse($rangeFrom)->startOfDay()
: null;
$to = $rangeTo !== null && $rangeTo !== ''
? Carbon::parse($rangeTo)->endOfDay()
: null;
if ($from === null && $to === null) {
return null;
}
return [
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
$to ?? Carbon::now()->endOfDay(),
];
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchShareRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id')
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->leftJoin('agent_nodes as sla', 'sla.id', '=', 'sl.agent_node_id')
->where('p.site_code', $siteCode)
->whereIn('sl.id', $ids)
->select([
'sl.id',
'sl.ticket_item_id',
'sl.player_id',
'sl.agent_node_id as share_agent_node_id',
'sl.shared_net_win_loss',
'sl.game_win_loss',
'sl.settled_at',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'ti.play_code',
'd.draw_no',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
'sla.code as share_agent_code',
'sla.name as share_agent_name',
]);
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
return $query->get()->all();
}
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
* @return array<string, mixed>
*/
private function formatShareEntry(object $row, array $ticketRefs): array
{
$ticketId = (int) ($row->ticket_item_id ?? 0);
$ticketRef = $ticketRefs[$ticketId] ?? null;
$signed = (int) ($row->shared_net_win_loss ?? 0);
return array_merge(
$this->baseRow(
entryKind: 'share',
entryId: (int) $row->id,
txnPrefix: 'SL',
playerId: (int) $row->player_id,
row: $row,
bizType: self::SHARE_BIZ_TYPE,
signedAmount: $signed,
createdAt: $row->settled_at,
ledgerSource: 'share_ledger',
settlementBillId: null,
billStatus: null,
billType: null,
billUnpaid: null,
refLabel: $ticketId > 0 ? '#'.$ticketId : null,
refType: $ticketId > 0 ? 'ticket_item' : null,
refId: $ticketId > 0 ? $ticketId : null,
),
$this->partyFieldsFromRow($row),
[
'play_code' => $ticketRef['play_code'] ?? $row->play_code ?? null,
'draw_no' => $ticketRef['draw_no'] ?? $row->draw_no ?? null,
'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
'available_actions' => ['view_player'],
],
);
}
/**
* @return array<int, object>
*/
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
{
$query = DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('sb.bill_type', 'player')
->select([
'sb.id',
'sb.owner_id as player_id',
'sb.status',
'sb.bill_type',
'sb.net_amount',
'sb.unpaid_amount',
'sb.paid_amount',
'sb.settlement_period_id',
])
->orderByDesc('sb.id');
if ($periodId !== null && $periodId > 0) {
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
$query->where('sb.settlement_period_id', $periodId);
}
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$map = [];
foreach ($query->limit(500)->get() as $bill) {
$pid = (int) $bill->player_id;
if (! isset($map[$pid])) {
$map[$pid] = $bill;
continue;
}
$existing = $map[$pid];
if ((string) $bill->status === 'pending_confirm') {
$map[$pid] = $bill;
} elseif ((string) $existing->status !== 'pending_confirm'
&& (int) $bill->unpaid_amount > 0
&& (int) $existing->unpaid_amount <= 0) {
$map[$pid] = $bill;
}
}
return $map;
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
* @return list<object>
*/
private function fetchCreditRows(
AdminUser $admin,
string $siteCode,
?array $range,
?int $playerId,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('cl.id');
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
return $query->limit(500)->get()->all();
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
* @return list<object>
*/
private function fetchBetFlowCreditRows(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->whereIn('cl.reason', [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
])
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('cl.id');
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
return $query->limit(5000)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchCreditRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->whereIn('cl.id', $ids)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
]);
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
return $query->get()->all();
}
/**
* @return list<object>
*/
private function fetchPaymentRows(
AdminUser $admin,
string $siteCode,
?int $periodId,
?int $playerId,
): array {
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'pr.id',
'pr.amount',
'pr.method',
'pr.status',
'pr.created_at',
'pr.settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('pr.id');
if ($periodId !== null && $periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
return [];
}
$query->whereIn('sp.admin_site_id', $siteIds);
}
return $query->limit(300)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchPaymentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return [];
}
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as owner_an', function ($join): void {
$join->on('owner_an.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'agent');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->whereIn('pr.id', $ids)
->select([
'pr.id',
'pr.amount',
'pr.method',
'pr.status',
'pr.created_at',
'pr.settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'),
DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'),
DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'),
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]);
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
$this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all();
}
/**
* @return list<object>
*/
private function fetchAdjustmentRows(
AdminUser $admin,
string $siteCode,
?int $periodId,
?int $playerId,
): array {
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'sa.id',
'sa.amount',
'sa.adjustment_type',
'sa.reason',
'sa.created_at',
'sa.original_bill_id as settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('sa.id');
if ($periodId !== null && $periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
return [];
}
$query->whereIn('sp.admin_site_id', $siteIds);
}
return $query->limit(300)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchAdjustmentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->whereIn('sa.id', $ids)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'sa.id',
'sa.amount',
'sa.adjustment_type',
'sa.reason',
'sa.created_at',
'sa.original_bill_id as settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
]);
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all();
}
/**
* @return array<string, mixed>
*/
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
*/
private function formatCreditEntry(object $row, ?object $bill, array $ticketRefs = []): array
{
$amount = (int) $row->amount;
$billId = $bill !== null ? (int) $bill->id : null;
$ticketRef = $this->resolveTicketRef($row, $ticketRefs);
return array_merge(
$this->baseRow(
entryKind: 'credit',
entryId: (int) $row->id,
txnPrefix: 'CL',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: (string) $row->reason,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'credit_ledger',
settlementBillId: $billId,
billStatus: $bill !== null ? (string) $bill->status : null,
billType: $bill !== null ? (string) $bill->bill_type : null,
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
refType: isset($row->ref_type) ? (string) $row->ref_type : null,
refId: isset($row->ref_id) ? (int) $row->ref_id : null,
),
$this->partyFieldsFromRow($row),
$ticketRef,
);
}
/**
* @return array<string, mixed>
*/
private function formatPaymentEntry(object $row): array
{
$amount = (int) $row->amount;
$method = trim((string) ($row->method ?? ''));
$methodLabel = $method !== '' && ! ctype_digit($method) ? ' · '.$method : '';
return array_merge(
$this->baseRow(
entryKind: 'payment',
entryId: (int) $row->id,
txnPrefix: 'PAY',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: 'payment_record',
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'payment_record',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: 'bill#'.$row->settlement_bill_id.$methodLabel,
),
$this->partyFieldsFromRow($row),
);
}
/**
* @return array<string, mixed>
*/
private function formatAdjustmentEntry(object $row): array
{
$amount = (int) $row->amount;
$type = (string) $row->adjustment_type;
return array_merge(
$this->baseRow(
entryKind: 'adjustment',
entryId: (int) $row->id,
txnPrefix: 'ADJ',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: $type,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'settlement_adjustment',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: $row->reason !== null && $row->reason !== ''
? (string) $row->reason
: 'bill#'.$row->settlement_bill_id,
),
$this->partyFieldsFromRow($row),
);
}
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
* @return array{play_code: string|null, draw_no: string|null, ticket_item_id: int|null}
*/
private function resolveTicketRef(object $row, array $ticketRefs): array
{
if ((string) ($row->ref_type ?? '') !== 'ticket_item') {
return ['play_code' => null, 'draw_no' => null, 'ticket_item_id' => null];
}
$ticketId = (int) ($row->ref_id ?? 0);
$ref = $ticketRefs[$ticketId] ?? null;
return [
'play_code' => $ref['play_code'] ?? null,
'draw_no' => $ref['draw_no'] ?? null,
'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
];
}
/**
* @return array<string, mixed>
*/
private function partyFieldsFromRow(object $row): array
{
$directId = (int) ($row->direct_agent_id ?? $row->agent_node_id ?? 0);
$parentId = (int) ($row->parent_agent_id ?? 0);
return [
'direct_agent_id' => $directId > 0 ? $directId : null,
'direct_agent_label' => $directId > 0
? $this->partyEnrichment->formatAgent((object) [
'name' => $row->direct_agent_name ?? null,
'code' => $row->direct_agent_code ?? null,
], $directId)
: null,
'parent_agent_id' => $parentId > 0 ? $parentId : null,
'parent_agent_label' => $parentId > 0
? $this->partyEnrichment->formatAgent((object) [
'name' => $row->parent_agent_name ?? null,
'code' => $row->parent_agent_code ?? null,
], $parentId)
: null,
];
}
/**
* @return array<string, mixed>
*/
private function baseRow(
string $entryKind,
int $entryId,
string $txnPrefix,
int $playerId,
object $row,
string $bizType,
int $signedAmount,
mixed $createdAt,
string $ledgerSource,
?int $settlementBillId,
?string $billStatus,
?string $billType,
?int $billUnpaid,
?string $refLabel = null,
?string $refType = null,
?int $refId = null,
): array {
$amountAbs = abs($signedAmount);
$currency = (string) ($row->default_currency ?? '');
return [
'entry_kind' => $entryKind,
'id' => $entryId,
'row_key' => $entryKind.'-'.$entryId,
'txn_no' => $txnPrefix.'-'.$entryId,
'player_id' => $playerId,
'site_code' => $row->site_code ?? null,
'site_player_id' => $row->site_player_id ?? null,
'username' => $row->username ?? null,
'nickname' => $row->nickname ?? null,
'biz_type' => $bizType,
'ref_type' => $refType,
'ref_id' => $refId,
'biz_no' => $refLabel ?? $this->creditRefLabel($row),
'direction' => $signedAmount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $signedAmount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null,
'ledger_source' => $ledgerSource,
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source ?? null,
'settlement_bill_id' => $settlementBillId,
'bill_status' => $billStatus,
'bill_type' => $billType,
'bill_unpaid_amount' => $billUnpaid,
'available_actions' => $this->resolveActions(
$entryKind,
$settlementBillId,
$billStatus,
$billType,
$billUnpaid,
),
];
}
/**
* @return list<string>
*/
private function resolveActions(
string $entryKind,
?int $billId,
?string $billStatus,
?string $billType,
?int $billUnpaid,
): array {
$actions = ['view_player'];
if ($billId === null || $billId <= 0) {
return $actions;
}
$actions[] = 'view_bill';
if ($billStatus === 'pending_confirm') {
$actions[] = 'confirm';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
&& ($billUnpaid ?? 0) > 0) {
$actions[] = 'payment';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
$actions[] = 'adjustment';
$actions[] = 'reversal';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
&& ($billUnpaid ?? 0) > 0
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
$actions[] = 'bad_debt';
}
return array_values(array_unique($actions));
}
private function creditRefLabel(object $row): ?string
{
if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
}