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,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],
];
}
}