395 lines
17 KiB
PHP
395 lines
17 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Settlement;
|
||
|
||
use App\Models\Draw;
|
||
use App\Models\TicketItem;
|
||
use App\Lottery\DrawStatus;
|
||
use App\Models\BetProvider;
|
||
use App\Models\JackpotPool;
|
||
use App\Models\DrawResultItem;
|
||
use App\Models\DrawResultBatch;
|
||
use App\Models\SettlementBatch;
|
||
use Illuminate\Support\Facades\DB;
|
||
use App\Lottery\DrawResultBatchStatus;
|
||
use App\Lottery\SettlementBatchStatus;
|
||
use App\Models\TicketSettlementDetail;
|
||
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;
|
||
|
||
/**
|
||
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。
|
||
*
|
||
* 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账。
|
||
*/
|
||
final class SettlementOrchestrator
|
||
{
|
||
public function __construct(
|
||
private readonly SettlementMatcherRegistry $matchers,
|
||
private readonly SettlementPayoutAdjuster $payoutAdjuster,
|
||
private readonly JackpotBurstAllocator $jackpotBurst,
|
||
private readonly RiskPoolService $riskPool,
|
||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||
private readonly AgentGameSettlementRecorder $agentGameSettlement,
|
||
) {}
|
||
|
||
/**
|
||
* @return bool true 表示已处理(新结算或补全期号状态)
|
||
*/
|
||
public function trySettleDraw(Draw $draw): bool
|
||
{
|
||
$afterCommit = DB::transaction(function () use ($draw): array {
|
||
/** @var Draw $locked */
|
||
$locked = Draw::query()->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<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];
|
||
}
|
||
|
||
/** @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<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $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);
|
||
}
|
||
}
|