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

@@ -66,7 +66,7 @@ final class AgentGameSettlementRecorder
$settledAt = now();
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void {
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void {
$item->forceFill([
'agent_node_id' => $snapshot['agent_node_id'],
'share_snapshot' => [
@@ -132,6 +132,8 @@ final class AgentGameSettlementRecorder
if ($gameWinLoss > 0) {
$this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id);
} elseif ($gameWinLoss < 0) {
$this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id);
}
});
}

View File

@@ -3,14 +3,12 @@
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
final class AgentPeriodAggregator
{
public function __construct(
private readonly ShareSettlementCalculator $calculator,
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
) {}
/**
@@ -54,15 +52,9 @@ final class AgentPeriodAggregator
$playerId = (int) $row->player_id;
$snapshot = $this->resolveSnapshotFromLedgerRow($row);
if ($snapshot === null) {
$player = Player::query()->find($playerId);
if ($player === null) {
continue;
}
$built = $this->snapshotBuilder->buildForPlayer($player);
$snapshot = [
'total_shares' => $built['total_shares'],
'chain_codes' => $built['chain_codes'],
];
throw new \InvalidArgumentException(
'share_snapshot_missing:ticket_item_id='.(int) $row->ticket_item_id,
);
}
$gameWinLoss = (int) $row->game_win_loss;

View File

@@ -9,6 +9,8 @@ final class AgentSettlementBillGuard
{
private const LOCKED_STATUSES = ['confirmed', 'partial_paid', 'settled', 'overdue', 'reversed'];
private const PAYABLE_STATUSES = ['confirmed', 'partial_paid', 'overdue'];
public function __construct(
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
@@ -37,6 +39,20 @@ final class AgentSettlementBillGuard
}
}
public function assertPayable(int $billId): void
{
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
if (! in_array((string) $bill->status, self::PAYABLE_STATUSES, true)) {
throw ValidationException::withMessages([
'bill' => ['not_payable'],
]);
}
}
public function markConfirmed(int $billId): void
{
$this->assertPeriodMutable($billId);

View File

@@ -4,8 +4,10 @@ namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use App\Services\Agent\AgentCreditAllocatedSyncService;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\AgentSettlementProductionGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentSettlementPeriodCloseService
{
@@ -27,47 +29,58 @@ final class AgentSettlementPeriodCloseService
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
if ($period === null) {
throw new \InvalidArgumentException('period_not_found');
throw ValidationException::withMessages([
'period' => ['period_not_found'],
]);
}
if ((string) $period->status === 'closed') {
throw new \InvalidArgumentException('period_already_closed');
if ((string) $period->status === 'closed' || (string) $period->status === 'completed') {
throw ValidationException::withMessages([
'period' => ['period_already_closed'],
]);
}
$adminSiteId = (int) $period->admin_site_id;
$aggregate = $this->aggregator->aggregate(
$adminSiteId,
[$periodStart, $periodEnd] = AgentSettlementPeriodWindow::boundStrings(
(string) $period->period_start,
(string) $period->period_end,
);
if ($aggregate['players'] === []) {
throw new \InvalidArgumentException('period_no_ledger_rows');
try {
$aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd);
} catch (\InvalidArgumentException $e) {
if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) {
throw ValidationException::withMessages([
'period' => ['share_snapshot_missing'],
]);
}
throw $e;
}
$billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate);
$roundingDiff = $this->platformRounding->apply($periodId, $aggregate);
$rebateStats = $this->periodCloseRebate->dispatchAndAllocate(
$periodId,
(string) $period->period_start,
(string) $period->period_end,
);
$rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $periodStart, $periodEnd);
$unsettled = $this->unsettledWarning->countForSite(
$adminSiteId,
(string) $period->period_start,
(string) $period->period_end,
);
$unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd);
DB::table('settlement_periods')->where('id', $periodId)->update([
'status' => 'closed',
'updated_at' => now(),
]);
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
DB::table('share_ledger')
->whereBetween('settled_at', [$period->period_start, $period->period_end])
->whereIn('id', function ($query) use ($siteCode, $periodStart, $periodEnd): void {
$query->select('sl.id')
->from('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]);
})
->update(['settlement_period_id' => $periodId]);
$this->reconcileAllocatedCreditForSite($adminSiteId);

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentSettlementPeriodOpenService
{
/**
* @param array{admin_site_id: int, period_start: string, period_end: string} $data
* @return object{id: int, admin_site_id: int, period_start: string, period_end: string, status: string}
*/
public function open(array $data): object
{
$siteId = (int) $data['admin_site_id'];
$start = (string) $data['period_start'];
$end = (string) $data['period_end'];
$existingSameRange = DB::table('settlement_periods')
->where('admin_site_id', $siteId)
->where('status', 'open')
->where('period_start', $start)
->where('period_end', $end)
->orderByDesc('id')
->first();
if ($existingSameRange !== null) {
throw ValidationException::withMessages([
'period_start' => ['period_already_open'],
]);
}
$otherOpen = DB::table('settlement_periods')
->where('admin_site_id', $siteId)
->where('status', 'open')
->orderByDesc('id')
->first();
if ($otherOpen !== null) {
throw ValidationException::withMessages([
'period_start' => ['period_site_has_open'],
]);
}
$id = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => $start,
'period_end' => $end,
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$row = DB::table('settlement_periods')->where('id', $id)->first();
if ($row === null) {
throw new \RuntimeException('period_insert_failed');
}
return $row;
}
}

View File

