Files
lotteryLaravel/app/Services/Ticket/PlayRuleEngine.php

339 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) => [
'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,
};
}
}