Files
lotteryLaravel/app/Services/AgentSettlement/SettlementCenterLedgerService.php
kang 2d32f006c5 feat: 增强代理结算和账单管理功能
- 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。
- 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。
- 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。
- 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。
- 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。
2026-06-05 18:00:56 +08:00

1489 lines
52 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\AdminDataScope;
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',
'settlement_confirm',
];
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");
AdminDataScope::applyToPlayersAlias($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");
AdminDataScope::applyToPlayersAlias($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);
AdminDataScope::applyToPlayersAlias($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)),
);
$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;
$items[] = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $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;
}
/**
* @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',
]);
AdminDataScope::applyToPlayersAlias($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);
}
AdminDataScope::applyToPlayersAlias($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');
AdminDataScope::applyToPlayersAlias($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');
AdminDataScope::applyToPlayersAlias($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',
]);
AdminDataScope::applyToPlayersAlias($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);
}
AdminDataScope::applyToPlayersAlias($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);
}
AdminDataScope::applyToPlayersAlias($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',
]);
AdminDataScope::applyToPlayersAlias($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;
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.($row->method ? ' · '.$row->method : ''),
),
$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;
}
}