@@ -2,19 +2,32 @@
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminDataScope;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** 账期窗口内信用流水与占成流水笔数(关账前诊断)。 */
final class AgentSettlementPeriodPipelineService
{
public function __construct(
private readonly UnsettledTicketPeriodWarning $unsettledWarning,
private readonly ShareLedgerScopedProfitAggregator $scopedProfitAggregator,
) {}
/**
* @param Collection<int, object> $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id
* @return array<int, array{credit_ledger_count: int, share_ledger_count: int}>
* @return array<int, array{
* credit_ledger_count: int,
* share_ledger_count: int,
* game_win_loss_total: int,
* win_loss_scope: 'platform'|'agent',
* basic_rebate_total: int,
* unsettled_ticket_count: int,
* }>
*/
public function countsForPeriods(Collection $periods): array
public function countsForPeriods(Collection $periods, ?AdminUser $admin = null): array
{
if ($periods->isEmpty()) {
return [];
@@ -26,37 +39,73 @@ final class AgentSettlementPeriodPipelineService
->pluck('code', 'id');
$out = [];
$viewer = $this->scopedProfitAggregator->resolveViewer($admin);
foreach ($periods as $period) {
$periodId = (int) $period->id;
$siteCode = (string) ($siteCodes[(int) $period->admin_site_id] ?? '');
if ($siteCode === '') {
$out[$periodId] = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
$out[$periodId] = [
'credit_ledger_count' => 0,
'share_ledger_count' => 0,
'game_win_loss_total' => 0,
'win_loss_scope' => $viewer['scope'],
'basic_rebate_total' => 0,
'unsettled_ticket_count' => 0,
];
continue;
}
$start = Carbon::parse($period->period_start)->startOfDay();
$end = Carbon::parse($period->period_end)->endOfDay();
[$start, $end] = AgentSettlementPeriodWindow::bounds(
(string) $period->period_start,
(string) $period->period_end,
);
$creditCount = (int) DB::table('credit_ledger as cl')
$creditQuery = 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)
->whereBetween('cl.created_at', [$start, $end])
->count();
->whereBetween('cl.created_at', [$start, $end]);
$shareCount = (int) DB::table('share_ledger as sl')
if ($admin !== null) {
AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p');
}
$shareQuery = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$start, $end])
->count();
->whereNull('sl.reversal_of_id');
if ($admin !== null) {
AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p');
}
$shareAgg = (clone $shareQuery)
->selectRaw('COUNT(*) as share_ledger_count')
->selectRaw('COALESCE(SUM(sl.basic_rebate), 0) as basic_rebate_total')
->first();
$scopedWinLoss = $viewer['scope'] === 'platform'
? $this->scopedProfitAggregator->sumRawGameWinLoss($shareQuery)
: $this->scopedProfitAggregator->sumForShareQuery($shareQuery, $viewer['key']);
$unsettled = $this->unsettledWarning->countForSite(
(int) $period->admin_site_id,
(string) $period->period_start,
(string) $period->period_end,
);
$out[$periodId] = [
'credit_ledger_count' => $creditCount,
'share_ledger_count' => $shareCount,
'credit_ledger_count' => (int) $creditQuery->count(),
'share_ledger_count' => (int) ($shareAgg->share_ledger_count ?? 0),
'game_win_loss_total' => $scopedWinLoss,
'win_loss_scope' => $viewer['scope'],
'basic_rebate_total' => (int) ($shareAgg->basic_rebate_total ?? 0),
'unsettled_ticket_count' => $unsettled['count'],
];
}

View File

@@ -2,6 +2,8 @@
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminAgentSettlementScope;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -10,19 +12,26 @@ final class AgentSettlementPeriodSummaryService
{
public function __construct(
private readonly AgentSettlementPeriodPipelineService $pipelineService,
private readonly ShareLedgerScopedProfitAggregator $scopedProfitAggregator,
) {}
/**
* @param list<int> $periodIds
* @return array<int, array<string, int>>
*/
public function summariesForPeriodIds(array $periodIds): array
public function summariesForPeriodIds(array $periodIds, ?AdminUser $admin = null): array
{
if ($periodIds === []) {
return [];
}
$rows = DB::table('settlement_bills')
->whereIn('settlement_period_id', $periodIds)
$query = DB::table('settlement_bills')
->whereIn('settlement_period_id', $periodIds);
if ($admin !== null) {
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin);
}
$rows = $query
->groupBy('settlement_period_id')
->selectRaw('settlement_period_id')
->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills")
@@ -32,6 +41,7 @@ final class AgentSettlementPeriodSummaryService
->selectRaw("SUM(CASE WHEN status IN ('confirmed', 'partial_paid', 'overdue') AND unpaid_amount > 0 THEN 1 ELSE 0 END) as awaiting_payment")
->selectRaw("SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled")
->selectRaw('COALESCE(SUM(unpaid_amount), 0) as total_unpaid')
->selectRaw('COALESCE(SUM(net_amount), 0) as total_net')
->get();
$out = [];
@@ -45,6 +55,7 @@ final class AgentSettlementPeriodSummaryService
'awaiting_payment' => (int) $row->awaiting_payment,
'settled' => (int) $row->settled,
'total_unpaid' => (int) $row->total_unpaid,
'total_net' => (int) $row->total_net,
];
}
@@ -55,11 +66,11 @@ final class AgentSettlementPeriodSummaryService
* @param Collection<int, object> $periods
* @return list<array<string, mixed>>
*/
public function attachToPeriodRows(Collection $periods): array
public function attachToPeriodRows(Collection $periods, ?AdminUser $admin = null): array
{
$ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$summaries = $this->summariesForPeriodIds($ids);
$pipelines = $this->pipelineService->countsForPeriods($periods);
$summaries = $this->summariesForPeriodIds($ids, $admin);
$pipelines = $this->pipelineService->countsForPeriods($periods, $admin);
$empty = [
'player_bills' => 0,
'agent_bills' => 0,
@@ -68,8 +79,17 @@ final class AgentSettlementPeriodSummaryService
'awaiting_payment' => 0,
'settled' => 0,
'total_unpaid' => 0,
'total_net' => 0,
];
$viewerScope = $this->scopedProfitAggregator->resolveViewer($admin)['scope'];
$emptyPipeline = [
'credit_ledger_count' => 0,
'share_ledger_count' => 0,
'game_win_loss_total' => 0,
'win_loss_scope' => $viewerScope,
'basic_rebate_total' => 0,
'unsettled_ticket_count' => 0,
];
$emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
$items = [];
foreach ($periods as $period) {

View File

@@ -4,6 +4,7 @@ namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminAgentSettlementScope;
use App\Support\AdminDataScope;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -39,12 +40,15 @@ final class AgentSettlementReportQueryService
{
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
return DB::table('share_ledger as sl')
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->whereNull('sl.reversal_of_id');
$this->applyPlayerSubtree($query, $admin, 'p');
return $query
->groupBy('sl.player_id', 'p.username', 'p.agent_node_id', 'ti.play_code')
->selectRaw('sl.player_id, p.username, p.agent_node_id, COALESCE(ti.play_code, ?) as game_type', ['*'])
->selectRaw('SUM(sl.game_win_loss) as game_win_loss')
@@ -69,11 +73,14 @@ final class AgentSettlementReportQueryService
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
return DB::table('share_ledger as sl')
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->whereNull('sl.reversal_of_id');
$this->applyAgentSubtree($query, $admin, 'sl.agent_node_id');
return $query
->groupBy('sl.agent_node_id')
->selectRaw('sl.agent_node_id, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as entry_count')
->orderByDesc('game_win_loss')
@@ -97,26 +104,32 @@ final class AgentSettlementReportQueryService
$base = DB::table('rebate_records as rr')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode);
$this->applyPlayerSubtree($base, $admin, 'p');
$accrued = (clone $base)->where('rr.status', 'accrued')->sum('rr.rebate_amount');
$inBill = (clone $base)->where('rr.status', 'in_bill')->sum('rr.rebate_amount');
$settled = (clone $base)->where('rr.status', 'settled')->sum('rr.rebate_amount');
$allocated = (int) DB::table('rebate_allocations as ra')
$allocatedQuery = DB::table('rebate_allocations as ra')
->join('rebate_records as rr', 'rr.id', '=', 'ra.rebate_record_id')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId))
->sum('ra.allocated_amount');
->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId));
$this->applyPlayerSubtree($allocatedQuery, $admin, 'p');
$allocated = (int) $allocatedQuery->sum('ra.allocated_amount');
return [
'accrued_total' => (int) $accrued,
'in_bill_total' => (int) $inBill,
'settled_total' => (int) $settled,
'allocated_total' => $allocated,
'by_type' => DB::table('rebate_records as rr')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->whereBetween('rr.created_at', [$periodStart, $periodEnd])
'by_type' => tap(
DB::table('rebate_records as rr')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->whereBetween('rr.created_at', [$periodStart, $periodEnd]),
fn (Builder $q) => $this->applyPlayerSubtree($q, $admin, 'p'),
)
->groupBy('rr.rebate_type', 'rr.status')
->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt')
->get()
@@ -137,10 +150,13 @@ final class AgentSettlementReportQueryService
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
$agents = DB::table('agent_profiles as ap')
$agentsQuery = DB::table('agent_profiles as ap')
->join('agent_nodes as an', 'an.id', '=', 'ap.agent_node_id')
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
->where('s.code', $siteCode)
->where('s.code', $siteCode);
$this->applyAgentSubtree($agentsQuery, $admin, 'ap.agent_node_id');
$agents = $agentsQuery
->selectRaw('ap.agent_node_id, an.code, an.name, ap.credit_limit, ap.allocated_credit, (ap.credit_limit - ap.allocated_credit) as available_credit')
->orderBy('an.depth')
->get()
@@ -154,9 +170,12 @@ final class AgentSettlementReportQueryService
])
->all();
$players = DB::table('player_credit_accounts as pc')
$playersQuery = DB::table('player_credit_accounts as pc')
->join('players as p', 'p.id', '=', 'pc.player_id')
->where('p.site_code', $siteCode)
->where('p.site_code', $siteCode);
$this->applyPlayerSubtree($playersQuery, $admin, 'p');
$players = $playersQuery
->selectRaw('pc.player_id, p.username, pc.credit_limit, pc.used_credit, pc.frozen_credit, (pc.credit_limit - pc.used_credit - pc.frozen_credit) as available_credit')
->orderByDesc('pc.used_credit')
->limit(500)
@@ -287,13 +306,16 @@ final class AgentSettlementReportQueryService
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
return DB::table('share_ledger as sl')
$query = DB::table('share_ledger as sl')
->join('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->join('players as p', 'p.id', '=', 'sl.player_id')
->join('draws as d', 'd.id', '=', 'ti.draw_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->whereNull('sl.reversal_of_id');
$this->applyPlayerSubtree($query, $admin, 'p');
return $query
->groupBy('ti.draw_id', 'd.draw_no')
->selectRaw('ti.draw_id, d.draw_no, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as ticket_count')
->orderBy('d.draw_no')
@@ -308,6 +330,27 @@ final class AgentSettlementReportQueryService
->all();
}
private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void
{
AdminDataScope::applyToPlayersAlias($query, $admin, $alias);
}
private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void
{
$subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin);
if ($subtreeIds === null) {
return;
}
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($agentNodeColumn, $subtreeIds);
}
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
{
if ($periodId > 0) {

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);
}
}

