Files
lotteryLaravel/app/Services/Settlement/SettlementOrchestrator.php

225 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Settlement;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use App\Models\JackpotPool;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\TicketSettlementDetail;
use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\Ticket\RiskPoolService;
use App\Services\Ticket\TicketWalletService;
use Illuminate\Support\Facades\DB;
/**
* 阶段 6对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 入账)。
*
* 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`。
*/
final class SettlementOrchestrator
{
public function __construct(
private readonly SettlementMatcherRegistry $matchers,
private readonly SettlementPayoutAdjuster $payoutAdjuster,
private readonly JackpotBurstAllocator $jackpotBurst,
private readonly TicketWalletService $wallet,
private readonly RiskPoolService $riskPool,
) {}
/**
* @return bool true 表示已处理(新结算或补全期号状态)
*/
public function trySettleDraw(Draw $draw): bool
{
return (bool) DB::transaction(function () use ($draw): bool {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if ($locked->status === DrawStatus::Settled->value) {
return false;
}
if ($locked->status !== DrawStatus::Settling->value) {
return false;
}
$publishedBatch = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->where('result_version', (int) $locked->current_result_version)
->orderByDesc('id')
->first();
if ($publishedBatch === null) {
return false;
}
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->where('status', SettlementBatchStatus::Completed->value)
->first();
if ($existingDone !== null) {
$locked->forceFill([
'status' => DrawStatus::Settled->value,
'settle_version' => (int) $existingDone->settle_version,
])->save();
return true;
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = (int) $locked->settle_version + 1;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'started_at' => now(),
]);
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'success')
->with(['combinations', 'order'])
->orderBy('id')
->get();
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($ticketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code);
$result = $matcher->match($item, $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[] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'match_detail' => $result['match_detail'],
];
}
$currency = strtoupper((string) ($ticketItems->first()?->order?->currency_code ?? 'NPR'));
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
$allocations = [];
$totalJackpotPayout = 0;
if ($pool !== null) {
$burstInput = collect($prepared)->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 = $burstOut['allocations'];
$totalJackpotPayout = (int) $burstOut['pool_payout'];
}
$playerTotals = [];
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
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'],
]);
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => now(),
'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose',
])->save();
if ($finalCredit > 0) {
$winCount++;
}
$totalPayout += $finalCredit;
$pid = (int) $item->player_id;
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $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, $item, $locks);
}
foreach ($playerTotals as $playerId => $amount) {
if ($amount <= 0) {
continue;
}
$player = Player::query()->whereKey($playerId)->firstOrFail();
$this->wallet->creditSettlementPayout($player, $currency, $amount, (int) $batchRow->id);
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::Completed->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
])->save();
$locked->forceFill([
'status' => DrawStatus::Settled->value,
'settle_version' => $nextSettleVersion,
])->save();
foreach ($ticketItems->pluck('order_id')->unique()->all() as $orderId) {
$pending = TicketItem::query()
->where('order_id', $orderId)
->whereNotIn('status', ['settled_win', 'settled_lose'])
->exists();
if (! $pending) {
TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']);
}
}
return true;
});
}
}