Files
lotteryLaravel/app/Services/Wallet/PlayerLedgerLogsService.php

656 lines
22 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\Wallet;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。
*/
final class PlayerLedgerLogsService
{
/** PRD 对外类型 → wallet_txns.biz_type */
private const WALLET_TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
/** PRD 对外类型 → credit_ledger.reason */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold', 'game_settlement_loss'],
'reversal' => ['bet_hold_release'],
'refund' => ['settlement_confirm'],
'prize' => ['game_settlement_win', 'settlement_payout'],
'transfer_in' => [],
'transfer_out' => [],
];
public function __construct(
private readonly PlayerCreditService $playerCreditService,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
private readonly SettlementPartyEnrichment $partyEnrichment,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* funding_mode: string,
* auth_source: string|null,
* }
*/
public function listForPlayerApi(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$meta = [
'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn',
'funding_mode' => (string) ($player->funding_mode ?? ''),
'auth_source' => $player->auth_source,
];
if (PlayerFundingMode::usesCredit($player)) {
$result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw);
return array_merge($result, $meta);
}
$result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw);
return array_merge($result, $meta);
}
/**
* 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger字段形状对齐 wallet_txns 列表。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function listForAdminPlayer(
Player $player,
int $page,
int $perPage,
?string $bizType = null,
): array {
if (! PlayerFundingMode::usesCredit($player)) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$currency = (string) $player->default_currency;
$rawRows = $this->creditLedgerQuery($player->id, [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'settlement_confirm',
'settlement_payout',
])->limit(5000)->get()->all();
$enriched = array_map(function (object $row) use ($player): object {
return (object) [
'id' => (int) $row->id,
'amount' => (int) $row->amount,
'reason' => (string) $row->reason,
'ref_type' => $row->ref_type ?? null,
'ref_id' => $row->ref_id ?? null,
'created_at' => $row->created_at ?? null,
'updated_at' => $row->updated_at ?? null,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'agent_node_id' => $player->agent_node_id,
'funding_mode' => $player->funding_mode,
'auth_source' => $player->auth_source,
'default_currency' => $player->default_currency,
'direct_agent_id' => null,
'direct_agent_code' => null,
'direct_agent_name' => null,
'parent_agent_id' => null,
'parent_agent_code' => null,
'parent_agent_name' => null,
];
}, $rawRows);
$ticketIds = [];
foreach ($enriched 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)));
$simplified = $this->betFlowPresenter->simplifyCreditRows(
$enriched,
$ticketRefs,
fn (object $row): array => $this->formatAdminCreditRow($row, $player, $currency, 0),
function (object $row) use ($player, $currency): array {
$formatted = $this->formatAdminCreditRow($row, $player, $currency, 0);
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$formatted['txn_no'] = 'CLS-T'.$ticketId;
}
return $formatted;
},
);
if ($bizType !== null && $bizType !== '') {
$filterType = trim($bizType);
$simplified = array_values(array_filter(
$simplified,
static fn (array $item): bool => ($item['biz_type'] ?? '') === $filterType
|| ($filterType === 'bet_hold' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD)
|| ($filterType === 'game_settlement' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT),
));
}
$total = count($simplified);
$offset = max(0, ($page - 1) * $perPage);
$pageRows = array_slice($simplified, $offset, $perPage);
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = [];
foreach ($pageRows as $formatted) {
$signed = (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
$items[] = array_merge($formatted, [
'balance_after' => $runningMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
'balance_before' => $signed >= 0
? max(0, $runningMinor - $signed)
: $runningMinor + abs($signed),
]);
$runningMinor -= $signed;
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listForAdminCreditIndex(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
?int $settlementPeriodId = null,
?int $playerId = null,
?string $reason = null,
?string $createdFrom = null,
?string $createdTo = null,
): 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',
'cl.updated_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 ($reason !== null && $reason !== '') {
$query->where('cl.reason', $reason);
}
$range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'ledger_source' => 'credit_ledger',
];
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
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<string, mixed>
*/
private function formatAdminCreditIndexRow(object $row): array
{
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$currency = (string) ($row->default_currency ?? '');
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $row->player_id,
'site_code' => (string) $row->site_code,
'site_player_id' => $row->site_player_id,
'username' => $row->username,
'nickname' => $row->nickname,
'biz_type' => (string) $row->reason,
'type' => $this->creditReasonToPublicType((string) $row->reason),
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $amount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source,
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateWalletTxns(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$bizFilter = $this->resolveWalletBizFilter($typeFilterRaw);
if (is_array($bizFilter) && $bizFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
if (is_array($reasonFilter) && $reasonFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$paginator = $this->creditLedgerQuery((int) $player->id, $reasonFilter)
->paginate($perPage, ['*'], 'page', $page);
$currency = (string) $player->default_currency;
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$amount = (int) $row->amount;
$formatted = $this->formatPlayerCreditRow($row, $player, $currency, $runningMinor);
$runningMinor -= $amount;
return $formatted;
})
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @param list<string>|null $reasonFilter
*/
private function creditLedgerQuery(int $playerId, ?array $reasonFilter)
{
$query = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->orderByDesc('id');
if ($reasonFilter !== null) {
$query->whereIn('reason', $reasonFilter);
}
return $query;
}
/**
* @return list<string>|null
*/
private function resolveWalletBizFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ);
}
/**
* @return list<string>|null
*/
private function resolveCreditReasonFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
}
/**
* @param array<string, list<string>> $map
* @return list<string>|null
*/
private function resolveTypeFilterMap(string $raw, array $map): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$resolved = [];
foreach ($parts as $part) {
$key = Str::lower($part);
if (! isset($map[$key])) {
continue;
}
foreach ($map[$key] as $value) {
$resolved[] = $value;
}
}
return array_values(array_unique($resolved));
}
/**
* @return array<string, mixed>
*/
private function formatWalletTxnRow(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->walletBizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedWalletAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
];
}
/**
* @return array<string, mixed>
*/
private function formatPlayerCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$publicType = $this->creditReasonToPublicType((string) $row->reason);
return [
'log_id' => 'CL-'.$row->id,
'type' => $publicType,
'biz_type' => (string) $row->reason,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'amount_abs' => $amountAbs,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'ref_id' => $this->creditRefLabel($row),
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$balanceBefore = $amount >= 0
? max(0, $balanceAfterMinor - $amount)
: $balanceAfterMinor + $amountAbs;
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'wallet_id' => null,
'biz_type' => (string) $row->reason,
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'balance_before' => $balanceBefore,
'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore),
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => null,
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
private function creditReasonToPublicType(string $reason): string
{
return match ($reason) {
'bet_hold', 'game_settlement_loss' => 'bet',
'bet_hold_release' => 'reversal',
'settlement_confirm' => 'refund',
'game_settlement_win', 'settlement_payout' => 'prize',
default => $reason,
};
}
private function walletBizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedWalletAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
private function creditRefLabel(object $row): ?string
{
if ($row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse($value)->toIso8601String();
}
}