['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' => [], 'transfer_in' => [], 'transfer_out' => [], ]; public function __construct( private readonly PlayerCreditService $playerCreditService, ) {} /** * @return array{ * items: list>, * 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>, 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]; } $reasonFilter = $bizType !== null && $bizType !== '' ? [trim($bizType)] : null; $paginator = $this->creditLedgerQuery($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->formatAdminCreditRow($row, $player, $currency, $runningMinor); $runningMinor -= $amount; return $formatted; }) ->values() ->all(); return [ 'items' => $items, 'total' => $paginator->total(), 'page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), ]; } /** * 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。 * * @return array{ * items: list>, * 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 */ 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>, 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>, 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|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|null */ private function resolveWalletBizFilter(string $raw): ?array { return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ); } /** * @return list|null */ private function resolveCreditReasonFilter(string $raw): ?array { return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON); } /** * @param array> $map * @return list|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 */ 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 */ 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 */ 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', 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(); } }