feat: 增强代理和玩家管理功能

- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
2026-06-04 18:00:50 +08:00
parent 96545f87f6
commit a44679665d
183 changed files with 10054 additions and 857 deletions

View File

@@ -0,0 +1,598 @@
<?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;
}
}