$payload * @return array */ public function place(Player $player, array $payload): array { $currencyCode = strtoupper((string) $payload['currency_code']); $expectedVersions = $payload['expected_config_versions'] ?? null; if (is_array($expectedVersions)) { $expectedVersions = [ 'play_config_version_no' => (int) $expectedVersions['play_config_version_no'], 'odds_version_no' => (int) $expectedVersions['odds_version_no'], 'risk_cap_version_no' => (int) $expectedVersions['risk_cap_version_no'], ]; } else { $expectedVersions = null; } $placement = DB::transaction(function () use ( $player, $currencyCode, $payload, $expectedVersions ): array { $draw = Draw::query() ->where('draw_no', (string) $payload['draw_id']) ->lockForUpdate() ->first(); if ($draw === null) { throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); } if ($draw->status !== DrawStatus::Open->value) { throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); $evaluatedLines = []; $totalBet = 0; $totalRebate = 0; $totalActualDeduct = 0; $totalEstimatedPayout = 0; foreach ((array) $payload['lines'] as $line) { $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_type'], $resolved['play_config'], $resolved['odds_items'], ); $locks = array_map(fn (array $combo): array => [ 'number_4d' => $combo['number_4d'], 'amount' => $combo['estimated_payout'], ], $evaluated['combinations']); $this->riskPoolService->preview((int) $draw->id, $locks); $evaluatedLines[] = $evaluated; $rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount']; $totalBet += (int) $evaluated['total_bet_amount']; $totalRebate += $rebateAmount; $totalActualDeduct += (int) $evaluated['actual_deduct_amount']; $totalEstimatedPayout += (int) $evaluated['estimated_max_payout']; } $order = TicketOrder::query()->create([ 'order_no' => $this->newOrderNo(), 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => $currencyCode, 'total_bet_amount' => $totalBet, 'total_rebate_amount' => $totalRebate, 'total_actual_deduct' => $totalActualDeduct, 'total_estimated_payout' => $totalEstimatedPayout, 'status' => 'placed', 'submit_source' => 'h5', 'client_trace_id' => $payload['client_trace_id'] ?? null, ]); $balanceAfter = $this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order); foreach ($evaluatedLines as $evaluated) { $item = TicketItem::query()->create([ 'ticket_no' => $this->newTicketNo(), 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => $evaluated['original_number'], 'normalized_number' => $this->normalizedNumberForStorage($evaluated), 'play_code' => $evaluated['play_code'], 'dimension' => $evaluated['dimension'], 'digit_slot' => $evaluated['digit_slot'], 'bet_mode' => $evaluated['bet_mode'], 'unit_bet_amount' => $evaluated['unit_bet_amount'], 'total_bet_amount' => $evaluated['total_bet_amount'], 'rebate_rate_snapshot' => $evaluated['rebate_rate_snapshot'], 'commission_rate_snapshot' => $evaluated['commission_rate_snapshot'], 'actual_deduct_amount' => $evaluated['actual_deduct_amount'], 'odds_snapshot_json' => $evaluated['odds_snapshot_json'], 'rule_snapshot_json' => $evaluated['rule_snapshot_json'], 'combination_count' => $evaluated['combination_count'], 'estimated_max_payout' => $evaluated['estimated_max_payout'], 'risk_locked_amount' => 0, 'status' => 'success', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, ]); $locks = []; foreach ($evaluated['combinations'] as $combo) { TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => $combo['combination_no'], 'number_4d' => $combo['number_4d'], 'bet_amount' => $combo['bet_amount'], 'estimated_payout' => $combo['estimated_payout'], 'created_at' => now(), ]); $locks[] = [ 'number_4d' => $combo['number_4d'], 'amount' => $combo['estimated_payout'], ]; } $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks); $item->forceFill(['risk_locked_amount' => $lockedAmount])->save(); $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, $currencyCode); } return [ 'order' => $order, 'balance_after' => $balanceAfter, ]; }); $order = $placement['order']; $balanceAfter = $placement['balance_after']; $draw = Draw::query()->whereKey($order->draw_id)->firstOrFail(); return [ 'order_no' => $order->order_no, 'draw' => [ 'draw_id' => $draw->draw_no, 'status' => $draw->status, ], 'summary' => [ 'total_bet_amount' => (int) $order->total_bet_amount, 'total_rebate_amount' => (int) $order->total_rebate_amount, 'total_actual_deduct' => (int) $order->total_actual_deduct, 'total_estimated_payout' => (int) $order->total_estimated_payout, ], 'balance_after' => $balanceAfter, 'items' => TicketItem::query() ->where('order_id', $order->id) ->orderBy('id') ->get() ->map(fn (TicketItem $item) => [ 'ticket_no' => $item->ticket_no, 'play_code' => $item->play_code, 'number' => $item->original_number, 'total_bet_amount' => (int) $item->total_bet_amount, 'actual_deduct_amount' => (int) $item->actual_deduct_amount, 'estimated_max_payout' => (int) $item->estimated_max_payout, 'combination_count' => (int) $item->combination_count, ])->values()->all(), ]; } private function newOrderNo(): string { return 'TO'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } private function newTicketNo(): string { return 'TK'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } /** * ticket_items.normalized_number 现为 char(4),短维度玩法需要统一填充。 * * @param array $evaluated */ private function normalizedNumberForStorage(array $evaluated): string { $number = (string) $evaluated['normalized_number']; if (strlen($number) === 4) { return $number; } return str_pad($number, 4, '0', STR_PAD_LEFT); } }