feat(draw): 支持多玩法provider及本地化结算时区功能
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
This commit is contained in:
2026-07-09 15:48:21 +08:00
parent c6c10f1397
commit d4779660c8
38 changed files with 1121 additions and 271 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
@@ -53,59 +54,6 @@ final class SettlementOrchestrator
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => 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 ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$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) {
$locked->forceFill([
'settle_version' => (int) $existingDone->settle_version,
])->save();
return [
'handled' => true,
'jackpot_bursts' => [],
'should_notify_status' => 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', 'pending_draw')
@@ -122,119 +70,188 @@ final class SettlementOrchestrator
]);
}
/** @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'],
];
}
$allocations = [];
$totalJackpotPayout = 0;
$jackpotBursts = [];
$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) {
$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;
}
$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;
$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 ($currencyPayout > 0 && is_string($burstOut['trigger'])) {
$jackpotBursts[] = [
'currency' => $currency,
'payout' => $currencyPayout,
'trigger' => $burstOut['trigger'],
'pool_after' => (int) $pool->fresh()->current_amount,
'winner_count' => count($burstOut['allocations']),
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'],
];
}
}
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
$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;
}
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;
$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;
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'],
]);
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']),
];
}
}
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => null,
'status' => $terminalStatus,
$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();
$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);
$handled = true;
}
$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();
if (! $handled) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$locked->forceFill([
'status' => DrawStatus::Settling->value,
'settle_version' => $nextSettleVersion,
'settle_version' => $latestSettleVersion,
])->save();
return [
@@ -242,7 +259,7 @@ final class SettlementOrchestrator
'jackpot_bursts' => array_map(fn (array $burst): array => [
'draw_id' => (int) $locked->id,
'draw_no' => (string) $locked->draw_no,
'first_prize_number' => $board->firstPrizeNumber4d(),
'first_prize_number' => (string) $burst['first_prize_number'],
'currency' => (string) $burst['currency'],
'payout' => (int) $burst['payout'],
'winner_count' => (int) $burst['winner_count'],