feat: 添加结算功能,更新 TicketItem 模型以支持最新结算详情,增强 DrawTickService 以自动处理结算,更新 TicketWalletService 以支持派彩入账,扩展 API 路由以管理结算批次和奖池

This commit is contained in:
2026-05-11 15:34:34 +08:00
parent 6a55fa9592
commit 19003f5041
50 changed files with 3604 additions and 3 deletions

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Services\Jackpot;
use App\Lottery\DrawStatus;
use App\Models\Draw;
use App\Models\JackpotPayoutLog;
use App\Models\JackpotPool;
use App\Models\TicketItem;
use Illuminate\Support\Collection;
/**
* 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例释放奖池,按注项 `total_bet_amount` 比例分配。
*/
final class JackpotBurstAllocator
{
/**
* @param Collection<int, array{item: TicketItem, matched_tier: ?string, gross_win: int}> $results
* @return array{allocations: array<int, int>, pool_payout: int, trigger: ?string}
*/
public function allocate(Draw $draw, JackpotPool $pool, Collection $results): array
{
$winners = $results->filter(
fn (array $r) => ($r['matched_tier'] ?? null) === 'first' && (int) $r['gross_win'] > 0,
);
if ($winners->isEmpty()) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
}
$thresholdOk = (int) $pool->current_amount >= (int) $pool->trigger_threshold;
$gapOk = $this->gapTriggerMet($pool);
if (! $thresholdOk && ! $gapOk) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
}
$trigger = $thresholdOk ? 'threshold' : 'forced_gap';
$poolBefore = (int) $pool->current_amount;
$poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate);
if ($poolPayout <= 0) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
}
$list = $winners->values()->all();
$weightTotal = 0;
foreach ($list as $r) {
$weightTotal += (int) $r['item']->total_bet_amount;
}
if ($weightTotal <= 0) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
}
$allocations = [];
$remaining = $poolPayout;
$n = count($list);
foreach ($list as $idx => $r) {
/** @var TicketItem $item */
$item = $r['item'];
$w = (int) $item->total_bet_amount;
if ($idx === $n - 1) {
$share = max(0, $remaining);
} else {
$share = (int) floor($poolPayout * $w / $weightTotal);
$remaining -= $share;
}
$allocations[(int) $item->id] = $share;
}
$pool->forceFill([
'current_amount' => max(0, $poolBefore - $poolPayout),
'last_trigger_draw_id' => $draw->id,
])->save();
JackpotPayoutLog::query()->create([
'draw_id' => $draw->id,
'jackpot_pool_id' => $pool->id,
'trigger_type' => $trigger,
'total_payout_amount' => $poolPayout,
'winner_count' => count($allocations),
'trigger_snapshot_json' => [
'threshold_ok' => $thresholdOk,
'gap_ok' => $gapOk,
'pool_amount_before' => $poolBefore,
'payout_rate' => (string) $pool->payout_rate,
],
]);
return ['allocations' => $allocations, 'pool_payout' => $poolPayout, 'trigger' => $trigger];
}
private function gapTriggerMet(JackpotPool $pool): bool
{
$gap = (int) $pool->force_trigger_draw_gap;
if ($gap <= 0) {
return false;
}
$lastId = (int) ($pool->last_trigger_draw_id ?? 0);
$count = Draw::query()
->where('status', DrawStatus::Settled->value)
->when($lastId > 0, fn ($q) => $q->where('id', '>', $lastId))
->count();
return $count >= $gap;
}
}