$line * @param Collection $oddsItems * @return array */ 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 */ 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 */ 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 $chars * @param array $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 */ 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 */ 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 */ 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 */ 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 */ 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 $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 $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, }; } }