Files
lotteryLaravel/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php
kang 3c57941895
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat: add idempotency support for settlement adjustments and payments, fix rebate handling
- Added idempotency_key parameter to settlement bill adjustment and payment endpoints to prevent duplicate operations
- Fixed player rebate profile updates to preserve existing values when only one field is modified
- Moved wallet player validation earlier in AdminPlayerStoreController to prevent unnecessary processing
- Enhanced extra rebate calculation to include 'in_bill' and 'settled' statuses in AgentP
2026-06-23 16:49:57 +08:00

246 lines
8.3 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\AgentSettlement;
use Carbon\Carbon;
/**
* 信用盘下注流水对外展示:待开奖「下注冻结」+ 已结算「开奖结算」(每注单一条,不重复展示占用)。
*/
final class CreditLedgerBetFlowPresenter
{
public const DISPLAY_BET_HOLD = 'bet_hold';
public const DISPLAY_GAME_SETTLEMENT = 'game_settlement';
private const SETTLEMENT_REASONS = ['bet_hold_release', 'game_settlement_loss', 'game_settlement_win'];
/**
* @param list<object> $rows credit_ledger 行(含 reason、ref_type、ref_id、amount、created_at
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int, actual_deduct_amount?: int}> $ticketRefs
* @return list<array<string, mixed>>
*/
public function simplifyCreditRows(
array $rows,
array $ticketRefs,
callable $formatHold,
callable $formatSettlement,
): array {
/** @var list<object> $holdRows */
$holdRows = [];
/** @var array<int, list<object>> $byTicket */
$byTicket = [];
foreach ($rows as $row) {
$reason = (string) ($row->reason ?? '');
if ($reason === self::DISPLAY_BET_HOLD) {
$holdRows[] = $row;
continue;
}
if (! in_array($reason, self::SETTLEMENT_REASONS, true)) {
continue;
}
$ticketId = $this->ticketItemId($row);
if ($ticketId <= 0) {
continue;
}
$byTicket[$ticketId][] = $row;
}
/** @var list<object> $mergedSettlements */
$mergedSettlements = [];
foreach ($byTicket as $ticketId => $entries) {
$merged = $this->mergeSettlementEntries($ticketId, $entries, $ticketRefs);
if ($merged !== null) {
$mergedSettlements[] = $merged;
}
}
$visibleHolds = $this->holdsWithoutSettledMatch($holdRows, $mergedSettlements, $ticketRefs);
$holdItems = array_map($formatHold, $visibleHolds);
$settlementItems = array_map(
fn (object $merged): array => $formatSettlement($merged, $ticketRefs),
$mergedSettlements,
);
$all = array_merge($holdItems, $settlementItems);
usort($all, static function (array $a, array $b): int {
$ta = isset($a['created_at']) ? strtotime((string) $a['created_at']) : 0;
$tb = isset($b['created_at']) ? strtotime((string) $b['created_at']) : 0;
if ($ta === $tb) {
return (int) ($b['id'] ?? 0) <=> (int) ($a['id'] ?? 0);
}
return $tb <=> $ta;
});
return $all;
}
/**
* 已开奖注单不再展示下注占用,避免与开奖结算同额时出现「扣两次」误解。
*
* @param list<object> $holdRows
* @param list<object> $mergedSettlements
* @param array<int, array{actual_deduct_amount?: int}> $ticketRefs
* @return list<object>
*/
private function holdsWithoutSettledMatch(
array $holdRows,
array $mergedSettlements,
array $ticketRefs,
): array {
if ($holdRows === [] || $mergedSettlements === []) {
return $holdRows;
}
$sortedHolds = $holdRows;
usort($sortedHolds, static function (object $a, object $b): int {
$ta = $a->created_at ?? null;
$tb = $b->created_at ?? null;
if ($ta === null || $tb === null) {
return (int) ($a->id ?? 0) <=> (int) ($b->id ?? 0);
}
return Carbon::parse($ta)->getTimestamp() <=> Carbon::parse($tb)->getTimestamp();
});
$consumedHoldIds = [];
foreach ($mergedSettlements as $settlement) {
$playerId = (int) ($settlement->player_id ?? 0);
$ticketId = (int) ($settlement->ref_id ?? 0);
$stake = $this->stakeMinorForSettlement($settlement, $ticketId, $ticketRefs);
if ($playerId <= 0 || $stake <= 0) {
continue;
}
$settledAt = $settlement->created_at ?? null;
foreach ($sortedHolds as $hold) {
$holdId = (int) ($hold->id ?? 0);
if ($holdId <= 0 || isset($consumedHoldIds[$holdId])) {
continue;
}
if ((int) ($hold->player_id ?? 0) !== $playerId) {
continue;
}
if (abs((int) ($hold->amount ?? 0)) !== $stake) {
continue;
}
$holdAt = $hold->created_at ?? null;
if ($holdAt !== null && $settledAt !== null
&& Carbon::parse($holdAt)->gt(Carbon::parse($settledAt))) {
continue;
}
$consumedHoldIds[$holdId] = true;
break;
}
}
return array_values(array_filter(
$holdRows,
static fn (object $hold): bool => ! isset($consumedHoldIds[(int) ($hold->id ?? 0)]),
));
}
/**
* @param array<int, array{actual_deduct_amount?: int}> $ticketRefs
*/
private function stakeMinorForSettlement(object $settlement, int $ticketId, array $ticketRefs): int
{
$fromTicketRef = (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 0);
if ($fromTicketRef > 0) {
return $fromTicketRef;
}
$amount = (int) ($settlement->amount ?? 0);
if ($amount < 0) {
return abs($amount);
}
return 0;
}
/**
* @param list<object> $entries
* @param array<int, array{actual_deduct_amount?: int}> $ticketRefs
*/
private function mergeSettlementEntries(int $ticketId, array $entries, array $ticketRefs): ?object
{
$loss = null;
$win = null;
$release = null;
$latestAt = null;
foreach ($entries as $entry) {
$reason = (string) ($entry->reason ?? '');
if ($reason === 'game_settlement_loss') {
$loss = $entry;
} elseif ($reason === 'game_settlement_win') {
$win = $entry;
} elseif ($reason === 'bet_hold_release') {
$release = $entry;
}
$at = $entry->created_at ?? null;
if ($at !== null && ($latestAt === null || Carbon::parse($at)->gt(Carbon::parse($latestAt)))) {
$latestAt = $at;
}
}
$primary = $loss ?? $win ?? $release;
if ($primary === null) {
return null;
}
$signed = $loss !== null
? (int) $loss->amount
: ($win !== null ? (int) $win->amount : 0);
return (object) [
'id' => (int) ($loss->id ?? $win->id ?? $release->id ?? 0),
'amount' => $signed,
'reason' => self::DISPLAY_GAME_SETTLEMENT,
'ref_type' => 'ticket_item',
'ref_id' => $ticketId,
'created_at' => $latestAt ?? $primary->created_at,
'player_id' => $primary->player_id ?? null,
'site_code' => $primary->site_code ?? null,
'site_player_id' => $primary->site_player_id ?? null,
'username' => $primary->username ?? null,
'nickname' => $primary->nickname ?? null,
'agent_node_id' => $primary->agent_node_id ?? null,
'funding_mode' => $primary->funding_mode ?? null,
'auth_source' => $primary->auth_source ?? null,
'default_currency' => $primary->default_currency ?? null,
'direct_agent_id' => $primary->direct_agent_id ?? null,
'direct_agent_code' => $primary->direct_agent_code ?? null,
'direct_agent_name' => $primary->direct_agent_name ?? null,
'parent_agent_id' => $primary->parent_agent_id ?? null,
'parent_agent_code' => $primary->parent_agent_code ?? null,
'parent_agent_name' => $primary->parent_agent_name ?? null,
'stake_minor' => (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? abs($signed)),
];
}
private function ticketItemId(object $row): int
{
if ((string) ($row->ref_type ?? '') !== 'ticket_item') {
return 0;
}
return (int) ($row->ref_id ?? 0);
}
}