whereKey($draw->id)->lockForUpdate()->firstOrFail(); if ($locked->status === DrawStatus::Settled->value) { return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; } if ($locked->status !== DrawStatus::Settling->value) { return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; } $jackpotBursts = []; $handled = false; $latestSettleVersion = (int) $locked->settle_version; $publishedBatches = DrawResultBatch::query() ->where('draw_id', $locked->id) ->where('status', DrawResultBatchStatus::Published->value) ->orderBy('provider_code') ->orderByDesc('result_version') ->orderByDesc('id') ->get() ->unique(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code)) ->keyBy(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code)); /** * @var array * }> $providerContexts */ $providerContexts = []; $openProviderContext = function (string $providerCode) use ( &$providerContexts, &$handled, &$latestSettleVersion, $locked, $publishedBatches, ): ?array { if (isset($providerContexts[$providerCode])) { return $providerContexts[$providerCode]; } /** @var DrawResultBatch|null $publishedBatch */ $publishedBatch = $publishedBatches->get($providerCode); if ($publishedBatch === null) { return null; } $batchRow = SettlementBatch::query() ->where('draw_id', $locked->id) ->where('result_batch_id', $publishedBatch->id) ->whereIn('status', [ SettlementBatchStatus::Running->value, SettlementBatchStatus::PendingReview->value, SettlementBatchStatus::Approved->value, SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value, ]) ->orderByDesc('id') ->first(); if ($batchRow !== null && in_array($batchRow->status, [ SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value, ], true)) { throw new \RuntimeException('settlement_batch_already_finalized_with_pending_tickets'); } 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(); 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; } $matcher = $this->matchers->for((string) $item->play_code); $result = $matcher->match($item, $context['board'], $item->combinations); $gross = max(0, (int) $result['win_amount']); $tier = $result['matched_prize_tier'] ?? null; $providerContexts[$providerCode]['prepared'][] = [ 'item' => $item, 'gross_win' => $gross, '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 $prepared */ $prepared = $context['prepared']; $allocations = []; $totalJackpotPayout = 0; $preparedByCurrency = collect($prepared)->groupBy( fn (array $p): string => strtoupper((string) ($p['item']->order?->currency_code ?? 'NPR')), ); foreach ($preparedByCurrency as $currency => $currencyPrepared) { $pool = JackpotPool::query() ->where('currency_code', $currency) ->where('status', 1) ->lockForUpdate() ->first(); if ($pool === null) { continue; } $burstInput = collect($currencyPrepared)->map(fn (array $p): array => [ 'item' => $p['item'], 'matched_tier' => $p['matched_tier'], 'gross_win' => $p['gross_win'], ]); $burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput); $allocations = array_replace($allocations, $burstOut['allocations']); $currencyPayout = (int) $burstOut['pool_payout']; $totalJackpotPayout += $currencyPayout; if ($currencyPayout > 0 && is_string($burstOut['trigger'])) { $jackpotBursts[] = [ 'first_prize_number' => $board->firstPrizeNumber4d(), 'currency' => $currency, 'payout' => $currencyPayout, 'trigger' => $burstOut['trigger'], 'pool_after' => (int) $pool->fresh()->current_amount, 'winner_count' => count($burstOut['allocations']), ]; } } $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 */ $item = $p['item']; $ticketCount++; $net = (int) $p['net_win']; $jackpotShare = (int) ($allocations[(int) $item->id] ?? 0); $finalCredit = $net + $jackpotShare; TicketSettlementDetail::query()->create([ 'settlement_batch_id' => $batchRow->id, 'ticket_item_id' => $item->id, 'matched_prize_tier' => $p['matched_tier'], 'win_amount' => $net, 'jackpot_allocation_amount' => $jackpotShare, 'match_detail_json' => $p['match_detail'], ]); $terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose'; $item->forceFill([ 'win_amount' => $net, 'jackpot_win_amount' => $jackpotShare, 'settled_at' => null, 'status' => $terminalStatus, ])->save(); $this->agentGameSettlement->recordForTicketItem( $item, $net, $terminalStatus, (int) $batchRow->settle_version, ); if ($finalCredit > 0) { $winCount++; } $totalPayout += $finalCredit; $locks = []; foreach ($item->combinations as $c) { $locks[] = [ 'number_4d' => (string) $c->number_4d, 'amount' => (int) $c->estimated_payout, ]; } $this->riskPool->release( (int) $locked->id, (string) ($item->provider_code ?: BetProvider::DEFAULT_CODE), $item, $locks, ); } $batchRow->forceFill([ 'status' => SettlementBatchStatus::Running->value, 'total_ticket_count' => $ticketCount, 'total_win_count' => $winCount, 'total_payout_amount' => $totalPayout, 'total_jackpot_payout_amount' => $totalJackpotPayout, '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]; } $locked->forceFill([ 'status' => DrawStatus::Settling->value, 'settle_version' => $latestSettleVersion, ])->save(); return [ 'handled' => true, 'jackpot_bursts' => array_map(fn (array $burst): array => [ 'draw_id' => (int) $locked->id, 'draw_no' => (string) $locked->draw_no, 'first_prize_number' => (string) $burst['first_prize_number'], 'currency' => (string) $burst['currency'], 'payout' => (int) $burst['payout'], 'winner_count' => (int) $burst['winner_count'], 'trigger' => (string) $burst['trigger'], 'pool_after' => (int) $burst['pool_after'], ], $jackpotBursts), 'should_notify_status' => true, ]; }); foreach ($afterCommit['jackpot_bursts'] as $burst) { $this->hallRealtime->notifyJackpotBurst( $burst['draw_id'], $burst['draw_no'], $burst['first_prize_number'], $burst['currency'], $burst['payout'], $burst['winner_count'], $burst['trigger'], $burst['pool_after'], ); } if (($afterCommit['should_notify_status'] ?? false) === true) { $this->hallRealtime->notifyStatusChange($this->hallSnapshot->build()); } return (bool) ($afterCommit['handled'] ?? false); } }