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([ $data = $request->validate([
'items' => ['required', 'array', 'min:1'], '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.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
'items.*.prize_scope' => ['required', 'string', 'max:32'], 'items.*.prize_scope' => ['required', 'string', 'max:32'],
'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4'],
'items.*.odds_value' => ['required', 'integer', 'min:0'], 'items.*.odds_value' => ['required', 'integer', 'min:0'],
'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'], 'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'],
'items.*.commission_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_minor' => $actualDeduct,
'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct), 'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct),
'risk_locked_amount' => (int) $item->risk_locked_amount, 'risk_locked_amount' => (int) $item->risk_locked_amount,
'odds_snapshot_json' => $item->odds_snapshot_json,
'status' => $item->status, 'status' => $item->status,
'fail_reason_code' => $item->fail_reason_code, 'fail_reason_code' => $item->fail_reason_code,
'fail_reason_text' => $item->fail_reason_text, 'fail_reason_text' => $item->fail_reason_text,

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,7 +88,7 @@ final class PlayCatalogResolver
/** /**
* @return array{play_config: PlayConfigItem, odds_items: Collection<int, OddsItem>} * @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() $playVersion = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value) ->where('status', ConfigVersionStatus::Active->value)
@@ -107,12 +107,16 @@ final class PlayCatalogResolver
->where('status', ConfigVersionStatus::Active->value) ->where('status', ConfigVersionStatus::Active->value)
->firstOrFail(); ->firstOrFail();
$oddsItems = OddsItem::query() $providerCode = strtoupper((string) ($providerCode ?: 'GLOBAL'));
$rawOddsItems = OddsItem::query()
->where('version_id', $oddsVersion->id) ->where('version_id', $oddsVersion->id)
->where('play_code', $playCode) ->where('play_code', $playCode)
->where('currency_code', strtoupper($currencyCode)) ->where('currency_code', strtoupper($currencyCode))
->whereIn('provider_code', ['GLOBAL', $providerCode])
->get(); ->get();
$oddsItems = $this->resolveProviderOddsItems($rawOddsItems, $providerCode);
if ($oddsItems->isEmpty()) { if ($oddsItems->isEmpty()) {
throw new TicketOperationException('odds_missing', ErrorCode::BetPlayUnsupported->value); 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 public function resolveCapAmount(int $drawId, string $number4d): int
{ {
$riskVersion = RiskCapVersion::query() $riskVersion = RiskCapVersion::query()

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ final class OddsStandardScopes
$pairs = OddsItem::query() $pairs = OddsItem::query()
->where('version_id', $vid) ->where('version_id', $vid)
->select(['play_code', 'currency_code']) ->select(['provider_code', 'play_code', 'currency_code'])
->distinct() ->distinct()
->get(); ->get();
@@ -58,6 +58,7 @@ final class OddsStandardScopes
->orderBy('play_code') ->orderBy('play_code')
->get(['play_code', 'dimension']) ->get(['play_code', 'dimension'])
->map(fn (PlayType $pt) => (object) [ ->map(fn (PlayType $pt) => (object) [
'provider_code' => 'GLOBAL',
'play_code' => $pt->play_code, 'play_code' => $pt->play_code,
'dimension' => $pt->dimension, 'dimension' => $pt->dimension,
'currency_code' => $currencyCode, 'currency_code' => $currencyCode,
@@ -67,14 +68,16 @@ final class OddsStandardScopes
// 按维度分组,获取每个维度的佣金率 // 按维度分组,获取每个维度的佣金率
$dimensionCommissions = []; $dimensionCommissions = [];
foreach ($pairs as $pair) { foreach ($pairs as $pair) {
$providerCode = strtoupper((string) ($pair->provider_code ?? 'GLOBAL'));
$dimension = $pair->dimension ?? null; $dimension = $pair->dimension ?? null;
$currencyCode = strtoupper((string) $pair->currency_code); $currencyCode = strtoupper((string) $pair->currency_code);
$key = $dimension.'|'.$currencyCode; $key = $providerCode.'|'.$dimension.'|'.$currencyCode;
if (!isset($dimensionCommissions[$key])) { if (!isset($dimensionCommissions[$key])) {
// 从现有记录中获取该维度的佣金率 // 从现有记录中获取该维度的佣金率
$anchor = OddsItem::query() $anchor = OddsItem::query()
->where('version_id', $vid) ->where('version_id', $vid)
->where('provider_code', $providerCode)
->where('currency_code', $currencyCode) ->where('currency_code', $currencyCode)
->where('dimension', $dimension) ->where('dimension', $dimension)
->orderByDesc('id') ->orderByDesc('id')
@@ -88,10 +91,11 @@ final class OddsStandardScopes
} }
foreach ($pairs as $pair) { foreach ($pairs as $pair) {
$providerCode = strtoupper((string) ($pair->provider_code ?? 'GLOBAL'));
$playCode = (string) $pair->play_code; $playCode = (string) $pair->play_code;
$dimension = $pair->dimension ?? null; $dimension = $pair->dimension ?? null;
$currencyCode = strtoupper((string) $pair->currency_code); $currencyCode = strtoupper((string) $pair->currency_code);
$key = $dimension.'|'.$currencyCode; $key = $providerCode.'|'.$dimension.'|'.$currencyCode;
$rebate = $dimensionCommissions[$key]['rebate'] ?? 0; $rebate = $dimensionCommissions[$key]['rebate'] ?? 0;
$commission = $dimensionCommissions[$key]['commission'] ?? 0; $commission = $dimensionCommissions[$key]['commission'] ?? 0;
@@ -99,6 +103,7 @@ final class OddsStandardScopes
foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
$exists = OddsItem::query() $exists = OddsItem::query()
->where('version_id', $vid) ->where('version_id', $vid)
->where('provider_code', $providerCode)
->where('play_code', $playCode) ->where('play_code', $playCode)
->where('currency_code', $currencyCode) ->where('currency_code', $currencyCode)
->where('prize_scope', $scope) ->where('prize_scope', $scope)
@@ -109,6 +114,7 @@ final class OddsStandardScopes
OddsItem::query()->create([ OddsItem::query()->create([
'version_id' => $vid, 'version_id' => $vid,
'provider_code' => $providerCode,
'play_code' => $playCode, 'play_code' => $playCode,
'prize_scope' => $scope, 'prize_scope' => $scope,
'dimension' => $dimension, '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::prefix('admin/docs')->group(function (): void {
Route::get('integration-guide', function () { Route::get('integration-guide', function () {
$adminDocs = rtrim( $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\Currency;
use App\Models\OddsItem; use App\Models\OddsItem;
use App\Models\PlayType; use App\Models\PlayType;
use App\Models\BetProvider;
use App\Models\OddsVersion; use App\Models\OddsVersion;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Support\OddsStandardScopes; use App\Support\OddsStandardScopes;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use App\Services\Ticket\PlayCatalogResolver;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; 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) { foreach (PlayType::query()->orderBy('play_code')->get() as $pt) {
OddsItem::query()->create([ OddsItem::query()->create([
'version_id' => $version->id, 'version_id' => $version->id,
'provider_code' => 'GLOBAL',
'play_code' => $pt->play_code, 'play_code' => $pt->play_code,
'prize_scope' => 'default', 'prize_scope' => 'default',
'odds_value' => 19_500, '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 function oddsPutPayloadFromDetail(array $items): array
{ {
return collect($items)->map(fn (array $r) => [ return collect($items)->map(fn (array $r) => [
'provider_code' => $r['provider_code'] ?? 'GLOBAL',
'play_code' => $r['play_code'], 'play_code' => $r['play_code'],
'prize_scope' => $r['prize_scope'], 'prize_scope' => $r['prize_scope'],
'dimension' => $r['dimension'] ?? null,
'odds_value' => (int) $r['odds_value'], 'odds_value' => (int) $r['odds_value'],
'rebate_rate' => (float) $r['rebate_rate'], 'rebate_rate' => (float) $r['rebate_rate'],
'commission_rate' => (float) $r['commission_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']); 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 { test('admin play config draft publish flow', function (): void {
$token = mintConfigAdminToken(); $token = mintConfigAdminToken();
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail(); $active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();