Files
lotteryLaravel/app/Services/Ticket/PlayRuleEngine.php
kang cec2fd7c4f
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(ticket): 修正 full_play/half_play/reverse 投注模式的金额计算逻辑
统一通过 $amountModePlayCode 映射选择类型到正确的金额模式:
- reverse / half_play / full_play 使用 ibox 模式(单注 × 组合数)
- full_cover 使用 mbox 模式(总金额平摊)
- straight 保持原有 play_code 规则
2026-07-15 12:02:10 +08:00

396 lines
15 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;
$selectionType = (string) ($line['selection_type'] ?? 'straight');
$amount = (int) $line['amount'];
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
$allowedSelectionTypes = match ((int) ($playConfig->dimension ?? 4)) {
4 => ['straight', 'reverse', 'full_cover', 'full_play', 'half_play'],
3 => ['straight', 'reverse', 'full_cover', 'full_play'],
default => ['straight'],
};
if (! in_array($selectionType, $allowedSelectionTypes, true)) {
throw new TicketOperationException('selection_type_not_allowed', ErrorCode::BetInvalidPlayInput->value);
}
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, $selectionType);
$combinationCount = count($combos);
if ($combinationCount < 1) {
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
}
if ($selectionType === 'full_cover' && $amount % $combinationCount !== 0) {
throw new TicketOperationException('full_cover_amount_not_divisible', ErrorCode::BetInvalidPlayInput->value);
}
// 种类金额模式:
// - full_cover全保→ 总金额平摊mbox
// - reverse / half_play / full_play → 单注 × 组合数ibox
// - straight → 沿用 play_code 自身规则
$amountModePlayCode = match ($selectionType) {
'full_cover' => 'mbox',
'full_play', 'reverse', 'half_play' => 'ibox',
default => $playCode,
};
$unitBetAmount = $this->resolveUnitBetAmount($amountModePlayCode, $amount, $combinationCount);
$totalBetAmount = $this->resolveTotalBetAmount($amountModePlayCode, $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' => $selectionType,
'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,
'selection_type' => $selectionType,
'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, string $selectionType = 'straight'): array
{
if (in_array($playCode, ['big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'four_any', 'four_top', 'four_lower'], true)) {
return $this->expandSelection(str_split($number), $selectionType);
}
if (str_starts_with($playCode, 'pos_3')) {
return collect($this->expandSelection(str_split($number), $selectionType))
->flatMap(fn (string $value) => $this->expandSuffix($value, 3))
->unique()->sort()->values()->all();
}
if (str_starts_with($playCode, 'pos_2')) {
return collect($this->expandSuffix($number, 2))->unique()->sort()->values()->all();
}
return match ($playCode) {
'straight' => [$number],
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
'roll' => $this->expandRoll($number),
'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),
};
}
/** @param list<string> $digits @return list<string> */
private function expandSelection(array $digits, string $selectionType): array
{
$original = implode('', $digits);
if ($selectionType === 'straight') return [$original];
if ($selectionType === 'reverse') return array_values(array_unique([$original, strrev($original)]));
$all = $this->uniquePermutations($original);
if ($selectionType === 'full_cover' || $selectionType === 'full_play') return $all;
if ($selectionType === 'half_play') {
if (count($digits) < 2) return $all;
$first = $digits[0];
$second = $digits[1];
if ($first === $second) return $all;
return array_values(array_filter($all, function (string $value) use ($first, $second): bool {
$firstIndex = array_search($first, str_split($value), true);
$secondIndex = array_search($second, str_split($value), true);
return $firstIndex !== false && $secondIndex !== false && $firstIndex < $secondIndex;
}));
}
throw new TicketOperationException('selection_type_invalid', ErrorCode::BetInvalidPlayInput->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,
};
}
}