['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'], 'win_credit' => ['game_settlement_win'], 'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'], 'bill_settlement' => ['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>, * 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]; } $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>, * 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; $reason = (string) $row->reason; $affectsBalance = $this->creditReasonAffectsAvailableBalance($reason); $formatted = $this->formatPlayerCreditRow( $row, $player, $currency, $affectsBalance ? $runningMinor : null, $affectsBalance, ); if ($affectsBalance) { $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, ?bool $affectsAvailableBalance = null, ): array { $amount = (int) $row->amount; $amountAbs = abs($amount); $reason = (string) $row->reason; $publicType = $this->creditReasonToPublicType($reason); $affectsBalance = $affectsAvailableBalance ?? $this->creditReasonAffectsAvailableBalance($reason); return [ 'log_id' => 'CL-'.$row->id, 'type' => $publicType, 'biz_type' => $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' => $affectsBalance ? $balanceAfterMinor : null, 'balance_after_formatted' => $affectsBalance && $balanceAfterMinor !== null ? CurrencyFormatter::fromMinor($balanceAfterMinor) : null, 'affects_available_credit' => $affectsBalance, '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, ]; } /** 账期收付记账不改变 player_credit_accounts,不参与可用信用倒推。 */ private function creditReasonAffectsAvailableBalance(string $reason): bool { return $reason !== 'settlement_payout'; } /** * @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', 'game_settlement_win' => 'win_credit', 'settlement_payout' => 'bill_settlement', 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(); } }