feat(lottery): 扩展传统马来赔率、玩法结算与开注商短码支持
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
2026-07-10 17:04:28 +08:00
parent 2ea3e810a0
commit cbb18b976f
24 changed files with 320 additions and 45 deletions

View File

@@ -29,6 +29,7 @@ final class AdminBetProviderController extends Controller
$data = $request->validate([
'code' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('bet_providers', 'code')],
'name' => ['required', 'string', 'max:64'],
'short_code' => ['required', 'string', 'size:1', 'regex:/^[A-Za-z]$/'],
'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
]);
@@ -36,6 +37,7 @@ final class AdminBetProviderController extends Controller
$provider = BetProvider::query()->create([
'code' => strtoupper((string) $data['code']),
'name' => trim((string) $data['name']),
'short_code' => strtoupper((string) $data['short_code']),
'is_enabled' => (bool) ($data['is_enabled'] ?? true),
'sort_order' => (int) ($data['sort_order'] ?? 0),
]);
@@ -47,12 +49,14 @@ final class AdminBetProviderController extends Controller
{
$data = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:64'],
'short_code' => ['sometimes', 'required', 'string', 'size:1', 'regex:/^[A-Za-z]$/'],
'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
]);
$betProvider->fill(array_filter([
'name' => isset($data['name']) ? trim((string) $data['name']) : null,
'short_code' => isset($data['short_code']) ? strtoupper((string) $data['short_code']) : null,
'is_enabled' => array_key_exists('is_enabled', $data) ? (bool) $data['is_enabled'] : null,
'sort_order' => array_key_exists('sort_order', $data) ? (int) $data['sort_order'] : null,
], static fn (mixed $value): bool => $value !== null))->save();
@@ -68,6 +72,7 @@ final class AdminBetProviderController extends Controller
'id' => (int) $provider->id,
'code' => $provider->code,
'name' => $provider->name,
'short_code' => $provider->short_code ?: strtoupper(substr((string) $provider->code, 0, 1)),
'is_enabled' => (bool) $provider->is_enabled,
'sort_order' => (int) $provider->sort_order,
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,

View File

@@ -30,6 +30,7 @@ final class AdminDrawIndexController extends Controller
$p = AdminApiList::readPaging($request);
$drawNo = trim((string) $request->query('draw_no', ''));
$status = trim((string) $request->query('status', ''));
$hasPlayerBets = $request->boolean('has_player_bets');
$scope = AdminScopePolicy::resolveContext($request, $admin);
$q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id');
@@ -42,6 +43,14 @@ final class AdminDrawIndexController extends Controller
$q->where('status', $status);
}
if ($hasPlayerBets) {
$ticketItems = TicketItem::query()
->selectRaw('1')
->whereColumn('ticket_items.draw_id', 'draws.id');
$this->scopeTicketItemsToVisiblePlayers($ticketItems, $scope);
$q->whereExists($ticketItems);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);

View File

@@ -16,10 +16,11 @@ final class BetProviderIndexController extends Controller
->where('is_enabled', true)
->orderBy('sort_order')
->orderBy('id')
->get(['code', 'name', 'sort_order'])
->get(['code', 'name', 'short_code', 'sort_order'])
->map(fn (BetProvider $provider): array => [
'code' => $provider->code,
'name' => $provider->name,
'short_code' => $provider->short_code ?: strtoupper(substr((string) $provider->code, 0, 1)),
'sort_order' => (int) $provider->sort_order,
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
])

View File

@@ -26,8 +26,11 @@ abstract class TicketBetRequest extends ApiFormRequest
'lines.*.number' => ['required', 'string', 'max:32'],
'lines.*.play_code' => ['required', 'string', 'max:32'],
'lines.*.amount' => ['required', 'integer', 'min:1'],
'lines.*.provider_codes' => ['nullable', 'array', 'min:1', 'max:16'],
'lines.*.provider_codes.*' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/'],
'lines.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'],
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
'lines.*.selection_type' => ['nullable', 'string', 'in:straight,reverse,full_cover,full_play,half_play'],
];
}
}

View File

@@ -12,6 +12,7 @@ final class BetProvider extends Model
protected $fillable = [
'code',
'name',
'short_code',
'is_enabled',
'sort_order',
];

View File

