Files
lotteryLaravel/app/Services/Settlement/SettlementOrchestrator.php
kang d4779660c8
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(draw): 支持多玩法provider及本地化结算时区功能
- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
2026-07-09 15:48:21 +08:00

293 lines
13 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\Models\Draw;
use App\Models\BetProvider;
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\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService;
use App\Services\Jackpot\JackpotBurstAllocator;
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];
}
$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');
foreach ($ticketItemsByProvider as $providerCode => $providerTicketItems) {
/** @var DrawResultBatch|null $publishedBatch */
$publishedBatch = $publishedBatches->get($providerCode);
if ($publishedBatch === null) {
continue;
}
$existingDone = 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,
])
->first();
if ($existingDone !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version);
continue;
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->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(),
]);
/** @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);
$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'],
];
}
$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 = 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'],
]);
$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);
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();
$handled = true;
}
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);
}
}