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数据正确顺序与一致性
This commit is contained in:
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user