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,17 @@
<?php
namespace App\Services\Settlement\Contracts;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
interface SettlementPlayMatcher
{
/**
* @param Collection<int, TicketCombination> $combinations
* @return array{win_amount: int, matched_prize_tier: ?string, match_detail: array<string, mixed>}
*/
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array;
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/**
* Big / 包号展开类:命中 23 档中**最优档**计奖(产品文档 Big / iBox / mBox / Box
*/
final class BigSpreadSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$lines = [];
$total = 0;
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$hit = $board->bestTierForNumber((string) $c->number_4d);
if ($hit === null) {
continue;
}
$tier = $hit['tier'];
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = [
'number_4d' => $c->number_4d,
'matched_tier' => $tier,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $payout,
];
if ($hit['rank'] < $bestRank) {
$bestRank = $hit['rank'];
$bestTier = $tier;
}
}
return [
'win_amount' => $total,
'matched_prize_tier' => $bestTier,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/**
* head / tail / odd / even / digit_big / digit_small展开组合中若有与**头奖 4D** 完全一致则中奖(赔率档 first
*/
final class FirstPrizeComboSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$first = $board->firstPrizeNumber4d();
if ($first === '') {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_first']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, 'first');
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
if ((string) $c->number_4d !== $first) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $c->number_4d, 'bet_amount' => $bet, 'payout' => $payout];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? 'first' : null,
'match_detail' => ['lines' => $lines, 'first_prize' => $first],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/**
* 阶段 6 首轮未实现的玩法:不派奖(后续补位置类、单双等匹配器)。
*/
final class NoopSettlementMatcher implements SettlementPlayMatcher
{
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
return [
'win_amount' => 0,
'matched_prize_tier' => null,
'match_detail' => ['play_code' => $item->play_code, 'skipped' => true],
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_2abc后二位命中头/二/三任意一档。 */
final class Pos2AbcSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tiers = ['first', 'second', 'third'];
$suffixByTier = [];
foreach ($tiers as $t) {
$s = $board->suffix2ForTier($t, 0);
if ($s !== '') {
$suffixByTier[$t] = $s;
}
}
if ($suffixByTier === []) {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$lines = [];
$total = 0;
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 2) {
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;
}
$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;
}
}
return [
'win_amount' => $total,
'matched_prize_tier' => $bestTier,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_2a / pos_2b / pos_2c后二位命中对应档。 */
final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
{
/** @var array<string, string> */
private const PLAY_TO_TIER = [
'pos_2a' => 'first',
'pos_2b' => 'second',
'pos_2c' => 'third',
];
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first';
$suffix = $board->suffix2ForTier($tier, 0);
if ($suffix === '') {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$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];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? $tier : null,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_3abc后三位命中头/二/三任意一档;取最优档赔率。 */
final class Pos3AbcSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tiers = ['first', 'second', 'third'];
$suffixByTier = [];
foreach ($tiers as $t) {
$s = $board->suffix3ForTier($t, 0);
if ($s !== '') {
$suffixByTier[$t] = $s;
}
}
if ($suffixByTier === []) {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$lines = [];
$total = 0;
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 3) {
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;
}
$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;
}
}
return [
'win_amount' => $total,
'matched_prize_tier' => $bestTier,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_3a / pos_3b / pos_3c后三位命中对应档。头奖命中时 `matched_prize_tier` 为 firstJackpot 口径)。 */
final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
{
/** @var array<string, string> */
private const PLAY_TO_TIER = [
'pos_3a' => 'first',
'pos_3b' => 'second',
'pos_3c' => 'third',
];
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first';
$suffix = $board->suffix3ForTier($tier, 0);
if ($suffix === '') {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_suffix']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$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];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? $tier : null,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_4a / pos_4b / pos_4c与对应档完整 4D 一致。 */
final class Pos4ExactTierSettlementMatcher implements SettlementPlayMatcher
{
/** @var array<string, string> */
private const PLAY_TO_TIER = [
'pos_4a' => 'first',
'pos_4b' => 'second',
'pos_4c' => 'third',
];
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tier = self::PLAY_TO_TIER[$item->play_code] ?? 'first';
$row = $board->row($tier, 0);
if ($row === null) {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_row']];
}
$target = (string) $row->number_4d;
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
if ((string) $c->number_4d !== $target) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $c->number_4d, 'bet_amount' => $bet, 'payout' => $payout];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? $tier : null,
'match_detail' => ['lines' => $lines, 'target' => $target],
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/** pos_4d特别奖/ pos_4e安慰奖命中任意一组即中奖。 */
final class Pos4ListTierSettlementMatcher implements SettlementPlayMatcher
{
/** @var array<string, string> */
private const PLAY_TO_TIER = [
'pos_4d' => 'starter',
'pos_4e' => 'consolation',
];
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$tier = self::PLAY_TO_TIER[$item->play_code] ?? 'starter';
$targets = array_flip($board->numbersForPrizeType($tier));
if ($targets === []) {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_targets']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (! isset($targets[$n])) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $n, 'bet_amount' => $bet, 'payout' => $payout, 'tier' => $tier];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? $tier : null,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/**
* Small仅头 / / 三奖(产品文档 Small
*/
final class SmallSpreadSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$lines = [];
$total = 0;
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$hit = $board->bestSmallTierForNumber((string) $c->number_4d);
if ($hit === null) {
continue;
}
$tier = $hit['tier'];
$oddsVal = $this->odds->oddsValueForScope($snapshot, $tier);
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = [
'number_4d' => $c->number_4d,
'matched_tier' => $tier,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $payout,
];
if ($hit['rank'] < $bestRank) {
$bestRank = $hit['rank'];
$bestTier = $tier;
}
}
return [
'win_amount' => $total,
'matched_prize_tier' => $bestTier,
'match_detail' => ['lines' => $lines],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Settlement\Matchers;
use App\Models\TicketCombination;
use App\Models\TicketItem;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
use Illuminate\Support\Collection;
/**
* 直选类:仅与**头奖**号码完全一致中奖(产品文档 Straight / 头奖口径)。
*
* 适用于 `straight``roll`(组合已展开为多条 4D
*/
final class StraightLikeSettlementMatcher implements SettlementPlayMatcher
{
public function __construct(
private readonly OddsSnapshotReader $odds,
) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$target = $board->firstPrizeNumber4d();
if ($target === '') {
return ['win_amount' => 0, 'matched_prize_tier' => null, 'match_detail' => ['reason' => 'no_first_prize']];
}
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$oddsVal = $this->odds->oddsValueForScope($snapshot, 'first');
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
if ((string) $c->number_4d !== $target) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = [
'number_4d' => $c->number_4d,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $payout,
];
}
return [
'win_amount' => $total,
'matched_prize_tier' => $total > 0 ? 'first' : null,
'match_detail' => ['lines' => $lines, 'first_prize' => $target],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Services\Settlement;
/**
* 从注单赔率快照 JSON 读取各档赔率(阶段 5 落库结构,与 {@see PlayRuleEngine} 一致)。
*/
final class OddsSnapshotReader
{
/**
* @param list<array<string, mixed>>|null $snapshot
*/
public function oddsValueForScope(?array $snapshot, string $scope): int
{
if ($snapshot === null) {
return 0;
}
foreach ($snapshot as $row) {
if (($row['prize_scope'] ?? null) === $scope) {
return (int) ($row['odds_value'] ?? 0);
}
}
return 0;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\Settlement;
use App\Models\DrawResultItem;
use Illuminate\Support\Collection;
/**
* 已发布开奖批次的号码视图,供结算匹配(产品文档 §5 奖项分区 {@see DrawPrizeLayout})。
*/
final class PublishedDrawResultBoard
{
/** @var array<string, int> prize_type => 越小越优 */
private const TIER_RANK = [
'first' => 0,
'second' => 1,
'third' => 2,
'starter' => 3,
'consolation' => 4,
];
/** @var Collection<int, DrawResultItem> */
private readonly Collection $items;
private string $firstPrizeNumber = '';
/** @var array<string, array{tier: string, rank: int}> */
private array $numberToBestTier = [];
/**
* @param Collection<int, DrawResultItem> $items
*/
public function __construct(Collection $items)
{
$this->items = $items;
$first = $items->firstWhere(fn (DrawResultItem $r) => $r->prize_type === 'first' && (int) $r->prize_index === 0);
$this->firstPrizeNumber = $first !== null ? (string) $first->number_4d : '';
foreach ($items as $row) {
$num = (string) $row->number_4d;
if ($num === '') {
continue;
}
$tier = (string) $row->prize_type;
$rank = self::TIER_RANK[$tier] ?? 99;
if (! isset($this->numberToBestTier[$num]) || $rank < $this->numberToBestTier[$num]['rank']) {
$this->numberToBestTier[$num] = ['tier' => $tier, 'rank' => $rank];
}
}
}
/** @return Collection<int, DrawResultItem> */
public function allRows(): Collection
{
return $this->items;
}
public function row(string $prizeType, int $prizeIndex = 0): ?DrawResultItem
{
return $this->items->firstWhere(
fn (DrawResultItem $r) => (string) $r->prize_type === $prizeType && (int) $r->prize_index === $prizeIndex,
);
}
/**
* @return list<string>
*/
public function numbersForPrizeType(string $prizeType): array
{
$out = [];
foreach ($this->items as $row) {
if ((string) $row->prize_type !== $prizeType) {
continue;
}
$n = (string) $row->number_4d;
if ($n !== '') {
$out[] = $n;
}
}
return $out;
}
public function firstPrizeNumber4d(): string
{
return $this->firstPrizeNumber;
}
public function suffix3ForTier(string $prizeType, int $prizeIndex = 0): string
{
$r = $this->row($prizeType, $prizeIndex);
return $r !== null ? (string) $r->suffix_3d : '';
}
public function suffix2ForTier(string $prizeType, int $prizeIndex = 0): string
{
$r = $this->row($prizeType, $prizeIndex);
return $r !== null ? (string) $r->suffix_2d : '';
}
/**
* Big任意 23 档中最佳命中档。
*
* @return array{tier: string, rank: int}|null
*/
public function bestTierForNumber(string $number4d): ?array
{
return $this->numberToBestTier[$number4d] ?? null;
}
/**
* Small仅头 / / 三奖rank 02
*
* @return array{tier: string, rank: int}|null
*/
public function bestSmallTierForNumber(string $number4d): ?array
{
$hit = $this->bestTierForNumber($number4d);
if ($hit === null) {
return null;
}
return $hit['rank'] <= 2 ? $hit : null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Settlement;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
use App\Services\Settlement\Matchers\BigSpreadSettlementMatcher;
use App\Services\Settlement\Matchers\FirstPrizeComboSettlementMatcher;
use App\Services\Settlement\Matchers\NoopSettlementMatcher;
use App\Services\Settlement\Matchers\Pos2AbcSettlementMatcher;
use App\Services\Settlement\Matchers\Pos2TierSettlementMatcher;
use App\Services\Settlement\Matchers\Pos3AbcSettlementMatcher;
use App\Services\Settlement\Matchers\Pos3TierSettlementMatcher;
use App\Services\Settlement\Matchers\Pos4ExactTierSettlementMatcher;
use App\Services\Settlement\Matchers\Pos4ListTierSettlementMatcher;
use App\Services\Settlement\Matchers\SmallSpreadSettlementMatcher;
use App\Services\Settlement\Matchers\StraightLikeSettlementMatcher;
final class SettlementMatcherRegistry
{
public function __construct(
private readonly StraightLikeSettlementMatcher $straight,
private readonly BigSpreadSettlementMatcher $big,
private readonly SmallSpreadSettlementMatcher $small,
private readonly Pos4ExactTierSettlementMatcher $pos4Exact,
private readonly Pos4ListTierSettlementMatcher $pos4List,
private readonly Pos3TierSettlementMatcher $pos3Tier,
private readonly Pos3AbcSettlementMatcher $pos3Abc,
private readonly Pos2TierSettlementMatcher $pos2Tier,
private readonly Pos2AbcSettlementMatcher $pos2Abc,
private readonly FirstPrizeComboSettlementMatcher $firstPrizeCombo,
private readonly NoopSettlementMatcher $noop,
) {}
public function for(string $playCode): SettlementPlayMatcher
{
// half_boxPRD 一期预留;结算按已落库组合逐条取 23 档最优档,与 big/box 家族一致§5.6.6)。
return match ($playCode) {
'straight', 'roll' => $this->straight,
'big', 'ibox', 'mbox', 'box', 'half_box' => $this->big,
'small' => $this->small,
'pos_4a', 'pos_4b', 'pos_4c' => $this->pos4Exact,
'pos_4d', 'pos_4e' => $this->pos4List,
'pos_3a', 'pos_3b', 'pos_3c' => $this->pos3Tier,
'pos_3abc' => $this->pos3Abc,
'pos_2a', 'pos_2b', 'pos_2c' => $this->pos2Tier,
'pos_2abc' => $this->pos2Abc,
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => $this->firstPrizeCombo,
default => $this->noop,
};
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Services\Settlement;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use App\Models\JackpotPool;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\TicketSettlementDetail;
use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\Ticket\RiskPoolService;
use App\Services\Ticket\TicketWalletService;
use Illuminate\Support\Facades\DB;
/**
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 回水派彩调整 Jackpot 爆池分配 明细 风险池释放 入账)。
*
* 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`
*/
final class SettlementOrchestrator
{
public function __construct(
private readonly SettlementMatcherRegistry $matchers,
private readonly SettlementPayoutAdjuster $payoutAdjuster,
private readonly JackpotBurstAllocator $jackpotBurst,
private readonly TicketWalletService $wallet,
private readonly RiskPoolService $riskPool,
) {}
/**
* @return bool true 表示已处理(新结算或补全期号状态)
*/
public function trySettleDraw(Draw $draw): bool
{
return (bool) DB::transaction(function () use ($draw): bool {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if ($locked->status === DrawStatus::Settled->value) {
return false;
}
if ($locked->status !== DrawStatus::Settling->value) {
return 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 false;
}
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->where('status', SettlementBatchStatus::Completed->value)
->first();
if ($existingDone !== null) {
$locked->forceFill([
'status' => DrawStatus::Settled->value,
'settle_version' => (int) $existingDone->settle_version,
])->save();
return 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,
'started_at' => now(),
]);
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'success')
->with(['combinations', 'order'])
->orderBy('id')
->get();
/** @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'],
];
}
$currency = strtoupper((string) ($ticketItems->first()?->order?->currency_code ?? 'NPR'));
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
$allocations = [];
$totalJackpotPayout = 0;
if ($pool !== null) {
$burstInput = collect($prepared)->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 = $burstOut['allocations'];
$totalJackpotPayout = (int) $burstOut['pool_payout'];
}
$playerTotals = [];
$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'],
]);
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => now(),
'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose',
])->save();
if ($finalCredit > 0) {
$winCount++;
}
$totalPayout += $finalCredit;
$pid = (int) $item->player_id;
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $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);
}
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,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
])->save();
$locked->forceFill([
'status' => DrawStatus::Settled->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;
});
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\Settlement;
use App\Models\TicketItem;
use App\Services\LotterySettings;
/**
* 派彩侧「回水再扣」开关(默认关:实扣已在下注阶段处理;与 PRD 一致时可打开)。
*/
final class SettlementPayoutAdjuster
{
public function adjustGrossWin(int $grossWin, TicketItem $item): int
{
if ($grossWin <= 0) {
return 0;
}
if (! (bool) LotterySettings::get('settlement.apply_rebate_to_payout', false)) {
return $grossWin;
}
$rebate = (float) $item->rebate_rate_snapshot;
return (int) floor($grossWin * max(0.0, 1.0 - $rebate));
}
}