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

@@ -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();