feat(odds): 支持基于开注商的赔率区分与优先级处理
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- 在赔率数据表中新增 provider_code 字段,默认值为 GLOBAL
- 赔率接口与服务中新增 provider_code 参数支持,实现开注商特定赔率加载
- 解析赔率集合时,根据 provider_code 优先级进行排序与筛选,保证特定开注商赔率优先
- 优化赔率唯一索引,新增 provider_code 列以防止不同开注商赔率重复冲突
- 调整赔率相关验证规则,支持开注商代码的合法性与可选性检查
- 玩法解析与出票相关逻辑改造,支持多开注商环境下的投注处理
- 管理界面赔率替换接口增加 provider_code 字段支持
- 同步和测试用例覆盖 provider_code 相关功能,验证开注商赔率隔离与优先切换
- PlayEffectiveCatalogController 支持按 provider_code 返回赔率目录数据
- TicketPlacementService 与 TicketPreviewService 修改为支持 provider_code 多开注商玩法解析
- 维护 OddsStandardScopes 同步逻辑,保证开注商维度佣金与赔率正确同步
- AdminConfigPresenter 增加了对应数据的 provider_code 与 dimension 输出字段
- SDK接口添加 odds_snapshot_json 中开注商字段,完善赔率快照信息
- 路由配置调整管理文档地址,无关改动细微修改
This commit is contained in:
2026-07-09 10:52:05 +08:00
parent 2d50980999
commit c6c10f1397
16 changed files with 351 additions and 36 deletions

View File

@@ -37,8 +37,10 @@ final class OddsItemsReplaceController extends Controller
$data = $request->validate([
'items' => ['required', 'array', 'min:1'],
'items.*.provider_code' => ['sometimes', 'string', 'max:32'],
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
'items.*.prize_scope' => ['required', 'string', 'max:32'],
'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4'],
'items.*.odds_value' => ['required', 'integer', 'min:0'],
'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'],
'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'],

View File

@@ -80,6 +80,7 @@ final class AdminTicketItemShowController extends Controller
'actual_deduct_amount_minor' => $actualDeduct,
'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct),
'risk_locked_amount' => (int) $item->risk_locked_amount,
'odds_snapshot_json' => $item->odds_snapshot_json,
'status' => $item->status,
'fail_reason_code' => $item->fail_reason_code,
'fail_reason_text' => $item->fail_reason_text,

View File

