feat(odds): 支持基于开注商的赔率区分与优先级处理
- 在赔率数据表中新增 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:
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'),
|
||||||
'/',
|
'/',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user