fix(core): harden settlement and wallet integration
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user