From 058f596f34fe6766d84f3ba405d2a139c7cd83fc Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 11:52:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E4=BB=A5=E6=94=AF=E6=8C=81=E6=8A=95?= =?UTF-8?q?=E6=B3=A8=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=A1=AB=E5=85=85=E5=99=A8=E4=BB=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=8E=A9=E6=B3=95=E5=92=8C=E8=B5=94=E7=8E=87=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E6=89=A9=E5=B1=95=20API=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E9=A3=8E=E9=99=A9=E6=B1=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Exceptions/TicketOperationException.php | 20 ++ .../Risk/AdminRiskPoolIndexController.php | 74 +++++ .../AdminRiskPoolLockLogIndexController.php | 70 ++++ .../Risk/AdminRiskPoolShowController.php | 89 +++++ .../Api/V1/Ticket/TicketPlaceController.php | 37 +++ .../Api/V1/Ticket/TicketPreviewController.php | 34 ++ .../Requests/Ticket/TicketPlaceRequest.php | 19 ++ .../Requests/Ticket/TicketPreviewRequest.php | 31 ++ app/Lottery/ErrorCode.php | 20 ++ app/Models/RiskPool.php | 37 +++ app/Models/RiskPoolLockLog.php | 42 +++ app/Models/TicketCombination.php | 37 +++ app/Models/TicketItem.php | 84 +++++ app/Models/TicketOrder.php | 52 +++ app/Services/Ticket/NumberNormalizer.php | 59 ++++ app/Services/Ticket/PlayCatalogResolver.php | 159 +++++++++ app/Services/Ticket/PlayRuleEngine.php | 312 ++++++++++++++++++ app/Services/Ticket/RiskPoolService.php | 159 +++++++++ .../Ticket/TicketPlacementService.php | 213 ++++++++++++ app/Services/Ticket/TicketPreviewService.php | 102 ++++++ app/Services/Ticket/TicketWalletService.php | 72 ++++ .../seeders/OperationalConfigV1Seeder.php | 163 +++++---- database/seeders/PlayTypeSeeder.php | 130 ++++---- lang/en/wallet.php | 9 + lang/ne/wallet.php | 9 + lang/zh/wallet.php | 9 + routes/api.php | 20 ++ tests/Feature/AdminRiskPoolApiTest.php | 133 ++++++++ tests/Feature/TicketBettingApiTest.php | 227 +++++++++++++ 29 files changed, 2300 insertions(+), 122 deletions(-) create mode 100644 app/Exceptions/TicketOperationException.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php create mode 100644 app/Http/Controllers/Api/V1/Ticket/TicketPlaceController.php create mode 100644 app/Http/Controllers/Api/V1/Ticket/TicketPreviewController.php create mode 100644 app/Http/Requests/Ticket/TicketPlaceRequest.php create mode 100644 app/Http/Requests/Ticket/TicketPreviewRequest.php create mode 100644 app/Models/RiskPool.php create mode 100644 app/Models/RiskPoolLockLog.php create mode 100644 app/Models/TicketCombination.php create mode 100644 app/Models/TicketItem.php create mode 100644 app/Models/TicketOrder.php create mode 100644 app/Services/Ticket/NumberNormalizer.php create mode 100644 app/Services/Ticket/PlayCatalogResolver.php create mode 100644 app/Services/Ticket/PlayRuleEngine.php create mode 100644 app/Services/Ticket/RiskPoolService.php create mode 100644 app/Services/Ticket/TicketPlacementService.php create mode 100644 app/Services/Ticket/TicketPreviewService.php create mode 100644 app/Services/Ticket/TicketWalletService.php create mode 100644 tests/Feature/AdminRiskPoolApiTest.php create mode 100644 tests/Feature/TicketBettingApiTest.php diff --git a/app/Exceptions/TicketOperationException.php b/app/Exceptions/TicketOperationException.php new file mode 100644 index 0000000..e0285e8 --- /dev/null +++ b/app/Exceptions/TicketOperationException.php @@ -0,0 +1,20 @@ +integer('per_page', 25), 1), 100); + $soldOutOnly = $request->boolean('sold_out_only'); + $sort = trim((string) $request->query('sort', 'usage_desc')); + + $q = RiskPool::query()->where('draw_id', $draw->id); + + if ($soldOutOnly) { + $q->where('sold_out_status', 1); + } + + match ($sort) { + 'locked_desc' => $q->orderByDesc('locked_amount')->orderBy('normalized_number'), + 'remaining_asc' => $q->orderBy('remaining_amount')->orderBy('normalized_number'), + 'number_asc' => $q->orderBy('normalized_number'), + default => $q->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') + ->orderByDesc('locked_amount') + ->orderBy('normalized_number'), + }; + + /** @var LengthAwarePaginator $paginator */ + $paginator = $q->paginate($perPage); + + return ApiResponse::success([ + 'draw_id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'items' => collect($paginator->items())->map(fn (RiskPool $row) => $this->row($row))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(RiskPool $pool): array + { + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + + return [ + 'normalized_number' => $pool->normalized_number, + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => (int) $pool->remaining_amount, + 'sold_out_status' => (int) $pool->sold_out_status, + 'is_sold_out' => (int) $pool->sold_out_status === 1, + 'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null, + 'version' => (int) $pool->version, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php new file mode 100644 index 0000000..875db77 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php @@ -0,0 +1,70 @@ +integer('per_page', 25), 1), 100); + $action = trim((string) $request->query('action_type', '')); + $number = trim((string) $request->query('normalized_number', '')); + + $q = RiskPoolLockLog::query() + ->where('draw_id', $draw->id) + ->with(['ticketItem:id,ticket_no,play_code,player_id']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + + if ($action !== '' && in_array($action, ['lock', 'release'], true)) { + $q->where('action_type', $action); + } + + if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) { + $q->where('normalized_number', $number); + } + + /** @var LengthAwarePaginator $paginator */ + $paginator = $q->paginate($perPage); + + return ApiResponse::success([ + 'draw_id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'items' => collect($paginator->items())->map(fn (RiskPoolLockLog $log) => $this->row($log))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(RiskPoolLockLog $log): array + { + return [ + 'id' => (int) $log->id, + 'normalized_number' => $log->normalized_number, + 'action_type' => $log->action_type, + 'amount' => (int) $log->amount, + 'source_reason' => $log->source_reason, + 'ticket_item_id' => $log->ticket_item_id, + 'ticket_no' => $log->ticketItem?->ticket_no, + 'play_code' => $log->ticketItem?->play_code, + 'player_id' => $log->ticketItem?->player_id, + 'created_at' => $log->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php new file mode 100644 index 0000000..43a47c6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php @@ -0,0 +1,89 @@ +value, null, 422); + } + + $pool = RiskPool::query() + ->where('draw_id', $draw->id) + ->where('normalized_number', $number_4d) + ->first(); + + if ($pool === null) { + return ApiResponse::error('该期尚无此号码的风险池记录', ErrorCode::NotFound->value, null, 404); + } + + $perPage = min(max((int) $request->integer('per_page', 20), 1), 100); + + /** @var LengthAwarePaginator $paginator */ + $paginator = RiskPoolLockLog::query() + ->where('draw_id', $draw->id) + ->where('normalized_number', $number_4d) + ->with(['ticketItem:id,ticket_no,play_code,player_id']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate($perPage); + + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + + return ApiResponse::success([ + 'draw_id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'pool' => [ + 'normalized_number' => $pool->normalized_number, + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => (int) $pool->remaining_amount, + 'sold_out_status' => (int) $pool->sold_out_status, + 'is_sold_out' => (int) $pool->sold_out_status === 1, + 'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null, + 'version' => (int) $pool->version, + ], + 'logs' => [ + 'items' => collect($paginator->items())->map(fn (RiskPoolLockLog $log) => $this->logRow($log))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ], + ]); + } + + /** @return array */ + private function logRow(RiskPoolLockLog $log): array + { + return [ + 'id' => (int) $log->id, + 'action_type' => $log->action_type, + 'amount' => (int) $log->amount, + 'source_reason' => $log->source_reason, + 'ticket_item_id' => $log->ticket_item_id, + 'ticket_no' => $log->ticketItem?->ticket_no, + 'play_code' => $log->ticketItem?->play_code, + 'player_id' => $log->ticketItem?->player_id, + 'created_at' => $log->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketPlaceController.php b/app/Http/Controllers/Api/V1/Ticket/TicketPlaceController.php new file mode 100644 index 0000000..267af70 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Ticket/TicketPlaceController.php @@ -0,0 +1,37 @@ +lotteryPlayer(); + abort_if($player === null, 500, 'lottery_player missing'); + + try { + $data = $this->placementService->place($player, $request->validated()); + } catch (TicketOperationException $e) { + return ApiResponse::error( + LotteryMessage::wallet($request, $e->lotteryCode), + $e->lotteryCode, + $e->payload, + $e->httpStatus, + ); + } + + return ApiResponse::success($data); + } +} diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketPreviewController.php b/app/Http/Controllers/Api/V1/Ticket/TicketPreviewController.php new file mode 100644 index 0000000..ed89cc1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Ticket/TicketPreviewController.php @@ -0,0 +1,34 @@ +previewService->preview($request->validated()); + } catch (TicketOperationException $e) { + return ApiResponse::error( + LotteryMessage::wallet($request, $e->lotteryCode), + $e->lotteryCode, + $e->payload, + $e->httpStatus, + ); + } + + return ApiResponse::success($data); + } +} diff --git a/app/Http/Requests/Ticket/TicketPlaceRequest.php b/app/Http/Requests/Ticket/TicketPlaceRequest.php new file mode 100644 index 0000000..531eb07 --- /dev/null +++ b/app/Http/Requests/Ticket/TicketPlaceRequest.php @@ -0,0 +1,19 @@ + + */ + public function rules(): array + { + return array_merge(parent::rules(), [ + 'expected_config_versions' => ['nullable', 'array'], + 'expected_config_versions.play_config_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'], + 'expected_config_versions.odds_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'], + 'expected_config_versions.risk_cap_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'], + ]); + } +} diff --git a/app/Http/Requests/Ticket/TicketPreviewRequest.php b/app/Http/Requests/Ticket/TicketPreviewRequest.php new file mode 100644 index 0000000..128293d --- /dev/null +++ b/app/Http/Requests/Ticket/TicketPreviewRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules(): array + { + return [ + 'draw_id' => ['required', 'string', 'max:32'], + 'currency_code' => ['required', 'string', 'max:16'], + 'client_trace_id' => ['nullable', 'string', 'max:64'], + 'lines' => ['required', 'array', 'min:1', 'max:100'], + 'lines.*.number' => ['required', 'string', 'max:32'], + 'lines.*.play_code' => ['required', 'string', 'max:32'], + 'lines.*.amount' => ['required', 'integer', 'min:1'], + 'lines.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'], + 'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'], + ]; + } +} diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index df36120..f14b244 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -60,6 +60,26 @@ enum ErrorCode: int /** PRD:下注语境余额不足(可与 1001 同语义) */ case BetInsufficientBalance = 2003; + /** 下注号码格式或长度不合法 */ + case BetInvalidNumber = 2004; + + /** 下注玩法不支持或参数缺失 */ + case BetInvalidPlayInput = 2005; + + /** 当前期号不存在或不是可下注期号 */ + case BetInvalidDraw = 2006; + + /** 当前玩法不存在或暂不支持下注 */ + case BetPlayUnsupported = 2007; + + /** + * 预览后玩法/赔率/封顶等配置版本已切换,需重新预览再提交。 + */ + case BetConfigStale = 2008; + + /** 风险池额度不足,号码已售罄 */ + case RiskPoolSoldOut = 4001; + /** 配置版本不是草稿,无法整表替换 items 或发布 */ case ConfigVersionNotDraft = 2101; diff --git a/app/Models/RiskPool.php b/app/Models/RiskPool.php new file mode 100644 index 0000000..7af2bd0 --- /dev/null +++ b/app/Models/RiskPool.php @@ -0,0 +1,37 @@ + 'integer', + 'total_cap_amount' => 'integer', + 'locked_amount' => 'integer', + 'remaining_amount' => 'integer', + 'sold_out_status' => 'integer', + 'version' => 'integer', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } +} diff --git a/app/Models/RiskPoolLockLog.php b/app/Models/RiskPoolLockLog.php new file mode 100644 index 0000000..8a02090 --- /dev/null +++ b/app/Models/RiskPoolLockLog.php @@ -0,0 +1,42 @@ + 'integer', + 'ticket_item_id' => 'integer', + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function ticketItem(): BelongsTo + { + return $this->belongsTo(TicketItem::class, 'ticket_item_id'); + } +} diff --git a/app/Models/TicketCombination.php b/app/Models/TicketCombination.php new file mode 100644 index 0000000..29d57b9 --- /dev/null +++ b/app/Models/TicketCombination.php @@ -0,0 +1,37 @@ + 'integer', + 'combination_no' => 'integer', + 'bet_amount' => 'integer', + 'estimated_payout' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function item(): BelongsTo + { + return $this->belongsTo(TicketItem::class, 'ticket_item_id'); + } +} diff --git a/app/Models/TicketItem.php b/app/Models/TicketItem.php new file mode 100644 index 0000000..d7cd5c3 --- /dev/null +++ b/app/Models/TicketItem.php @@ -0,0 +1,84 @@ + 'integer', + 'player_id' => 'integer', + 'draw_id' => 'integer', + 'dimension' => 'integer', + 'digit_slot' => 'integer', + 'unit_bet_amount' => 'integer', + 'total_bet_amount' => 'integer', + 'rebate_rate_snapshot' => 'decimal:4', + 'commission_rate_snapshot' => 'decimal:4', + 'actual_deduct_amount' => 'integer', + 'odds_snapshot_json' => 'json', + 'rule_snapshot_json' => 'json', + 'combination_count' => 'integer', + 'estimated_max_payout' => 'integer', + 'risk_locked_amount' => 'integer', + 'win_amount' => 'integer', + 'jackpot_win_amount' => 'integer', + 'settled_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(TicketOrder::class, 'order_id'); + } + + public function player(): BelongsTo + { + return $this->belongsTo(Player::class); + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function combinations(): HasMany + { + return $this->hasMany(TicketCombination::class, 'ticket_item_id'); + } +} diff --git a/app/Models/TicketOrder.php b/app/Models/TicketOrder.php new file mode 100644 index 0000000..c6bba10 --- /dev/null +++ b/app/Models/TicketOrder.php @@ -0,0 +1,52 @@ + 'integer', + 'draw_id' => 'integer', + 'total_bet_amount' => 'integer', + 'total_rebate_amount' => 'integer', + 'total_actual_deduct' => 'integer', + 'total_estimated_payout' => 'integer', + ]; + } + + public function player(): BelongsTo + { + return $this->belongsTo(Player::class); + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function items(): HasMany + { + return $this->hasMany(TicketItem::class, 'order_id'); + } +} diff --git a/app/Services/Ticket/NumberNormalizer.php b/app/Services/Ticket/NumberNormalizer.php new file mode 100644 index 0000000..c84ae4e --- /dev/null +++ b/app/Services/Ticket/NumberNormalizer.php @@ -0,0 +1,59 @@ + $this->normalizeRoll($trimmed), + default => $this->normalizeDigits($playCode, $trimmed, $dimension), + }; + } + + private function normalizeRoll(string $value): string + { + if (! preg_match('/^[0-9R]{4}$/', $value)) { + throw new TicketOperationException('invalid_roll_number', ErrorCode::BetInvalidNumber->value); + } + + if (! str_contains($value, 'R')) { + throw new TicketOperationException('roll_requires_r', ErrorCode::BetInvalidPlayInput->value); + } + + return $value; + } + + private function normalizeDigits(string $playCode, string $value, ?string $dimension = null): string + { + if (! preg_match('/^[0-9]+$/', $value)) { + throw new TicketOperationException('invalid_number', ErrorCode::BetInvalidNumber->value); + } + + $length = strlen($value); + $expected = match ($playCode) { + 'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight', 'box', 'ibox', 'mbox' => 4, + 'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => 3, + 'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => 2, + 'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => match ($dimension) { + 'D2' => 1, + 'D3' => 1, + 'D4', null => 1, + default => 1, + }, + default => null, + }; + + if ($expected !== null && $length !== $expected) { + throw new TicketOperationException('invalid_number_length', ErrorCode::BetInvalidNumber->value); + } + + return $value; + } +} diff --git a/app/Services/Ticket/PlayCatalogResolver.php b/app/Services/Ticket/PlayCatalogResolver.php new file mode 100644 index 0000000..1702a1e --- /dev/null +++ b/app/Services/Ticket/PlayCatalogResolver.php @@ -0,0 +1,159 @@ +where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->firstOrFail(); + + $oddsV = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->firstOrFail(); + + $riskV = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->firstOrFail(); + + return [ + 'play_config_version_no' => (int) $playV->version_no, + 'odds_version_no' => (int) $oddsV->version_no, + 'risk_cap_version_no' => (int) $riskV->version_no, + ]; + } + + /** + * 下注事务内:按固定顺序锁住当前生效的三套配置版本,与后台切版互斥;可选与预览戳比对。 + * + * @param array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}|null $expectedFromPreview + */ + public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): void + { + $playV = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->lockForUpdate() + ->firstOrFail(); + + $oddsV = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->lockForUpdate() + ->firstOrFail(); + + $riskV = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->orderBy('id') + ->lockForUpdate() + ->firstOrFail(); + + if ($expectedFromPreview !== null) { + if ((int) $playV->version_no !== (int) $expectedFromPreview['play_config_version_no'] + || (int) $oddsV->version_no !== (int) $expectedFromPreview['odds_version_no'] + || (int) $riskV->version_no !== (int) $expectedFromPreview['risk_cap_version_no']) { + throw new TicketOperationException('config_version_stale', ErrorCode::BetConfigStale->value); + } + } + } + + /** + * @return array{play_type: PlayType, play_config: PlayConfigItem, odds_items: Collection} + */ + public function resolve(string $playCode, string $currencyCode): array + { + $playType = PlayType::query()->where('play_code', $playCode)->first(); + if ($playType === null) { + throw new TicketOperationException('play_not_found', ErrorCode::BetPlayUnsupported->value); + } + if (! $playType->is_enabled) { + throw new TicketOperationException('play_master_disabled', ErrorCode::PlayModeClosed->value); + } + + $playVersion = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $playConfig = PlayConfigItem::query() + ->where('version_id', $playVersion->id) + ->where('play_code', $playCode) + ->first(); + + if ($playConfig === null || ! $playConfig->is_enabled) { + throw new TicketOperationException('play_config_disabled', ErrorCode::PlayModeClosed->value); + } + + $oddsVersion = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $oddsItems = OddsItem::query() + ->where('version_id', $oddsVersion->id) + ->where('play_code', $playCode) + ->where('currency_code', strtoupper($currencyCode)) + ->get(); + + if ($oddsItems->isEmpty()) { + throw new TicketOperationException('odds_missing', ErrorCode::BetPlayUnsupported->value); + } + + return [ + 'play_type' => $playType, + 'play_config' => $playConfig, + 'odds_items' => $oddsItems, + ]; + } + + public function resolveCapAmount(int $drawId, string $number4d): int + { + $riskVersion = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $specific = RiskCapItem::query() + ->where('version_id', $riskVersion->id) + ->where('draw_id', $drawId) + ->where('normalized_number', $number4d) + ->orderByDesc('id') + ->first(); + + if ($specific !== null) { + return (int) $specific->cap_amount; + } + + $generic = RiskCapItem::query() + ->where('version_id', $riskVersion->id) + ->whereNull('draw_id') + ->where('normalized_number', $number4d) + ->orderByDesc('id') + ->first(); + + if ($generic !== null) { + return (int) $generic->cap_amount; + } + + return 50_000_000_000; + } +} diff --git a/app/Services/Ticket/PlayRuleEngine.php b/app/Services/Ticket/PlayRuleEngine.php new file mode 100644 index 0000000..c5d0622 --- /dev/null +++ b/app/Services/Ticket/PlayRuleEngine.php @@ -0,0 +1,312 @@ + $line + * @param Collection $oddsItems + * @return array + */ + public function evaluateLine(array $line, PlayType $playType, 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); + $primaryOdds = $this->pickPrimaryOdds($oddsItems); + $rebateRate = (float) $primaryOdds->rebate_rate; + $commissionRate = (float) $primaryOdds->commission_rate; + $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, $playType), + 'digit_slot' => $digitSlotInt, + 'bet_mode' => $playType->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, + ], + '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(); + } + + private function toDimensionInt(?string $dimension, PlayType $playType): ?int + { + return match ($dimension) { + 'D2' => 2, + 'D3' => 3, + 'D4' => 4, + default => $playType->dimension === null ? null : (int) $playType->dimension, + }; + } +} diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php new file mode 100644 index 0000000..791d5dd --- /dev/null +++ b/app/Services/Ticket/RiskPoolService.php @@ -0,0 +1,159 @@ + $locks + * @return list + */ + public function preview(int $drawId, array $locks): array + { + $rows = []; + foreach ($locks as $lock) { + $pool = $this->firstOrMakePool($drawId, $lock['number_4d']); + $remaining = (int) $pool->remaining_amount; + if ($remaining < (int) $lock['amount']) { + throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); + } + + $usage = (int) $pool->total_cap_amount > 0 + ? ((int) $pool->locked_amount + (int) $lock['amount']) / (int) $pool->total_cap_amount + : 1; + + $rows[] = [ + 'number_4d' => $lock['number_4d'], + 'amount' => (int) $lock['amount'], + 'warning' => $usage >= 0.8, + ]; + } + + return $rows; + } + + /** + * @param list $locks + */ + public function acquire(int $drawId, ?TicketItem $ticketItem, array $locks): int + { + $total = 0; + foreach ($locks as $lock) { + $pool = RiskPool::query() + ->where('draw_id', $drawId) + ->where('normalized_number', $lock['number_4d']) + ->lockForUpdate() + ->first(); + + if ($pool === null) { + $pool = $this->createPool($drawId, $lock['number_4d']); + $pool = RiskPool::query() + ->where('draw_id', $drawId) + ->where('normalized_number', $lock['number_4d']) + ->lockForUpdate() + ->firstOrFail(); + } + + $amount = (int) $lock['amount']; + if ((int) $pool->remaining_amount < $amount) { + throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); + } + + $pool->forceFill([ + 'locked_amount' => (int) $pool->locked_amount + $amount, + 'remaining_amount' => (int) $pool->remaining_amount - $amount, + 'sold_out_status' => ((int) $pool->remaining_amount - $amount) <= 0 ? 1 : 0, + 'version' => (int) $pool->version + 1, + ])->save(); + + RiskPoolLockLog::query()->create([ + 'draw_id' => $drawId, + 'normalized_number' => $lock['number_4d'], + 'ticket_item_id' => $ticketItem?->id, + 'action_type' => 'lock', + 'amount' => $amount, + 'source_reason' => 'ticket_place', + 'created_at' => now(), + ]); + + $total += $amount; + } + + return $total; + } + + /** + * @param list $locks + */ + public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void + { + foreach ($locks as $lock) { + $pool = RiskPool::query() + ->where('draw_id', $drawId) + ->where('normalized_number', $lock['number_4d']) + ->lockForUpdate() + ->first(); + + if ($pool === null) { + continue; + } + + $amount = min((int) $lock['amount'], (int) $pool->locked_amount); + $pool->forceFill([ + 'locked_amount' => (int) $pool->locked_amount - $amount, + 'remaining_amount' => (int) $pool->remaining_amount + $amount, + 'sold_out_status' => 0, + 'version' => (int) $pool->version + 1, + ])->save(); + + RiskPoolLockLog::query()->create([ + 'draw_id' => $drawId, + 'normalized_number' => $lock['number_4d'], + 'ticket_item_id' => $ticketItem?->id, + 'action_type' => 'release', + 'amount' => $amount, + 'source_reason' => 'ticket_rollback', + 'created_at' => now(), + ]); + } + } + + private function firstOrMakePool(int $drawId, string $number4d): RiskPool + { + return RiskPool::query()->firstOrCreate( + ['draw_id' => $drawId, 'normalized_number' => $number4d], + [ + 'total_cap_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d), + 'locked_amount' => 0, + 'remaining_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d), + 'sold_out_status' => 0, + 'version' => 0, + ], + ); + } + + private function createPool(int $drawId, string $number4d): RiskPool + { + $cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d); + + return RiskPool::query()->create([ + 'draw_id' => $drawId, + 'normalized_number' => $number4d, + 'total_cap_amount' => $cap, + 'locked_amount' => 0, + 'remaining_amount' => $cap, + 'sold_out_status' => 0, + 'version' => 0, + ]); + } +} diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php new file mode 100644 index 0000000..ef5f3a7 --- /dev/null +++ b/app/Services/Ticket/TicketPlacementService.php @@ -0,0 +1,213 @@ + $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; + } + + $order = DB::transaction(function () use ( + $player, + $currencyCode, + $payload, + $expectedVersions + ): TicketOrder { + $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, + ]); + + $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(); + } + + return $order; + }); + + $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, + ], + '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); + } +} diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php new file mode 100644 index 0000000..dd9c357 --- /dev/null +++ b/app/Services/Ticket/TicketPreviewService.php @@ -0,0 +1,102 @@ + $payload + * @return array + */ + public function preview(array $payload): array + { + $draw = Draw::query()->where('draw_no', (string) $payload['draw_id'])->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); + } + + $currencyCode = strtoupper((string) $payload['currency_code']); + $lines = []; + $totalBet = 0; + $totalRebate = 0; + $totalActualDeduct = 0; + $totalEstimatedPayout = 0; + $warningRows = []; + + foreach ((array) $payload['lines'] as $index => $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']); + $riskPreview = $this->riskPoolService->preview((int) $draw->id, $locks); + foreach ($riskPreview as $riskRow) { + if ($riskRow['warning']) { + $warningRows[] = [ + 'number_4d' => $riskRow['number_4d'], + 'message' => '该号码赔付池已使用 80% 以上,可能即将售罄', + ]; + } + } + + $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']; + + $lines[] = [ + 'client_line_no' => $index + 1, + 'number' => $evaluated['original_number'], + 'play_code' => $evaluated['play_code'], + 'normalized_number' => $evaluated['normalized_number'], + 'combination_count' => $evaluated['combination_count'], + 'total_bet_amount' => $evaluated['total_bet_amount'], + 'rebate_rate' => $evaluated['rebate_rate_snapshot'], + 'rebate_amount' => $rebateAmount, + 'actual_deduct_amount' => $evaluated['actual_deduct_amount'], + 'estimated_max_payout' => $evaluated['estimated_max_payout'], + 'risk_status' => 'ok', + 'warnings' => [], + 'rule_snapshot_json' => $evaluated['rule_snapshot_json'], + ]; + } + + return [ + 'draw' => [ + 'draw_id' => $draw->draw_no, + 'status' => $draw->status, + ], + 'config_versions' => $this->catalogResolver->currentActiveVersionStamp(), + 'summary' => [ + 'total_bet_amount' => $totalBet, + 'total_rebate_amount' => $totalRebate, + 'total_actual_deduct' => $totalActualDeduct, + 'total_estimated_payout' => $totalEstimatedPayout, + ], + 'lines' => $lines, + 'warnings' => $warningRows, + ]; + } +} diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php new file mode 100644 index 0000000..a2a3975 --- /dev/null +++ b/app/Services/Ticket/TicketWalletService.php @@ -0,0 +1,72 @@ +where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', strtoupper($currencyCode)) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => strtoupper($currencyCode), + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + } + + $before = (int) $wallet->balance; + if ($before < $amountMinor) { + throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); + } + + $after = $before - $amountMinor; + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'bet_deduct', + 'biz_no' => $order->order_no, + 'direction' => self::TXN_DIR_OUT, + 'amount' => $amountMinor, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => $order->client_trace_id, + 'remark' => null, + ]); + } + + private function newTxnNo(): string + { + return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + } +} diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php index 87dddf2..4f2d0dc 100644 --- a/database/seeders/OperationalConfigV1Seeder.php +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -16,80 +16,117 @@ use Illuminate\Support\Facades\DB; /** * 阶段 4:写入首套 **active** 玩法配置 / 赔率 / 风控封顶版本(依赖 {@see PlayTypeSeeder}、{@see CurrencySeeder})。 + * + * 幂等:仅当三套版本均已有 active 行时跳过;否则只补缺失的一类(避免「仅有 play active 时整段被跳过」导致 /play/effective 不可用)。 */ class OperationalConfigV1Seeder extends Seeder { public function run(): void { - if (PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->exists()) { + $hasPlay = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->exists(); + $hasOdds = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->exists(); + $hasRisk = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->exists(); + + if ($hasPlay && $hasOdds && $hasRisk) { return; } - DB::transaction(function (): void { - $playVersion = PlayConfigVersion::query()->create([ - 'version_no' => 1, - 'status' => ConfigVersionStatus::Active->value, - 'effective_at' => now(), - 'updated_by' => null, - 'reason' => 'seed:v1', - ]); - - foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) { - PlayConfigItem::query()->create([ - 'version_id' => $playVersion->id, - 'play_code' => $pt->play_code, - 'is_enabled' => (bool) $pt->is_enabled, - 'min_bet_amount' => 100, - 'max_bet_amount' => 500_000_000, - 'display_order' => (int) $pt->sort_order, - 'rule_text_zh' => null, - 'rule_text_en' => null, - 'rule_text_ne' => null, - 'extra_config_json' => null, - ]); + DB::transaction(function () use ($hasPlay, $hasOdds, $hasRisk): void { + if (! $hasPlay) { + $this->seedActivePlayConfigVersion(); } - - $oddsVersion = OddsVersion::query()->create([ - 'version_no' => 1, - 'status' => ConfigVersionStatus::Active->value, - 'effective_at' => now(), - 'updated_by' => null, - 'reason' => 'seed:v1', - ]); - - /** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */ - foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) { - foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { - OddsItem::query()->create([ - 'version_id' => $oddsVersion->id, - 'play_code' => $pt->play_code, - 'prize_scope' => $scope, - 'odds_value' => $oddsValue, - 'rebate_rate' => 0, - 'commission_rate' => 0, - 'currency_code' => 'NPR', - 'extra_config_json' => null, - ]); - } + if (! $hasOdds) { + $this->seedActiveOddsVersion(); } - - $riskVersion = RiskCapVersion::query()->create([ - 'version_no' => 1, - 'status' => ConfigVersionStatus::Active->value, - 'effective_at' => now(), - 'updated_by' => null, - 'reason' => 'seed:v1', - ]); - - foreach (['0000', '1234', '9999'] as $num) { - RiskCapItem::query()->create([ - 'version_id' => $riskVersion->id, - 'draw_id' => null, - 'normalized_number' => $num, - 'cap_amount' => 50_000_000_000, - 'cap_type' => 'per_number', - ]); + if (! $hasRisk) { + $this->seedActiveRiskCapVersion(); } }); } + + private function seedActivePlayConfigVersion(): void + { + $versionNo = (int) (PlayConfigVersion::query()->max('version_no') ?? 0) + 1; + + $playVersion = PlayConfigVersion::query()->create([ + 'version_no' => $versionNo, + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => null, + 'reason' => 'seed:v1', + ]); + + foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) { + PlayConfigItem::query()->create([ + 'version_id' => $playVersion->id, + 'play_code' => $pt->play_code, + 'is_enabled' => (bool) $pt->is_enabled, + 'min_bet_amount' => 100, + 'max_bet_amount' => 500_000_000, + 'display_order' => (int) $pt->sort_order, + 'rule_text_zh' => null, + 'rule_text_en' => null, + 'rule_text_ne' => null, + 'extra_config_json' => null, + ]); + } + } + + private function seedActiveOddsVersion(): void + { + $versionNo = (int) (OddsVersion::query()->max('version_no') ?? 0) + 1; + + $oddsVersion = OddsVersion::query()->create([ + 'version_no' => $versionNo, + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => null, + 'reason' => 'seed:v1', + ]); + + /** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */ + foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) { + foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { + OddsItem::query()->create([ + 'version_id' => $oddsVersion->id, + 'play_code' => $pt->play_code, + 'prize_scope' => $scope, + 'odds_value' => $oddsValue, + 'rebate_rate' => 0, + 'commission_rate' => 0, + 'currency_code' => 'NPR', + 'extra_config_json' => null, + ]); + } + } + } + + private function seedActiveRiskCapVersion(): void + { + $versionNo = (int) (RiskCapVersion::query()->max('version_no') ?? 0) + 1; + + $riskVersion = RiskCapVersion::query()->create([ + 'version_no' => $versionNo, + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => null, + 'reason' => 'seed:v1', + ]); + + foreach (['0000', '1234', '9999'] as $num) { + RiskCapItem::query()->create([ + 'version_id' => $riskVersion->id, + 'draw_id' => null, + 'normalized_number' => $num, + 'cap_amount' => 50_000_000_000, + 'cap_type' => 'per_number', + ]); + } + } } diff --git a/database/seeders/PlayTypeSeeder.php b/database/seeders/PlayTypeSeeder.php index b35f71f..1962c2e 100644 --- a/database/seeders/PlayTypeSeeder.php +++ b/database/seeders/PlayTypeSeeder.php @@ -5,75 +5,87 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; -/** - * 首期玩法占位数据,play_code 与 `docs/04` 玩法编码(小写)一致,便于前端/配置对齐。 - */ class PlayTypeSeeder extends Seeder { public function run(): void { $defaults = ['created_at' => now(), 'updated_at' => now()]; - $rows = [ - [ - 'play_code' => 'big', - 'category' => 'standard', - 'dimension' => 2, - 'bet_mode' => null, - 'display_name_zh' => 'Big', - 'display_name_en' => 'Big', - 'display_name_ne' => 'Big', - 'is_enabled' => true, - 'sort_order' => 10, - 'supports_multi_number' => false, - 'reserved_rule_json' => null, - ], - [ - 'play_code' => 'small', - 'category' => 'standard', - 'dimension' => 2, - 'bet_mode' => null, - 'display_name_zh' => 'Small', - 'display_name_en' => 'Small', - 'display_name_ne' => 'Small', - 'is_enabled' => true, - 'sort_order' => 20, - 'supports_multi_number' => false, - 'reserved_rule_json' => null, - ], - [ - 'play_code' => 'head', - 'category' => 'digit', - 'dimension' => 2, - 'bet_mode' => null, - 'display_name_zh' => 'Head', - 'display_name_en' => 'Head', - 'display_name_ne' => 'Head', - 'is_enabled' => true, - 'sort_order' => 30, - 'supports_multi_number' => false, - 'reserved_rule_json' => null, - ], - [ - 'play_code' => 'tail', - 'category' => 'digit', - 'dimension' => 2, - 'bet_mode' => null, - 'display_name_zh' => 'Tail', - 'display_name_en' => 'Tail', - 'display_name_ne' => 'Tail', - 'is_enabled' => true, - 'sort_order' => 40, - 'supports_multi_number' => false, - 'reserved_rule_json' => null, - ], - ]; - - foreach ($rows as $row) { + foreach ($this->rows() as $row) { DB::table('play_types')->updateOrInsert( ['play_code' => $row['play_code']], array_merge($row, $defaults), ); } } + + /** + * @return list> + */ + private function rows(): array + { + return [ + $this->row('big', 'standard', 4, 'single', 'Big', 10), + $this->row('small', 'standard', 4, 'single', 'Small', 20), + + $this->row('pos_4a', 'position', 4, 'single', '4A', 30, false, ['prize_scope' => ['first']]), + $this->row('pos_4b', 'position', 4, 'single', '4B', 40, false, ['prize_scope' => ['second']]), + $this->row('pos_4c', 'position', 4, 'single', '4C', 50, false, ['prize_scope' => ['third']]), + $this->row('pos_4d', 'position', 4, 'single', '4D', 60, false, ['prize_scope' => ['starter']]), + $this->row('pos_4e', 'position', 4, 'single', '4E', 70, false, ['prize_scope' => ['consolation']]), + + $this->row('pos_3a', 'position', 3, 'single', '3A', 80, false, ['prize_scope' => ['first']]), + $this->row('pos_3b', 'position', 3, 'single', '3B', 90, false, ['prize_scope' => ['second']]), + $this->row('pos_3c', 'position', 3, 'single', '3C', 100, false, ['prize_scope' => ['third']]), + $this->row('pos_3abc', 'position', 3, 'single', '3ABC', 110, false, ['prize_scope' => ['first', 'second', 'third']]), + + $this->row('pos_2a', 'position', 2, 'single', '2A', 120, false, ['prize_scope' => ['first']]), + $this->row('pos_2b', 'position', 2, 'single', '2B', 130, false, ['prize_scope' => ['second']]), + $this->row('pos_2c', 'position', 2, 'single', '2C', 140, false, ['prize_scope' => ['third']]), + $this->row('pos_2abc', 'position', 2, 'single', '2ABC', 150, false, ['prize_scope' => ['first', 'second', 'third']]), + + $this->row('straight', 'box', 4, 'single', 'Straight', 160, false, ['expand_mode' => 'straight']), + $this->row('box', 'box', 4, 'single', 'Box', 170, false, ['expand_mode' => 'box']), + $this->row('ibox', 'box', 4, 'per_combination', 'iBox', 180, true, ['expand_mode' => 'box']), + $this->row('mbox', 'box', 4, 'shared_total', 'mBox', 190, true, ['expand_mode' => 'box']), + $this->row('roll', 'box', 4, 'per_combination', 'Roll', 200, true, ['expand_mode' => 'roll']), + $this->row('half_box', 'box', 4, 'single', 'Half Box', 210, true, ['reserved' => true], false), + + $this->row('head', 'attribute', 4, 'single', 'Head', 220, false, ['attribute' => 'head']), + $this->row('tail', 'attribute', 4, 'single', 'Tail', 230, false, ['attribute' => 'tail']), + $this->row('odd', 'attribute', null, 'single', 'Odd', 240, false, ['attribute' => 'odd']), + $this->row('even', 'attribute', null, 'single', 'Even', 250, false, ['attribute' => 'even']), + $this->row('digit_big', 'attribute', null, 'single', 'Big Digit', 260, false, ['attribute' => 'digit_big']), + $this->row('digit_small', 'attribute', null, 'single', 'Small Digit', 270, false, ['attribute' => 'digit_small']), + ]; + } + + /** + * @return array + */ + private function row( + string $playCode, + string $category, + ?int $dimension, + ?string $betMode, + string $name, + int $sortOrder, + bool $supportsMultiNumber = false, + ?array $rules = null, + bool $isEnabled = true, + ): array { + return [ + 'play_code' => $playCode, + 'category' => $category, + 'dimension' => $dimension, + 'bet_mode' => $betMode, + 'display_name_zh' => $name, + 'display_name_en' => $name, + 'display_name_ne' => $name, + 'is_enabled' => $isEnabled, + 'sort_order' => $sortOrder, + 'supports_multi_number' => $supportsMultiNumber, + 'reserved_rule_json' => $rules === null ? null : json_encode($rules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + } } diff --git a/lang/en/wallet.php b/lang/en/wallet.php index 534b04c..2b90539 100644 --- a/lang/en/wallet.php +++ b/lang/en/wallet.php @@ -14,4 +14,13 @@ return [ '1008' => 'Invalid amount; enter a positive integer in minor units', '1009' => 'Main wallet operation failed; please try again later', '1010' => 'Do not reuse an idempotency key with different transfer parameters', + '2001' => 'The current draw is already closed', + '2002' => 'This play is closed', + '2003' => 'Insufficient balance. Please transfer in before betting', + '2004' => 'Invalid number format', + '2005' => 'Play input is incomplete or invalid', + '2006' => 'The draw is not open for betting', + '2007' => 'This play is not supported yet', + '2008' => 'Odds or play settings changed; please preview again before placing', + '4001' => 'This number is sold out for the current draw', ]; diff --git a/lang/ne/wallet.php b/lang/ne/wallet.php index 75c9c51..b6fe800 100644 --- a/lang/ne/wallet.php +++ b/lang/ne/wallet.php @@ -13,4 +13,13 @@ return [ '1008' => 'रकम अमान्य', '1009' => 'मुख्य वालेट असफल, पछि प्रयास गर्नुहोस्', '1010' => 'एउटै कुञ्जी दोहोर्याउनुहुन्न', + '2001' => 'हालको ड्र बन्द भइसकेको छ', + '2002' => 'यो खेल बन्द गरिएको छ', + '2003' => 'ब्यालेन्स अपर्याप्त छ, कृपया पहिले ट्रान्सफर इन गर्नुहोस्', + '2004' => 'नम्बरको ढाँचा अमान्य छ', + '2005' => 'खेल इनपुट अपूर्ण वा अमान्य छ', + '2006' => 'यो ड्र अहिले बेटिङका लागि खुला छैन', + '2007' => 'यो खेल अझै समर्थित छैन', + '2008' => 'अड्स वा सेटिङ परिवर्तन भयो; पुन: पूर्वावलोकन गर्नुहोस्', + '4001' => 'यो नम्बर हालको ड्रका लागि sold out भइसकेको छ', ]; diff --git a/lang/zh/wallet.php b/lang/zh/wallet.php index 8398480..e1a2c0c 100644 --- a/lang/zh/wallet.php +++ b/lang/zh/wallet.php @@ -13,4 +13,13 @@ return [ '1008' => '金额无效,请输入正整数(最小货币单位)', '1009' => '主站钱包处理失败,请稍后重试', '1010' => '请勿重复使用幂等键发起不同金额的转账', + '2001' => '当前期已封盘,暂不可下注', + '2002' => '玩法已关闭', + '2003' => '余额不足,请先转入后再下注', + '2004' => '号码格式不正确', + '2005' => '玩法参数不完整或不合法', + '2006' => '当前期号不可下注', + '2007' => '该玩法暂不支持下注', + '2008' => '赔率或玩法配置已变更,请重新预览后再提交', + '4001' => '该号码本期已售罄', ]; diff --git a/routes/api.php b/routes/api.php index e7966b7..de7d49d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,6 +25,9 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController; use App\Http\Controllers\Api\V1\Admin\PlayTypeIndexController; use App\Http\Controllers\Api\V1\Admin\PlayTypePatchController; +use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolIndexController; +use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolLockLogIndexController; +use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolShowController; use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController; use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; @@ -34,6 +37,8 @@ use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; +use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController; +use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController; use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; use App\Http\Controllers\Api\V1\Wallet\WalletLogsController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController; @@ -86,6 +91,13 @@ Route::prefix('v1')->group(function (): void { // 名称:彩票 → 主站 转出 Route::post('transfer-out', WalletTransferOutController::class)->name('transfer-out'); }); + + Route::prefix('ticket') + ->name('api.v1.ticket.') + ->group(function (): void { + Route::post('preview', TicketPreviewController::class)->name('preview'); + Route::post('place', TicketPlaceController::class)->name('place'); + }); }); Route::prefix('admin') @@ -114,6 +126,14 @@ Route::prefix('v1')->group(function (): void { Route::get('draws/{draw}', AdminDrawShowController::class)->name('draws.show'); Route::get('draws/{draw}/result-batches', AdminDrawResultBatchesIndexController::class) ->name('draws.result-batches.index'); + // 阶段 5:风险池 / 占用流水 / 售罄监控(后台 §13.4) + Route::get('draws/{draw}/risk-pools/{number_4d}', AdminRiskPoolShowController::class) + ->where('number_4d', '[0-9]{4}') + ->name('draws.risk-pools.show'); + Route::get('draws/{draw}/risk-pool-lock-logs', AdminRiskPoolLockLogIndexController::class) + ->name('draws.risk-pool-lock-logs.index'); + Route::get('draws/{draw}/risk-pools', AdminRiskPoolIndexController::class) + ->name('draws.risk-pools.index'); // 名称:发布待审核开奖批次(人工审核) Route::post( 'draws/{draw}/result-batches/{batch}/publish', diff --git a/tests/Feature/AdminRiskPoolApiTest.php b/tests/Feature/AdminRiskPoolApiTest.php new file mode 100644 index 0000000..a52bf6c --- /dev/null +++ b/tests/Feature/AdminRiskPoolApiTest.php @@ -0,0 +1,133 @@ +create([ + 'username' => 'risk_pool_admin', + 'name' => 'Risk QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin risk pools index returns rows for draw', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-001', + 'business_date' => '2026-05-12', + 'sequence_no' => 1, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000_000, + 'locked_amount' => 200_000, + 'remaining_amount' => 800_000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '9999', + 'total_cap_amount' => 100, + 'locked_amount' => 100, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + 'version' => 2, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?per_page=10') + ->assertOk() + ->assertJsonPath('data.draw_no', '20260512-001') + ->assertJsonPath('data.meta.total', 2); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?sold_out_only=1') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.normalized_number', '9999') + ->assertJsonPath('data.items.0.is_sold_out', true); +}); + +test('admin risk pool lock logs include ticket_no when linked', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-002', + 'business_date' => '2026-05-12', + 'sequence_no' => 2, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPoolLockLog::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '5678', + 'ticket_item_id' => null, + 'action_type' => 'lock', + 'amount' => 50, + 'source_reason' => 'ticket_place', + 'created_at' => now(), + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.amount', 50); +}); + +test('admin risk pool show 404 when pool missing', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-003', + 'business_date' => '2026-05-12', + 'sequence_no' => 3, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/0000') + ->assertStatus(404); +}); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php new file mode 100644 index 0000000..b482755 --- /dev/null +++ b/tests/Feature/TicketBettingApiTest.php @@ -0,0 +1,227 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +function ticketPlayerWithWallet(int $balance = 200_000): Player +{ + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'ticket-player-1', + 'username' => 'tp1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => $balance, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + return $player; +} + +function ticketOpenDraw(string $drawNo = '20260511-001'): Draw +{ + return Draw::query()->create([ + 'draw_no' => $drawNo, + 'business_date' => '2026-05-11', + 'sequence_no' => 1, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(2), + 'close_time' => now()->addMinutes(5), + 'draw_time' => now()->addMinutes(6), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); +} + +function ticketPreviewPayload(string $drawNo = '20260511-001'): array +{ + return [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-001', + 'lines' => [ + [ + 'number' => '1234', + 'play_code' => 'big', + 'amount' => 10_000, + ], + [ + 'number' => '1234', + 'play_code' => 'ibox', + 'amount' => 100, + ], + ], + ]; +} + +test('ticket preview returns computed summary for open draw', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.draw.draw_id', '20260511-001') + ->assertJsonPath('data.summary.total_bet_amount', 12_400) + ->assertJsonPath('data.summary.total_actual_deduct', 12_400) + ->assertJsonPath('data.config_versions.play_config_version_no', 1) + ->assertJsonPath('data.config_versions.odds_version_no', 1) + ->assertJsonPath('data.config_versions.risk_cap_version_no', 1) + ->assertJsonCount(2, 'data.lines'); +}); + +test('ticket place deducts wallet and persists order items combinations and logs', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.draw.draw_id', '20260511-001') + ->assertJsonPath('data.summary.total_bet_amount', 12_400) + ->assertJsonPath('data.summary.total_actual_deduct', 12_400); + + expect(TicketOrder::query()->count())->toBe(1); + expect(TicketItem::query()->count())->toBe(2); + expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); + expect(RiskPool::query()->count())->toBeGreaterThan(0); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(200_000 - 12_400); +}); + +test('ticket place rejects closed draw', function (): void { + $player = ticketPlayerWithWallet(); + $draw = ticketOpenDraw(); + $draw->update(['status' => DrawStatus::Closed->value]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::DrawClosed->value); +}); + +test('ticket place rejects insufficient balance', function (): void { + $player = ticketPlayerWithWallet(1_000); + ticketOpenDraw(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value); + + expect(TicketOrder::query()->count())->toBe(0); + expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); +}); + +test('ticket place succeeds when expected_config_versions matches preview', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) + ->assertOk() + ->json('data.config_versions'); + + $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + expect(TicketOrder::query()->count())->toBe(1); +}); + +test('ticket place rejects stale expected_config_versions', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) + ->assertOk() + ->json('data.config_versions'); + + OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]); + + $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::BetConfigStale->value); + + expect(TicketOrder::query()->count())->toBe(0); +}); + +test('ticket place rejects sold out risk pool', function (): void { + $player = ticketPlayerWithWallet(); + $draw = ticketOpenDraw(); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 100, + 'locked_amount' => 100, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + 'version' => 1, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-002', + 'lines' => [ + [ + 'number' => '1234', + 'play_code' => 'big', + 'amount' => 10_000, + ], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(200_000); + expect(TicketOrder::query()->count())->toBe(0); +});