fix(core): harden settlement and wallet integration

This commit is contained in:
wchino
2026-07-22 01:40:37 +08:00
parent 150c3e7ebd
commit 35f6e46958
54 changed files with 2564 additions and 359 deletions

View File

@@ -3,9 +3,9 @@
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\BetProvider;
use App\Models\JackpotPool;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -14,10 +14,10 @@ use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\TicketSettlementDetail;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
/**
@@ -54,50 +54,46 @@ final class SettlementOrchestrator
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->with(['combinations', 'order'])
->orderBy('id')
->limit(10_000)
->get();
if ($ticketItems->count() >= 10_000) {
\Illuminate\Support\Facades\Log::warning('SettlementOrchestrator: ticket_items hit safety cap', [
'draw_id' => $locked->id,
'draw_no' => $locked->draw_no,
'loaded_count' => $ticketItems->count(),
]);
}
$jackpotBursts = [];
$handled = false;
$latestSettleVersion = (int) $locked->settle_version;
$ticketItemsByProvider = $ticketItems->isEmpty()
? collect([BetProvider::DEFAULT_CODE => collect()])
: $ticketItems->groupBy(
fn (TicketItem $item): string => strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE)),
);
$providerCodes = $ticketItemsByProvider->keys()->values()->all();
$publishedBatches = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->whereIn('provider_code', $providerCodes)
->orderBy('provider_code')
->orderByDesc('result_version')
->orderByDesc('id')
->get()
->unique('provider_code')
->keyBy('provider_code');
->unique(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code))
->keyBy(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code));
/**
* @var array<string, array{
* published_batch: DrawResultBatch,
* settlement_batch: SettlementBatch,
* board: PublishedDrawResultBoard,
* prepared: list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}>
* }> $providerContexts
*/
$providerContexts = [];
$openProviderContext = function (string $providerCode) use (
&$providerContexts,
&$handled,
&$latestSettleVersion,
$locked,
$publishedBatches,
): ?array {
if (isset($providerContexts[$providerCode])) {
return $providerContexts[$providerCode];
}
foreach ($ticketItemsByProvider as $providerCode => $providerTicketItems) {
/** @var DrawResultBatch|null $publishedBatch */
$publishedBatch = $publishedBatches->get($providerCode);
if ($publishedBatch === null) {
continue;
return null;
}
$existingDone = SettlementBatch::query()
$batchRow = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
@@ -107,48 +103,109 @@ final class SettlementOrchestrator
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->orderByDesc('id')
->first();
if ($existingDone !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version);
continue;
if ($batchRow !== null && in_array($batchRow->status, [
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
], true)) {
throw new \RuntimeException('settlement_batch_already_finalized_with_pending_tickets');
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
if ($batchRow !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $batchRow->settle_version);
$batchRow->forceFill([
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'reviewed_by' => null,
'reviewed_at' => null,
'review_remark' => null,
'finished_at' => null,
])->save();
} else {
$latestSettleVersion++;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $latestSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
}
$providerContexts[$providerCode] = [
'published_batch' => $publishedBatch,
'settlement_batch' => $batchRow,
'board' => new PublishedDrawResultBoard(
DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get(),
),
'prepared' => [],
];
return $providerContexts[$providerCode];
};
$chunkSize = max(1, (int) config('lottery.settlement.ticket_chunk_size', 10_000));
$lastTicketItemId = 0;
$sawPendingTicket = false;
while (true) {
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->where('id', '>', $lastTicketItemId)
->with(['combinations', 'order'])
->orderBy('id')
->limit($chunkSize)
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = $latestSettleVersion + 1;
$latestSettleVersion = $nextSettleVersion;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
if ($ticketItems->isEmpty()) {
break;
}
$sawPendingTicket = true;
$lastTicketItemId = (int) $ticketItems->last()->id;
foreach ($ticketItems as $item) {
$providerCode = strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE));
$context = $openProviderContext($providerCode);
if ($context === null) {
continue;
}
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($providerTicketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code);
$result = $matcher->match($item, $board, $item->combinations);
$result = $matcher->match($item, $context['board'], $item->combinations);
$gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null;
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
$providerContexts[$providerCode]['prepared'][] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'matched_tier' => is_string($tier) ? $tier : null,
'net_win' => $this->payoutAdjuster->adjustGrossWin($gross, $item),
'match_detail' => $result['match_detail'],
];
}
}
if (! $sawPendingTicket
&& $publishedBatches->has(BetProvider::DEFAULT_CODE)
&& ! SettlementBatch::query()->where('draw_id', $locked->id)->exists()
) {
$openProviderContext(BetProvider::DEFAULT_CODE);
}
foreach ($providerContexts as $providerCode => $context) {
$board = $context['board'];
$batchRow = $context['settlement_batch'];
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = $context['prepared'];
$allocations = [];
$totalJackpotPayout = 0;
@@ -187,9 +244,10 @@ final class SettlementOrchestrator
}
}
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
$ticketCount = (int) $batchRow->total_ticket_count;
$winCount = (int) $batchRow->total_win_count;
$totalPayout = (int) $batchRow->total_payout_amount;
$totalJackpotPayout += (int) $batchRow->total_jackpot_payout_amount;
foreach ($prepared as $p) {
/** @var TicketItem $item */
@@ -216,7 +274,12 @@ final class SettlementOrchestrator
'status' => $terminalStatus,
])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
$this->agentGameSettlement->recordForTicketItem(
$item,
$net,
$terminalStatus,
(int) $batchRow->settle_version,
);
if ($finalCredit > 0) {
$winCount++;
@@ -239,17 +302,51 @@ final class SettlementOrchestrator
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value,
'status' => SettlementBatchStatus::Running->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
'finished_at' => null,
])->save();
$handled = true;
}
$hasPendingDrawTickets = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->exists();
if (! $hasPendingDrawTickets) {
$finishedBatchCount = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('status', SettlementBatchStatus::Running->value)
->update([
'status' => SettlementBatchStatus::PendingReview->value,
'finished_at' => now(),
'updated_at' => now(),
]);
$handled = $handled || $finishedBatchCount > 0;
}
if (! $handled) {
$activeBatch = SettlementBatch::query()
->where('draw_id', $locked->id)
->whereIn('status', [
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->orderByDesc('settle_version')
->first();
if ($activeBatch !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $activeBatch->settle_version);
}
}
if (! $handled) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}