204 lines
7.6 KiB
PHP
204 lines
7.6 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Settlement;
|
||
|
||
use App\Models\Draw;
|
||
use App\Models\TicketItem;
|
||
use App\Lottery\DrawStatus;
|
||
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\Jackpot\JackpotBurstAllocator;
|
||
|
||
/**
|
||
* 阶段 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,
|
||
) {}
|
||
|
||
/**
|
||
* @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)
|
||
->whereIn('status', [
|
||
SettlementBatchStatus::PendingReview->value,
|
||
SettlementBatchStatus::Approved->value,
|
||
SettlementBatchStatus::Paid->value,
|
||
SettlementBatchStatus::Completed->value,
|
||
])
|
||
->first();
|
||
|
||
if ($existingDone !== null) {
|
||
$locked->forceFill([
|
||
'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,
|
||
'review_status' => 'pending',
|
||
'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'];
|
||
}
|
||
|
||
$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' => null,
|
||
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose',
|
||
])->save();
|
||
|
||
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, $item, $locks);
|
||
}
|
||
|
||
$batchRow->forceFill([
|
||
'status' => SettlementBatchStatus::PendingReview->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::Settling->value,
|
||
'settle_version' => $nextSettleVersion,
|
||
])->save();
|
||
|
||
return true;
|
||
});
|
||
}
|
||
}
|