@@ -20,9 +20,11 @@ final class PlayEffectiveCatalogController extends Controller
{
$currency = $request->query('currency');
$c = is_string($currency) && $currency !== '' ? $currency : null;
$provider = $request->query('provider_code');
$providerCode = is_string($provider) && $provider !== '' ? $provider : null;
try {
return ApiResponse::success($catalog->build($c));
return ApiResponse::success($catalog->build($c, $providerCode));
} catch (ModelNotFoundException) {
return ApiMessage::errorResponse($request, 'effective_config_not_initialized', ErrorCode::NotFound->value, null, 404);
} catch (\InvalidArgumentException $e) {

View File

@@ -14,6 +14,7 @@ final class OddsItem extends Model
{
protected $fillable = [
'version_id',
'provider_code',
'play_code',
'prize_scope',
'dimension',

View File

@@ -20,9 +20,10 @@ final class EffectivePlayCatalogService
/**
* @return array<string, mixed>
*/
public function build(?string $currencyCode = null): array
public function build(?string $currencyCode = null, ?string $providerCode = null): array
{
$currency = $this->resolveBettableCurrency($currencyCode);
$providerCode = strtoupper((string) ($providerCode ?: 'GLOBAL'));
$playVersion = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
@@ -45,6 +46,7 @@ final class EffectivePlayCatalogService
$oddsRows = OddsItem::query()
->where('version_id', $oddsVersion->id)
->where('currency_code', $currency->code)
->whereIn('provider_code', ['GLOBAL', $providerCode])
->get();
/** @var Collection<string, Collection<int, OddsItem>> */
@@ -60,8 +62,11 @@ final class EffectivePlayCatalogService
['display_order', 'asc'],
['play_code', 'asc'],
])
->map(function (PlayConfigItem $c) use ($oddsByPlay): array {
$items = $oddsByPlay->get($c->play_code, collect());
->map(function (PlayConfigItem $c) use ($oddsByPlay, $providerCode): array {
$items = $this->resolveProviderOddsItems(
$oddsByPlay->get($c->play_code, collect()),
$providerCode,
);
$o = $this->pickPrimaryOddsItem($items);
return [
@@ -80,6 +85,7 @@ final class EffectivePlayCatalogService
return [
'currency_code' => $currency->code,
'provider_code' => $providerCode,
'effective_versions' => [
'play_config' => $this->serializeVersionHead($playVersion),
'odds' => $this->serializeVersionHead($oddsVersion),
@@ -90,6 +96,30 @@ final class EffectivePlayCatalogService
];
}
/**
* @param Collection<int, OddsItem> $items
* @return Collection<int, OddsItem>
*/
private function resolveProviderOddsItems(Collection $items, string $providerCode): Collection
{
$selected = [];
foreach ($items->sortBy(fn (OddsItem $row): int => (string) $row->provider_code === $providerCode ? 0 : 1) as $row) {
$dimension = $row->dimension === null ? 'null' : (string) $row->dimension;
$key = implode('|', [
(string) $row->prize_scope,
strtoupper((string) $row->currency_code),
$dimension,
]);
if (! isset($selected[$key])) {
$selected[$key] = $row;
}
}
return collect(array_values($selected));
}
/**
* 大厅列表展示单档赔率:优先头奖档 {@see first},兼容历史 {@see default}
*
@@ -164,6 +194,7 @@ final class EffectivePlayCatalogService
private function serializeOddsItem(OddsItem $r): array
{
return [
'provider_code' => $r->provider_code ?? 'GLOBAL',
'prize_scope' => $r->prize_scope,
'odds_value' => (int) $r->odds_value,
'rebate_rate' => (string) $r->rebate_rate,

View File

@@ -6,6 +6,7 @@ use App\Models\Currency;
use App\Models\OddsItem;
use App\Models\PlayType;
use App\Models\AdminUser;
use App\Models\BetProvider;
use App\Models\OddsVersion;
use Illuminate\Http\Request;
use App\Services\AuditLogger;
@@ -61,6 +62,7 @@ final class OddsStreamService
foreach ($source->items()->orderBy('currency_code')->orderBy('play_code')->get() as $row) {
OddsItem::query()->create([
'version_id' => $draft->id,
'provider_code' => $row->provider_code ?? 'GLOBAL',
'play_code' => $row->play_code,
'prize_scope' => $row->prize_scope,
'dimension' => $row->dimension,
@@ -77,6 +79,7 @@ final class OddsStreamService
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
OddsItem::query()->create([
'version_id' => $draft->id,
'provider_code' => 'GLOBAL',
'play_code' => $pt->play_code,
'prize_scope' => $scope,
'dimension' => $pt->dimension,
@@ -108,6 +111,7 @@ final class OddsStreamService
foreach ($items as $row) {
OddsItem::query()->create([
'version_id' => $draft->id,
'provider_code' => strtoupper((string) ($row['provider_code'] ?? 'GLOBAL')),
'play_code' => (string) $row['play_code'],
'prize_scope' => (string) $row['prize_scope'],
'dimension' => isset($row['dimension']) ? (int) $row['dimension'] : null,
@@ -222,6 +226,14 @@ final class OddsStreamService
true,
);
$allowedScopes = array_fill_keys(OddsStandardScopes::SCOPE_KEYS, true);
$allowedProviderCodes = array_fill_keys(
BetProvider::query()
->pluck('code')
->map(fn (string $code) => strtoupper($code))
->all(),
true,
);
$allowedProviderCodes['GLOBAL'] = true;
$errors = [];
$seenKeys = [];
@@ -232,13 +244,18 @@ final class OddsStreamService
foreach ($items as $index => $row) {
$playCode = (string) $row->play_code;
$providerCode = strtoupper((string) ($row->provider_code ?? 'GLOBAL'));
$scope = (string) $row->prize_scope;
$dimension = $row->dimension;
$currencyCode = strtoupper((string) $row->currency_code);
$oddsValue = (int) $row->odds_value;
$rebateRate = (float) $row->rebate_rate;
$commissionRate = (float) $row->commission_rate;
$key = $playCode.'|'.$scope.'|'.$currencyCode;
$key = $providerCode.'|'.$playCode.'|'.$scope.'|'.$currencyCode.'|'.($dimension === null ? 'null' : (string) $dimension);
if (! isset($allowedProviderCodes[$providerCode])) {
$errors["items.$index.provider_code"][] = '开注商不存在';
}
if (! isset($allowedPlayCodes[$playCode])) {
$errors["items.$index.play_code"][] = '玩法不存在';
@@ -257,7 +274,7 @@ final class OddsStreamService
}
if (isset($seenKeys[$key])) {
$errors["items.$index"][] = '同一玩法、档位、币种存在重复赔率项';
$errors["items.$index"][] = '同一开注商、玩法、档位、币种、维度存在重复赔率项';
}
$seenKeys[$key] = true;

View File

@@ -88,7 +88,7 @@ final class PlayCatalogResolver
/**
* @return array{play_config: PlayConfigItem, odds_items: Collection<int, OddsItem>}
*/
public function resolve(string $playCode, string $currencyCode): array
public function resolve(string $playCode, string $currencyCode, ?string $providerCode = null): array
{
$playVersion = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
@@ -107,12 +107,16 @@ final class PlayCatalogResolver
->where('status', ConfigVersionStatus::Active->value)
->firstOrFail();
$oddsItems = OddsItem::query()
$providerCode = strtoupper((string) ($providerCode ?: 'GLOBAL'));
$rawOddsItems = OddsItem::query()
->where('version_id', $oddsVersion->id)
->where('play_code', $playCode)
->where('currency_code', strtoupper($currencyCode))
->whereIn('provider_code', ['GLOBAL', $providerCode])
->get();
$oddsItems = $this->resolveProviderOddsItems($rawOddsItems, $providerCode);
if ($oddsItems->isEmpty()) {
throw new TicketOperationException('odds_missing', ErrorCode::BetPlayUnsupported->value);
}
@@ -123,6 +127,30 @@ final class PlayCatalogResolver
];
}
/**
* @param Collection<int, OddsItem> $items
* @return Collection<int, OddsItem>
*/
private function resolveProviderOddsItems(Collection $items, string $providerCode): Collection
{
$selected = [];
foreach ($items->sortBy(fn (OddsItem $row): int => (string) $row->provider_code === $providerCode ? 0 : 1) as $row) {
$dimension = $row->dimension === null ? 'null' : (string) $row->dimension;
$key = implode('|', [
(string) $row->prize_scope,
strtoupper((string) $row->currency_code),
$dimension,
]);
if (! isset($selected[$key])) {
$selected[$key] = $row;
}
}
return collect(array_values($selected));
}
public function resolveCapAmount(int $drawId, string $number4d): int
{
$riskVersion = RiskCapVersion::query()

View File

@@ -73,6 +73,7 @@ final class PlayRuleEngine
'combination_count' => $combinationCount,
'estimated_max_payout' => $estimatedMaxPayout,
'odds_snapshot_json' => $oddsItems->map(fn (OddsItem $row) => [
'provider_code' => (string) ($row->provider_code ?? 'GLOBAL'),
'prize_scope' => $row->prize_scope,
'odds_value' => (int) $row->odds_value,
'rebate_rate' => (string) $row->rebate_rate,

View File

@@ -131,8 +131,9 @@ final class TicketPlacementService
$closedPlayCleanupRows = [];
foreach ((array) $payload['lines'] as $index => $line) {
foreach ($providers as $provider) {
try {
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode);
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
} catch (TicketOperationException $e) {
if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) {
$closedPlayCleanupRows[] = [
@@ -140,12 +141,12 @@ final class TicketPlacementService
'play_code' => (string) ($line['play_code'] ?? ''),
];
continue;
continue 2;
}
throw $e;
}
foreach ($providers as $provider) {
$evaluated = $this->ruleEngine->evaluateLine(
(array) $line,
$resolved['play_config'],

View File

@@ -52,8 +52,9 @@ final class TicketPreviewService
$closedPlayCleanupRows = [];
foreach ((array) $payload['lines'] as $index => $line) {
foreach ($providers as $provider) {
try {
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode);
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']);
} catch (TicketOperationException $e) {
if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) {
$closedPlayCleanupRows[] = [
@@ -61,12 +62,12 @@ final class TicketPreviewService
'play_code' => (string) ($line['play_code'] ?? ''),
];
continue;
continue 2;
}
throw $e;
}
foreach ($providers as $provider) {
$evaluated = $this->ruleEngine->evaluateLine(
(array) $line,
$resolved['play_config'],

View File

@@ -107,8 +107,10 @@ final class AdminConfigPresenter
{
return [
'id' => (int) $r->id,
'provider_code' => $r->provider_code ?? 'GLOBAL',
'play_code' => $r->play_code,
'prize_scope' => $r->prize_scope,
'dimension' => $r->dimension === null ? null : (int) $r->dimension,
'odds_value' => (int) $r->odds_value,
'rebate_rate' => (string) $r->rebate_rate,
'commission_rate' => (string) $r->commission_rate,

View File

@@ -40,7 +40,7 @@ final class OddsStandardScopes
$pairs = OddsItem::query()
->where('version_id', $vid)
->select(['play_code', 'currency_code'])
->select(['provider_code', 'play_code', 'currency_code'])
->distinct()
->get();
@@ -58,6 +58,7 @@ final class OddsStandardScopes
->orderBy('play_code')
->get(['play_code', 'dimension'])
->map(fn (PlayType $pt) => (object) [
'provider_code' => 'GLOBAL',
'play_code' => $pt->play_code,
'dimension' => $pt->dimension,
'currency_code' => $currencyCode,
@@ -67,14 +68,16 @@ final class OddsStandardScopes
// 按维度分组,获取每个维度的佣金率
$dimensionCommissions = [];
foreach ($pairs as $pair) {
$providerCode = strtoupper((string) ($pair->provider_code ?? 'GLOBAL'));
$dimension = $pair->dimension ?? null;
$currencyCode = strtoupper((string) $pair->currency_code);
$key = $dimension.'|'.$currencyCode;
$key = $providerCode.'|'.$dimension.'|'.$currencyCode;
if (!isset($dimensionCommissions[$key])) {
// 从现有记录中获取该维度的佣金率
$anchor = OddsItem::query()
->where('version_id', $vid)
->where('provider_code', $providerCode)
->where('currency_code', $currencyCode)
->where('dimension', $dimension)
->orderByDesc('id')
@@ -88,10 +91,11 @@ final class OddsStandardScopes
}
foreach ($pairs as $pair) {
$providerCode = strtoupper((string) ($pair->provider_code ?? 'GLOBAL'));
$playCode = (string) $pair->play_code;
$dimension = $pair->dimension ?? null;
$currencyCode = strtoupper((string) $pair->currency_code);
$key = $dimension.'|'.$currencyCode;
$key = $providerCode.'|'.$dimension.'|'.$currencyCode;
$rebate = $dimensionCommissions[$key]['rebate'] ?? 0;
$commission = $dimensionCommissions[$key]['commission'] ?? 0;
@@ -99,6 +103,7 @@ final class OddsStandardScopes
foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
$exists = OddsItem::query()
->where('version_id', $vid)
->where('provider_code', $providerCode)
->where('play_code', $playCode)
->where('currency_code', $currencyCode)
->where('prize_scope', $scope)
@@ -109,6 +114,7 @@ final class OddsStandardScopes
OddsItem::query()->create([
'version_id' => $vid,
'provider_code' => $providerCode,
'play_code' => $playCode,
'prize_scope' => $scope,
'dimension' => $dimension,

View File

@@ -0,0 +1,40 @@
<?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('odds_items', function (Blueprint $table): void {
$table->string('provider_code', 32)->default('GLOBAL')->after('version_id');
});
DB::table('odds_items')->update(['provider_code' => 'GLOBAL']);
Schema::table('odds_items', function (Blueprint $table): void {
$table->dropUnique('uk_odds_items_version_play_prize_currency_dimension');
$table->index(['version_id', 'provider_code', 'play_code'], 'idx_odds_items_version_provider_play');
$table->unique(
['version_id', 'provider_code', 'play_code', 'prize_scope', 'currency_code', 'dimension'],
'uk_odds_items_version_provider_play_prize_currency_dimension'
);
});
}
public function down(): void
{
Schema::table('odds_items', function (Blueprint $table): void {
$table->dropUnique('uk_odds_items_version_provider_play_prize_currency_dimension');
$table->dropIndex('idx_odds_items_version_provider_play');
$table->unique(
['version_id', 'play_code', 'prize_scope', 'currency_code', 'dimension'],
'uk_odds_items_version_play_prize_currency_dimension'
);
$table->dropColumn('provider_code');
});
}
};

View File

@@ -9,7 +9,7 @@ Route::get('/', function () {
Route::prefix('admin/docs')->group(function (): void {
Route::get('integration-guide', function () {
$adminDocs = rtrim(
(string) env('LOTTERY_ADMIN_DOCS_URL', 'https://lotteryadmin.tanumo.com'),
(string) env('LOTTERY_ADMIN_DOCS_URL', 'https://lottery-admin.cjdhr.top'),
'/',
);

View File

@@ -3,9 +3,13 @@
use App\Models\Currency;
use App\Models\OddsItem;
use App\Models\PlayType;
use App\Models\BetProvider;
use App\Models\OddsVersion;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Support\OddsStandardScopes;
use App\Lottery\ConfigVersionStatus;
use App\Services\Ticket\PlayCatalogResolver;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -29,6 +33,7 @@ test('syncMissingForVersion adds five scopes from default-only rows', function (
foreach (PlayType::query()->orderBy('play_code')->get() as $pt) {
OddsItem::query()->create([
'version_id' => $version->id,
'provider_code' => 'GLOBAL',
'play_code' => $pt->play_code,
'prize_scope' => 'default',
'odds_value' => 19_500,
@@ -61,3 +66,132 @@ test('syncMissingForVersion adds five scopes from default-only rows', function (
}
}
});
test('syncMissingForVersion keeps provider-specific odds rows separate', function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
$play = PlayType::query()->orderBy('play_code')->firstOrFail();
BetProvider::query()->create([
'code' => 'MY',
'name' => 'Malaysia',
'is_enabled' => true,
'sort_order' => 2,
]);
$version = OddsVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'provider test',
]);
OddsItem::query()->create([
'version_id' => $version->id,
'provider_code' => 'MY',
'play_code' => $play->play_code,
'prize_scope' => 'first',
'dimension' => $play->dimension,
'odds_value' => 88_000,
'rebate_rate' => 0,
'commission_rate' => 0,
'currency_code' => $currency->code,
'extra_config_json' => null,
]);
OddsStandardScopes::syncMissingForVersion($version);
foreach (OddsStandardScopes::SCOPE_KEYS as $scope) {
$this->assertDatabaseHas('odds_items', [
'version_id' => $version->id,
'provider_code' => 'MY',
'play_code' => $play->play_code,
'currency_code' => $currency->code,
'prize_scope' => $scope,
]);
}
});
test('play catalog resolver uses provider odds and falls back to global scopes', function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
$play = PlayType::query()->where('play_code', 'big')->firstOrFail();
BetProvider::query()->create([
'code' => 'MY',
'name' => 'Malaysia',
'is_enabled' => true,
'sort_order' => 2,
]);
$playVersion = PlayConfigVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'play',
]);
PlayConfigItem::query()->create([
'version_id' => $playVersion->id,
'play_code' => $play->play_code,
'category' => $play->category,
'dimension' => $play->dimension,
'bet_mode' => $play->bet_mode,
'display_name' => $play->display_name ?? $play->play_code,
'is_enabled' => true,
'min_bet_amount' => 100,
'max_bet_amount' => 1_000_000,
'display_order' => $play->sort_order,
'supports_multi_number' => $play->supports_multi_number,
'reserved_rule_json' => null,
]);
$oddsVersion = OddsVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'odds',
]);
foreach (OddsStandardScopes::SCOPE_KEYS as $scope) {
OddsItem::query()->create([
'version_id' => $oddsVersion->id,
'provider_code' => 'GLOBAL',
'play_code' => $play->play_code,
'prize_scope' => $scope,
'dimension' => $play->dimension,
'odds_value' => 10_000,
'rebate_rate' => 0,
'commission_rate' => 0,
'currency_code' => $currency->code,
'extra_config_json' => null,
]);
}
OddsItem::query()->create([
'version_id' => $oddsVersion->id,
'provider_code' => 'MY',
'play_code' => $play->play_code,
'prize_scope' => 'first',
'dimension' => $play->dimension,
'odds_value' => 99_000,
'rebate_rate' => 0,
'commission_rate' => 0,
'currency_code' => $currency->code,
'extra_config_json' => null,
]);
$resolved = app(PlayCatalogResolver::class)->resolve($play->play_code, $currency->code, 'MY');
$first = $resolved['odds_items']->firstWhere('prize_scope', 'first');
$second = $resolved['odds_items']->firstWhere('prize_scope', 'second');
expect((int) $first->odds_value)->toBe(99_000)
->and((string) $first->provider_code)->toBe('MY')
->and((int) $second->odds_value)->toBe(10_000)
->and((string) $second->provider_code)->toBe('GLOBAL');
});

View File

@@ -39,8 +39,10 @@ function mintConfigAdminToken(): string
function oddsPutPayloadFromDetail(array $items): array
{
return collect($items)->map(fn (array $r) => [
'provider_code' => $r['provider_code'] ?? 'GLOBAL',
'play_code' => $r['play_code'],
'prize_scope' => $r['prize_scope'],
'dimension' => $r['dimension'] ?? null,
'odds_value' => (int) $r['odds_value'],
'rebate_rate' => (float) $r['rebate_rate'],
'commission_rate' => (float) $r['commission_rate'],
@@ -58,6 +60,52 @@ test('play effective catalog is public and merged', function (): void {
expect($plays[0])->toHaveKeys(['play_code', 'config', 'odds', 'master_enabled']);
});
test('play effective catalog applies provider odds over global fallback', function (): void {
$activeOdds = OddsVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->firstOrFail();
$globalFirst = DB::table('odds_items')
->where('version_id', $activeOdds->id)
->where('provider_code', 'GLOBAL')
->where('play_code', 'big')
->where('prize_scope', 'first')
->first();
expect($globalFirst)->not->toBeNull();
DB::table('bet_providers')->insert([
'code' => 'MY',
'name' => 'Malaysia',
'is_enabled' => true,
'sort_order' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('odds_items')->insert([
'version_id' => $activeOdds->id,
'provider_code' => 'MY',
'play_code' => 'big',
'prize_scope' => 'first',
'dimension' => $globalFirst->dimension,
'odds_value' => 99_000,
'rebate_rate' => $globalFirst->rebate_rate,
'commission_rate' => $globalFirst->commission_rate,
'currency_code' => $globalFirst->currency_code,
'extra_config_json' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$globalPlays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
$providerPlays = collect($this->getJson('/api/v1/play/effective?currency=NPR&provider_code=MY')->assertOk()->json('data.plays'));
expect($globalPlays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe((int) $globalFirst->odds_value)
->and($providerPlays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(99_000)
->and($providerPlays->firstWhere('play_code', 'big')['odds']['provider_code'])->toBe('MY');
});
test('admin play config draft publish flow', function (): void {
$token = mintConfigAdminToken();
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();