diff --git a/app/Http/Controllers/Api/V1/Admin/BetProvider/AdminBetProviderController.php b/app/Http/Controllers/Api/V1/Admin/BetProvider/AdminBetProviderController.php index 0320708..2c6cc0e 100644 --- a/app/Http/Controllers/Api/V1/Admin/BetProvider/AdminBetProviderController.php +++ b/app/Http/Controllers/Api/V1/Admin/BetProvider/AdminBetProviderController.php @@ -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, diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index ac413c6..f1757b8 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -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']); diff --git a/app/Http/Controllers/Api/V1/BetProvider/BetProviderIndexController.php b/app/Http/Controllers/Api/V1/BetProvider/BetProviderIndexController.php index a23b9a3..e417dca 100644 --- a/app/Http/Controllers/Api/V1/BetProvider/BetProviderIndexController.php +++ b/app/Http/Controllers/Api/V1/BetProvider/BetProviderIndexController.php @@ -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, ]) diff --git a/app/Http/Requests/Ticket/TicketBetRequest.php b/app/Http/Requests/Ticket/TicketBetRequest.php index 1835d09..86380da 100644 --- a/app/Http/Requests/Ticket/TicketBetRequest.php +++ b/app/Http/Requests/Ticket/TicketBetRequest.php @@ -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'], ]; } } diff --git a/app/Models/BetProvider.php b/app/Models/BetProvider.php index c870fae..eecc745 100644 --- a/app/Models/BetProvider.php +++ b/app/Models/BetProvider.php @@ -12,6 +12,7 @@ final class BetProvider extends Model protected $fillable = [ 'code', 'name', + 'short_code', 'is_enabled', 'sort_order', ]; diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 1194567..4d0042f 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -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', diff --git a/app/Services/Currency/CurrencyActivationService.php b/app/Services/Currency/CurrencyActivationService.php index dcba5e2..2bcf4a2 100644 --- a/app/Services/Currency/CurrencyActivationService.php +++ b/app/Services/Currency/CurrencyActivationService.php @@ -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, diff --git a/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php index 9bb59aa..631c09d 100644 --- a/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php @@ -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( diff --git a/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php index 9034f37..b041f49 100644 --- a/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php @@ -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( diff --git a/app/Services/Settlement/Matchers/ScopeFilteredSettlementMatcher.php b/app/Services/Settlement/Matchers/ScopeFilteredSettlementMatcher.php new file mode 100644 index 0000000..cc17681 --- /dev/null +++ b/app/Services/Settlement/Matchers/ScopeFilteredSettlementMatcher.php @@ -0,0 +1,45 @@ +> */ + 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]]; + } +} diff --git a/app/Services/Settlement/SettlementMatcherRegistry.php b/app/Services/Settlement/SettlementMatcherRegistry.php index 171f7ef..be59ccf 100644 --- a/app/Services/Settlement/SettlementMatcherRegistry.php +++ b/app/Services/Settlement/SettlementMatcherRegistry.php @@ -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_box:PRD 一期预留;结算按已落库组合逐条取 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, }; diff --git a/app/Services/Ticket/NumberNormalizer.php b/app/Services/Ticket/NumberNormalizer.php index 72a9583..1b45e7d 100644 --- a/app/Services/Ticket/NumberNormalizer.php +++ b/app/Services/Ticket/NumberNormalizer.php @@ -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, diff --git a/app/Services/Ticket/PlayRuleEngine.php b/app/Services/Ticket/PlayRuleEngine.php index e233885..7df4684 100644 --- a/app/Services/Ticket/PlayRuleEngine.php +++ b/app/Services/Ticket/PlayRuleEngine.php @@ -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 */ - 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 $digits @return list */ + 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 */ diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 86731e5..dd654d2 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -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']); diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index 20b5398..8cb8089 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -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']); diff --git a/app/Support/OddsStandardScopes.php b/app/Support/OddsStandardScopes.php index 29efc78..6aefd4e 100644 --- a/app/Support/OddsStandardScopes.php +++ b/app/Support/OddsStandardScopes.php @@ -15,13 +15,43 @@ final class OddsStandardScopes { /** @var array */ 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 $odds @return array */ + private static function scale(array $odds, int $divisor): array + { + return array_map(static fn (int $value): int => (int) floor($value / $divisor), $odds); + } + /** @var list */ 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) diff --git a/database/migrations/2026_07_10_120000_add_short_code_to_bet_providers.php b/database/migrations/2026_07_10_120000_add_short_code_to_bet_providers.php new file mode 100644 index 0000000..78d9bbc --- /dev/null +++ b/database/migrations/2026_07_10_120000_add_short_code_to_bet_providers.php @@ -0,0 +1,29 @@ +string('short_code', 1)->nullable()->after('name'); + }); + + DB::table('bet_providers')->orderBy('id')->get(['id', 'code'])->each(function (object $provider): void { + DB::table('bet_providers') + ->where('id', $provider->id) + ->update(['short_code' => strtoupper(substr((string) $provider->code, 0, 1))]); + }); + } + + public function down(): void + { + Schema::table('bet_providers', function (Blueprint $table): void { + $table->dropColumn('short_code'); + }); + } +}; diff --git a/database/seeders/BetProviderSeeder.php b/database/seeders/BetProviderSeeder.php index 856d41d..272c234 100644 --- a/database/seeders/BetProviderSeeder.php +++ b/database/seeders/BetProviderSeeder.php @@ -10,9 +10,9 @@ final class BetProviderSeeder extends Seeder public function run(): void { $rows = [ - ['code' => 'SG', 'name' => 'Singapore', 'sort_order' => 10], - ['code' => 'MY', 'name' => 'Malaysia', 'sort_order' => 20], - ['code' => 'TH', 'name' => 'Thailand', 'sort_order' => 30], + ['code' => 'SG', 'name' => 'Singapore', 'short_code' => 'S', 'sort_order' => 10], + ['code' => 'MY', 'name' => 'Malaysia', 'short_code' => 'M', 'sort_order' => 20], + ['code' => 'TH', 'name' => 'Thailand', 'short_code' => 'T', 'sort_order' => 30], ]; foreach ($rows as $row) { @@ -20,6 +20,7 @@ final class BetProviderSeeder extends Seeder ['code' => $row['code']], [ 'name' => $row['name'], + 'short_code' => $row['short_code'], 'is_enabled' => true, 'sort_order' => $row['sort_order'], ], diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php index 463ab9a..ff61f8b 100644 --- a/database/seeders/OperationalConfigV1Seeder.php +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -98,7 +98,7 @@ final class OperationalConfigV1Seeder extends Seeder /** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */ 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' => $oddsVersion->id, 'play_code' => $pt->play_code, diff --git a/database/seeders/PlayOperationalAlignmentSeeder.php b/database/seeders/PlayOperationalAlignmentSeeder.php index a25dc9f..134d200 100644 --- a/database/seeders/PlayOperationalAlignmentSeeder.php +++ b/database/seeders/PlayOperationalAlignmentSeeder.php @@ -118,7 +118,7 @@ final class PlayOperationalAlignmentSeeder extends Seeder $rebate = (float) ($anchor?->rebate_rate ?? 0); $commission = (float) ($anchor?->commission_rate ?? 0); - foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { + foreach (OddsStandardScopes::presetOddsForPlay((string) $playCode) as $scope => $oddsValue) { $exists = OddsItem::query() ->where('version_id', $vid) ->where('play_code', $playCode) diff --git a/database/seeders/PlayTypeSeeder.php b/database/seeders/PlayTypeSeeder.php index 83406d3..f02aeee 100644 --- a/database/seeders/PlayTypeSeeder.php +++ b/database/seeders/PlayTypeSeeder.php @@ -17,6 +17,7 @@ final class PlayTypeSeeder extends Seeder array_merge($row, $defaults), ); } + } /** @@ -25,24 +26,36 @@ final class PlayTypeSeeder extends Seeder private function rows(): array { return [ - $this->row('big', 'standard', 4, 'single', 'Big', 10), - $this->row('small', 'standard', 4, 'single', 'Small', 20), + $this->row('big', 'standard', 4, 'single', 'B', 10), + $this->row('small', 'standard', 4, 'single', 'S', 20), $this->row('pos_4a', 'position', 4, 'single', '4A', 30, false, ['prize_scope' => ['first']]), $this->row('pos_4b', 'position', 4, 'single', '4B', 40, false, ['prize_scope' => ['second']]), $this->row('pos_4c', 'position', 4, 'single', '4C', 50, false, ['prize_scope' => ['third']]), $this->row('pos_4d', 'position', 4, 'single', '4D', 60, false, ['prize_scope' => ['starter']]), $this->row('pos_4e', 'position', 4, 'single', '4E', 70, false, ['prize_scope' => ['consolation']]), + $this->row('four_any', 'position', 4, 'single', '4N', 75, false, ['prize_scope' => ['first', 'second', 'third', 'starter', 'consolation']]), + $this->row('four_top', 'position', 4, 'single', 'A', 76, false, ['prize_scope' => ['first', 'second', 'third']]), + $this->row('four_lower', 'position', 4, 'single', 'C', 77, false, ['prize_scope' => ['starter', 'consolation']]), - $this->row('pos_3a', 'position', 3, 'single', '3A', 80, false, ['prize_scope' => ['first']]), + $this->row('pos_3a', 'position', 3, 'single', 'A', 80, false, ['prize_scope' => ['first']]), $this->row('pos_3b', 'position', 3, 'single', '3B', 90, false, ['prize_scope' => ['second']]), $this->row('pos_3c', 'position', 3, 'single', '3C', 100, false, ['prize_scope' => ['third']]), - $this->row('pos_3abc', 'position', 3, 'single', '3ABC', 110, false, ['prize_scope' => ['first', 'second', 'third']]), + $this->row('pos_3d', 'position', 3, 'single', '3D', 110, false, ['prize_scope' => ['starter']]), + $this->row('pos_3e', 'position', 3, 'single', '3E', 115, false, ['prize_scope' => ['consolation']]), + $this->row('pos_3lower', 'position', 3, 'single', 'C', 117, false, ['prize_scope' => ['starter', 'consolation']]), $this->row('pos_2a', 'position', 2, 'single', '2A', 120, false, ['prize_scope' => ['first']]), $this->row('pos_2b', 'position', 2, 'single', '2B', 130, false, ['prize_scope' => ['second']]), $this->row('pos_2c', 'position', 2, 'single', '2C', 140, false, ['prize_scope' => ['third']]), - $this->row('pos_2abc', 'position', 2, 'single', '2ABC', 150, false, ['prize_scope' => ['first', 'second', 'third']]), + $this->row('pos_2d', 'position', 2, 'single', '2D', 150, false, ['prize_scope' => ['starter']]), + $this->row('pos_2e', 'position', 2, 'single', '2E', 155, false, ['prize_scope' => ['consolation']]), + $this->row('pos_2any', 'position', 2, 'single', '2N', 157, false, ['prize_scope' => ['first', 'second', 'third', 'starter', 'consolation']]), + + // 5D / 6D require their own published result source. Keep them in the catalog, + // but disabled until that independent draw pipeline is enabled. + $this->row('five_d', 'position', 5, 'single', '5D', 158, false, ['requires_independent_draw' => true], false), + $this->row('six_d', 'position', 6, 'single', '6D', 159, false, ['requires_independent_draw' => true], false), $this->row('straight', 'box', 4, 'single', 'Straight', 160, false, ['expand_mode' => 'straight']), $this->row('box', 'box', 4, 'single', 'Box', 170, false, ['expand_mode' => 'box']), diff --git a/database/seeders/TraditionalMalaysiaOddsSeeder.php b/database/seeders/TraditionalMalaysiaOddsSeeder.php new file mode 100644 index 0000000..86bfd46 --- /dev/null +++ b/database/seeders/TraditionalMalaysiaOddsSeeder.php @@ -0,0 +1,38 @@ +whereIn('status', [ConfigVersionStatus::Active->value, ConfigVersionStatus::Draft->value])->cursor() as $version) { + foreach ($codes as $code) { + foreach (OddsStandardScopes::presetOddsForPlay($code) as $scope => $oddsValue) { + OddsItem::query() + ->where('version_id', $version->id) + ->where('play_code', $code) + ->where('prize_scope', $scope) + ->update(['odds_value' => $oddsValue]); + } + } + } + + // Existing default cap was calibrated for the prior 25x test multiplier. + RiskCapItem::query()->where('cap_type', 'default')->update(['cap_amount' => 5_000_000_000_000]); + } +} diff --git a/tests/Feature/AdminDrawApiTest.php b/tests/Feature/AdminDrawApiTest.php index 8699114..fbc29d1 100644 --- a/tests/Feature/AdminDrawApiTest.php +++ b/tests/Feature/AdminDrawApiTest.php @@ -2,7 +2,10 @@ use Carbon\Carbon; use App\Models\Draw; +use App\Models\Player; use App\Models\AdminUser; +use App\Models\TicketItem; +use App\Models\TicketOrder; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use Illuminate\Support\Facades\Hash; @@ -58,6 +61,41 @@ test('admin draws index returns pagination', function (): void { Carbon::setTestNow(); }); +test('admin draws index can filter to draws with player bets', function (): void { + $drawWithBet = Draw::query()->create([ + 'draw_no' => '20260509-101', 'business_date' => '2026-05-09', 'sequence_no' => 101, 'status' => 'closed', + 'start_time' => now()->subHour(), 'close_time' => now(), 'draw_time' => now()->addHour(), + 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, + ]); + Draw::query()->create([ + 'draw_no' => '20260509-102', 'business_date' => '2026-05-09', 'sequence_no' => 102, 'status' => 'pending', + 'start_time' => now()->addHour(), 'close_time' => now()->addHours(2), 'draw_time' => now()->addHours(3), + 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, + ]); + $player = Player::query()->create([ + 'site_code' => 'main', 'site_player_id' => 'draw-filter-player', 'username' => 'draw_filter_player', + 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, + ]); + $order = TicketOrder::query()->create([ + 'order_no' => 'DRAW-FILTER-ORDER', 'player_id' => $player->id, 'draw_id' => $drawWithBet->id, 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, 'total_rebate_amount' => 0, 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, 'status' => 'paid', 'submit_source' => 'h5', 'client_trace_id' => 'draw-filter-trace', + ]); + TicketItem::query()->create([ + 'ticket_no' => 'DRAW-FILTER-TICKET', 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $drawWithBet->id, + 'provider_code' => 'SG', 'provider_name' => 'Singapore', 'original_number' => '1234', 'normalized_number' => '1234', + 'play_code' => 'big', 'dimension' => 4, 'digit_slot' => null, 'bet_mode' => 'single', 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, 'rebate_rate_snapshot' => '0.0000', 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => 1000, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'status' => 'paid', + ]); + + $this->withHeader('Authorization', 'Bearer '.mintAdminBearer()) + ->getJson('/api/v1/admin/draws?has_player_bets=1') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.draw_no', '20260509-101'); +}); + test('admin draw show exposes hall preview status', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC'); diff --git a/tests/Feature/SettlementOrchestratorTest.php b/tests/Feature/SettlementOrchestratorTest.php index 5f4cecf..70e82b7 100644 --- a/tests/Feature/SettlementOrchestratorTest.php +++ b/tests/Feature/SettlementOrchestratorTest.php @@ -134,7 +134,7 @@ test('settlement pays big winner and marks ticket settled', function (): void { $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect($item->status)->toBe('settled_win'); - expect((int) $item->win_amount)->toBe(250_000); + expect((int) $item->win_amount)->toBe(25_000_000); $order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail(); expect($order->status)->toBe('settled'); @@ -142,7 +142,7 @@ test('settlement pays big winner and marks ticket settled', function (): void { expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); - expect((int) $wallet->balance)->toBe(5_000_000 - (int) $item->actual_deduct_amount + 250_000); + expect((int) $wallet->balance)->toBe(5_000_000 - (int) $item->actual_deduct_amount + 25_000_000); expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); }); @@ -403,15 +403,15 @@ test('admin settlement requires review before payout and can export report', fun ->assertOk() ->assertJsonPath('data.items.0.total_bet_amount', 10_000) ->assertJsonPath('data.items.0.total_actual_deduct', 10_000) - ->assertJsonPath('data.items.0.total_payout_amount', 250_000) - ->assertJsonPath('data.items.0.platform_profit', -240_000); + ->assertJsonPath('data.items.0.total_payout_amount', 25_000_000) + ->assertJsonPath('data.items.0.platform_profit', -24_990_000); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson("/api/v1/admin/settlement-batches/{$settlement->id}") ->assertOk() ->assertJsonPath('data.total_bet_amount', 10_000) ->assertJsonPath('data.total_actual_deduct', 10_000) - ->assertJsonPath('data.platform_profit', -240_000); + ->assertJsonPath('data.platform_profit', -24_990_000); $item->refresh(); expect($item->status)->toBe('settled_win');