From c6c10f1397a85668f47d1662e20da91a88835100 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 9 Jul 2026 10:52:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(odds):=20=E6=94=AF=E6=8C=81=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E5=BC=80=E6=B3=A8=E5=95=86=E7=9A=84=E8=B5=94=E7=8E=87?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E4=B8=8E=E4=BC=98=E5=85=88=E7=BA=A7=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在赔率数据表中新增 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 中开注商字段,完善赔率快照信息 - 路由配置调整管理文档地址,无关改动细微修改 --- .../Config/OddsItemsReplaceController.php | 2 + .../Ticket/AdminTicketItemShowController.php | 1 + .../Play/PlayEffectiveCatalogController.php | 4 +- app/Models/OddsItem.php | 1 + .../Config/EffectivePlayCatalogService.php | 37 ++++- app/Services/Config/OddsStreamService.php | 21 ++- app/Services/Ticket/PlayCatalogResolver.php | 32 ++++- app/Services/Ticket/PlayRuleEngine.php | 1 + .../Ticket/TicketPlacementService.php | 25 ++-- app/Services/Ticket/TicketPreviewService.php | 25 ++-- app/Support/AdminConfigPresenter.php | 2 + app/Support/OddsStandardScopes.php | 12 +- ...120000_add_provider_code_to_odds_items.php | 40 ++++++ routes/web.php | 2 +- tests/Feature/OddsStandardScopesSyncTest.php | 134 ++++++++++++++++++ tests/Feature/OperationalConfigApiTest.php | 48 +++++++ 16 files changed, 351 insertions(+), 36 deletions(-) create mode 100644 database/migrations/2026_07_09_120000_add_provider_code_to_odds_items.php diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php index 491d555..73faa62 100644 --- a/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php @@ -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'], diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php index d1a773c..e78b6cc 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php @@ -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, diff --git a/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php b/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php index fa09ce1..0ee0f59 100644 --- a/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php +++ b/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php @@ -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) { diff --git a/app/Models/OddsItem.php b/app/Models/OddsItem.php index bed9166..098594b 100644 --- a/app/Models/OddsItem.php +++ b/app/Models/OddsItem.php @@ -14,6 +14,7 @@ final class OddsItem extends Model { protected $fillable = [ 'version_id', + 'provider_code', 'play_code', 'prize_scope', 'dimension', diff --git a/app/Services/Config/EffectivePlayCatalogService.php b/app/Services/Config/EffectivePlayCatalogService.php index e51ac61..77196e2 100644 --- a/app/Services/Config/EffectivePlayCatalogService.php +++ b/app/Services/Config/EffectivePlayCatalogService.php @@ -20,9 +20,10 @@ final class EffectivePlayCatalogService /** * @return array */ - 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> */ @@ -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 $items + * @return Collection + */ + 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, diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 3a7a21e..1194567 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -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; diff --git a/app/Services/Ticket/PlayCatalogResolver.php b/app/Services/Ticket/PlayCatalogResolver.php index 5ff9219..88f9432 100644 --- a/app/Services/Ticket/PlayCatalogResolver.php +++ b/app/Services/Ticket/PlayCatalogResolver.php @@ -88,7 +88,7 @@ final class PlayCatalogResolver /** * @return array{play_config: PlayConfigItem, odds_items: Collection} */ - 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 $items + * @return Collection + */ + 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() diff --git a/app/Services/Ticket/PlayRuleEngine.php b/app/Services/Ticket/PlayRuleEngine.php index 42933dd..e233885 100644 --- a/app/Services/Ticket/PlayRuleEngine.php +++ b/app/Services/Ticket/PlayRuleEngine.php @@ -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, diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index bb3d0e7..47ccadb 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -131,21 +131,22 @@ final class TicketPlacementService $closedPlayCleanupRows = []; foreach ((array) $payload['lines'] as $index => $line) { - try { - $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); - } catch (TicketOperationException $e) { - if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { - $closedPlayCleanupRows[] = [ - 'client_line_no' => $index + 1, - 'play_code' => (string) ($line['play_code'] ?? ''), - ]; + foreach ($providers as $provider) { + try { + $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']); + } catch (TicketOperationException $e) { + if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { + $closedPlayCleanupRows[] = [ + 'client_line_no' => $index + 1, + 'play_code' => (string) ($line['play_code'] ?? ''), + ]; - continue; + continue 2; + } + + throw $e; } - throw $e; - } - foreach ($providers as $provider) { $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_config'], diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index 6660510..eeb281c 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -52,21 +52,22 @@ final class TicketPreviewService $closedPlayCleanupRows = []; foreach ((array) $payload['lines'] as $index => $line) { - try { - $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); - } catch (TicketOperationException $e) { - if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { - $closedPlayCleanupRows[] = [ - 'client_line_no' => $index + 1, - 'play_code' => (string) ($line['play_code'] ?? ''), - ]; + foreach ($providers as $provider) { + try { + $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode, (string) $provider['code']); + } catch (TicketOperationException $e) { + if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { + $closedPlayCleanupRows[] = [ + 'client_line_no' => $index + 1, + 'play_code' => (string) ($line['play_code'] ?? ''), + ]; - continue; + continue 2; + } + + throw $e; } - throw $e; - } - foreach ($providers as $provider) { $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_config'], diff --git a/app/Support/AdminConfigPresenter.php b/app/Support/AdminConfigPresenter.php index 2216a84..b22b046 100644 --- a/app/Support/AdminConfigPresenter.php +++ b/app/Support/AdminConfigPresenter.php @@ -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, diff --git a/app/Support/OddsStandardScopes.php b/app/Support/OddsStandardScopes.php index e61124e..29efc78 100644 --- a/app/Support/OddsStandardScopes.php +++ b/app/Support/OddsStandardScopes.php @@ -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, diff --git a/database/migrations/2026_07_09_120000_add_provider_code_to_odds_items.php b/database/migrations/2026_07_09_120000_add_provider_code_to_odds_items.php new file mode 100644 index 0000000..e5e535b --- /dev/null +++ b/database/migrations/2026_07_09_120000_add_provider_code_to_odds_items.php @@ -0,0 +1,40 @@ +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'); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 562e398..69c66cf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'), '/', ); diff --git a/tests/Feature/OddsStandardScopesSyncTest.php b/tests/Feature/OddsStandardScopesSyncTest.php index bb95dbb..1147636 100644 --- a/tests/Feature/OddsStandardScopesSyncTest.php +++ b/tests/Feature/OddsStandardScopesSyncTest.php @@ -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'); +}); diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php index 2fbfc67..656e482 100644 --- a/tests/Feature/OperationalConfigApiTest.php +++ b/tests/Feature/OperationalConfigApiTest.php @@ -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();