- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。 - 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
599 lines
20 KiB
PHP
599 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Services\AgentSettlement;
|
||
|
||
use App\Models\AdminUser;
|
||
use App\Support\AdminDataScope;
|
||
use App\Support\AdminAgentSettlementScope;
|
||
use App\Support\CurrencyFormatter;
|
||
use App\Support\PlayerFundingMode;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */
|
||
final class SettlementCenterLedgerService
|
||
{
|
||
/**
|
||
* @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);
|
||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||
|
||
$items = [];
|
||
$includeCredit = $this->includeEntryKind($filters, 'credit');
|
||
$includePayment = $this->includeEntryKind($filters, 'payment');
|
||
$includeAdjustment = $this->includeEntryKind($filters, 'adjustment');
|
||
|
||
if ($includeCredit) {
|
||
$creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId);
|
||
foreach ($creditRows as $row) {
|
||
$pid = (int) $row->player_id;
|
||
$bill = $playerBills[$pid] ?? null;
|
||
$items[] = $this->formatCreditEntry($row, $bill);
|
||
}
|
||
}
|
||
if ($includePayment) {
|
||
foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||
$items[] = $this->formatPaymentEntry($row);
|
||
}
|
||
}
|
||
if ($includeAdjustment) {
|
||
foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||
if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') {
|
||
continue;
|
||
}
|
||
$items[] = $this->formatAdjustmentEntry($row);
|
||
}
|
||
}
|
||
|
||
$items = $this->applyFilters($items, $filters);
|
||
|
||
usort($items, static function (array $a, array $b): int {
|
||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||
});
|
||
|
||
$total = count($items);
|
||
$offset = max(0, ($page - 1) * $perPage);
|
||
$pageItems = array_slice($items, $offset, $perPage);
|
||
|
||
return [
|
||
'items' => array_values($pageItems),
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'per_page' => $perPage,
|
||
'ledger_source' => 'settlement_ledger',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @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->txnNo !== null) {
|
||
$needle = strtolower($filters->txnNo);
|
||
$hay = strtolower((string) ($row['txn_no'] ?? ''));
|
||
if (! str_contains($hay, $needle)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if ($filters->playerAccount !== null) {
|
||
$needle = strtolower($filters->playerAccount);
|
||
$haystack = strtolower(implode(' ', array_filter([
|
||
(string) ($row['username'] ?? ''),
|
||
(string) ($row['nickname'] ?? ''),
|
||
(string) ($row['site_player_id'] ?? ''),
|
||
])));
|
||
if (! str_contains($haystack, $needle)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) {
|
||
return false;
|
||
}
|
||
|
||
if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) {
|
||
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 {
|
||
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
|
||
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
|
||
if ($period === null) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
Carbon::parse($period->period_start)->startOfDay(),
|
||
Carbon::parse($period->period_end)->endOfDay(),
|
||
];
|
||
}
|
||
|
||
$from = $createdFrom !== null && $createdFrom !== ''
|
||
? Carbon::parse($createdFrom)->startOfDay()
|
||
: null;
|
||
$to = $createdTo !== null && $createdTo !== ''
|
||
? Carbon::parse($createdTo)->endOfDay()
|
||
: null;
|
||
|
||
if ($from === null && $to === null) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
|
||
$to ?? Carbon::now()->endOfDay(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @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');
|
||
})
|
||
->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.funding_mode',
|
||
'p.auth_source',
|
||
'p.default_currency',
|
||
])
|
||
->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();
|
||
}
|
||
|
||
/**
|
||
* @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)
|
||
->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.auth_source',
|
||
'p.funding_mode',
|
||
'p.default_currency',
|
||
])
|
||
->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();
|
||
}
|
||
|
||
/**
|
||
* @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)
|
||
->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.auth_source',
|
||
'p.funding_mode',
|
||
'p.default_currency',
|
||
])
|
||
->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();
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function formatCreditEntry(object $row, ?object $bill): array
|
||
{
|
||
$amount = (int) $row->amount;
|
||
$billId = $bill !== null ? (int) $bill->id : null;
|
||
|
||
return $this->baseRow(
|
||
entryKind: 'credit',
|
||
entryId: (int) $row->id,
|
||
txnPrefix: 'CL',
|
||
playerId: (int) $row->player_id,
|
||
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,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function formatPaymentEntry(object $row): array
|
||
{
|
||
$amount = (int) $row->amount;
|
||
|
||
return $this->baseRow(
|
||
entryKind: 'payment',
|
||
entryId: (int) $row->id,
|
||
txnPrefix: 'PAY',
|
||
playerId: (int) $row->player_id,
|
||
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 : ''),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function formatAdjustmentEntry(object $row): array
|
||
{
|
||
$amount = (int) $row->amount;
|
||
$type = (string) $row->adjustment_type;
|
||
|
||
return $this->baseRow(
|
||
entryKind: 'adjustment',
|
||
entryId: (int) $row->id,
|
||
txnPrefix: 'ADJ',
|
||
playerId: (int) $row->player_id,
|
||
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,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @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,
|
||
): 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,
|
||
'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;
|
||
}
|
||
}
|