feat: 增强代理结算和账单管理功能

- 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。
- 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。
- 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。
- 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。
- 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。
This commit is contained in:
2026-06-05 18:00:56 +08:00
parent a44679665d
commit 2d32f006c5
63 changed files with 4893 additions and 288 deletions

View File

@@ -0,0 +1,237 @@
<?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'];
/**
* @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
{
$fromLoss = abs((int) ($settlement->amount ?? 0));
if ($fromLoss > 0) {
return $fromLoss;
}
return (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 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;
$release = null;
$latestAt = null;
foreach ($entries as $entry) {
$reason = (string) ($entry->reason ?? '');
if ($reason === 'game_settlement_loss') {
$loss = $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 ?? $release;
if ($primary === null) {
return null;
}
$signed = $loss !== null
? (int) $loss->amount
: 0;
return (object) [
'id' => (int) ($loss->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);
}
}