- 在赔率数据表中新增 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 中开注商字段,完善赔率快照信息 - 路由配置调整管理文档地址,无关改动细微修改
260 lines
9.4 KiB
PHP
260 lines
9.4 KiB
PHP
<?php
|
|
|
|
use App\Models\PlayType;
|
|
use App\Models\AdminUser;
|
|
use App\Models\OddsVersion;
|
|
use App\Models\RiskCapVersion;
|
|
use App\Models\PlayConfigVersion;
|
|
use App\Lottery\ConfigVersionStatus;
|
|
use App\Lottery\ErrorCode;
|
|
use Database\Seeders\CurrencySeeder;
|
|
use Database\Seeders\PlayTypeSeeder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Database\Seeders\OperationalConfigV1Seeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
$this->seed(CurrencySeeder::class);
|
|
$this->seed(PlayTypeSeeder::class);
|
|
$this->seed(OperationalConfigV1Seeder::class);
|
|
});
|
|
|
|
function mintConfigAdminToken(): string
|
|
{
|
|
$admin = AdminUser::query()->create([
|
|
'username' => 'config_admin',
|
|
'name' => 'Config QA',
|
|
'email' => null,
|
|
'password' => Hash::make('secret-strong'),
|
|
'status' => 0,
|
|
]);
|
|
grantSuperAdminRole($admin);
|
|
|
|
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
|
}
|
|
|
|
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'],
|
|
'currency_code' => $r['currency_code'],
|
|
'extra_config_json' => $r['extra_config_json'] ?? null,
|
|
])->all();
|
|
}
|
|
|
|
test('play effective catalog is public and merged', function (): void {
|
|
$resp = $this->getJson('/api/v1/play/effective?currency=NPR');
|
|
$resp->assertOk()->assertJsonPath('data.currency_code', 'NPR');
|
|
|
|
$plays = $resp->json('data.plays');
|
|
expect($plays)->toBeArray()->not->toBeEmpty();
|
|
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();
|
|
|
|
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
|
'reason' => 'test draft',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
expect($draftId)->toBeGreaterThan(0);
|
|
expect($create->json('data.status'))->toBe(ConfigVersionStatus::Draft->value);
|
|
|
|
$types = PlayType::query()->orderBy('play_code')->get();
|
|
$itemPayload = [];
|
|
foreach ($types as $t) {
|
|
$itemPayload[] = [
|
|
'play_code' => $t->play_code,
|
|
'category' => $t->category,
|
|
'dimension' => $t->dimension,
|
|
'bet_mode' => $t->bet_mode,
|
|
'display_name' => $t->display_name ?? $t->play_code,
|
|
'is_enabled' => true,
|
|
'min_bet_amount' => 200,
|
|
'max_bet_amount' => 400_000_000,
|
|
'display_order' => (int) $t->sort_order,
|
|
'supports_multi_number' => (bool) $t->supports_multi_number,
|
|
'reserved_rule_json' => $t->reserved_rule_json,
|
|
];
|
|
}
|
|
|
|
$this->putJson(
|
|
'/api/v1/admin/config/play-versions/'.$draftId.'/items',
|
|
['items' => $itemPayload],
|
|
['Authorization' => 'Bearer '.$token],
|
|
)->assertOk()->assertJsonPath('data.items.0.min_bet_amount', 200);
|
|
|
|
$this->postJson(
|
|
'/api/v1/admin/config/play-versions/'.$draftId.'/publish',
|
|
[],
|
|
['Authorization' => 'Bearer '.$token],
|
|
)->assertOk()->assertJsonPath('data.status', ConfigVersionStatus::Active->value);
|
|
|
|
$active->refresh();
|
|
expect($active->status)->toBe(ConfigVersionStatus::Archived->value);
|
|
|
|
expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
|
|
});
|
|
|
|
test('admin play config publish rejects empty draft items', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
|
'reason' => 'empty draft',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
|
|
DB::table('play_config_items')->where('version_id', $draftId)->delete();
|
|
|
|
$this->postJson(
|
|
'/api/v1/admin/config/play-versions/'.$draftId.'/publish',
|
|
[],
|
|
['Authorization' => 'Bearer '.$token],
|
|
)->assertStatus(422);
|
|
});
|
|
|
|
test('admin play-types requires authentication', function (): void {
|
|
$this->getJson('/api/v1/admin/play-types')->assertUnauthorized();
|
|
});
|
|
|
|
test('admin cannot delete active play config version', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();
|
|
|
|
$this->deleteJson('/api/v1/admin/config/play-versions/'.$active->id, [], [
|
|
'Authorization' => 'Bearer '.$token,
|
|
])
|
|
->assertStatus(400)
|
|
->assertJsonPath('code', ErrorCode::ConfigVersionCannotDeleteActive->value);
|
|
});
|
|
|
|
test('admin can delete draft play config version', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
|
'reason' => 'to delete',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
|
|
$this->deleteJson('/api/v1/admin/config/play-versions/'.$draftId, [], [
|
|
'Authorization' => 'Bearer '.$token,
|
|
])
|
|
->assertOk()
|
|
->assertJsonPath('data.deleted', true);
|
|
|
|
expect(PlayConfigVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('admin can delete draft odds version', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$create = $this->postJson('/api/v1/admin/config/odds-versions', [
|
|
'reason' => 'to delete',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
|
|
$this->deleteJson('/api/v1/admin/config/odds-versions/'.$draftId, [], [
|
|
'Authorization' => 'Bearer '.$token,
|
|
])
|
|
->assertOk()
|
|
->assertJsonPath('data.deleted', true);
|
|
|
|
expect(OddsVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('admin can delete draft risk cap version', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', [
|
|
'reason' => 'to delete',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
|
|
$this->deleteJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, [], [
|
|
'Authorization' => 'Bearer '.$token,
|
|
])
|
|
->assertOk()
|
|
->assertJsonPath('data.deleted', true);
|
|
|
|
expect(RiskCapVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('admin odds config rejects out of range rebate rate', function (): void {
|
|
$token = mintConfigAdminToken();
|
|
$create = $this->postJson('/api/v1/admin/config/odds-versions', [
|
|
'reason' => 'rate bound',
|
|
], ['Authorization' => 'Bearer '.$token]);
|
|
$create->assertOk();
|
|
$draftId = (int) $create->json('data.id');
|
|
|
|
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, [
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->assertOk()->json('data.items');
|
|
$payload = oddsPutPayloadFromDetail($detail);
|
|
$payload[0]['rebate_rate'] = 1.2;
|
|
|
|
$this->putJson(
|
|
'/api/v1/admin/config/odds-versions/'.$draftId.'/items',
|
|
['items' => $payload],
|
|
['Authorization' => 'Bearer '.$token],
|
|
)->assertStatus(422);
|
|
});
|