@@ -76,7 +76,7 @@ final class OddsStreamService
} else {
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
foreach (OddsStandardScopes::presetOddsForPlay((string) $pt->play_code) as $scope => $oddsValue) {
OddsItem::query()->create([
'version_id' => $draft->id,
'provider_code' => 'GLOBAL',

View File

@@ -117,7 +117,7 @@ final class CurrencyActivationService
}
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $playType) {
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
foreach (OddsStandardScopes::presetOddsForPlay((string) $playType->play_code) as $scope => $oddsValue) {
OddsItem::query()->firstOrCreate(
[
'version_id' => $version->id,

View File

@@ -16,6 +16,8 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
'pos_2a' => 'first',
'pos_2b' => 'second',
'pos_2c' => 'third',
'pos_2d' => 'starter',
'pos_2e' => 'consolation',
];
public function __construct(

View File

@@ -16,6 +16,8 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
'pos_3a' => 'first',
'pos_3b' => 'second',
'pos_3c' => 'third',
'pos_3d' => 'starter',
'pos_3e' => 'consolation',
];
public function __construct(

View File

@@ -0,0 +1,45 @@
<?php
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;
use App\Services\Settlement\Contracts\SettlementPlayMatcher;
/** Settles aggregate 4D / suffix plays against an explicit group of prize scopes. */
final class ScopeFilteredSettlementMatcher implements SettlementPlayMatcher
{
/** @var array<string, list<string>> */
private const SCOPES = [
'four_lower' => ['starter', 'consolation'],
'pos_3lower' => ['starter', 'consolation'],
'pos_2any' => ['first', 'second', 'third', 'starter', 'consolation'],
];
public function __construct(private readonly OddsSnapshotReader $odds) {}
public function match(TicketItem $item, PublishedDrawResultBoard $board, Collection $combinations): array
{
$allowed = self::SCOPES[(string) $item->play_code] ?? [];
$snapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : null;
$lines = [];
$total = 0;
$bestTier = null;
foreach ($combinations as $combo) {
/** @var TicketCombination $combo */
$hit = $board->bestTierForNumber((string) $combo->number_4d);
if ($hit === null || ! in_array($hit['tier'], $allowed, true)) continue;
$oddsValue = $this->odds->oddsValueForScope($snapshot, $hit['tier']);
$payout = (int) floor((int) $combo->bet_amount * ($oddsValue / 10_000));
$total += $payout;
$bestTier ??= $hit['tier'];
$lines[] = ['number_4d' => $combo->number_4d, 'tier' => $hit['tier'], 'payout' => $payout];
}
return ['win_amount' => $total, 'matched_prize_tier' => $bestTier, 'match_detail' => ['lines' => $lines]];
}
}

View File

@@ -14,6 +14,7 @@ use App\Services\Settlement\Matchers\Pos4ListTierSettlementMatcher;
use App\Services\Settlement\Matchers\StraightLikeSettlementMatcher;
use App\Services\Settlement\Matchers\Pos4ExactTierSettlementMatcher;
use App\Services\Settlement\Matchers\FirstPrizeComboSettlementMatcher;
use App\Services\Settlement\Matchers\ScopeFilteredSettlementMatcher;
final class SettlementMatcherRegistry
{
@@ -28,6 +29,7 @@ final class SettlementMatcherRegistry
private readonly Pos2TierSettlementMatcher $pos2Tier,
private readonly Pos2AbcSettlementMatcher $pos2Abc,
private readonly FirstPrizeComboSettlementMatcher $firstPrizeCombo,
private readonly ScopeFilteredSettlementMatcher $scopeFiltered,
private readonly NoopSettlementMatcher $noop,
) {}
@@ -36,14 +38,15 @@ final class SettlementMatcherRegistry
// 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,
'big', 'ibox', 'mbox', 'box', 'half_box', 'four_any' => $this->big,
'small', 'four_top' => $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_3a', 'pos_3b', 'pos_3c', 'pos_3d', 'pos_3e' => $this->pos3Tier,
'pos_3abc' => $this->pos3Abc,
'pos_2a', 'pos_2b', 'pos_2c' => $this->pos2Tier,
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2d', 'pos_2e' => $this->pos2Tier,
'pos_2abc' => $this->pos2Abc,
'four_lower', 'pos_3lower', 'pos_2any' => $this->scopeFiltered,
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => $this->firstPrizeCombo,
default => $this->noop,
};

View File

@@ -38,9 +38,11 @@ final class NumberNormalizer
$length = strlen($value);
$expected = match ($playCode) {
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight', 'box', 'ibox', 'mbox' => 4,
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => 3,
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => 2,
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'four_any', 'four_top', 'four_lower', 'straight', 'box', 'ibox', 'mbox' => 4,
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3d', 'pos_3e', 'pos_3lower', 'pos_3abc' => 3,
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2d', 'pos_2e', 'pos_2any', 'pos_2abc' => 2,
'five_d' => 5,
'six_d' => 6,
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => match ($dimension) {
'D2' => 1,
'D3' => 1,

View File

@@ -24,9 +24,19 @@ final class PlayRuleEngine
$playCode = (string) $line['play_code'];
$dimension = $line['dimension'] ?? null;
$digitSlot = $line['digit_slot'] ?? null;
$selectionType = (string) ($line['selection_type'] ?? 'straight');
$amount = (int) $line['amount'];
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
$allowedSelectionTypes = match ((int) ($playConfig->dimension ?? 4)) {
4 => ['straight', 'reverse', 'full_cover', 'full_play', 'half_play'],
3 => ['straight', 'reverse', 'full_cover', 'full_play'],
default => ['straight'],
};
if (! in_array($selectionType, $allowedSelectionTypes, true)) {
throw new TicketOperationException('selection_type_not_allowed', ErrorCode::BetInvalidPlayInput->value);
}
if ($amount < (int) $playConfig->min_bet_amount || $amount > (int) $playConfig->max_bet_amount) {
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
}
@@ -40,14 +50,17 @@ final class PlayRuleEngine
}
$digitSlotInt = $digitSlot === null ? null : (int) $digitSlot;
$combos = $this->expandToCombinations($playCode, $number, is_string($dimension) ? $dimension : null, $digitSlotInt);
$combos = $this->expandToCombinations($playCode, $number, is_string($dimension) ? $dimension : null, $digitSlotInt, $selectionType);
$combinationCount = count($combos);
if ($combinationCount < 1) {
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
}
if ($selectionType === 'full_cover' && $amount % $combinationCount !== 0) {
throw new TicketOperationException('full_cover_amount_not_divisible', ErrorCode::BetInvalidPlayInput->value);
}
$unitBetAmount = $this->resolveUnitBetAmount($playCode, $amount, $combinationCount);
$totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount);
$unitBetAmount = $this->resolveUnitBetAmount($selectionType === 'full_cover' ? 'mbox' : $playCode, $amount, $combinationCount);
$totalBetAmount = $this->resolveTotalBetAmount($selectionType === 'full_cover' ? 'mbox' : $playCode, $amount, $unitBetAmount, $combinationCount);
$dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig);
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
$rebateRate = (float) $primaryOdds->rebate_rate;
@@ -64,7 +77,7 @@ final class PlayRuleEngine
'play_code' => $playCode,
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig),
'digit_slot' => $digitSlotInt,
'bet_mode' => $playConfig->bet_mode,
'bet_mode' => $selectionType,
'unit_bet_amount' => $unitBetAmount,
'total_bet_amount' => $totalBetAmount,
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
@@ -83,6 +96,7 @@ final class PlayRuleEngine
'play_code' => $playCode,
'dimension' => $dimension,
'digit_slot' => $digitSlotInt,
'selection_type' => $selectionType,
'combination_count' => $combinationCount,
'rounding_refund_amount' => $playCode === 'mbox'
? max(0, $amount - $totalBetAmount)
@@ -102,14 +116,24 @@ final class PlayRuleEngine
/**
* @return list<string>
*/
private function expandToCombinations(string $playCode, string $number, ?string $dimension, ?int $digitSlot): array
private function expandToCombinations(string $playCode, string $number, ?string $dimension, ?int $digitSlot, string $selectionType = 'straight'): array
{
if (in_array($playCode, ['big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'four_any', 'four_top', 'four_lower'], true)) {
return $this->expandSelection(str_split($number), $selectionType);
}
if (str_starts_with($playCode, 'pos_3')) {
return collect($this->expandSelection(str_split($number), $selectionType))
->flatMap(fn (string $value) => $this->expandSuffix($value, 3))
->unique()->sort()->values()->all();
}
if (str_starts_with($playCode, 'pos_2')) {
return collect($this->expandSuffix($number, 2))->unique()->sort()->values()->all();
}
return match ($playCode) {
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight' => [$number],
'straight' => [$number],
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
'roll' => $this->expandRoll($number),
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => $this->expandSuffix($number, 3),
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => $this->expandSuffix($number, 2),
'head' => $this->expandHeadTail(true),
'tail' => $this->expandHeadTail(false),
'odd' => $this->expandOddEven($dimension, true),
@@ -120,6 +144,29 @@ final class PlayRuleEngine
};
}
/** @param list<string> $digits @return list<string> */
private function expandSelection(array $digits, string $selectionType): array
{
$original = implode('', $digits);
if ($selectionType === 'straight') return [$original];
if ($selectionType === 'reverse') return array_values(array_unique([$original, strrev($original)]));
$all = $this->uniquePermutations($original);
if ($selectionType === 'full_cover' || $selectionType === 'full_play') return $all;
if ($selectionType === 'half_play') {
if (count($digits) < 2) return $all;
$first = $digits[0];
$second = $digits[1];
if ($first === $second) return $all;
return array_values(array_filter($all, function (string $value) use ($first, $second): bool {
$firstIndex = array_search($first, str_split($value), true);
$secondIndex = array_search($second, str_split($value), true);
return $firstIndex !== false && $secondIndex !== false && $firstIndex < $secondIndex;
}));
}
throw new TicketOperationException('selection_type_invalid', ErrorCode::BetInvalidPlayInput->value);
}
/**
* @return list<string>
*/

View File

@@ -120,9 +120,9 @@ final class TicketPlacementService
}
$configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
$providers = $this->betProviderResolver->resolve(
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
);
$fallbackProviderCodes = is_array($payload['provider_codes'] ?? null)
? $payload['provider_codes']
: null;
$evaluatedLines = [];
$totalBet = 0;
@@ -132,6 +132,9 @@ final class TicketPlacementService
$closedPlayCleanupRows = [];
foreach ((array) $payload['lines'] as $index => $line) {
$providers = $this->betProviderResolver->resolve(
is_array($line['provider_codes'] ?? null) ? $line['provider_codes'] : $fallbackProviderCodes,
);
foreach ($providers as $provider) {
try {
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);

View File

@@ -39,9 +39,9 @@ final class TicketPreviewService
$currencyCode = strtoupper((string) $payload['currency_code']);
$this->assertCreditCurrencyMatchesPlayer($player, $currencyCode);
$providers = $this->betProviderResolver->resolve(
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
);
$fallbackProviderCodes = is_array($payload['provider_codes'] ?? null)
? $payload['provider_codes']
: null;
$lines = [];
$totalBet = 0;
@@ -52,6 +52,9 @@ final class TicketPreviewService
$closedPlayCleanupRows = [];
foreach ((array) $payload['lines'] as $index => $line) {
$providers = $this->betProviderResolver->resolve(
is_array($line['provider_codes'] ?? null) ? $line['provider_codes'] : $fallbackProviderCodes,
);
foreach ($providers as $provider) {
try {
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);

View File

@@ -15,13 +15,43 @@ final class OddsStandardScopes
{
/** @var array<string, int> */
public const PRESET_ODDS_BY_SCOPE = [
'first' => 250_000,
'second' => 110_000,
'third' => 55_000,
'starter' => 22_000,
'consolation' => 6_500,
'first' => 25_000_000,
'second' => 10_000_000,
'third' => 5_000_000,
'starter' => 1_800_000,
'consolation' => 600_000,
];
/** Traditional Malaysia 4D defaults, in multiplier x 10,000. */
public static function presetOddsForPlay(string $playCode): array
{
if ($playCode === 'small') {
return [
'first' => 35_000_000,
'second' => 20_000_000,
'third' => 10_000_000,
'starter' => 0,
'consolation' => 0,
];
}
if (str_starts_with($playCode, 'pos_3')) {
return self::scale(self::PRESET_ODDS_BY_SCOPE, 10);
}
if (str_starts_with($playCode, 'pos_2')) {
return self::scale(self::PRESET_ODDS_BY_SCOPE, 100);
}
return self::PRESET_ODDS_BY_SCOPE;
}
/** @param array<string, int> $odds @return array<string, int> */
private static function scale(array $odds, int $divisor): array
{
return array_map(static fn (int $value): int => (int) floor($value / $divisor), $odds);
}
/** @var list<string> */
public const SCOPE_KEYS = ['first', 'second', 'third', 'starter', 'consolation'];
@@ -100,7 +130,7 @@ final class OddsStandardScopes
$rebate = $dimensionCommissions[$key]['rebate'] ?? 0;
$commission = $dimensionCommissions[$key]['commission'] ?? 0;
foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
foreach (self::presetOddsForPlay($playCode) as $scope => $oddsValue) {
$exists = OddsItem::query()
->where('version_id', $vid)
->where('provider_code', $providerCode)