feat(lottery): 扩展传统马来赔率、玩法结算与开注商短码支持
This commit is contained in:
@@ -29,6 +29,7 @@ final class AdminBetProviderController extends Controller
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'code' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('bet_providers', 'code')],
|
'code' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('bet_providers', 'code')],
|
||||||
'name' => ['required', 'string', 'max:64'],
|
'name' => ['required', 'string', 'max:64'],
|
||||||
|
'short_code' => ['required', 'string', 'size:1', 'regex:/^[A-Za-z]$/'],
|
||||||
'is_enabled' => ['sometimes', 'boolean'],
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
]);
|
]);
|
||||||
@@ -36,6 +37,7 @@ final class AdminBetProviderController extends Controller
|
|||||||
$provider = BetProvider::query()->create([
|
$provider = BetProvider::query()->create([
|
||||||
'code' => strtoupper((string) $data['code']),
|
'code' => strtoupper((string) $data['code']),
|
||||||
'name' => trim((string) $data['name']),
|
'name' => trim((string) $data['name']),
|
||||||
|
'short_code' => strtoupper((string) $data['short_code']),
|
||||||
'is_enabled' => (bool) ($data['is_enabled'] ?? true),
|
'is_enabled' => (bool) ($data['is_enabled'] ?? true),
|
||||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||||
]);
|
]);
|
||||||
@@ -47,12 +49,14 @@ final class AdminBetProviderController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'name' => ['sometimes', 'required', 'string', 'max:64'],
|
'name' => ['sometimes', 'required', 'string', 'max:64'],
|
||||||
|
'short_code' => ['sometimes', 'required', 'string', 'size:1', 'regex:/^[A-Za-z]$/'],
|
||||||
'is_enabled' => ['sometimes', 'boolean'],
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$betProvider->fill(array_filter([
|
$betProvider->fill(array_filter([
|
||||||
'name' => isset($data['name']) ? trim((string) $data['name']) : null,
|
'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,
|
'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,
|
'sort_order' => array_key_exists('sort_order', $data) ? (int) $data['sort_order'] : null,
|
||||||
], static fn (mixed $value): bool => $value !== null))->save();
|
], static fn (mixed $value): bool => $value !== null))->save();
|
||||||
@@ -68,6 +72,7 @@ final class AdminBetProviderController extends Controller
|
|||||||
'id' => (int) $provider->id,
|
'id' => (int) $provider->id,
|
||||||
'code' => $provider->code,
|
'code' => $provider->code,
|
||||||
'name' => $provider->name,
|
'name' => $provider->name,
|
||||||
|
'short_code' => $provider->short_code ?: strtoupper(substr((string) $provider->code, 0, 1)),
|
||||||
'is_enabled' => (bool) $provider->is_enabled,
|
'is_enabled' => (bool) $provider->is_enabled,
|
||||||
'sort_order' => (int) $provider->sort_order,
|
'sort_order' => (int) $provider->sort_order,
|
||||||
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
|
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ final class AdminDrawIndexController extends Controller
|
|||||||
$p = AdminApiList::readPaging($request);
|
$p = AdminApiList::readPaging($request);
|
||||||
$drawNo = trim((string) $request->query('draw_no', ''));
|
$drawNo = trim((string) $request->query('draw_no', ''));
|
||||||
$status = trim((string) $request->query('status', ''));
|
$status = trim((string) $request->query('status', ''));
|
||||||
|
$hasPlayerBets = $request->boolean('has_player_bets');
|
||||||
$scope = AdminScopePolicy::resolveContext($request, $admin);
|
$scope = AdminScopePolicy::resolveContext($request, $admin);
|
||||||
|
|
||||||
$q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id');
|
$q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id');
|
||||||
@@ -42,6 +43,14 @@ final class AdminDrawIndexController extends Controller
|
|||||||
$q->where('status', $status);
|
$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 */
|
/** @var LengthAwarePaginator $paginator */
|
||||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ final class BetProviderIndexController extends Controller
|
|||||||
->where('is_enabled', true)
|
->where('is_enabled', true)
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['code', 'name', 'sort_order'])
|
->get(['code', 'name', 'short_code', 'sort_order'])
|
||||||
->map(fn (BetProvider $provider): array => [
|
->map(fn (BetProvider $provider): array => [
|
||||||
'code' => $provider->code,
|
'code' => $provider->code,
|
||||||
'name' => $provider->name,
|
'name' => $provider->name,
|
||||||
|
'short_code' => $provider->short_code ?: strtoupper(substr((string) $provider->code, 0, 1)),
|
||||||
'sort_order' => (int) $provider->sort_order,
|
'sort_order' => (int) $provider->sort_order,
|
||||||
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
|
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ abstract class TicketBetRequest extends ApiFormRequest
|
|||||||
'lines.*.number' => ['required', 'string', 'max:32'],
|
'lines.*.number' => ['required', 'string', 'max:32'],
|
||||||
'lines.*.play_code' => ['required', 'string', 'max:32'],
|
'lines.*.play_code' => ['required', 'string', 'max:32'],
|
||||||
'lines.*.amount' => ['required', 'integer', 'min:1'],
|
'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.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'],
|
||||||
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
|
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
|
||||||
|
'lines.*.selection_type' => ['nullable', 'string', 'in:straight,reverse,full_cover,full_play,half_play'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class BetProvider extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
'name',
|
'name',
|
||||||
|
'short_code',
|
||||||
'is_enabled',
|
'is_enabled',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ final class OddsStreamService
|
|||||||
} else {
|
} else {
|
||||||
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
|
$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 (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([
|
OddsItem::query()->create([
|
||||||
'version_id' => $draft->id,
|
'version_id' => $draft->id,
|
||||||
'provider_code' => 'GLOBAL',
|
'provider_code' => 'GLOBAL',
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ final class CurrencyActivationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $playType) {
|
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(
|
OddsItem::query()->firstOrCreate(
|
||||||
[
|
[
|
||||||
'version_id' => $version->id,
|
'version_id' => $version->id,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
|
|||||||
'pos_2a' => 'first',
|
'pos_2a' => 'first',
|
||||||
'pos_2b' => 'second',
|
'pos_2b' => 'second',
|
||||||
'pos_2c' => 'third',
|
'pos_2c' => 'third',
|
||||||
|
'pos_2d' => 'starter',
|
||||||
|
'pos_2e' => 'consolation',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
|
|||||||
'pos_3a' => 'first',
|
'pos_3a' => 'first',
|
||||||
'pos_3b' => 'second',
|
'pos_3b' => 'second',
|
||||||
'pos_3c' => 'third',
|
'pos_3c' => 'third',
|
||||||
|
'pos_3d' => 'starter',
|
||||||
|
'pos_3e' => 'consolation',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -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]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use App\Services\Settlement\Matchers\Pos4ListTierSettlementMatcher;
|
|||||||
use App\Services\Settlement\Matchers\StraightLikeSettlementMatcher;
|
use App\Services\Settlement\Matchers\StraightLikeSettlementMatcher;
|
||||||
use App\Services\Settlement\Matchers\Pos4ExactTierSettlementMatcher;
|
use App\Services\Settlement\Matchers\Pos4ExactTierSettlementMatcher;
|
||||||
use App\Services\Settlement\Matchers\FirstPrizeComboSettlementMatcher;
|
use App\Services\Settlement\Matchers\FirstPrizeComboSettlementMatcher;
|
||||||
|
use App\Services\Settlement\Matchers\ScopeFilteredSettlementMatcher;
|
||||||
|
|
||||||
final class SettlementMatcherRegistry
|
final class SettlementMatcherRegistry
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,7 @@ final class SettlementMatcherRegistry
|
|||||||
private readonly Pos2TierSettlementMatcher $pos2Tier,
|
private readonly Pos2TierSettlementMatcher $pos2Tier,
|
||||||
private readonly Pos2AbcSettlementMatcher $pos2Abc,
|
private readonly Pos2AbcSettlementMatcher $pos2Abc,
|
||||||
private readonly FirstPrizeComboSettlementMatcher $firstPrizeCombo,
|
private readonly FirstPrizeComboSettlementMatcher $firstPrizeCombo,
|
||||||
|
private readonly ScopeFilteredSettlementMatcher $scopeFiltered,
|
||||||
private readonly NoopSettlementMatcher $noop,
|
private readonly NoopSettlementMatcher $noop,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,14 +38,15 @@ final class SettlementMatcherRegistry
|
|||||||
// half_box:PRD 一期预留;结算按已落库组合逐条取 23 档最优档,与 big/box 家族一致(§5.6.6)。
|
// half_box:PRD 一期预留;结算按已落库组合逐条取 23 档最优档,与 big/box 家族一致(§5.6.6)。
|
||||||
return match ($playCode) {
|
return match ($playCode) {
|
||||||
'straight', 'roll' => $this->straight,
|
'straight', 'roll' => $this->straight,
|
||||||
'big', 'ibox', 'mbox', 'box', 'half_box' => $this->big,
|
'big', 'ibox', 'mbox', 'box', 'half_box', 'four_any' => $this->big,
|
||||||
'small' => $this->small,
|
'small', 'four_top' => $this->small,
|
||||||
'pos_4a', 'pos_4b', 'pos_4c' => $this->pos4Exact,
|
'pos_4a', 'pos_4b', 'pos_4c' => $this->pos4Exact,
|
||||||
'pos_4d', 'pos_4e' => $this->pos4List,
|
'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_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,
|
'pos_2abc' => $this->pos2Abc,
|
||||||
|
'four_lower', 'pos_3lower', 'pos_2any' => $this->scopeFiltered,
|
||||||
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => $this->firstPrizeCombo,
|
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => $this->firstPrizeCombo,
|
||||||
default => $this->noop,
|
default => $this->noop,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ final class NumberNormalizer
|
|||||||
|
|
||||||
$length = strlen($value);
|
$length = strlen($value);
|
||||||
$expected = match ($playCode) {
|
$expected = match ($playCode) {
|
||||||
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight', 'box', 'ibox', 'mbox' => 4,
|
'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_3abc' => 3,
|
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3d', 'pos_3e', 'pos_3lower', 'pos_3abc' => 3,
|
||||||
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => 2,
|
'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) {
|
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => match ($dimension) {
|
||||||
'D2' => 1,
|
'D2' => 1,
|
||||||
'D3' => 1,
|
'D3' => 1,
|
||||||
|
|||||||
@@ -24,9 +24,19 @@ final class PlayRuleEngine
|
|||||||
$playCode = (string) $line['play_code'];
|
$playCode = (string) $line['play_code'];
|
||||||
$dimension = $line['dimension'] ?? null;
|
$dimension = $line['dimension'] ?? null;
|
||||||
$digitSlot = $line['digit_slot'] ?? null;
|
$digitSlot = $line['digit_slot'] ?? null;
|
||||||
|
$selectionType = (string) ($line['selection_type'] ?? 'straight');
|
||||||
$amount = (int) $line['amount'];
|
$amount = (int) $line['amount'];
|
||||||
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
|
$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) {
|
if ($amount < (int) $playConfig->min_bet_amount || $amount > (int) $playConfig->max_bet_amount) {
|
||||||
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
|
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
|
||||||
}
|
}
|
||||||
@@ -40,14 +50,17 @@ final class PlayRuleEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$digitSlotInt = $digitSlot === null ? null : (int) $digitSlot;
|
$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);
|
$combinationCount = count($combos);
|
||||||
if ($combinationCount < 1) {
|
if ($combinationCount < 1) {
|
||||||
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
|
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);
|
$unitBetAmount = $this->resolveUnitBetAmount($selectionType === 'full_cover' ? 'mbox' : $playCode, $amount, $combinationCount);
|
||||||
$totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount);
|
$totalBetAmount = $this->resolveTotalBetAmount($selectionType === 'full_cover' ? 'mbox' : $playCode, $amount, $unitBetAmount, $combinationCount);
|
||||||
$dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig);
|
$dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig);
|
||||||
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
|
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
|
||||||
$rebateRate = (float) $primaryOdds->rebate_rate;
|
$rebateRate = (float) $primaryOdds->rebate_rate;
|
||||||
@@ -64,7 +77,7 @@ final class PlayRuleEngine
|
|||||||
'play_code' => $playCode,
|
'play_code' => $playCode,
|
||||||
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig),
|
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig),
|
||||||
'digit_slot' => $digitSlotInt,
|
'digit_slot' => $digitSlotInt,
|
||||||
'bet_mode' => $playConfig->bet_mode,
|
'bet_mode' => $selectionType,
|
||||||
'unit_bet_amount' => $unitBetAmount,
|
'unit_bet_amount' => $unitBetAmount,
|
||||||
'total_bet_amount' => $totalBetAmount,
|
'total_bet_amount' => $totalBetAmount,
|
||||||
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
|
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
|
||||||
@@ -83,6 +96,7 @@ final class PlayRuleEngine
|
|||||||
'play_code' => $playCode,
|
'play_code' => $playCode,
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'digit_slot' => $digitSlotInt,
|
'digit_slot' => $digitSlotInt,
|
||||||
|
'selection_type' => $selectionType,
|
||||||
'combination_count' => $combinationCount,
|
'combination_count' => $combinationCount,
|
||||||
'rounding_refund_amount' => $playCode === 'mbox'
|
'rounding_refund_amount' => $playCode === 'mbox'
|
||||||
? max(0, $amount - $totalBetAmount)
|
? max(0, $amount - $totalBetAmount)
|
||||||
@@ -102,14 +116,24 @@ final class PlayRuleEngine
|
|||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @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) {
|
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),
|
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
|
||||||
'roll' => $this->expandRoll($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),
|
'head' => $this->expandHeadTail(true),
|
||||||
'tail' => $this->expandHeadTail(false),
|
'tail' => $this->expandHeadTail(false),
|
||||||
'odd' => $this->expandOddEven($dimension, true),
|
'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>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ final class TicketPlacementService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
|
$configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
|
||||||
$providers = $this->betProviderResolver->resolve(
|
$fallbackProviderCodes = is_array($payload['provider_codes'] ?? null)
|
||||||
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
|
? $payload['provider_codes']
|
||||||
);
|
: null;
|
||||||
|
|
||||||
$evaluatedLines = [];
|
$evaluatedLines = [];
|
||||||
$totalBet = 0;
|
$totalBet = 0;
|
||||||
@@ -132,6 +132,9 @@ final class TicketPlacementService
|
|||||||
$closedPlayCleanupRows = [];
|
$closedPlayCleanupRows = [];
|
||||||
|
|
||||||
foreach ((array) $payload['lines'] as $index => $line) {
|
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) {
|
foreach ($providers as $provider) {
|
||||||
try {
|
try {
|
||||||
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
|
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ final class TicketPreviewService
|
|||||||
|
|
||||||
$currencyCode = strtoupper((string) $payload['currency_code']);
|
$currencyCode = strtoupper((string) $payload['currency_code']);
|
||||||
$this->assertCreditCurrencyMatchesPlayer($player, $currencyCode);
|
$this->assertCreditCurrencyMatchesPlayer($player, $currencyCode);
|
||||||
$providers = $this->betProviderResolver->resolve(
|
$fallbackProviderCodes = is_array($payload['provider_codes'] ?? null)
|
||||||
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
|
? $payload['provider_codes']
|
||||||
);
|
: null;
|
||||||
|
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$totalBet = 0;
|
$totalBet = 0;
|
||||||
@@ -52,6 +52,9 @@ final class TicketPreviewService
|
|||||||
$closedPlayCleanupRows = [];
|
$closedPlayCleanupRows = [];
|
||||||
|
|
||||||
foreach ((array) $payload['lines'] as $index => $line) {
|
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) {
|
foreach ($providers as $provider) {
|
||||||
try {
|
try {
|
||||||
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
|
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
|
||||||
|
|||||||
@@ -15,13 +15,43 @@ final class OddsStandardScopes
|
|||||||
{
|
{
|
||||||
/** @var array<string, int> */
|
/** @var array<string, int> */
|
||||||
public const PRESET_ODDS_BY_SCOPE = [
|
public const PRESET_ODDS_BY_SCOPE = [
|
||||||
'first' => 250_000,
|
'first' => 25_000_000,
|
||||||
'second' => 110_000,
|
'second' => 10_000_000,
|
||||||
'third' => 55_000,
|
'third' => 5_000_000,
|
||||||
'starter' => 22_000,
|
'starter' => 1_800_000,
|
||||||
'consolation' => 6_500,
|
'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> */
|
/** @var list<string> */
|
||||||
public const SCOPE_KEYS = ['first', 'second', 'third', 'starter', 'consolation'];
|
public const SCOPE_KEYS = ['first', 'second', 'third', 'starter', 'consolation'];
|
||||||
|
|
||||||
@@ -100,7 +130,7 @@ final class OddsStandardScopes
|
|||||||
$rebate = $dimensionCommissions[$key]['rebate'] ?? 0;
|
$rebate = $dimensionCommissions[$key]['rebate'] ?? 0;
|
||||||
$commission = $dimensionCommissions[$key]['commission'] ?? 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()
|
$exists = OddsItem::query()
|
||||||
->where('version_id', $vid)
|
->where('version_id', $vid)
|
||||||
->where('provider_code', $providerCode)
|
->where('provider_code', $providerCode)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('bet_providers', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,9 +10,9 @@ final class BetProviderSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$rows = [
|
$rows = [
|
||||||
['code' => 'SG', 'name' => 'Singapore', 'sort_order' => 10],
|
['code' => 'SG', 'name' => 'Singapore', 'short_code' => 'S', 'sort_order' => 10],
|
||||||
['code' => 'MY', 'name' => 'Malaysia', 'sort_order' => 20],
|
['code' => 'MY', 'name' => 'Malaysia', 'short_code' => 'M', 'sort_order' => 20],
|
||||||
['code' => 'TH', 'name' => 'Thailand', 'sort_order' => 30],
|
['code' => 'TH', 'name' => 'Thailand', 'short_code' => 'T', 'sort_order' => 30],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
@@ -20,6 +20,7 @@ final class BetProviderSeeder extends Seeder
|
|||||||
['code' => $row['code']],
|
['code' => $row['code']],
|
||||||
[
|
[
|
||||||
'name' => $row['name'],
|
'name' => $row['name'],
|
||||||
|
'short_code' => $row['short_code'],
|
||||||
'is_enabled' => true,
|
'is_enabled' => true,
|
||||||
'sort_order' => $row['sort_order'],
|
'sort_order' => $row['sort_order'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ final class OperationalConfigV1Seeder extends Seeder
|
|||||||
|
|
||||||
/** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */
|
/** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */
|
||||||
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
|
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([
|
OddsItem::query()->create([
|
||||||
'version_id' => $oddsVersion->id,
|
'version_id' => $oddsVersion->id,
|
||||||
'play_code' => $pt->play_code,
|
'play_code' => $pt->play_code,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ final class PlayOperationalAlignmentSeeder extends Seeder
|
|||||||
$rebate = (float) ($anchor?->rebate_rate ?? 0);
|
$rebate = (float) ($anchor?->rebate_rate ?? 0);
|
||||||
$commission = (float) ($anchor?->commission_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()
|
$exists = OddsItem::query()
|
||||||
->where('version_id', $vid)
|
->where('version_id', $vid)
|
||||||
->where('play_code', $playCode)
|
->where('play_code', $playCode)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class PlayTypeSeeder extends Seeder
|
|||||||
array_merge($row, $defaults),
|
array_merge($row, $defaults),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,24 +26,36 @@ final class PlayTypeSeeder extends Seeder
|
|||||||
private function rows(): array
|
private function rows(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
$this->row('big', 'standard', 4, 'single', 'Big', 10),
|
$this->row('big', 'standard', 4, 'single', 'B', 10),
|
||||||
$this->row('small', 'standard', 4, 'single', 'Small', 20),
|
$this->row('small', 'standard', 4, 'single', 'S', 20),
|
||||||
|
|
||||||
$this->row('pos_4a', 'position', 4, 'single', '4A', 30, false, ['prize_scope' => ['first']]),
|
$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_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_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_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('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_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_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_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_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_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('straight', 'box', 4, 'single', 'Straight', 160, false, ['expand_mode' => 'straight']),
|
||||||
$this->row('box', 'box', 4, 'single', 'Box', 170, false, ['expand_mode' => 'box']),
|
$this->row('box', 'box', 4, 'single', 'Box', 170, false, ['expand_mode' => 'box']),
|
||||||
|
|||||||
38
database/seeders/TraditionalMalaysiaOddsSeeder.php
Normal file
38
database/seeders/TraditionalMalaysiaOddsSeeder.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\RiskCapItem;
|
||||||
|
use App\Support\OddsStandardScopes;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/** Applies the traditional Malaysia baseline to active and draft odds versions. */
|
||||||
|
final class TraditionalMalaysiaOddsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$codes = [
|
||||||
|
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'four_any', 'four_top', 'four_lower',
|
||||||
|
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3d', 'pos_3e', 'pos_3lower',
|
||||||
|
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2d', 'pos_2e', 'pos_2any',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (OddsVersion::query()->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
|
use App\Models\Player;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\TicketItem;
|
||||||
|
use App\Models\TicketOrder;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -58,6 +61,41 @@ test('admin draws index returns pagination', function (): void {
|
|||||||
Carbon::setTestNow();
|
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 {
|
test('admin draw show exposes hall preview status', function (): void {
|
||||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC'));
|
Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC'));
|
||||||
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
|
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ test('settlement pays big winner and marks ticket settled', function (): void {
|
|||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
expect($item->status)->toBe('settled_win');
|
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();
|
$order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail();
|
||||||
expect($order->status)->toBe('settled');
|
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);
|
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||||
|
|
||||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
$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);
|
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()
|
->assertOk()
|
||||||
->assertJsonPath('data.items.0.total_bet_amount', 10_000)
|
->assertJsonPath('data.items.0.total_bet_amount', 10_000)
|
||||||
->assertJsonPath('data.items.0.total_actual_deduct', 10_000)
|
->assertJsonPath('data.items.0.total_actual_deduct', 10_000)
|
||||||
->assertJsonPath('data.items.0.total_payout_amount', 250_000)
|
->assertJsonPath('data.items.0.total_payout_amount', 25_000_000)
|
||||||
->assertJsonPath('data.items.0.platform_profit', -240_000);
|
->assertJsonPath('data.items.0.platform_profit', -24_990_000);
|
||||||
|
|
||||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
->getJson("/api/v1/admin/settlement-batches/{$settlement->id}")
|
->getJson("/api/v1/admin/settlement-batches/{$settlement->id}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.total_bet_amount', 10_000)
|
->assertJsonPath('data.total_bet_amount', 10_000)
|
||||||
->assertJsonPath('data.total_actual_deduct', 10_000)
|
->assertJsonPath('data.total_actual_deduct', 10_000)
|
||||||
->assertJsonPath('data.platform_profit', -240_000);
|
->assertJsonPath('data.platform_profit', -24_990_000);
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->status)->toBe('settled_win');
|
expect($item->status)->toBe('settled_win');
|
||||||
|
|||||||
Reference in New Issue
Block a user