feat: 拆分开奖与结算审核流程,新增手动结果录入、重开和派彩审批接口
This commit is contained in:
51
app/Services/Draw/DrawAdminActionService.php
Normal file
51
app/Services/Draw/DrawAdminActionService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DrawAdminActionService
|
||||
{
|
||||
public function manualClose(Draw $draw): Draw
|
||||
{
|
||||
return DB::transaction(function () use ($draw): Draw {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
if (! in_array($locked->status, [DrawStatus::Open->value, DrawStatus::Pending->value], true)) {
|
||||
throw new \RuntimeException('draw_not_closeable');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Closing->value,
|
||||
'close_time' => now(),
|
||||
])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function cancelBeforeResult(Draw $draw): Draw
|
||||
{
|
||||
return DB::transaction(function () use ($draw): Draw {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
if (! in_array($locked->status, [
|
||||
DrawStatus::Pending->value,
|
||||
DrawStatus::Open->value,
|
||||
DrawStatus::Closing->value,
|
||||
DrawStatus::Closed->value,
|
||||
], true)) {
|
||||
throw new \RuntimeException('draw_not_cancelable');
|
||||
}
|
||||
if ($locked->resultBatches()->exists()) {
|
||||
throw new \RuntimeException('draw_result_exists');
|
||||
}
|
||||
|
||||
$locked->forceFill(['status' => DrawStatus::Cancelled->value])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
84
app/Services/Draw/DrawManualResultService.php
Normal file
84
app/Services/Draw/DrawManualResultService.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\DrawResultSourceType;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
|
||||
final class DrawManualResultService
|
||||
{
|
||||
/**
|
||||
* @param list<array{prize_type: string, prize_index: int, number_4d: string}> $items
|
||||
*/
|
||||
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items): DrawResultBatch
|
||||
{
|
||||
return DB::transaction(function () use ($draw, $admin, $items): DrawResultBatch {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
if (! in_array($locked->status, [DrawStatus::Closed->value, DrawStatus::Review->value], true)) {
|
||||
throw new \RuntimeException('draw_not_editable');
|
||||
}
|
||||
if ($locked->settle_version > 0 || $locked->status === DrawStatus::Settled->value) {
|
||||
throw new \RuntimeException('draw_already_settled');
|
||||
}
|
||||
|
||||
$nextVersion = max(1, (int) $locked->current_result_version + 1);
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $locked->id,
|
||||
'result_version' => $nextVersion,
|
||||
'source_type' => DrawResultSourceType::Manual->value,
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::PendingReview->value,
|
||||
'created_by' => $admin->id,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
|
||||
foreach ($this->sortByLayout($items) as $item) {
|
||||
$number = (string) $item['number_4d'];
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $locked->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $item['prize_type'],
|
||||
'prize_index' => (int) $item['prize_index'],
|
||||
'number_4d' => $number,
|
||||
'suffix_3d' => substr($number, -3),
|
||||
'suffix_2d' => substr($number, -2),
|
||||
'head_digit' => (int) substr($number, 0, 1),
|
||||
'tail_digit' => (int) substr($number, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Review->value,
|
||||
'result_source' => DrawResultSourceType::Manual->value,
|
||||
])->save();
|
||||
|
||||
return $batch->fresh(['items']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{prize_type: string, prize_index: int, number_4d: string}> $items
|
||||
* @return list<array{prize_type: string, prize_index: int, number_4d: string}>
|
||||
*/
|
||||
private function sortByLayout(array $items): array
|
||||
{
|
||||
$order = [];
|
||||
foreach (DrawPrizeLayout::slots() as $i => $slot) {
|
||||
$order[$slot['prize_type'].':'.$slot['prize_index']] = $i;
|
||||
}
|
||||
|
||||
usort($items, fn (array $a, array $b): int => ($order[$a['prize_type'].':'.$a['prize_index']] ?? 99)
|
||||
<=> ($order[$b['prize_type'].':'.$b['prize_index']] ?? 99));
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
35
app/Services/Draw/DrawReopenService.php
Normal file
35
app/Services/Draw/DrawReopenService.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DrawReopenService
|
||||
{
|
||||
public function reopenCooldownDraw(Draw $draw, AdminUser $admin, ?string $reason = null): Draw
|
||||
{
|
||||
unset($admin, $reason);
|
||||
|
||||
return DB::transaction(function () use ($draw): Draw {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== DrawStatus::Cooldown->value) {
|
||||
throw new \RuntimeException('draw_not_in_cooldown');
|
||||
}
|
||||
if ((int) $locked->settle_version > 0) {
|
||||
throw new \RuntimeException('draw_already_settled');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'cooling_end_time' => null,
|
||||
'is_reopened' => true,
|
||||
])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Settlement\Matchers;
|
||||
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketCombination;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Services\Settlement\OddsSnapshotReader;
|
||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||
@@ -36,42 +35,39 @@ final class Pos2AbcSettlementMatcher implements SettlementPlayMatcher
|
||||
$bestTier = null;
|
||||
$bestRank = 99;
|
||||
|
||||
foreach ($combinations as $c) {
|
||||
/** @var TicketCombination $c */
|
||||
$n = (string) $c->number_4d;
|
||||
if (strlen($n) < 2) {
|
||||
$suf = substr((string) $item->normalized_number, -2);
|
||||
$hitTier = null;
|
||||
$rank = 99;
|
||||
foreach ($suffixByTier as $t => $sx) {
|
||||
if ($suf !== $sx) {
|
||||
continue;
|
||||
}
|
||||
$suf = substr($n, -2);
|
||||
$hitTier = null;
|
||||
$rank = 99;
|
||||
foreach ($suffixByTier as $t => $sx) {
|
||||
if ($suf !== $sx) {
|
||||
continue;
|
||||
}
|
||||
$r = match ($t) {
|
||||
'first' => 0,
|
||||
'second' => 1,
|
||||
'third' => 2,
|
||||
default => 99,
|
||||
};
|
||||
if ($r < $rank) {
|
||||
$rank = $r;
|
||||
$hitTier = $t;
|
||||
}
|
||||
}
|
||||
if ($hitTier === null) {
|
||||
continue;
|
||||
$r = match ($t) {
|
||||
'first' => 0,
|
||||
'second' => 1,
|
||||
'third' => 2,
|
||||
default => 99,
|
||||
};
|
||||
if ($r < $rank) {
|
||||
$rank = $r;
|
||||
$hitTier = $t;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hitTier !== null) {
|
||||
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
||||
$bet = (int) $c->bet_amount;
|
||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$total += $payout;
|
||||
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
|
||||
if ($rank < $bestRank) {
|
||||
$bestRank = $rank;
|
||||
$bestTier = $hitTier;
|
||||
}
|
||||
$bet = (int) $item->unit_bet_amount;
|
||||
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$lines[] = [
|
||||
'number' => $item->original_number,
|
||||
'suffix2' => $suf,
|
||||
'tier' => $hitTier,
|
||||
'bet_amount' => $bet,
|
||||
'odds_value' => $oddsVal,
|
||||
'payout' => $total,
|
||||
];
|
||||
$bestRank = $rank;
|
||||
$bestTier = $hitTier;
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Settlement\Matchers;
|
||||
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketCombination;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Services\Settlement\OddsSnapshotReader;
|
||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||
@@ -36,16 +35,16 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
|
||||
$lines = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($combinations as $c) {
|
||||
/** @var TicketCombination $c */
|
||||
$n = (string) $c->number_4d;
|
||||
if (strlen($n) < 2 || substr($n, -2) !== $suffix) {
|
||||
continue;
|
||||
}
|
||||
$bet = (int) $c->bet_amount;
|
||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$total += $payout;
|
||||
$lines[] = ['number_4d' => $n, 'suffix2' => $suffix, 'payout' => $payout];
|
||||
if (substr((string) $item->normalized_number, -2) === $suffix) {
|
||||
$bet = (int) $item->unit_bet_amount;
|
||||
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$lines[] = [
|
||||
'number' => $item->original_number,
|
||||
'suffix2' => $suffix,
|
||||
'bet_amount' => $bet,
|
||||
'odds_value' => $oddsVal,
|
||||
'payout' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Settlement\Matchers;
|
||||
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketCombination;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Services\Settlement\OddsSnapshotReader;
|
||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||
@@ -36,42 +35,39 @@ final class Pos3AbcSettlementMatcher implements SettlementPlayMatcher
|
||||
$bestTier = null;
|
||||
$bestRank = 99;
|
||||
|
||||
foreach ($combinations as $c) {
|
||||
/** @var TicketCombination $c */
|
||||
$n = (string) $c->number_4d;
|
||||
if (strlen($n) < 3) {
|
||||
$suf = substr((string) $item->normalized_number, -3);
|
||||
$hitTier = null;
|
||||
$rank = 99;
|
||||
foreach ($suffixByTier as $t => $sx) {
|
||||
if ($suf !== $sx) {
|
||||
continue;
|
||||
}
|
||||
$suf = substr($n, -3);
|
||||
$hitTier = null;
|
||||
$rank = 99;
|
||||
foreach ($suffixByTier as $t => $sx) {
|
||||
if ($suf !== $sx) {
|
||||
continue;
|
||||
}
|
||||
$r = match ($t) {
|
||||
'first' => 0,
|
||||
'second' => 1,
|
||||
'third' => 2,
|
||||
default => 99,
|
||||
};
|
||||
if ($r < $rank) {
|
||||
$rank = $r;
|
||||
$hitTier = $t;
|
||||
}
|
||||
}
|
||||
if ($hitTier === null) {
|
||||
continue;
|
||||
$r = match ($t) {
|
||||
'first' => 0,
|
||||
'second' => 1,
|
||||
'third' => 2,
|
||||
default => 99,
|
||||
};
|
||||
if ($r < $rank) {
|
||||
$rank = $r;
|
||||
$hitTier = $t;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hitTier !== null) {
|
||||
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
||||
$bet = (int) $c->bet_amount;
|
||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$total += $payout;
|
||||
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
|
||||
if ($rank < $bestRank) {
|
||||
$bestRank = $rank;
|
||||
$bestTier = $hitTier;
|
||||
}
|
||||
$bet = (int) $item->unit_bet_amount;
|
||||
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$lines[] = [
|
||||
'number' => $item->original_number,
|
||||
'suffix3' => $suf,
|
||||
'tier' => $hitTier,
|
||||
'bet_amount' => $bet,
|
||||
'odds_value' => $oddsVal,
|
||||
'payout' => $total,
|
||||
];
|
||||
$bestRank = $rank;
|
||||
$bestTier = $hitTier;
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Settlement\Matchers;
|
||||
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketCombination;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Services\Settlement\OddsSnapshotReader;
|
||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||
@@ -36,16 +35,16 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
|
||||
$lines = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($combinations as $c) {
|
||||
/** @var TicketCombination $c */
|
||||
$n = (string) $c->number_4d;
|
||||
if (strlen($n) < 3 || substr($n, -3) !== $suffix) {
|
||||
continue;
|
||||
}
|
||||
$bet = (int) $c->bet_amount;
|
||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$total += $payout;
|
||||
$lines[] = ['number_4d' => $n, 'suffix3' => $suffix, 'payout' => $payout];
|
||||
if (substr((string) $item->normalized_number, -3) === $suffix) {
|
||||
$bet = (int) $item->unit_bet_amount;
|
||||
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||
$lines[] = [
|
||||
'number' => $item->original_number,
|
||||
'suffix3' => $suffix,
|
||||
'bet_amount' => $bet,
|
||||
'odds_value' => $oddsVal,
|
||||
'payout' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
134
app/Services/Settlement/SettlementBatchWorkflowService.php
Normal file
134
app/Services/Settlement/SettlementBatchWorkflowService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\SettlementBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\Ticket\TicketWalletService;
|
||||
|
||||
final class SettlementBatchWorkflowService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TicketWalletService $wallet,
|
||||
) {}
|
||||
|
||||
public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch {
|
||||
/** @var SettlementBatch $locked */
|
||||
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== SettlementBatchStatus::PendingReview->value) {
|
||||
throw new \RuntimeException('settlement_not_pending_review');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => SettlementBatchStatus::Approved->value,
|
||||
'review_status' => 'approved',
|
||||
'reviewed_by' => $admin->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_remark' => $remark,
|
||||
])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function reject(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch {
|
||||
/** @var SettlementBatch $locked */
|
||||
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== SettlementBatchStatus::PendingReview->value) {
|
||||
throw new \RuntimeException('settlement_not_pending_review');
|
||||
}
|
||||
|
||||
TicketItem::query()
|
||||
->whereIn('id', $locked->details()->pluck('ticket_item_id'))
|
||||
->where('status', 'pending_payout')
|
||||
->update(['status' => 'success', 'win_amount' => 0, 'jackpot_win_amount' => 0]);
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => SettlementBatchStatus::Rejected->value,
|
||||
'review_status' => 'rejected',
|
||||
'reviewed_by' => $admin->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_remark' => $remark,
|
||||
])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function payout(SettlementBatch $batch): SettlementBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch): SettlementBatch {
|
||||
/** @var SettlementBatch $locked */
|
||||
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== SettlementBatchStatus::Approved->value || $locked->review_status !== 'approved') {
|
||||
throw new \RuntimeException('settlement_not_approved');
|
||||
}
|
||||
|
||||
$details = $locked->details()->with(['ticketItem.order'])->get();
|
||||
$playerTotals = [];
|
||||
$currencyByPlayer = [];
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$item = $detail->ticketItem;
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
$finalCredit = (int) $detail->win_amount + (int) $detail->jackpot_allocation_amount;
|
||||
if ($finalCredit > 0) {
|
||||
$pid = (int) $item->player_id;
|
||||
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit;
|
||||
$currencyByPlayer[$pid] = strtoupper((string) ($item->order?->currency_code ?? 'NPR'));
|
||||
$item->forceFill(['status' => 'settled_win', 'settled_at' => now()])->save();
|
||||
} elseif ($item->status !== 'settled_lose') {
|
||||
$item->forceFill(['status' => 'settled_lose', 'settled_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($playerTotals as $playerId => $amount) {
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
$player = Player::query()->whereKey($playerId)->firstOrFail();
|
||||
$this->wallet->creditSettlementPayout($player, $currencyByPlayer[$playerId] ?? 'NPR', $amount, (int) $locked->id);
|
||||
}
|
||||
|
||||
$orderIds = TicketItem::query()
|
||||
->whereIn('id', $locked->details()->pluck('ticket_item_id'))
|
||||
->pluck('order_id')
|
||||
->unique()
|
||||
->all();
|
||||
foreach ($orderIds as $orderId) {
|
||||
$pending = TicketItem::query()
|
||||
->where('order_id', $orderId)
|
||||
->whereNotIn('status', ['settled_win', 'settled_lose'])
|
||||
->exists();
|
||||
if (! $pending) {
|
||||
TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']);
|
||||
}
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => SettlementBatchStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
Draw::query()->whereKey($locked->draw_id)->update([
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'settle_version' => (int) $locked->settle_version,
|
||||
]);
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
@@ -16,13 +14,12 @@ use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Models\TicketSettlementDetail;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Ticket\TicketWalletService;
|
||||
use App\Services\Jackpot\JackpotBurstAllocator;
|
||||
|
||||
/**
|
||||
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 入账)。
|
||||
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。
|
||||
*
|
||||
* 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`。
|
||||
* 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账。
|
||||
*/
|
||||
final class SettlementOrchestrator
|
||||
{
|
||||
@@ -30,7 +27,6 @@ final class SettlementOrchestrator
|
||||
private readonly SettlementMatcherRegistry $matchers,
|
||||
private readonly SettlementPayoutAdjuster $payoutAdjuster,
|
||||
private readonly JackpotBurstAllocator $jackpotBurst,
|
||||
private readonly TicketWalletService $wallet,
|
||||
private readonly RiskPoolService $riskPool,
|
||||
) {}
|
||||
|
||||
@@ -65,12 +61,16 @@ final class SettlementOrchestrator
|
||||
$existingDone = SettlementBatch::query()
|
||||
->where('draw_id', $locked->id)
|
||||
->where('result_batch_id', $publishedBatch->id)
|
||||
->where('status', SettlementBatchStatus::Completed->value)
|
||||
->whereIn('status', [
|
||||
SettlementBatchStatus::PendingReview->value,
|
||||
SettlementBatchStatus::Approved->value,
|
||||
SettlementBatchStatus::Paid->value,
|
||||
SettlementBatchStatus::Completed->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($existingDone !== null) {
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'settle_version' => (int) $existingDone->settle_version,
|
||||
])->save();
|
||||
|
||||
@@ -91,6 +91,7 @@ final class SettlementOrchestrator
|
||||
'result_batch_id' => $publishedBatch->id,
|
||||
'settle_version' => $nextSettleVersion,
|
||||
'status' => SettlementBatchStatus::Running->value,
|
||||
'review_status' => 'pending',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -139,7 +140,6 @@ final class SettlementOrchestrator
|
||||
$totalJackpotPayout = (int) $burstOut['pool_payout'];
|
||||
}
|
||||
|
||||
$playerTotals = [];
|
||||
$ticketCount = 0;
|
||||
$winCount = 0;
|
||||
$totalPayout = 0;
|
||||
@@ -164,8 +164,8 @@ final class SettlementOrchestrator
|
||||
$item->forceFill([
|
||||
'win_amount' => $net,
|
||||
'jackpot_win_amount' => $jackpotShare,
|
||||
'settled_at' => now(),
|
||||
'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose',
|
||||
'settled_at' => null,
|
||||
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose',
|
||||
])->save();
|
||||
|
||||
if ($finalCredit > 0) {
|
||||
@@ -173,9 +173,6 @@ final class SettlementOrchestrator
|
||||
}
|
||||
$totalPayout += $finalCredit;
|
||||
|
||||
$pid = (int) $item->player_id;
|
||||
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit;
|
||||
|
||||
$locks = [];
|
||||
foreach ($item->combinations as $c) {
|
||||
$locks[] = [
|
||||
@@ -186,16 +183,8 @@ final class SettlementOrchestrator
|
||||
$this->riskPool->release((int) $locked->id, $item, $locks);
|
||||
}
|
||||
|
||||
foreach ($playerTotals as $playerId => $amount) {
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
$player = Player::query()->whereKey($playerId)->firstOrFail();
|
||||
$this->wallet->creditSettlementPayout($player, $currency, $amount, (int) $batchRow->id);
|
||||
}
|
||||
|
||||
$batchRow->forceFill([
|
||||
'status' => SettlementBatchStatus::Completed->value,
|
||||
'status' => SettlementBatchStatus::PendingReview->value,
|
||||
'total_ticket_count' => $ticketCount,
|
||||
'total_win_count' => $winCount,
|
||||
'total_payout_amount' => $totalPayout,
|
||||
@@ -204,20 +193,10 @@ final class SettlementOrchestrator
|
||||
])->save();
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'settle_version' => $nextSettleVersion,
|
||||
])->save();
|
||||
|
||||
foreach ($ticketItems->pluck('order_id')->unique()->all() as $orderId) {
|
||||
$pending = TicketItem::query()
|
||||
->where('order_id', $orderId)
|
||||
->whereNotIn('status', ['settled_win', 'settled_lose'])
|
||||
->exists();
|
||||
if (! $pending) {
|
||||
TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ final class PlayRuleEngine
|
||||
'dimension' => $dimension,
|
||||
'digit_slot' => $digitSlotInt,
|
||||
'combination_count' => $combinationCount,
|
||||
'rounding_refund_amount' => $playCode === 'mbox'
|
||||
? max(0, $amount - $totalBetAmount)
|
||||
: 0,
|
||||
],
|
||||
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user