426 lines
16 KiB
PHP
426 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Wallet;
|
|
|
|
use Carbon\Carbon;
|
|
use App\Models\Player;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 将信用盘底层账务过程整理成玩家能理解的业务活动。
|
|
*
|
|
* 原始 credit_ledger 仍由管理端与审计使用;玩家端按订单、开奖结果和账期收付展示。
|
|
*/
|
|
final class PlayerCreditActivityService
|
|
{
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function build(Player $player): array
|
|
{
|
|
$orders = DB::table('ticket_orders as o')
|
|
->leftJoin('draws as d', 'd.id', '=', 'o.draw_id')
|
|
->where('o.player_id', (int) $player->id)
|
|
->where('o.total_actual_deduct', '>', 0)
|
|
->orderByDesc('o.id')
|
|
->get([
|
|
'o.id',
|
|
'o.order_no',
|
|
'o.currency_code',
|
|
'o.total_actual_deduct',
|
|
'o.status',
|
|
'o.created_at',
|
|
'o.updated_at',
|
|
'd.draw_no',
|
|
]);
|
|
|
|
$orderActivities = $this->orderActivities($player, $orders);
|
|
$paymentActivities = $this->paymentActivities($player);
|
|
$activities = array_merge($orderActivities, $paymentActivities);
|
|
|
|
usort($activities, static function (array $left, array $right): int {
|
|
$leftTime = isset($left['created_at']) ? strtotime((string) $left['created_at']) : 0;
|
|
$rightTime = isset($right['created_at']) ? strtotime((string) $right['created_at']) : 0;
|
|
if ($leftTime === $rightTime) {
|
|
return strcmp((string) ($right['log_id'] ?? ''), (string) ($left['log_id'] ?? ''));
|
|
}
|
|
|
|
return $rightTime <=> $leftTime;
|
|
});
|
|
|
|
return $activities;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, object> $orders
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function orderActivities(Player $player, Collection $orders): array
|
|
{
|
|
if ($orders->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$orderIds = $orders->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
|
$items = DB::table('ticket_items')
|
|
->whereIn('order_id', $orderIds)
|
|
->where('actual_deduct_amount', '>', 0)
|
|
->orderBy('id')
|
|
->get([
|
|
'id',
|
|
'order_id',
|
|
'ticket_no',
|
|
'actual_deduct_amount',
|
|
'status',
|
|
'win_amount',
|
|
'jackpot_win_amount',
|
|
'settled_at',
|
|
'updated_at',
|
|
]);
|
|
$itemsByOrder = $items->groupBy(static fn (object $item): int => (int) $item->order_id);
|
|
$ticketIds = $items->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
|
|
|
$activeShares = $this->activeShareRows($ticketIds)->keyBy('ticket_item_id');
|
|
$activeRebates = $this->activeRebateTotals($ticketIds);
|
|
$availableDeltas = $this->ticketAvailableDeltas((int) $player->id, $ticketIds);
|
|
|
|
$activities = [];
|
|
foreach ($orders as $order) {
|
|
/** @var Collection<int, object> $orderItems */
|
|
$orderItems = $itemsByOrder->get((int) $order->id, collect());
|
|
if ($orderItems->isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
$stakeAmount = (int) $orderItems->sum(
|
|
static fn (object $item): int => (int) $item->actual_deduct_amount,
|
|
);
|
|
$rebateAmount = 0;
|
|
$gameWinLoss = 0;
|
|
$winAmount = 0;
|
|
$availableDelta = 0;
|
|
$hasSettlement = false;
|
|
$hasPendingPayout = false;
|
|
$latestSettlementAt = null;
|
|
|
|
foreach ($orderItems as $item) {
|
|
$ticketId = (int) $item->id;
|
|
$share = $activeShares->get($ticketId);
|
|
if ($share !== null) {
|
|
$ticketGameWinLoss = (int) $share->game_win_loss;
|
|
$gameWinLoss += $ticketGameWinLoss;
|
|
$winAmount += max(0, -$ticketGameWinLoss);
|
|
$hasSettlement = true;
|
|
$latestSettlementAt = $this->latestTimestamp(
|
|
$latestSettlementAt,
|
|
$share->settled_at ?? $share->created_at ?? null,
|
|
);
|
|
} elseif (in_array((string) $item->status, ['settled_win', 'settled_lose', 'pending_payout'], true)) {
|
|
$fallbackWin = (int) $item->win_amount + (int) $item->jackpot_win_amount;
|
|
$ticketGameWinLoss = (string) $item->status === 'settled_lose'
|
|
? (int) $item->actual_deduct_amount
|
|
: -$fallbackWin;
|
|
$gameWinLoss += $ticketGameWinLoss;
|
|
$winAmount += max(0, -$ticketGameWinLoss);
|
|
$hasSettlement = true;
|
|
}
|
|
|
|
$rebateAmount += (int) ($activeRebates[$ticketId] ?? 0);
|
|
$availableDelta += (int) ($availableDeltas[$ticketId] ?? 0);
|
|
$hasPendingPayout = $hasPendingPayout || (string) $item->status === 'pending_payout';
|
|
$latestSettlementAt = $this->latestTimestamp(
|
|
$latestSettlementAt,
|
|
$item->settled_at ?? $item->updated_at ?? null,
|
|
);
|
|
}
|
|
|
|
$refunded = in_array((string) $order->status, ['refunded', 'cancelled'], true)
|
|
|| $orderItems->every(
|
|
static fn (object $item): bool => in_array((string) $item->status, ['refunded', 'failed'], true),
|
|
);
|
|
$ticketCount = $orderItems->count();
|
|
$firstTicketNo = $ticketCount === 1 ? (string) ($orderItems->first()->ticket_no ?? '') : null;
|
|
|
|
if ($refunded) {
|
|
$activities[] = $this->formatActivity(
|
|
logId: 'CA-ORDER-'.$order->id,
|
|
type: 'reversal',
|
|
bizType: 'bet_refund',
|
|
activityKind: 'refund',
|
|
activityStatus: 'reversed',
|
|
amount: $stakeAmount,
|
|
currency: (string) $order->currency_code,
|
|
availableDelta: 0,
|
|
createdAt: $this->isoTimestamp($order->updated_at ?? $order->created_at ?? null),
|
|
order: $order,
|
|
ticketCount: $ticketCount,
|
|
ticketNo: $firstTicketNo,
|
|
stakeAmount: $stakeAmount,
|
|
winAmount: 0,
|
|
rebateAmount: 0,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($hasSettlement) {
|
|
$netAmount = -$gameWinLoss + $rebateAmount;
|
|
$activities[] = $this->formatActivity(
|
|
logId: 'CA-ORDER-'.$order->id,
|
|
type: 'game_settlement',
|
|
bizType: $netAmount > 0 ? 'settled_win' : ($netAmount < 0 ? 'settled_loss' : 'settled_even'),
|
|
activityKind: 'draw_result',
|
|
activityStatus: $hasPendingPayout ? 'pending' : 'completed',
|
|
amount: $netAmount,
|
|
currency: (string) $order->currency_code,
|
|
availableDelta: $availableDelta,
|
|
createdAt: $this->isoTimestamp($latestSettlementAt ?? $order->updated_at ?? null),
|
|
order: $order,
|
|
ticketCount: $ticketCount,
|
|
ticketNo: $firstTicketNo,
|
|
stakeAmount: $stakeAmount,
|
|
winAmount: $winAmount,
|
|
rebateAmount: $rebateAmount,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$activities[] = $this->formatActivity(
|
|
logId: 'CA-ORDER-'.$order->id,
|
|
type: 'bet',
|
|
bizType: 'bet_pending',
|
|
activityKind: 'bet',
|
|
activityStatus: 'pending',
|
|
amount: -$stakeAmount,
|
|
currency: (string) $order->currency_code,
|
|
availableDelta: -$stakeAmount,
|
|
createdAt: $this->isoTimestamp($order->created_at ?? null),
|
|
order: $order,
|
|
ticketCount: $ticketCount,
|
|
ticketNo: $firstTicketNo,
|
|
stakeAmount: $stakeAmount,
|
|
winAmount: 0,
|
|
rebateAmount: 0,
|
|
);
|
|
}
|
|
|
|
return $activities;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function paymentActivities(Player $player): array
|
|
{
|
|
$rows = DB::table('payment_records as pr')
|
|
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
|
|
->where('sb.bill_type', 'player')
|
|
->where('sb.owner_type', 'player')
|
|
->where('sb.owner_id', (int) $player->id)
|
|
->where('pr.status', 'confirmed')
|
|
->orderByDesc('pr.id')
|
|
->get([
|
|
'pr.id',
|
|
'pr.settlement_bill_id',
|
|
'pr.payer_type',
|
|
'pr.payer_id',
|
|
'pr.payee_type',
|
|
'pr.payee_id',
|
|
'pr.amount',
|
|
'pr.confirmed_at',
|
|
'pr.created_at',
|
|
]);
|
|
|
|
$activities = [];
|
|
foreach ($rows as $row) {
|
|
$playerPaid = (string) $row->payer_type === 'player'
|
|
&& (int) $row->payer_id === (int) $player->id;
|
|
$playerReceived = (string) $row->payee_type === 'player'
|
|
&& (int) $row->payee_id === (int) $player->id;
|
|
if (! $playerPaid && ! $playerReceived) {
|
|
continue;
|
|
}
|
|
|
|
$amount = (int) $row->amount * ($playerReceived ? 1 : -1);
|
|
$activities[] = [
|
|
'log_id' => 'CA-PAYMENT-'.$row->id,
|
|
'type' => 'bill_settlement',
|
|
'biz_type' => $playerReceived ? 'period_received' : 'period_paid',
|
|
'activity_kind' => 'period_settlement',
|
|
'activity_status' => 'completed',
|
|
'amount' => $amount,
|
|
'amount_abs' => abs($amount),
|
|
'direction' => $amount >= 0 ? 'in' : 'out',
|
|
'currency_code' => (string) $player->default_currency,
|
|
'balance_after' => null,
|
|
'affects_available_credit' => $playerPaid,
|
|
'_available_delta' => $playerPaid ? (int) $row->amount : 0,
|
|
'ref_id' => 'settlement_bill#'.$row->settlement_bill_id,
|
|
'settlement_bill_id' => (int) $row->settlement_bill_id,
|
|
'order_no' => null,
|
|
'draw_no' => null,
|
|
'ticket_no' => null,
|
|
'ticket_count' => 0,
|
|
'stake_amount' => 0,
|
|
'win_amount' => 0,
|
|
'rebate_amount' => 0,
|
|
'net_amount' => $amount,
|
|
'idempotent_key' => null,
|
|
'external_ref_no' => null,
|
|
'status' => 'posted',
|
|
'remark' => null,
|
|
'created_at' => $this->isoTimestamp($row->confirmed_at ?? $row->created_at ?? null),
|
|
'ledger_source' => 'credit_activity',
|
|
'funding_mode' => (string) $player->funding_mode,
|
|
'auth_source' => $player->auth_source,
|
|
];
|
|
}
|
|
|
|
return $activities;
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ticketIds
|
|
* @return Collection<int, object>
|
|
*/
|
|
private function activeShareRows(array $ticketIds): Collection
|
|
{
|
|
if ($ticketIds === []) {
|
|
return collect();
|
|
}
|
|
|
|
return DB::table('share_ledger as sl')
|
|
->whereIn('sl.ticket_item_id', $ticketIds)
|
|
->whereNull('sl.reversal_of_id')
|
|
->whereNotExists(function ($query): void {
|
|
$query->selectRaw('1')
|
|
->from('share_ledger as reversal')
|
|
->whereColumn('reversal.reversal_of_id', 'sl.id');
|
|
})
|
|
->get(['sl.ticket_item_id', 'sl.game_win_loss', 'sl.settled_at', 'sl.created_at']);
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ticketIds
|
|
* @return array<int, int>
|
|
*/
|
|
private function activeRebateTotals(array $ticketIds): array
|
|
{
|
|
if ($ticketIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return DB::table('rebate_records as rr')
|
|
->whereIn('rr.ticket_item_id', $ticketIds)
|
|
->whereNull('rr.reversal_of_id')
|
|
->whereNotExists(function ($query): void {
|
|
$query->selectRaw('1')
|
|
->from('rebate_records as reversal')
|
|
->whereColumn('reversal.reversal_of_id', 'rr.id');
|
|
})
|
|
->selectRaw('rr.ticket_item_id, SUM(rr.rebate_amount) as total_rebate')
|
|
->groupBy('rr.ticket_item_id')
|
|
->pluck('total_rebate', 'ticket_item_id')
|
|
->map(static fn ($amount): int => (int) $amount)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ticketIds
|
|
* @return array<int, int>
|
|
*/
|
|
private function ticketAvailableDeltas(int $playerId, array $ticketIds): array
|
|
{
|
|
if ($ticketIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return DB::table('credit_ledger')
|
|
->where('owner_type', 'player')
|
|
->where('owner_id', $playerId)
|
|
->where('ref_type', 'ticket_item')
|
|
->whereIn('ref_id', $ticketIds)
|
|
->whereIn('reason', ['game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'])
|
|
->selectRaw('ref_id, SUM(amount) as available_delta')
|
|
->groupBy('ref_id')
|
|
->pluck('available_delta', 'ref_id')
|
|
->map(static fn ($amount): int => (int) $amount)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function formatActivity(
|
|
string $logId,
|
|
string $type,
|
|
string $bizType,
|
|
string $activityKind,
|
|
string $activityStatus,
|
|
int $amount,
|
|
string $currency,
|
|
int $availableDelta,
|
|
?string $createdAt,
|
|
object $order,
|
|
int $ticketCount,
|
|
?string $ticketNo,
|
|
int $stakeAmount,
|
|
int $winAmount,
|
|
int $rebateAmount,
|
|
): array {
|
|
return [
|
|
'log_id' => $logId,
|
|
'type' => $type,
|
|
'biz_type' => $bizType,
|
|
'activity_kind' => $activityKind,
|
|
'activity_status' => $activityStatus,
|
|
'amount' => $amount,
|
|
'amount_abs' => abs($amount),
|
|
'direction' => $amount >= 0 ? 'in' : 'out',
|
|
'currency_code' => $currency,
|
|
'balance_after' => null,
|
|
'affects_available_credit' => $availableDelta !== 0,
|
|
'_available_delta' => $availableDelta,
|
|
'ref_id' => (string) $order->order_no,
|
|
'order_no' => (string) $order->order_no,
|
|
'draw_no' => $order->draw_no !== null ? (string) $order->draw_no : null,
|
|
'ticket_no' => $ticketNo !== '' ? $ticketNo : null,
|
|
'ticket_count' => $ticketCount,
|
|
'stake_amount' => $stakeAmount,
|
|
'win_amount' => $winAmount,
|
|
'rebate_amount' => $rebateAmount,
|
|
'net_amount' => $amount,
|
|
'settlement_bill_id' => null,
|
|
'idempotent_key' => null,
|
|
'external_ref_no' => null,
|
|
'status' => $activityStatus === 'pending' ? 'pending_reconcile' : ($activityStatus === 'reversed' ? 'reversed' : 'posted'),
|
|
'remark' => null,
|
|
'created_at' => $createdAt,
|
|
'ledger_source' => 'credit_activity',
|
|
];
|
|
}
|
|
|
|
private function latestTimestamp(mixed $current, mixed $candidate): mixed
|
|
{
|
|
if ($candidate === null || $candidate === '') {
|
|
return $current;
|
|
}
|
|
if ($current === null || $current === '') {
|
|
return $candidate;
|
|
}
|
|
|
|
return strtotime((string) $candidate) > strtotime((string) $current) ? $candidate : $current;
|
|
}
|
|
|
|
private function isoTimestamp(mixed $value): ?string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
return Carbon::parse((string) $value)->toIso8601String();
|
|
}
|
|
}
|