View File

@@ -69,7 +69,7 @@ final class SettlementBillGenerator
'platform_rounding_adjustment' => 0,
'net_amount' => $amount,
'paid_amount' => 0,
'unpaid_amount' => $amount,
'unpaid_amount' => abs($amount),
'status' => 'pending_confirm',
'meta_json' => json_encode([
'edge' => $edge,

View File

@@ -5,6 +5,7 @@ namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminDataScope;
use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
@@ -13,6 +14,25 @@ use Illuminate\Support\Facades\DB;
/** 结算中心统一账务流水credit_ledger + 收付 + 调账)。 */
final class SettlementCenterLedgerService
{
private const CREDIT_BIZ_TYPES = [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'settlement_confirm',
];
private const ADJUSTMENT_BIZ_TYPES = [
'adjustment',
'reversal',
'bad_debt',
];
private const SHARE_BIZ_TYPE = 'share_ledger';
public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
@@ -31,54 +51,522 @@ final class SettlementCenterLedgerService
): array {
$periodId = $filters->settlementPeriodId;
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
$settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
$items = [];
$includeCredit = $this->includeEntryKind($filters, 'credit');
$includePayment = $this->includeEntryKind($filters, 'payment');
$includeAdjustment = $this->includeEntryKind($filters, 'adjustment');
if ($includeCredit) {
$creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId);
foreach ($creditRows as $row) {
$pid = (int) $row->player_id;
$bill = $playerBills[$pid] ?? null;
$items[] = $this->formatCreditEntry($row, $bill);
$stubQueries = [];
if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
$creditStub = $this->creditStubQuery($admin, $siteCode, $range, $filters);
if ($creditStub !== null) {
$stubQueries[] = $creditStub;
}
}
if ($includePayment) {
foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
$items[] = $this->formatPaymentEntry($row);
if ($this->shouldIncludeLedgerStub($filters, 'payment')) {
$paymentStub = $this->paymentStubQuery($admin, $siteCode, $periodId, $filters);
if ($paymentStub !== null) {
$stubQueries[] = $paymentStub;
}
}
if ($includeAdjustment) {
foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') {
continue;
}
$items[] = $this->formatAdjustmentEntry($row);
if ($this->shouldIncludeLedgerStub($filters, 'adjustment')) {
$adjustmentStub = $this->adjustmentStubQuery($admin, $siteCode, $periodId, $filters);
if ($adjustmentStub !== null) {
$stubQueries[] = $adjustmentStub;
}
}
if ($this->shouldIncludeLedgerStub($filters, 'share')) {
$shareStub = $this->shareStubQuery($admin, $siteCode, $settledRange, $filters);
if ($shareStub !== null) {
$stubQueries[] = $shareStub;
}
}
if ($stubQueries === []) {
return [
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
];
}
$offset = max(0, ($page - 1) * $perPage);
if (count($stubQueries) === 1) {
$base = $stubQueries[0];
$total = (int) (clone $base)->count();
$stubs = (clone $base)
->orderByDesc('sort_at')
->orderByDesc('entry_id')
->offset($offset)
->limit($perPage)
->get();
} else {
$union = null;
foreach ($stubQueries as $stubQuery) {
$union = $union === null ? $stubQuery : $union->unionAll($stubQuery);
}
$wrapped = DB::query()->fromSub($union, 'ledger_page');
$total = (int) (clone $wrapped)->count();
$stubs = $wrapped
->orderByDesc('sort_at')
->orderByDesc('entry_id')
->offset($offset)
->limit($perPage)
->get();
}
$items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
$items = $this->applyFilters($items, $filters);
$filteredCount = count($items);
usort($items, static function (array $a, array $b): int {
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
});
if ($filteredCount !== count($stubs)) {
$total = $filteredCount;
}
return [
'items' => array_values($items),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
];
}
/**
* 下注流水简化展示:下注占用 + 按注单合并的开奖结算。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listBetFlowSimplified(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
): array {
$periodId = $filters->settlementPeriodId;
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
$rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
$ticketIds = [];
foreach ($rows 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)));
$items = $this->betFlowPresenter->simplifyCreditRows(
$rows,
$ticketRefs,
function (object $row) use ($playerBills, $ticketRefs): array {
$pid = (int) ($row->player_id ?? 0);
return $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
},
function (object $row) use ($playerBills, $ticketRefs): array {
$pid = (int) ($row->player_id ?? 0);
$formatted = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$formatted['txn_no'] = 'CLS-T'.$ticketId;
$formatted['row_key'] = 'settlement-'.$ticketId;
}
return $formatted;
},
);
if ($filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD
|| $filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT) {
$items = array_values(array_filter(
$items,
static fn (array $item): bool => ($item['biz_type'] ?? '') === $filters->bizType,
));
}
$total = count($items);
$offset = max(0, ($page - 1) * $perPage);
$pageItems = array_slice($items, $offset, $perPage);
return [
'items' => array_values($pageItems),
'items' => $pageItems,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
'ledger_source' => 'credit_ledger',
];
}
private function shouldIncludeLedgerStub(SettlementLedgerListFilters $filters, string $kind): bool
{
if (! $this->includeEntryKind($filters, $kind)) {
return false;
}
if ($filters->bizType === null || $filters->bizType === '') {
return true;
}
return match ($kind) {
'credit' => in_array($filters->bizType, self::CREDIT_BIZ_TYPES, true),
'payment' => $filters->bizType === 'payment_record',
'adjustment' => in_array($filters->bizType, self::ADJUSTMENT_BIZ_TYPES, true),
'share' => $filters->bizType === self::SHARE_BIZ_TYPE
|| ($filters->entryKind === 'share' && ($filters->bizType === null || $filters->bizType === '')),
default => false,
};
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
*/
private function shareStubQuery(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereNull('sl.reversal_of_id')
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('sl.settled_at', $range);
}
$this->applyTxnNoStubFilter($query, 'sl.id', 'SL', $filters->txnNo);
return $query;
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
*/
private function creditStubQuery(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$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)
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
if ($filters->bizType !== null && $filters->bizType !== '') {
$query->where('cl.reason', $filters->bizType);
} elseif ($filters->betFlowOnly) {
$query->whereIn('cl.reason', [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
]);
}
$this->applyTxnNoStubFilter($query, 'cl.id', 'CL', $filters->txnNo);
return $query;
}
private function paymentStubQuery(
AdminUser $admin,
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
}
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->selectRaw("'payment' as entry_kind, pr.id as entry_id, pr.created_at as sort_at");
if ($periodId !== null && $periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
$this->applyLedgerSiteScope($query, $admin, 'sp');
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
$this->applyPaymentPlayerFilters($query, $filters);
if ($filters->billStatus !== null && $filters->billStatus !== '') {
$query->where('sb.status', $filters->billStatus);
}
$this->applyTxnNoStubFilter($query, 'pr.id', 'PAY', $filters->txnNo);
return $query;
}
private function applyPaymentPlayerFilters(
\Illuminate\Database\Query\Builder $query,
SettlementLedgerListFilters $filters,
): void {
if ($filters->playerId !== null && $filters->playerId > 0) {
$query->where('sb.owner_type', 'player')
->where('sb.owner_id', $filters->playerId);
}
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
$query->where('sb.owner_type', 'player');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
}
}
private function adjustmentStubQuery(
AdminUser $admin,
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
}
$query = DB::table('settlement_adjustments as sa')
->join('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->selectRaw("'adjustment' as entry_kind, sa.id as entry_id, sa.created_at as sort_at");
if ($periodId !== null && $periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($filters->badDebtOnly) {
$query->where('sa.adjustment_type', 'bad_debt');
} elseif ($filters->entryKind === 'adjustment') {
$query->where('sa.adjustment_type', '!=', 'bad_debt');
}
$this->applyLedgerSiteScope($query, $admin, 'sp');
$this->applyAdjustmentPlayerScope($query, $admin, $siteCode, $filters);
if ($filters->billStatus !== null && $filters->billStatus !== '') {
$query->where('sb.status', $filters->billStatus);
}
if ($filters->bizType !== null && $filters->bizType !== '') {
$query->where('sa.adjustment_type', $filters->bizType);
}
$this->applyTxnNoStubFilter($query, 'sa.id', 'ADJ', $filters->txnNo);
return $query;
}
private function applyAdjustmentPlayerScope(
\Illuminate\Database\Query\Builder $query,
AdminUser $admin,
string $siteCode,
SettlementLedgerListFilters $filters,
): void {
$query->where(function (\Illuminate\Database\Query\Builder $outer) use ($admin, $siteCode, $filters): void {
$outer->whereNull('p.id')
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
$scoped->where('p.site_code', $siteCode);
AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p');
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
});
});
}
private function applyTxnNoStubFilter(
\Illuminate\Database\Query\Builder $query,
string $idColumn,
string $prefix,
?string $txnNo,
): void {
if ($txnNo === null || $txnNo === '') {
return;
}
$needle = strtolower(trim($txnNo));
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($idColumn, $prefix, $needle): void {
if (ctype_digit($needle)) {
$match->where($idColumn, (int) $needle);
}
$match->orWhereRaw(
'LOWER(CONCAT(?, \'-\', '.$idColumn.')) LIKE ?',
[$prefix, '%'.$needle.'%'],
);
});
}
private function applyLedgerSiteScope(\Illuminate\Database\Query\Builder $query, AdminUser $admin, string $periodsAlias): void
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return;
}
if ($siteIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($periodsAlias.'.admin_site_id', $siteIds);
}
private function applyLedgerPlayerFilters(
\Illuminate\Database\Query\Builder $query,
string $playerAlias,
SettlementLedgerListFilters $filters,
): void {
if ($filters->playerId !== null && $filters->playerId > 0) {
$query->where("{$playerAlias}.id", $filters->playerId);
}
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
$like = '%'.addcslashes($filters->playerAccount, '%_\\').'%';
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($playerAlias, $like): void {
$match->where("{$playerAlias}.username", 'like', $like)
->orWhere("{$playerAlias}.site_player_id", 'like', $like)
->orWhere("{$playerAlias}.nickname", 'like', $like);
});
}
}
/**
* @param \Illuminate\Support\Collection<int, object> $stubs
* @param array<int, object> $playerBills
* @return list<array<string, mixed>>
*/
private function hydrateLedgerStubs(
AdminUser $admin,
string $siteCode,
\Illuminate\Support\Collection $stubs,
array $playerBills,
): array {
if ($stubs->isEmpty()) {
return [];
}
$creditIds = [];
$paymentIds = [];
$adjustmentIds = [];
$shareIds = [];
foreach ($stubs as $stub) {
$kind = (string) $stub->entry_kind;
$id = (int) $stub->entry_id;
if ($kind === 'credit') {
$creditIds[] = $id;
} elseif ($kind === 'payment') {
$paymentIds[] = $id;
} elseif ($kind === 'adjustment') {
$adjustmentIds[] = $id;
} elseif ($kind === 'share') {
$shareIds[] = $id;
}
}
$creditById = [];
if ($creditIds !== []) {
foreach ($this->fetchCreditRowsByIds($admin, $siteCode, $creditIds) as $row) {
$creditById[(int) $row->id] = $row;
}
}
$ticketRefs = $this->partyEnrichment->loadTicketRefs(
array_values(array_filter(array_map(
static fn (object $row): int => (string) ($row->ref_type ?? '') === 'ticket_item'
? (int) ($row->ref_id ?? 0)
: 0,
array_values($creditById),
), static fn (int $id): bool => $id > 0)),
);
$paymentById = [];
if ($paymentIds !== []) {
foreach ($this->fetchPaymentRowsByIds($admin, $siteCode, $paymentIds) as $row) {
$paymentById[(int) $row->id] = $row;
}
}
$adjustmentById = [];
if ($adjustmentIds !== []) {
foreach ($this->fetchAdjustmentRowsByIds($admin, $siteCode, $adjustmentIds) as $row) {
$adjustmentById[(int) $row->id] = $row;
}
}
$shareById = [];
if ($shareIds !== []) {
foreach ($this->fetchShareRowsByIds($admin, $siteCode, $shareIds) as $row) {
$shareById[(int) $row->id] = $row;
}
}
$shareTicketRefs = $this->partyEnrichment->loadTicketRefs(
array_values(array_filter(array_map(
static fn (object $row): int => (int) ($row->ticket_item_id ?? 0),
array_values($shareById),
), static fn (int $id): bool => $id > 0)),
);
$items = [];
foreach ($stubs as $stub) {
$kind = (string) $stub->entry_kind;
$id = (int) $stub->entry_id;
if ($kind === 'credit' && isset($creditById[$id])) {
$row = $creditById[$id];
$pid = (int) $row->player_id;
$items[] = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs);
} elseif ($kind === 'payment' && isset($paymentById[$id])) {
$items[] = $this->formatPaymentEntry($paymentById[$id]);
} elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) {
$items[] = $this->formatAdjustmentEntry($adjustmentById[$id]);
} elseif ($kind === 'share' && isset($shareById[$id])) {
$items[] = $this->formatShareEntry($shareById[$id], $shareTicketRefs);
}
}
return $items;
}
/**
* @return array{0: Carbon|null, 1: Carbon|null}
*/
@@ -113,34 +601,6 @@ final class SettlementCenterLedgerService
}
}
if ($filters->txnNo !== null) {
$needle = strtolower($filters->txnNo);
$hay = strtolower((string) ($row['txn_no'] ?? ''));
if (! str_contains($hay, $needle)) {
return false;
}
}
if ($filters->playerAccount !== null) {
$needle = strtolower($filters->playerAccount);
$haystack = strtolower(implode(' ', array_filter([
(string) ($row['username'] ?? ''),
(string) ($row['nickname'] ?? ''),
(string) ($row['site_player_id'] ?? ''),
])));
if (! str_contains($haystack, $needle)) {
return false;
}
}
if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) {
return false;
}
if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) {
return false;
}
if ($filters->actionableOnly) {
$actions = $row['available_actions'] ?? [];
$operational = array_filter(
@@ -160,6 +620,28 @@ final class SettlementCenterLedgerService
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolveSettledRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo);
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolvePeriodRange(
?int $settlementPeriodId,
?string $rangeFrom,
?string $rangeTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
@@ -167,17 +649,17 @@ final class SettlementCenterLedgerService
return null;
}
return [
Carbon::parse($period->period_start)->startOfDay(),
Carbon::parse($period->period_end)->endOfDay(),
];
return AgentSettlementPeriodWindow::bounds(
(string) $period->period_start,
(string) $period->period_end,
);
}
$from = $createdFrom !== null && $createdFrom !== ''
? Carbon::parse($createdFrom)->startOfDay()
$from = $rangeFrom !== null && $rangeFrom !== ''
? Carbon::parse($rangeFrom)->startOfDay()
: null;
$to = $createdTo !== null && $createdTo !== ''
? Carbon::parse($createdTo)->endOfDay()
$to = $rangeTo !== null && $rangeTo !== ''
? Carbon::parse($rangeTo)->endOfDay()
: null;
if ($from === null && $to === null) {
@@ -190,6 +672,97 @@ final class SettlementCenterLedgerService
];
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchShareRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id')
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->leftJoin('agent_nodes as sla', 'sla.id', '=', 'sl.agent_node_id')
->where('p.site_code', $siteCode)
->whereIn('sl.id', $ids)
->select([
'sl.id',
'sl.ticket_item_id',
'sl.player_id',
'sl.agent_node_id as share_agent_node_id',
'sl.shared_net_win_loss',
'sl.game_win_loss',
'sl.settled_at',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'ti.play_code',
'd.draw_no',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
'sla.code as share_agent_code',
'sla.name as share_agent_name',
]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
return $query->get()->all();
}
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
* @return array<string, mixed>
*/
private function formatShareEntry(object $row, array $ticketRefs): array
{
$ticketId = (int) ($row->ticket_item_id ?? 0);
$ticketRef = $ticketRefs[$ticketId] ?? null;
$signed = (int) ($row->shared_net_win_loss ?? 0);
return array_merge(
$this->baseRow(
entryKind: 'share',
entryId: (int) $row->id,
txnPrefix: 'SL',
playerId: (int) $row->player_id,
row: $row,
bizType: self::SHARE_BIZ_TYPE,
signedAmount: $signed,
createdAt: $row->settled_at,
ledgerSource: 'share_ledger',
settlementBillId: null,
billStatus: null,
billType: null,
billUnpaid: null,
refLabel: $ticketId > 0 ? '#'.$ticketId : null,
refType: $ticketId > 0 ? 'ticket_item' : null,
refId: $ticketId > 0 ? $ticketId : null,
),
$this->partyFieldsFromRow($row),
[
'play_code' => $ticketRef['play_code'] ?? $row->play_code ?? null,
'draw_no' => $ticketRef['draw_no'] ?? $row->draw_no ?? null,
'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
'available_actions' => ['view_player'],
],
);
}
/**
* @return array<int, object>
*/
@@ -257,6 +830,8 @@ final class SettlementCenterLedgerService
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
@@ -271,9 +846,16 @@ final class SettlementCenterLedgerService
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('cl.id');
@@ -290,6 +872,114 @@ final class SettlementCenterLedgerService
return $query->limit(500)->get()->all();
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
* @return list<object>
*/
private function fetchBetFlowCreditRows(
AdminUser $admin,
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): 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');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->whereIn('cl.reason', [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
])
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
return $query->limit(5000)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchCreditRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$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');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->whereIn('cl.id', $ids)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
return $query->get()->all();
}
/**
* @return list<object>
*/
@@ -307,6 +997,8 @@ final class SettlementCenterLedgerService
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'pr.id',
'pr.amount',
@@ -321,9 +1013,16 @@ final class SettlementCenterLedgerService
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('pr.id');
@@ -348,6 +1047,69 @@ final class SettlementCenterLedgerService
return $query->limit(300)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchPaymentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return [];
}
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sp.admin_site_id', $adminSiteId)
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->leftJoin('agent_nodes as owner_an', function ($join): void {
$join->on('owner_an.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'agent');
})
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->whereIn('pr.id', $ids)
->select([
'pr.id',
'pr.amount',
'pr.method',
'pr.status',
'pr.created_at',
'pr.settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'),
DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'),
DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'),
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]);
AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb');
$this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all();
}
/**
* @return list<object>
*/
@@ -365,6 +1127,8 @@ final class SettlementCenterLedgerService
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'sa.id',
'sa.amount',
@@ -379,9 +1143,16 @@ final class SettlementCenterLedgerService
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
])
->orderByDesc('sa.id');
@@ -406,28 +1177,91 @@ final class SettlementCenterLedgerService
return $query->limit(300)->get()->all();
}
/**
* @param list<int> $ids
* @return list<object>
*/
private function fetchAdjustmentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array
{
if ($ids === []) {
return [];
}
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->whereIn('sa.id', $ids)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
->select([
'sa.id',
'sa.amount',
'sa.adjustment_type',
'sa.reason',
'sa.created_at',
'sa.original_bill_id as settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.agent_node_id',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
'da.id as direct_agent_id',
'da.code as direct_agent_code',
'da.name as direct_agent_name',
'pa.id as parent_agent_id',
'pa.code as parent_agent_code',
'pa.name as parent_agent_name',
]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all();
}
/**
* @return array<string, mixed>
*/
private function formatCreditEntry(object $row, ?object $bill): array
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
*/
private function formatCreditEntry(object $row, ?object $bill, array $ticketRefs = []): array
{
$amount = (int) $row->amount;
$billId = $bill !== null ? (int) $bill->id : null;
$ticketRef = $this->resolveTicketRef($row, $ticketRefs);
return $this->baseRow(
entryKind: 'credit',
entryId: (int) $row->id,
txnPrefix: 'CL',
playerId: (int) $row->player_id,
row: $row,
bizType: (string) $row->reason,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'credit_ledger',
settlementBillId: $billId,
billStatus: $bill !== null ? (string) $bill->status : null,
billType: $bill !== null ? (string) $bill->bill_type : null,
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
return array_merge(
$this->baseRow(
entryKind: 'credit',
entryId: (int) $row->id,
txnPrefix: 'CL',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: (string) $row->reason,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'credit_ledger',
settlementBillId: $billId,
billStatus: $bill !== null ? (string) $bill->status : null,
billType: $bill !== null ? (string) $bill->bill_type : null,
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
refType: isset($row->ref_type) ? (string) $row->ref_type : null,
refId: isset($row->ref_id) ? (int) $row->ref_id : null,
),
$this->partyFieldsFromRow($row),
$ticketRef,
);
}
@@ -438,21 +1272,24 @@ final class SettlementCenterLedgerService
{
$amount = (int) $row->amount;
return $this->baseRow(
entryKind: 'payment',
entryId: (int) $row->id,
txnPrefix: 'PAY',
playerId: (int) $row->player_id,
row: $row,
bizType: 'payment_record',
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'payment_record',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''),
return array_merge(
$this->baseRow(
entryKind: 'payment',
entryId: (int) $row->id,
txnPrefix: 'PAY',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: 'payment_record',
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'payment_record',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''),
),
$this->partyFieldsFromRow($row),
);
}
@@ -464,26 +1301,75 @@ final class SettlementCenterLedgerService
$amount = (int) $row->amount;
$type = (string) $row->adjustment_type;
return $this->baseRow(
entryKind: 'adjustment',
entryId: (int) $row->id,
txnPrefix: 'ADJ',
playerId: (int) $row->player_id,
row: $row,
bizType: $type,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'settlement_adjustment',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: $row->reason !== null && $row->reason !== ''
? (string) $row->reason
: 'bill#'.$row->settlement_bill_id,
return array_merge(
$this->baseRow(
entryKind: 'adjustment',
entryId: (int) $row->id,
txnPrefix: 'ADJ',
playerId: (int) ($row->player_id ?? 0),
row: $row,
bizType: $type,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'settlement_adjustment',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: $row->reason !== null && $row->reason !== ''
? (string) $row->reason
: 'bill#'.$row->settlement_bill_id,
),
$this->partyFieldsFromRow($row),
);
}
/**
* @param array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int}> $ticketRefs
* @return array{play_code: string|null, draw_no: string|null, ticket_item_id: int|null}
*/
private function resolveTicketRef(object $row, array $ticketRefs): array
{
if ((string) ($row->ref_type ?? '') !== 'ticket_item') {
return ['play_code' => null, 'draw_no' => null, 'ticket_item_id' => null];
}
$ticketId = (int) ($row->ref_id ?? 0);
$ref = $ticketRefs[$ticketId] ?? null;
return [
'play_code' => $ref['play_code'] ?? null,
'draw_no' => $ref['draw_no'] ?? null,
'ticket_item_id' => $ticketId > 0 ? $ticketId : null,
];
}
/**
* @return array<string, mixed>
*/
private function partyFieldsFromRow(object $row): array
{
$directId = (int) ($row->direct_agent_id ?? $row->agent_node_id ?? 0);
$parentId = (int) ($row->parent_agent_id ?? 0);
return [
'direct_agent_id' => $directId > 0 ? $directId : null,
'direct_agent_label' => $directId > 0
? $this->partyEnrichment->formatAgent((object) [
'name' => $row->direct_agent_name ?? null,
'code' => $row->direct_agent_code ?? null,
], $directId)
: null,
'parent_agent_id' => $parentId > 0 ? $parentId : null,
'parent_agent_label' => $parentId > 0
? $this->partyEnrichment->formatAgent((object) [
'name' => $row->parent_agent_name ?? null,
'code' => $row->parent_agent_code ?? null,
], $parentId)
: null,
];
}
/**
* @return array<string, mixed>
*/
@@ -502,6 +1388,8 @@ final class SettlementCenterLedgerService
?string $billType,
?int $billUnpaid,
?string $refLabel = null,
?string $refType = null,
?int $refId = null,
): array {
$amountAbs = abs($signedAmount);
$currency = (string) ($row->default_currency ?? '');
@@ -517,6 +1405,8 @@ final class SettlementCenterLedgerService
'username' => $row->username ?? null,
'nickname' => $row->nickname ?? null,
'biz_type' => $bizType,
'ref_type' => $refType,
'ref_id' => $refId,
'biz_no' => $refLabel ?? $this->creditRefLabel($row),
'direction' => $signedAmount >= 0 ? 1 : 2,
'amount' => $amountAbs,

View File

@@ -17,6 +17,8 @@ final class SettlementLedgerListFilters
public readonly ?string $createdFrom = null,
public readonly ?string $createdTo = null,
public readonly bool $badDebtOnly = false,
public readonly bool $betFlowOnly = false,
public readonly bool $betFlowDisplaySimple = false,
) {}
public static function fromQuery(array $query): self
@@ -35,9 +37,23 @@ final class SettlementLedgerListFilters
createdFrom: self::nonEmptyString($query['created_from'] ?? null),
createdTo: self::nonEmptyString($query['created_to'] ?? null),
badDebtOnly: filter_var($query['bad_debt_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
betFlowOnly: filter_var($query['bet_flow_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
betFlowDisplaySimple: self::betFlowDisplaySimple($query),
);
}
/**
* @param array<string, mixed> $query
*/
private static function betFlowDisplaySimple(array $query): bool
{
if (filter_var($query['bet_flow_only'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
return true;
}
return trim((string) ($query['bet_flow_display'] ?? '')) === 'simple';
}
private static function positiveInt(mixed $value): ?int
{
$id = (int) $value;

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** 结算列表:玩家 / 代理 / 注单关联字段 enrichment。 */
final class SettlementPartyEnrichment
{
/**
* @param Collection<int, object> $agents keyed by id
* @return array{
* direct_agent_id: int|null,
* direct_agent_label: string|null,
* parent_agent_id: int|null,
* parent_agent_label: string|null,
* }
*/
public function agentLineLabels(?int $directAgentId, Collection $agents): array
{
if ($directAgentId === null || $directAgentId <= 0) {
return [
'direct_agent_id' => null,
'direct_agent_label' => null,
'parent_agent_id' => null,
'parent_agent_label' => null,
];
}
$direct = $agents->get($directAgentId);
$directLabel = $this->formatAgent($direct, $directAgentId);
$parentId = $direct !== null ? (int) ($direct->parent_id ?? 0) : 0;
$parent = $parentId > 0 ? $agents->get($parentId) : null;
return [
'direct_agent_id' => $directAgentId,
'direct_agent_label' => $directLabel,
'parent_agent_id' => $parentId > 0 ? $parentId : null,
'parent_agent_label' => $parentId > 0 ? $this->formatAgent($parent, $parentId) : null,
];
}
/**
* @param list<int> $agentIds
* @return Collection<int, object>
*/
public function loadAgents(array $agentIds): Collection
{
$ids = array_values(array_unique(array_filter($agentIds, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return collect();
}
$rows = DB::table('agent_nodes')->whereIn('id', $ids)->get()->keyBy('id');
$parentIds = $rows
->pluck('parent_id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->unique()
->values()
->all();
$missingParents = array_diff($parentIds, $ids);
if ($missingParents !== []) {
$parents = DB::table('agent_nodes')->whereIn('id', $missingParents)->get()->keyBy('id');
foreach ($parents as $id => $row) {
$rows->put((int) $id, $row);
}
}
return $rows;
}
/**
* @param list<int> $ticketItemIds
* @return array<int, array{play_code: string|null, draw_no: string|null, ticket_item_id: int, actual_deduct_amount: int}>
*/
public function loadTicketRefs(array $ticketItemIds): array
{
$ids = array_values(array_unique(array_filter($ticketItemIds, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return [];
}
$map = [];
foreach (DB::table('ticket_items as ti')
->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id')
->whereIn('ti.id', $ids)
->select(['ti.id', 'ti.play_code', 'ti.actual_deduct_amount', 'd.draw_no'])
->get() as $row) {
$map[(int) $row->id] = [
'ticket_item_id' => (int) $row->id,
'play_code' => $row->play_code !== null ? (string) $row->play_code : null,
'draw_no' => $row->draw_no !== null ? (string) $row->draw_no : null,
'actual_deduct_amount' => (int) ($row->actual_deduct_amount ?? 0),
];
}
return $map;
}
public function formatAgent(?object $agent, int $fallbackId): string
{
if ($agent === null) {
return "agent#{$fallbackId}";
}
$name = trim((string) ($agent->name ?? ''));
$code = trim((string) ($agent->code ?? ''));
if ($name !== '' && $code !== '') {
return "{$name} ({$code})";
}
return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}");
}
public function formatPlayerUsername(?object $player): ?string
{
if ($player === null) {
return null;
}
$username = trim((string) ($player->username ?? ''));
return $username !== '' ? $username : null;
}
public function formatPlayerSiteId(?object $player): ?string
{
if ($player === null) {
return null;
}
$sitePlayerId = trim((string) ($player->site_player_id ?? ''));
return $sitePlayerId !== '' ? $sitePlayerId : null;
}
public function formatCounterpartyLabel(string $type, int $id, Collection $agents): string
{
if ($type === 'platform' || $id <= 0) {
return 'platform';
}
if ($type === 'agent') {
return $this->formatAgent($agents->get($id), $id);
}
return "{$type}#{$id}";
}
}

View File

@@ -31,18 +31,21 @@ final class SettlementPaymentService
}
$this->billGuard->assertPeriodMutable($billId);
$this->billGuard->assertPayable($billId);
$amount = min($amount, (int) $bill->unpaid_amount);
$amount = min($amount, abs((int) $bill->unpaid_amount));
if ($amount <= 0) {
return;
}
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => (string) $bill->owner_type,
'payer_id' => (int) $bill->owner_id,
'payee_type' => (string) $bill->counterparty_type,
'payee_id' => (int) $bill->counterparty_id,
'payer_type' => $payerType,
'payer_id' => $payerId,
'payee_type' => $payeeType,
'payee_id' => $payeeId,
'amount' => $amount,
'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null,
@@ -69,7 +72,12 @@ final class SettlementPaymentService
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
if ((int) $bill->net_amount > 0) {
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
} elseif ((int) $bill->net_amount < 0) {
$this->playerCreditService->applySettlementPayout($player, $amount, $billId);
}
if ($status === 'settled') {
$this->periodCloseRebate->markRebatesSettledForBill($billId);
}
@@ -78,4 +86,28 @@ final class SettlementPaymentService
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
}
/**
* net_amount > 0owner 应付 counterparty< 0counterparty 应付 owner。
*
* @return array{0: string, 1: int, 2: string, 3: int}
*/
private function resolvePayerPayee(object $bill): array
{
if ((int) $bill->net_amount < 0) {
return [
(string) $bill->counterparty_type,
(int) $bill->counterparty_id,
(string) $bill->owner_type,
(int) $bill->owner_id,
];
}
return [
(string) $bill->owner_type,
(int) $bill->owner_id,
(string) $bill->counterparty_type,
(int) $bill->counterparty_id,
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminAgentScope;
use Illuminate\Database\Query\Builder;
/** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */
final class ShareLedgerScopedProfitAggregator
{
public function __construct(
private readonly ShareSettlementCalculator $calculator,
) {}
/**
* @return array{key: string, scope: 'platform'|'agent'}
*/
public function resolveViewer(?AdminUser $admin): array
{
if ($admin === null) {
return ['key' => 'platform', 'scope' => 'platform'];
}
$node = AdminAgentScope::primaryAgentNode($admin);
if ($node === null) {
return ['key' => 'platform', 'scope' => 'platform'];
}
return ['key' => (string) $node->code, 'scope' => 'agent'];
}
public function sumForShareQuery(Builder $shareQuery, string $profitKey): int
{
$rows = (clone $shareQuery)
->select([
'sl.allocations_json',
'sl.game_win_loss',
'sl.basic_rebate',
'sl.share_snapshot',
])
->get();
$total = 0;
foreach ($rows as $row) {
$total += $this->profitFromRow($row, $profitKey);
}
return $total;
}
public function sumRawGameWinLoss(Builder $shareQuery): int
{
return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0);
}
private function profitFromRow(object $row, string $profitKey): int
{
$allocations = $this->decodeJsonObject($row->allocations_json ?? null);
if (array_key_exists($profitKey, $allocations)) {
return (int) round((float) $allocations[$profitKey]);
}
$snapshot = $this->resolveSnapshot($row->share_snapshot ?? null);
if ($snapshot === null) {
return 0;
}
$result = $this->calculator->calculate(
sharedNetWinLoss: 0,
totalSharesByCode: $snapshot['total_shares'],
gameWinLoss: (int) $row->game_win_loss,
basicRebate: (int) $row->basic_rebate,
chainFromPlayer: $snapshot['chain_codes'],
);
return (int) round($result->finalProfits[$profitKey] ?? 0);
}
/**
* @return array<string, float|int>
*/
private function decodeJsonObject(mixed $raw): array
{
if ($raw === null || $raw === '') {
return [];
}
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (! is_array($decoded)) {
return [];
}
return $decoded;
}
/**
* @return array{total_shares: array<string, float>, chain_codes: list<string>}|null
*/
private function resolveSnapshot(mixed $raw): ?array
{
$decoded = $this->decodeJsonObject($raw);
if ($decoded === []) {
return null;
}
$totalShares = $decoded['total_shares'] ?? null;
$chainCodes = $decoded['chain_codes'] ?? null;
if (! is_array($totalShares) || ! is_array($chainCodes) || $chainCodes === []) {
return null;
}
$shares = [];
foreach ($totalShares as $code => $rate) {
$shares[(string) $code] = (float) $rate;
}
return [
'total_shares' => $shares,
'chain_codes' => array_values(array_map(strval(...), $chainCodes)),
];
}
}

View File

@@ -2,22 +2,35 @@
namespace App\Services\AgentSettlement;
use App\Support\AgentSettlementPeriodWindow;
use Illuminate\Support\Facades\DB;
final class UnsettledTicketPeriodWarning
{
/**
* 未结算注单优先按游戏结算落账时间settled_at归属账期未开奖仍按下注时间created_at
*
* @return array{count: int, ticket_item_ids: list<int>}
*/
public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array
{
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
[$start, $end] = AgentSettlementPeriodWindow::boundStrings($periodStart, $periodEnd);
$rows = DB::table('ticket_items as ti')
->join('players as p', 'p.id', '=', 'ti.player_id')
->where('p.site_code', $siteCode)
->whereIn('ti.status', ['pending_draw', 'pending_confirm', 'pending_payout'])
->whereBetween('ti.created_at', [$periodStart, $periodEnd])
->whereNull('ti.agent_settled_at')
->where(function ($query) use ($start, $end): void {
$query->where(function ($settled) use ($start, $end): void {
$settled->whereNotNull('ti.settled_at')
->whereBetween('ti.settled_at', [$start, $end]);
})->orWhere(function ($pending) use ($start, $end): void {
$pending->whereNull('ti.settled_at')
->whereBetween('ti.created_at', [$start, $end]);
});
})
->pluck('ti.id')
->map(fn ($id): int => (int) $id)
->all();