Files
lotteryLaravel/app/Services/Ticket/PlayRuleEngine.php
kang c6c10f1397
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
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 中开注商字段,完善赔率快照信息
- 路由配置调整管理文档地址,无关改动细微修改
2026-07-09 10:52:05 +08:00

340 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Ticket;
use App\Models\OddsItem;
use App\Lottery\ErrorCode;
use App\Models\PlayConfigItem;
use Illuminate\Support\Collection;
use App\Exceptions\TicketOperationException;
final class PlayRuleEngine
{
public function __construct(
private readonly NumberNormalizer $normalizer,
) {}
/**
* @param array<string, mixed> $line
* @param Collection<int, OddsItem> $oddsItems
* @return array<string, mixed>
*/
public function evaluateLine(array $line, PlayConfigItem $playConfig, Collection $oddsItems): array
{
$playCode = (string) $line['play_code'];
$dimension = $line['dimension'] ?? null;
$digitSlot = $line['digit_slot'] ?? null;
$amount = (int) $line['amount'];
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
if ($amount < (int) $playConfig->min_bet_amount || $amount > (int) $playConfig->max_bet_amount) {
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
}
if (in_array($playCode, ['odd', 'even', 'digit_big', 'digit_small'], true) && ! is_string($dimension)) {
throw new TicketOperationException('dimension_required', ErrorCode::BetInvalidPlayInput->value);
}
if (in_array($playCode, ['digit_big', 'digit_small'], true) && ! is_int($digitSlot) && ! ctype_digit((string) $digitSlot)) {
throw new TicketOperationException('digit_slot_required', ErrorCode::BetInvalidPlayInput->value);
}
$digitSlotInt = $digitSlot === null ? null : (int) $digitSlot;
$combos = $this->expandToCombinations($playCode, $number, is_string($dimension) ? $dimension : null, $digitSlotInt);
$combinationCount = count($combos);
if ($combinationCount < 1) {
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
}
$unitBetAmount = $this->resolveUnitBetAmount($playCode, $amount, $combinationCount);
$totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount);
$dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig);
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
$rebateRate = (float) $primaryOdds->rebate_rate;
// 佣金按维度2D/3D/4D配置从 odds_items 中查找匹配维度的佣金率
$commissionRate = $this->pickCommissionByDimension($oddsItems, $dimensionInt);
$actualDeductAmount = max(0, (int) floor($totalBetAmount * (1 - $rebateRate)));
$maxOdds = $oddsItems->max(fn (OddsItem $row) => (int) $row->odds_value) ?? 0;
$estimatedPayoutPerCombo = (int) floor($unitBetAmount * ($maxOdds / 10000));
$estimatedMaxPayout = $estimatedPayoutPerCombo * $combinationCount;
return [
'original_number' => (string) $line['number'],
'normalized_number' => $number,
'play_code' => $playCode,
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig),
'digit_slot' => $digitSlotInt,
'bet_mode' => $playConfig->bet_mode,
'unit_bet_amount' => $unitBetAmount,
'total_bet_amount' => $totalBetAmount,
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
'commission_rate_snapshot' => number_format($commissionRate, 4, '.', ''),
'actual_deduct_amount' => $actualDeductAmount,
'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,
'commission_rate' => (string) $row->commission_rate,
])->values()->all(),
'rule_snapshot_json' => [
'play_code' => $playCode,
'dimension' => $dimension,
'digit_slot' => $digitSlotInt,
'combination_count' => $combinationCount,
'rounding_refund_amount' => $playCode === 'mbox'
? max(0, $amount - $totalBetAmount)
: 0,
],
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
return [
'combination_no' => $index + 1,
'number_4d' => $combo,
'bet_amount' => $unitBetAmount,
'estimated_payout' => $estimatedPayoutPerCombo,
];
})->all(),
];
}
/**
* @return list<string>
*/
private function expandToCombinations(string $playCode, string $number, ?string $dimension, ?int $digitSlot): array
{
return match ($playCode) {
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight' => [$number],
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
'roll' => $this->expandRoll($number),
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => $this->expandSuffix($number, 3),
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => $this->expandSuffix($number, 2),
'head' => $this->expandHeadTail(true),
'tail' => $this->expandHeadTail(false),
'odd' => $this->expandOddEven($dimension, true),
'even' => $this->expandOddEven($dimension, false),
'digit_big' => $this->expandDigitSize($dimension, $digitSlot, true),
'digit_small' => $this->expandDigitSize($dimension, $digitSlot, false),
default => throw new TicketOperationException('unsupported_play_expand', ErrorCode::BetPlayUnsupported->value),
};
}
/**
* @return list<string>
*/
private function uniquePermutations(string $digits): array
{
$results = [];
$this->permute(str_split($digits), 0, $results);
$values = array_values(array_unique($results));
sort($values);
return $values;
}
/**
* @param array<int, string> $chars
* @param array<int, string> $results
*/
private function permute(array $chars, int $index, array &$results): void
{
if ($index === count($chars) - 1) {
$results[] = implode('', $chars);
return;
}
$seen = [];
for ($i = $index; $i < count($chars); $i++) {
if (isset($seen[$chars[$i]])) {
continue;
}
$seen[$chars[$i]] = true;
[$chars[$index], $chars[$i]] = [$chars[$i], $chars[$index]];
$this->permute($chars, $index + 1, $results);
}
}
/**
* @return list<string>
*/
private function expandRoll(string $pattern): array
{
$results = [''];
foreach (str_split($pattern) as $char) {
$next = [];
if ($char === 'R') {
foreach ($results as $prefix) {
for ($i = 0; $i <= 9; $i++) {
$next[] = $prefix.$i;
}
}
} else {
foreach ($results as $prefix) {
$next[] = $prefix.$char;
}
}
$results = $next;
}
return $results;
}
/**
* @return list<string>
*/
private function expandSuffix(string $suffix, int $length): array
{
$results = [];
$prefixLength = 4 - $length;
$max = 10 ** $prefixLength;
for ($i = 0; $i < $max; $i++) {
$results[] = str_pad((string) $i, $prefixLength, '0', STR_PAD_LEFT).$suffix;
}
return $results;
}
/**
* @return list<string>
*/
private function expandHeadTail(bool $isHead): array
{
$results = [];
$start = $isHead ? 5 : 0;
$end = $isHead ? 9 : 4;
for ($first = $start; $first <= $end; $first++) {
for ($rest = 0; $rest < 1000; $rest++) {
$results[] = $first.str_pad((string) $rest, 3, '0', STR_PAD_LEFT);
}
}
return $results;
}
/**
* @return list<string>
*/
private function expandOddEven(?string $dimension, bool $odd): array
{
$digits = $odd ? ['1', '3', '5', '7', '9'] : ['0', '2', '4', '6', '8'];
$suffixLength = match ($dimension) {
'D2' => 2,
'D3' => 3,
default => 4,
};
$prefixLength = 4 - $suffixLength;
$prefixMax = 10 ** max($prefixLength, 0);
$middleLength = max($suffixLength - 1, 0);
$middleMax = 10 ** $middleLength;
$results = [];
for ($prefix = 0; $prefix < $prefixMax; $prefix++) {
for ($mid = 0; $mid < $middleMax; $mid++) {
foreach ($digits as $last) {
$results[] = str_pad((string) $prefix, $prefixLength, '0', STR_PAD_LEFT)
.str_pad((string) $mid, $middleLength, '0', STR_PAD_LEFT)
.$last;
}
}
}
return $results;
}
/**
* @return list<string>
*/
private function expandDigitSize(?string $dimension, ?int $digitSlot, bool $isBig): array
{
if ($digitSlot === null) {
throw new TicketOperationException('digit_slot_missing', ErrorCode::BetInvalidPlayInput->value);
}
$validSlots = match ($dimension) {
'D2' => [2, 3],
'D3' => [1, 2, 3],
default => [0, 1, 2, 3],
};
if (! in_array($digitSlot, $validSlots, true)) {
throw new TicketOperationException('digit_slot_invalid', ErrorCode::BetInvalidPlayInput->value);
}
$targetDigits = $isBig ? ['5', '6', '7', '8', '9'] : ['0', '1', '2', '3', '4'];
$results = [];
for ($i = 0; $i < 10000; $i++) {
$number = str_pad((string) $i, 4, '0', STR_PAD_LEFT);
if (in_array($number[$digitSlot], $targetDigits, true)) {
$results[] = $number;
}
}
return $results;
}
private function resolveUnitBetAmount(string $playCode, int $amount, int $combinationCount): int
{
return match ($playCode) {
'mbox' => max(0, intdiv($amount, $combinationCount)),
default => $amount,
};
}
private function resolveTotalBetAmount(string $playCode, int $rawAmount, int $unitBetAmount, int $combinationCount): int
{
return match ($playCode) {
'ibox', 'roll' => $rawAmount * $combinationCount,
'mbox' => $unitBetAmount * $combinationCount,
default => $rawAmount,
};
}
/**
* @param Collection<int, OddsItem> $oddsItems
*/
private function pickPrimaryOdds(Collection $oddsItems): OddsItem
{
foreach (['first', 'default', 'second', 'third', 'starter', 'consolation'] as $scope) {
$hit = $oddsItems->firstWhere('prize_scope', $scope);
if ($hit !== null) {
return $hit;
}
}
return $oddsItems->firstOrFail();
}
/**
* 按维度2D/3D/4D获取佣金率
*
* @param Collection<int, OddsItem> $oddsItems
*/
private function pickCommissionByDimension(Collection $oddsItems, ?int $dimension): float
{
if ($dimension === null) {
// 如果维度为空,返回第一个 odds item 的佣金率(向后兼容)
return (float) ($oddsItems->first()?->commission_rate ?? 0);
}
// 查找匹配维度的 odds item
$dimensionItem = $oddsItems->firstWhere('dimension', $dimension);
if ($dimensionItem !== null) {
return (float) $dimensionItem->commission_rate;
}
// 如果没有找到匹配维度的,返回第一个的佣金率(向后兼容)
return (float) ($oddsItems->first()?->commission_rate ?? 0);
}
private function toDimensionInt(?string $dimension, PlayConfigItem $playConfig): ?int
{
return match ($dimension) {
'D2' => 2,
'D3' => 3,
'D4' => 4,
default => $playConfig->dimension === null ? null : (int) $playConfig->dimension,
};
}
}