diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php new file mode 100644 index 0000000..115f1b6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php @@ -0,0 +1,54 @@ +lotteryAdmin(); + + /** @var OddsVersion $version */ + $version = OddsVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $data = $request->validate([ + 'items' => ['required', 'array', 'min:1'], + 'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')], + 'items.*.prize_scope' => ['required', 'string', 'max:32'], + 'items.*.odds_value' => ['required', 'integer', 'min:0'], + 'items.*.rebate_rate' => ['sometimes', 'numeric'], + 'items.*.commission_rate' => ['sometimes', 'numeric'], + 'items.*.currency_code' => ['required', 'string', 'max:16', Rule::exists('currencies', 'code')], + 'items.*.extra_config_json' => ['sometimes', 'nullable', 'array'], + ]); + + $service->replaceItems($version, $data['items'], $admin); + + return ApiResponse::success( + AdminConfigPresenter::oddsVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionIndexController.php new file mode 100644 index 0000000..f19187b --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionIndexController.php @@ -0,0 +1,33 @@ +integer('per_page', 25), 1), 100); + $status = trim((string) $request->query('status', '')); + + $paginator = $service->paginate($status === '' ? null : $status, $perPage); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (OddsVersion $v) => AdminConfigPresenter::oddsVersionSummary($v))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionPublishController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionPublishController.php new file mode 100644 index 0000000..c4bcce3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionPublishController.php @@ -0,0 +1,42 @@ +lotteryAdmin(); + + /** @var OddsVersion $version */ + $version = OddsVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $service->publish($version, $admin, $request); + + return ApiResponse::success( + AdminConfigPresenter::oddsVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionShowController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionShowController.php new file mode 100644 index 0000000..a234992 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionShowController.php @@ -0,0 +1,21 @@ +with('items')->whereKey($id)->firstOrFail(); + + return ApiResponse::success(AdminConfigPresenter::oddsVersionDetail($v)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionStoreController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionStoreController.php new file mode 100644 index 0000000..3c8fbae --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionStoreController.php @@ -0,0 +1,36 @@ +lotteryAdmin(); + + $data = $request->validate([ + 'reason' => ['nullable', 'string', 'max:255'], + 'clone_from_version_id' => ['nullable', 'integer', 'exists:odds_versions,id'], + ]); + + $draft = $service->createDraft( + $admin, + $data['reason'] ?? null, + $data['clone_from_version_id'] ?? null, + ); + + return ApiResponse::success( + AdminConfigPresenter::oddsVersionDetail($draft->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php new file mode 100644 index 0000000..f1c1fbf --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php @@ -0,0 +1,56 @@ +lotteryAdmin(); + + /** @var PlayConfigVersion $version */ + $version = PlayConfigVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $data = $request->validate([ + 'items' => ['required', 'array', 'min:1'], + 'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')], + 'items.*.is_enabled' => ['sometimes', 'boolean'], + 'items.*.min_bet_amount' => ['required', 'integer', 'min:0'], + 'items.*.max_bet_amount' => ['required', 'integer', 'min:0'], + 'items.*.display_order' => ['sometimes', 'integer'], + 'items.*.rule_text_zh' => ['sometimes', 'nullable', 'string'], + 'items.*.rule_text_en' => ['sometimes', 'nullable', 'string'], + 'items.*.rule_text_ne' => ['sometimes', 'nullable', 'string'], + 'items.*.extra_config_json' => ['sometimes', 'nullable', 'array'], + ]); + + $service->replaceItems($version, $data['items'], $admin); + + return ApiResponse::success( + AdminConfigPresenter::playConfigVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionIndexController.php new file mode 100644 index 0000000..586484d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionIndexController.php @@ -0,0 +1,33 @@ +integer('per_page', 25), 1), 100); + $status = trim((string) $request->query('status', '')); + + $paginator = $service->paginate($status === '' ? null : $status, $perPage); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (PlayConfigVersion $v) => AdminConfigPresenter::playConfigVersionSummary($v))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionPublishController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionPublishController.php new file mode 100644 index 0000000..bdd587a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionPublishController.php @@ -0,0 +1,42 @@ +lotteryAdmin(); + + /** @var PlayConfigVersion $version */ + $version = PlayConfigVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $service->publish($version, $admin, $request); + + return ApiResponse::success( + AdminConfigPresenter::playConfigVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionShowController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionShowController.php new file mode 100644 index 0000000..814c9a7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionShowController.php @@ -0,0 +1,21 @@ +with('items')->whereKey($id)->firstOrFail(); + + return ApiResponse::success(AdminConfigPresenter::playConfigVersionDetail($v)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionStoreController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionStoreController.php new file mode 100644 index 0000000..778a349 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionStoreController.php @@ -0,0 +1,36 @@ +lotteryAdmin(); + + $data = $request->validate([ + 'reason' => ['nullable', 'string', 'max:255'], + 'clone_from_version_id' => ['nullable', 'integer', 'exists:play_config_versions,id'], + ]); + + $draft = $service->createDraft( + $admin, + $data['reason'] ?? null, + $data['clone_from_version_id'] ?? null, + ); + + return ApiResponse::success( + AdminConfigPresenter::playConfigVersionDetail($draft->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php new file mode 100644 index 0000000..15b7b6a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapItemsReplaceController.php @@ -0,0 +1,50 @@ +lotteryAdmin(); + + /** @var RiskCapVersion $version */ + $version = RiskCapVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $data = $request->validate([ + 'items' => ['required', 'array', 'min:1'], + 'items.*.draw_id' => ['sometimes', 'nullable', 'integer', 'exists:draws,id'], + 'items.*.normalized_number' => ['required', 'string', 'size:4', 'regex:/^[0-9]{4}$/'], + 'items.*.cap_amount' => ['required', 'integer', 'min:0'], + 'items.*.cap_type' => ['required', 'string', 'max:16'], + ]); + + $service->replaceItems($version, $data['items'], $admin); + + return ApiResponse::success( + AdminConfigPresenter::riskCapVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionIndexController.php new file mode 100644 index 0000000..3eb9a90 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionIndexController.php @@ -0,0 +1,33 @@ +integer('per_page', 25), 1), 100); + $status = trim((string) $request->query('status', '')); + + $paginator = $service->paginate($status === '' ? null : $status, $perPage); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (RiskCapVersion $v) => AdminConfigPresenter::riskCapVersionSummary($v))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionPublishController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionPublishController.php new file mode 100644 index 0000000..42d2556 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionPublishController.php @@ -0,0 +1,42 @@ +lotteryAdmin(); + + /** @var RiskCapVersion $version */ + $version = RiskCapVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status !== ConfigVersionStatus::Draft->value) { + return ApiResponse::error( + 'version is not draft', + ErrorCode::ConfigVersionNotDraft->value, + null, + 400, + ); + } + + $service->publish($version, $admin, $request); + + return ApiResponse::success( + AdminConfigPresenter::riskCapVersionDetail($version->fresh()->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionShowController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionShowController.php new file mode 100644 index 0000000..a42c62d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionShowController.php @@ -0,0 +1,21 @@ +with('items')->whereKey($id)->firstOrFail(); + + return ApiResponse::success(AdminConfigPresenter::riskCapVersionDetail($v)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionStoreController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionStoreController.php new file mode 100644 index 0000000..a678c86 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionStoreController.php @@ -0,0 +1,36 @@ +lotteryAdmin(); + + $data = $request->validate([ + 'reason' => ['nullable', 'string', 'max:255'], + 'clone_from_version_id' => ['nullable', 'integer', 'exists:risk_cap_versions,id'], + ]); + + $draft = $service->createDraft( + $admin, + $data['reason'] ?? null, + $data['clone_from_version_id'] ?? null, + ); + + return ApiResponse::success( + AdminConfigPresenter::riskCapVersionDetail($draft->load('items')), + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/PlayTypeIndexController.php b/app/Http/Controllers/Api/V1/Admin/PlayTypeIndexController.php new file mode 100644 index 0000000..ddc3344 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/PlayTypeIndexController.php @@ -0,0 +1,22 @@ +orderBy('sort_order')->orderBy('play_code')->get(); + + return ApiResponse::success([ + 'items' => $rows->map(fn (PlayType $t) => AdminConfigPresenter::playType($t))->values()->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php b/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php new file mode 100644 index 0000000..2d6f089 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php @@ -0,0 +1,37 @@ +where('play_code', $play_code)->firstOrFail(); + + $data = $request->validate([ + 'is_enabled' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + 'display_name_zh' => ['sometimes', 'nullable', 'string', 'max:64'], + 'display_name_en' => ['sometimes', 'nullable', 'string', 'max:64'], + 'display_name_ne' => ['sometimes', 'nullable', 'string', 'max:64'], + 'supports_multi_number' => ['sometimes', 'boolean'], + 'reserved_rule_json' => ['sometimes', 'nullable', 'array'], + ]); + + if ($data !== []) { + $type->fill($data); + $type->save(); + } + + return ApiResponse::success(AdminConfigPresenter::playType($type->fresh())); + } +} diff --git a/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php b/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php new file mode 100644 index 0000000..9398337 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Play/PlayEffectiveCatalogController.php @@ -0,0 +1,45 @@ +query('currency'); + $c = is_string($currency) && $currency !== '' ? $currency : null; + + try { + return ApiResponse::success($catalog->build($c)); + } catch (ModelNotFoundException) { + return ApiResponse::error( + 'effective config not initialized', + ErrorCode::NotFound->value, + null, + 404, + ); + } catch (\InvalidArgumentException $e) { + if ($e->getMessage() === 'currency') { + return ApiResponse::error( + 'invalid or disabled currency', + ErrorCode::ConfigCurrencyInvalid->value, + null, + 400, + ); + } + + throw $e; + } + } +} diff --git a/app/Lottery/ConfigVersionStatus.php b/app/Lottery/ConfigVersionStatus.php new file mode 100644 index 0000000..818f2d0 --- /dev/null +++ b/app/Lottery/ConfigVersionStatus.php @@ -0,0 +1,18 @@ + 'integer', + 'odds_value' => 'integer', + 'rebate_rate' => 'decimal:4', + 'commission_rate' => 'decimal:4', + 'extra_config_json' => 'json', + ]; + } + + /** @return BelongsTo */ + public function version(): BelongsTo + { + return $this->belongsTo(OddsVersion::class, 'version_id'); + } +} diff --git a/app/Models/OddsVersion.php b/app/Models/OddsVersion.php new file mode 100644 index 0000000..ef2b33a --- /dev/null +++ b/app/Models/OddsVersion.php @@ -0,0 +1,55 @@ + 'integer', + 'effective_at' => 'datetime', + 'updated_by' => 'integer', + ]; + } + + protected static function booted(): void + { + static::saving(function (OddsVersion $m): void { + if ($m->status === null || $m->status === '') { + $m->status = ConfigVersionStatus::Draft->value; + } + }); + } + + /** @return BelongsTo */ + public function updatedByAdmin(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'updated_by'); + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(OddsItem::class, 'version_id'); + } + + public function isDraft(): bool + { + return $this->status === ConfigVersionStatus::Draft->value; + } +} diff --git a/app/Models/PlayConfigItem.php b/app/Models/PlayConfigItem.php new file mode 100644 index 0000000..98cf91d --- /dev/null +++ b/app/Models/PlayConfigItem.php @@ -0,0 +1,47 @@ + 'integer', + 'is_enabled' => 'boolean', + 'min_bet_amount' => 'integer', + 'max_bet_amount' => 'integer', + 'display_order' => 'integer', + 'extra_config_json' => 'json', + ]; + } + + /** @return BelongsTo */ + public function version(): BelongsTo + { + return $this->belongsTo(PlayConfigVersion::class, 'version_id'); + } + + /** @return BelongsTo */ + public function playType(): BelongsTo + { + return $this->belongsTo(PlayType::class, 'play_code', 'play_code'); + } +} diff --git a/app/Models/PlayConfigVersion.php b/app/Models/PlayConfigVersion.php new file mode 100644 index 0000000..03e4bf8 --- /dev/null +++ b/app/Models/PlayConfigVersion.php @@ -0,0 +1,55 @@ + 'integer', + 'effective_at' => 'datetime', + 'updated_by' => 'integer', + ]; + } + + protected static function booted(): void + { + static::saving(function (PlayConfigVersion $m): void { + if ($m->status === null || $m->status === '') { + $m->status = ConfigVersionStatus::Draft->value; + } + }); + } + + /** @return BelongsTo */ + public function updatedByAdmin(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'updated_by'); + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(PlayConfigItem::class, 'version_id'); + } + + public function isDraft(): bool + { + return $this->status === ConfigVersionStatus::Draft->value; + } +} diff --git a/app/Models/PlayType.php b/app/Models/PlayType.php new file mode 100644 index 0000000..de2d8e6 --- /dev/null +++ b/app/Models/PlayType.php @@ -0,0 +1,34 @@ + 'integer', + 'is_enabled' => 'boolean', + 'sort_order' => 'integer', + 'supports_multi_number' => 'boolean', + 'reserved_rule_json' => 'json', + ]; + } +} diff --git a/app/Models/RiskCapItem.php b/app/Models/RiskCapItem.php new file mode 100644 index 0000000..146a048 --- /dev/null +++ b/app/Models/RiskCapItem.php @@ -0,0 +1,43 @@ + 'integer', + 'draw_id' => 'integer', + 'cap_amount' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function version(): BelongsTo + { + return $this->belongsTo(RiskCapVersion::class, 'version_id'); + } + + /** @return BelongsTo */ + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class, 'draw_id'); + } +} diff --git a/app/Models/RiskCapVersion.php b/app/Models/RiskCapVersion.php new file mode 100644 index 0000000..7ee339c --- /dev/null +++ b/app/Models/RiskCapVersion.php @@ -0,0 +1,55 @@ + 'integer', + 'effective_at' => 'datetime', + 'updated_by' => 'integer', + ]; + } + + protected static function booted(): void + { + static::saving(function (RiskCapVersion $m): void { + if ($m->status === null || $m->status === '') { + $m->status = ConfigVersionStatus::Draft->value; + } + }); + } + + /** @return BelongsTo */ + public function updatedByAdmin(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'updated_by'); + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(RiskCapItem::class, 'version_id'); + } + + public function isDraft(): bool + { + return $this->status === ConfigVersionStatus::Draft->value; + } +} diff --git a/app/Services/Config/EffectivePlayCatalogService.php b/app/Services/Config/EffectivePlayCatalogService.php new file mode 100644 index 0000000..3500a8c --- /dev/null +++ b/app/Services/Config/EffectivePlayCatalogService.php @@ -0,0 +1,183 @@ + + */ + public function build(?string $currencyCode = null): array + { + $currency = $this->resolveBettableCurrency($currencyCode); + + $playVersion = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $oddsVersion = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $riskVersion = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + $playTypes = PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get(); + + /** @var Collection $configByCode */ + $configByCode = PlayConfigItem::query() + ->where('version_id', $playVersion->id) + ->get() + ->keyBy('play_code'); + + $oddsRows = OddsItem::query() + ->where('version_id', $oddsVersion->id) + ->where('currency_code', $currency->code) + ->get(); + + /** @var Collection> */ + $oddsByPlay = $oddsRows->groupBy('play_code'); + + $riskItems = RiskCapItem::query() + ->where('version_id', $riskVersion->id) + ->orderBy('normalized_number') + ->get(); + + $plays = $playTypes->map(function (PlayType $pt) use ($configByCode, $oddsByPlay): array { + $c = $configByCode->get($pt->play_code); + $items = $oddsByPlay->get($pt->play_code, collect()); + $o = $this->pickPrimaryOddsItem($items); + + return [ + 'play_code' => $pt->play_code, + 'category' => $pt->category, + 'dimension' => $pt->dimension, + 'bet_mode' => $pt->bet_mode, + 'display_name_zh' => $pt->display_name_zh, + 'display_name_en' => $pt->display_name_en, + 'display_name_ne' => $pt->display_name_ne, + 'sort_order' => (int) $pt->sort_order, + 'supports_multi_number' => (bool) $pt->supports_multi_number, + 'master_enabled' => (bool) $pt->is_enabled, + 'config' => $c === null ? null : $this->serializePlayConfigItem($c), + 'odds' => $o === null ? null : $this->serializeOddsItem($o), + ]; + })->values()->all(); + + return [ + 'currency_code' => $currency->code, + 'effective_versions' => [ + 'play_config' => $this->serializeVersionHead($playVersion), + 'odds' => $this->serializeVersionHead($oddsVersion), + 'risk_cap' => $this->serializeVersionHead($riskVersion), + ], + 'plays' => $plays, + 'risk_cap_items' => $riskItems->map(fn (RiskCapItem $r) => $this->serializeRiskItem($r))->all(), + ]; + } + + /** + * 大厅列表展示单档赔率:优先头奖档 {@see first},兼容历史 {@see default}。 + * + * @param Collection $items + */ + private function pickPrimaryOddsItem(Collection $items): ?OddsItem + { + if ($items->isEmpty()) { + return null; + } + + foreach (['first', 'default', 'second', 'third', 'starter', 'consolation'] as $scope) { + $hit = $items->firstWhere('prize_scope', $scope); + if ($hit !== null) { + return $hit; + } + } + + return $items->first(); + } + + private function resolveBettableCurrency(?string $currencyCode): Currency + { + if ($currencyCode !== null && $currencyCode !== '') { + $row = Currency::query()->where('code', strtoupper($currencyCode))->first(); + if ($row === null || ! $row->is_enabled || ! $row->is_bettable) { + throw new \InvalidArgumentException('currency'); + } + + return $row; + } + + return Currency::query() + ->where('is_enabled', true) + ->where('is_bettable', true) + ->orderBy('code') + ->firstOrFail(); + } + + /** @return array */ + private function serializeVersionHead(PlayConfigVersion|OddsVersion|RiskCapVersion $v): array + { + return [ + 'id' => (int) $v->getKey(), + 'version_no' => (int) $v->version_no, + 'effective_at' => $v->effective_at?->toIso8601String(), + ]; + } + + /** @return array */ + private function serializePlayConfigItem(PlayConfigItem $r): array + { + return [ + 'is_enabled' => (bool) $r->is_enabled, + 'min_bet_amount' => (int) $r->min_bet_amount, + 'max_bet_amount' => (int) $r->max_bet_amount, + 'display_order' => (int) $r->display_order, + 'rule_text_zh' => $r->rule_text_zh, + 'rule_text_en' => $r->rule_text_en, + 'rule_text_ne' => $r->rule_text_ne, + 'extra_config_json' => $r->extra_config_json, + ]; + } + + /** @return array */ + private function serializeOddsItem(OddsItem $r): array + { + return [ + 'prize_scope' => $r->prize_scope, + 'odds_value' => (int) $r->odds_value, + 'rebate_rate' => (string) $r->rebate_rate, + 'commission_rate' => (string) $r->commission_rate, + 'currency_code' => $r->currency_code, + 'extra_config_json' => $r->extra_config_json, + /** 赔率乘数小数位 = odds_value / 10000 */ + 'odds_multiplier' => round($r->odds_value / 10000, 4), + ]; + } + + /** @return array */ + private function serializeRiskItem(RiskCapItem $r): array + { + return [ + 'draw_id' => $r->draw_id, + 'normalized_number' => $r->normalized_number, + 'cap_amount' => (int) $r->cap_amount, + 'cap_type' => $r->cap_type, + ]; + } +} diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php new file mode 100644 index 0000000..c146c77 --- /dev/null +++ b/app/Services/Config/OddsStreamService.php @@ -0,0 +1,163 @@ + */ + public function paginate(?string $status, int $perPage): LengthAwarePaginator + { + $q = OddsVersion::query()->orderByDesc('id'); + if ($status !== null && $status !== '') { + $q->where('status', $status); + } + + return $q->paginate($perPage); + } + + public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): OddsVersion + { + $nextNo = (int) (OddsVersion::query()->max('version_no') ?? 0) + 1; + + return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): OddsVersion { + $draft = OddsVersion::query()->create([ + 'version_no' => $nextNo, + 'status' => ConfigVersionStatus::Draft->value, + 'effective_at' => null, + 'updated_by' => $admin->id, + 'reason' => $reason, + ]); + + $source = null; + if ($cloneFromVersionId !== null) { + $source = OddsVersion::query()->whereKey($cloneFromVersionId)->firstOrFail(); + } else { + $source = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->first(); + } + + if ($source !== null) { + foreach ($source->items()->orderBy('currency_code')->orderBy('play_code')->get() as $row) { + OddsItem::query()->create([ + 'version_id' => $draft->id, + 'play_code' => $row->play_code, + 'prize_scope' => $row->prize_scope, + 'odds_value' => $row->odds_value, + 'rebate_rate' => $row->rebate_rate, + 'commission_rate' => $row->commission_rate, + 'currency_code' => $row->currency_code, + 'extra_config_json' => $row->extra_config_json, + ]); + } + } else { + $currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail(); + 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' => $draft->id, + 'play_code' => $pt->play_code, + 'prize_scope' => $scope, + 'odds_value' => $oddsValue, + 'rebate_rate' => 0, + 'commission_rate' => 0, + 'currency_code' => $currency->code, + 'extra_config_json' => null, + ]); + } + } + } + + $draft->refresh(); + OddsStandardScopes::syncMissingForVersion($draft); + + return $draft->fresh(['items']); + }); + } + + /** + * @param array> $items + */ + public function replaceItems(OddsVersion $draft, array $items, AdminUser $admin): void + { + DB::transaction(function () use ($draft, $items, $admin): void { + OddsItem::query()->where('version_id', $draft->id)->delete(); + + foreach ($items as $row) { + OddsItem::query()->create([ + 'version_id' => $draft->id, + 'play_code' => (string) $row['play_code'], + 'prize_scope' => (string) $row['prize_scope'], + 'odds_value' => (int) $row['odds_value'], + 'rebate_rate' => (float) ($row['rebate_rate'] ?? 0), + 'commission_rate' => (float) ($row['commission_rate'] ?? 0), + 'currency_code' => strtoupper((string) $row['currency_code']), + 'extra_config_json' => $row['extra_config_json'] ?? null, + ]); + } + + $draft->forceFill(['updated_by' => $admin->id])->save(); + }); + } + + public function publish(OddsVersion $draft, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($draft); + + DB::transaction(function () use ($draft, $admin): void { + /** @var OddsVersion|null $current */ + $current = OddsVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->lockForUpdate() + ->first(); + + if ($current !== null) { + $current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save(); + } + + $draft->forceFill([ + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => $admin->id, + ])->save(); + }); + + $after = $this->snapshotVersion($draft->fresh(['items'])); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'odds', + actionCode: 'publish', + targetType: 'odds_version', + targetId: (string) $draft->id, + beforeJson: $before, + afterJson: $after, + ); + } + + /** @return array */ + private function snapshotVersion(OddsVersion $v): array + { + return [ + 'id' => $v->id, + 'version_no' => $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'items_count' => $v->items()->count(), + ]; + } +} diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php new file mode 100644 index 0000000..4679038 --- /dev/null +++ b/app/Services/Config/PlayConfigStreamService.php @@ -0,0 +1,161 @@ + */ + public function paginate(?string $status, int $perPage): LengthAwarePaginator + { + $q = PlayConfigVersion::query()->orderByDesc('id'); + if ($status !== null && $status !== '') { + $q->where('status', $status); + } + + return $q->paginate($perPage); + } + + public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): PlayConfigVersion + { + $nextNo = (int) (PlayConfigVersion::query()->max('version_no') ?? 0) + 1; + + return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): PlayConfigVersion { + $draft = PlayConfigVersion::query()->create([ + 'version_no' => $nextNo, + 'status' => ConfigVersionStatus::Draft->value, + 'effective_at' => null, + 'updated_by' => $admin->id, + 'reason' => $reason, + ]); + + $source = null; + if ($cloneFromVersionId !== null) { + $source = PlayConfigVersion::query()->whereKey($cloneFromVersionId)->firstOrFail(); + } else { + $source = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->first(); + } + + if ($source !== null) { + foreach ($source->items()->orderBy('play_code')->get() as $row) { + PlayConfigItem::query()->create([ + 'version_id' => $draft->id, + 'play_code' => $row->play_code, + 'is_enabled' => $row->is_enabled, + 'min_bet_amount' => $row->min_bet_amount, + 'max_bet_amount' => $row->max_bet_amount, + 'display_order' => $row->display_order, + 'rule_text_zh' => $row->rule_text_zh, + 'rule_text_en' => $row->rule_text_en, + 'rule_text_ne' => $row->rule_text_ne, + 'extra_config_json' => $row->extra_config_json, + ]); + } + } else { + foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) { + PlayConfigItem::query()->create([ + 'version_id' => $draft->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, + ]); + } + } + + return $draft->fresh(['items']); + }); + } + + /** + * @param array> $items + */ + public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void + { + DB::transaction(function () use ($draft, $items, $admin): void { + PlayConfigItem::query()->where('version_id', $draft->id)->delete(); + + foreach ($items as $row) { + PlayConfigItem::query()->create([ + 'version_id' => $draft->id, + 'play_code' => (string) $row['play_code'], + 'is_enabled' => (bool) ($row['is_enabled'] ?? true), + 'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0), + 'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0), + 'display_order' => (int) ($row['display_order'] ?? 0), + 'rule_text_zh' => isset($row['rule_text_zh']) ? (string) $row['rule_text_zh'] : null, + 'rule_text_en' => isset($row['rule_text_en']) ? (string) $row['rule_text_en'] : null, + 'rule_text_ne' => isset($row['rule_text_ne']) ? (string) $row['rule_text_ne'] : null, + 'extra_config_json' => $row['extra_config_json'] ?? null, + ]); + } + + $draft->forceFill(['updated_by' => $admin->id])->save(); + }); + } + + public function publish(PlayConfigVersion $draft, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($draft); + + DB::transaction(function () use ($draft, $admin): void { + /** @var PlayConfigVersion|null $current */ + $current = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->lockForUpdate() + ->first(); + + if ($current !== null) { + $current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save(); + } + + $draft->forceFill([ + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => $admin->id, + ])->save(); + }); + + $after = $this->snapshotVersion($draft->fresh(['items'])); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'play_config', + actionCode: 'publish', + targetType: 'play_config_version', + targetId: (string) $draft->id, + beforeJson: $before, + afterJson: $after, + ); + } + + /** @return array */ + private function snapshotVersion(PlayConfigVersion $v): array + { + return [ + 'id' => $v->id, + 'version_no' => $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'items_count' => $v->items()->count(), + ]; + } +} diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php new file mode 100644 index 0000000..e90af5c --- /dev/null +++ b/app/Services/Config/RiskCapStreamService.php @@ -0,0 +1,145 @@ + */ + public function paginate(?string $status, int $perPage): LengthAwarePaginator + { + $q = RiskCapVersion::query()->orderByDesc('id'); + if ($status !== null && $status !== '') { + $q->where('status', $status); + } + + return $q->paginate($perPage); + } + + public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): RiskCapVersion + { + $nextNo = (int) (RiskCapVersion::query()->max('version_no') ?? 0) + 1; + + return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): RiskCapVersion { + $draft = RiskCapVersion::query()->create([ + 'version_no' => $nextNo, + 'status' => ConfigVersionStatus::Draft->value, + 'effective_at' => null, + 'updated_by' => $admin->id, + 'reason' => $reason, + ]); + + $source = null; + if ($cloneFromVersionId !== null) { + $source = RiskCapVersion::query()->whereKey($cloneFromVersionId)->firstOrFail(); + } else { + $source = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->first(); + } + + if ($source !== null) { + foreach ($source->items()->orderBy('normalized_number')->get() as $row) { + RiskCapItem::query()->create([ + 'version_id' => $draft->id, + 'draw_id' => $row->draw_id, + 'normalized_number' => $row->normalized_number, + 'cap_amount' => $row->cap_amount, + 'cap_type' => $row->cap_type, + ]); + } + } else { + foreach (['0000', '1234', '9999'] as $num) { + RiskCapItem::query()->create([ + 'version_id' => $draft->id, + 'draw_id' => null, + 'normalized_number' => $num, + 'cap_amount' => 50_000_000_000, + 'cap_type' => 'per_number', + ]); + } + } + + return $draft->fresh(['items']); + }); + } + + /** + * @param array> $items + */ + public function replaceItems(RiskCapVersion $draft, array $items, AdminUser $admin): void + { + DB::transaction(function () use ($draft, $items, $admin): void { + RiskCapItem::query()->where('version_id', $draft->id)->delete(); + + foreach ($items as $row) { + RiskCapItem::query()->create([ + 'version_id' => $draft->id, + 'draw_id' => isset($row['draw_id']) ? (int) $row['draw_id'] : null, + 'normalized_number' => (string) $row['normalized_number'], + 'cap_amount' => (int) $row['cap_amount'], + 'cap_type' => (string) $row['cap_type'], + ]); + } + + $draft->forceFill(['updated_by' => $admin->id])->save(); + }); + } + + public function publish(RiskCapVersion $draft, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($draft); + + DB::transaction(function () use ($draft, $admin): void { + /** @var RiskCapVersion|null $current */ + $current = RiskCapVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->lockForUpdate() + ->first(); + + if ($current !== null) { + $current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save(); + } + + $draft->forceFill([ + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => $admin->id, + ])->save(); + }); + + $after = $this->snapshotVersion($draft->fresh(['items'])); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'risk_cap', + actionCode: 'publish', + targetType: 'risk_cap_version', + targetId: (string) $draft->id, + beforeJson: $before, + afterJson: $after, + ); + } + + /** @return array */ + private function snapshotVersion(RiskCapVersion $v): array + { + return [ + 'id' => $v->id, + 'version_no' => $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'items_count' => $v->items()->count(), + ]; + } +} diff --git a/app/Support/AdminConfigPresenter.php b/app/Support/AdminConfigPresenter.php new file mode 100644 index 0000000..203c1a3 --- /dev/null +++ b/app/Support/AdminConfigPresenter.php @@ -0,0 +1,151 @@ + */ + public static function playType(PlayType $t): array + { + return [ + 'id' => (int) $t->id, + 'play_code' => $t->play_code, + 'category' => $t->category, + 'dimension' => $t->dimension, + 'bet_mode' => $t->bet_mode, + 'display_name_zh' => $t->display_name_zh, + 'display_name_en' => $t->display_name_en, + 'display_name_ne' => $t->display_name_ne, + 'is_enabled' => (bool) $t->is_enabled, + 'sort_order' => (int) $t->sort_order, + 'supports_multi_number' => (bool) $t->supports_multi_number, + 'reserved_rule_json' => $t->reserved_rule_json, + 'updated_at' => $t->updated_at?->toIso8601String(), + ]; + } + + /** @return array */ + public static function playConfigVersionSummary(PlayConfigVersion $v): array + { + return [ + 'id' => (int) $v->id, + 'version_no' => (int) $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'updated_by' => $v->updated_by, + 'reason' => $v->reason, + 'created_at' => $v->created_at?->toIso8601String(), + 'updated_at' => $v->updated_at?->toIso8601String(), + ]; + } + + /** @return array */ + public static function playConfigVersionDetail(PlayConfigVersion $v): array + { + $base = self::playConfigVersionSummary($v); + $base['items'] = $v->items->map(fn (PlayConfigItem $r) => self::playConfigItem($r))->values()->all(); + + return $base; + } + + /** @return array */ + public static function playConfigItem(PlayConfigItem $r): array + { + return [ + 'id' => (int) $r->id, + 'play_code' => $r->play_code, + 'is_enabled' => (bool) $r->is_enabled, + 'min_bet_amount' => (int) $r->min_bet_amount, + 'max_bet_amount' => (int) $r->max_bet_amount, + 'display_order' => (int) $r->display_order, + 'rule_text_zh' => $r->rule_text_zh, + 'rule_text_en' => $r->rule_text_en, + 'rule_text_ne' => $r->rule_text_ne, + 'extra_config_json' => $r->extra_config_json, + ]; + } + + /** @return array */ + public static function oddsVersionSummary(OddsVersion $v): array + { + return [ + 'id' => (int) $v->id, + 'version_no' => (int) $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'updated_by' => $v->updated_by, + 'reason' => $v->reason, + 'created_at' => $v->created_at?->toIso8601String(), + 'updated_at' => $v->updated_at?->toIso8601String(), + ]; + } + + /** @return array */ + public static function oddsVersionDetail(OddsVersion $v): array + { + $base = self::oddsVersionSummary($v); + $base['items'] = $v->items->map(fn (OddsItem $r) => self::oddsItem($r))->values()->all(); + + return $base; + } + + /** @return array */ + public static function oddsItem(OddsItem $r): array + { + return [ + 'id' => (int) $r->id, + 'play_code' => $r->play_code, + 'prize_scope' => $r->prize_scope, + 'odds_value' => (int) $r->odds_value, + 'rebate_rate' => (string) $r->rebate_rate, + 'commission_rate' => (string) $r->commission_rate, + 'currency_code' => $r->currency_code, + 'extra_config_json' => $r->extra_config_json, + ]; + } + + /** @return array */ + public static function riskCapVersionSummary(RiskCapVersion $v): array + { + return [ + 'id' => (int) $v->id, + 'version_no' => (int) $v->version_no, + 'status' => $v->status, + 'effective_at' => $v->effective_at?->toIso8601String(), + 'updated_by' => $v->updated_by, + 'reason' => $v->reason, + 'created_at' => $v->created_at?->toIso8601String(), + 'updated_at' => $v->updated_at?->toIso8601String(), + ]; + } + + /** @return array */ + public static function riskCapVersionDetail(RiskCapVersion $v): array + { + $base = self::riskCapVersionSummary($v); + $base['items'] = $v->items->map(fn (RiskCapItem $r) => self::riskCapItem($r))->values()->all(); + + return $base; + } + + /** @return array */ + public static function riskCapItem(RiskCapItem $r): array + { + return [ + 'id' => (int) $r->id, + 'draw_id' => $r->draw_id, + 'normalized_number' => $r->normalized_number, + 'cap_amount' => (int) $r->cap_amount, + 'cap_type' => $r->cap_type, + ]; + } +} diff --git a/app/Support/OddsStandardScopes.php b/app/Support/OddsStandardScopes.php new file mode 100644 index 0000000..3d220b1 --- /dev/null +++ b/app/Support/OddsStandardScopes.php @@ -0,0 +1,103 @@ + */ + public const PRESET_ODDS_BY_SCOPE = [ + 'first' => 250_000, + 'second' => 110_000, + 'third' => 55_000, + 'starter' => 22_000, + 'consolation' => 6_500, + ]; + + /** @var list */ + public const SCOPE_KEYS = ['first', 'second', 'third', 'starter', 'consolation']; + + /** + * 为指定赔率版本补全缺失的标准五档行(幂等)。 + * + * 仅对「该版本里已出现过的 (play_code, currency_code)」补全,避免给从未配置过的玩法凭空加行。 + * 若版本下无任何 odds_items,则用当前全部玩法 × 首个可下注币种补全。 + */ + public static function syncMissingForVersion(OddsVersion $version): void + { + DB::transaction(function () use ($version): void { + $vid = (int) $version->id; + + $pairs = OddsItem::query() + ->where('version_id', $vid) + ->select(['play_code', 'currency_code']) + ->distinct() + ->get(); + + if ($pairs->isEmpty()) { + $currencyCode = Currency::query() + ->where('is_bettable', true) + ->where('is_enabled', true) + ->orderBy('code') + ->value('code'); + if ($currencyCode === null || $currencyCode === '') { + return; + } + $pairs = PlayType::query() + ->orderBy('sort_order') + ->orderBy('play_code') + ->get(['play_code']) + ->map(fn (PlayType $pt) => (object) [ + 'play_code' => $pt->play_code, + 'currency_code' => $currencyCode, + ]); + } + + foreach ($pairs as $pair) { + $playCode = (string) $pair->play_code; + $currencyCode = strtoupper((string) $pair->currency_code); + + $anchor = OddsItem::query() + ->where('version_id', $vid) + ->where('play_code', $playCode) + ->where('currency_code', $currencyCode) + ->orderByDesc('id') + ->first(); + + $rebate = (float) ($anchor?->rebate_rate ?? 0); + $commission = (float) ($anchor?->commission_rate ?? 0); + + foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { + $exists = OddsItem::query() + ->where('version_id', $vid) + ->where('play_code', $playCode) + ->where('currency_code', $currencyCode) + ->where('prize_scope', $scope) + ->exists(); + if ($exists) { + continue; + } + + OddsItem::query()->create([ + 'version_id' => $vid, + 'play_code' => $playCode, + 'prize_scope' => $scope, + 'odds_value' => $oddsValue, + 'rebate_rate' => $rebate, + 'commission_rate' => $commission, + 'currency_code' => $currencyCode, + 'extra_config_json' => null, + ]); + } + } + }); + } +} diff --git a/config/lottery.php b/config/lottery.php index 3996248..d43c19f 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -76,7 +76,7 @@ return [ /** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 6–12 */ 'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)), /** true:RNG 后进入 review,需后台接口发布 */ - 'require_manual_review' => filter_var(env('LOTTERY_DRAW_REQUIRE_MANUAL_REVIEW', false), FILTER_VALIDATE_BOOLEAN), + 'require_manual_review' => filter_var(env('LOTTERY_DRAW_REQUIRE_MANUAL_REVIEW', true), FILTER_VALIDATE_BOOLEAN), /** 结果发布后的冷静期(分钟),{@see draws.cooling_end_time} */ 'cooldown_minutes' => max(0, (int) env('LOTTERY_DRAW_COOLDOWN_MINUTES', 15)), ], diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a17d19e..89c6238 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,8 @@ class DatabaseSeeder extends Seeder $this->call([ CurrencySeeder::class, PlayTypeSeeder::class, + OperationalConfigV1Seeder::class, + OddsPrizeScopesBackfillSeeder::class, LotterySettingsSeeder::class, ]); diff --git a/database/seeders/OddsPrizeScopesBackfillSeeder.php b/database/seeders/OddsPrizeScopesBackfillSeeder.php new file mode 100644 index 0000000..a3566ef --- /dev/null +++ b/database/seeders/OddsPrizeScopesBackfillSeeder.php @@ -0,0 +1,20 @@ +orderBy('id')->cursor() as $version) { + OddsStandardScopes::syncMissingForVersion($version); + } + } +} diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php new file mode 100644 index 0000000..87dddf2 --- /dev/null +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -0,0 +1,95 @@ +where('status', ConfigVersionStatus::Active->value)->exists()) { + 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, + ]); + } + + $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, + ]); + } + } + + $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', + ]); + } + }); + } +} diff --git a/routes/api.php b/routes/api.php index cda08f5..e7966b7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,18 +2,36 @@ use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController; use App\Http\Controllers\Api\V1\Admin\Auth\LoginController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsItemsReplaceController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionIndexController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionPublishController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionShowController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionStoreController; +use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigItemsReplaceController; +use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionIndexController; +use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionPublishController; +use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionShowController; +use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionStoreController; +use App\Http\Controllers\Api\V1\Admin\Config\RiskCapItemsReplaceController; +use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionIndexController; +use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionPublishController; +use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionShowController; +use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionStoreController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController; 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\Wallet\TransferOrderListController; use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; 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\Wallet\WalletBalanceController; @@ -38,6 +56,9 @@ Route::prefix('v1')->group(function (): void { ->where('draw_no', '[0-9]{8}-[0-9]{3}') ->name('api.v1.draw.results.show'); + // 名称:生效玩法 / 赔率 / 封顶目录(阶段 4;公开) + Route::get('play/effective', PlayEffectiveCatalogController::class)->name('api.v1.play.effective'); + Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { @@ -98,6 +119,50 @@ Route::prefix('v1')->group(function (): void { 'draws/{draw}/result-batches/{batch}/publish', DrawResultBatchPublishController::class, )->name('draws.result-batches.publish'); + + // 阶段 4:玩法目录 + 赔率 + 风控封顶(版本化管理) + Route::get('play-types', PlayTypeIndexController::class)->name('play-types.index'); + Route::patch('play-types/{play_code}', PlayTypePatchController::class) + ->where('play_code', '[a-z0-9_]+') + ->name('play-types.patch'); + + Route::prefix('config')->name('config.')->group(function (): void { + Route::get('play-versions', PlayConfigVersionIndexController::class)->name('play-versions.index'); + Route::post('play-versions', PlayConfigVersionStoreController::class)->name('play-versions.store'); + Route::get('play-versions/{id}', PlayConfigVersionShowController::class) + ->whereNumber('id') + ->name('play-versions.show'); + Route::put('play-versions/{id}/items', PlayConfigItemsReplaceController::class) + ->whereNumber('id') + ->name('play-versions.items.replace'); + Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class) + ->whereNumber('id') + ->name('play-versions.publish'); + + Route::get('odds-versions', OddsVersionIndexController::class)->name('odds-versions.index'); + Route::post('odds-versions', OddsVersionStoreController::class)->name('odds-versions.store'); + Route::get('odds-versions/{id}', OddsVersionShowController::class) + ->whereNumber('id') + ->name('odds-versions.show'); + Route::put('odds-versions/{id}/items', OddsItemsReplaceController::class) + ->whereNumber('id') + ->name('odds-versions.items.replace'); + Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class) + ->whereNumber('id') + ->name('odds-versions.publish'); + + Route::get('risk-cap-versions', RiskCapVersionIndexController::class)->name('risk-cap-versions.index'); + Route::post('risk-cap-versions', RiskCapVersionStoreController::class)->name('risk-cap-versions.store'); + Route::get('risk-cap-versions/{id}', RiskCapVersionShowController::class) + ->whereNumber('id') + ->name('risk-cap-versions.show'); + Route::put('risk-cap-versions/{id}/items', RiskCapItemsReplaceController::class) + ->whereNumber('id') + ->name('risk-cap-versions.items.replace'); + Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class) + ->whereNumber('id') + ->name('risk-cap-versions.publish'); + }); }); }); }); diff --git a/tests/Feature/OddsStandardScopesSyncTest.php b/tests/Feature/OddsStandardScopesSyncTest.php new file mode 100644 index 0000000..0ad0157 --- /dev/null +++ b/tests/Feature/OddsStandardScopesSyncTest.php @@ -0,0 +1,63 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + + $currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail(); + + $version = OddsVersion::query()->create([ + 'version_no' => 1, + 'status' => ConfigVersionStatus::Active->value, + 'effective_at' => now(), + 'updated_by' => null, + 'reason' => 'test', + ]); + + foreach (PlayType::query()->orderBy('play_code')->get() as $pt) { + OddsItem::query()->create([ + 'version_id' => $version->id, + 'play_code' => $pt->play_code, + 'prize_scope' => 'default', + 'odds_value' => 19_500, + 'rebate_rate' => 0.01, + 'commission_rate' => 0, + 'currency_code' => $currency->code, + 'extra_config_json' => null, + ]); + } + + OddsStandardScopes::syncMissingForVersion($version); + + $playCount = PlayType::query()->count(); + expect(OddsItem::query()->where('version_id', $version->id)->count())->toBe( + $playCount * (1 + count(OddsStandardScopes::SCOPE_KEYS)), + ); + foreach (PlayType::query()->get() as $pt) { + foreach (OddsStandardScopes::SCOPE_KEYS as $scope) { + $row = OddsItem::query() + ->where('version_id', $version->id) + ->where('play_code', $pt->play_code) + ->where('currency_code', $currency->code) + ->where('prize_scope', $scope) + ->firstOrFail(); + + expect((int) $row->odds_value)->toBe(OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$scope]); + /** @var string $rebate Eloquent `decimal:4` cast */ + $rebate = (string) $row->rebate_rate; + expect($rebate)->toBe('0.0100'); + } + } +}); diff --git a/tests/Feature/OperationalConfigAcceptanceTest.php b/tests/Feature/OperationalConfigAcceptanceTest.php new file mode 100644 index 0000000..48c16f4 --- /dev/null +++ b/tests/Feature/OperationalConfigAcceptanceTest.php @@ -0,0 +1,321 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); +}); + +function acceptanceMintAdminToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'acceptance_admin', + 'name' => 'Acceptance QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +function oddsPutPayloadFromDetail(array $items): array +{ + return collect($items)->map(fn (array $r) => [ + 'play_code' => $r['play_code'], + 'prize_scope' => $r['prize_scope'], + 'odds_value' => (int) $r['odds_value'], + 'rebate_rate' => (float) $r['rebate_rate'], + 'commission_rate' => (float) $r['commission_rate'], + 'currency_code' => $r['currency_code'], + 'extra_config_json' => $r['extra_config_json'] ?? null, + ])->all(); +} + +test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $itemPayload = []; + foreach (PlayType::query()->orderBy('play_code')->get() as $t) { + $itemPayload[] = [ + 'play_code' => $t->play_code, + 'is_enabled' => true, + 'min_bet_amount' => 777, + 'max_bet_amount' => 400_000_000, + 'display_order' => (int) $t->sort_order, + ]; + } + + $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); + expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777); +}); + +test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); + $payload = oddsPutPayloadFromDetail($detail); + foreach ($payload as &$row) { + if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { + $row['odds_value'] = 333_333; + } + } + unset($row); + + $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); + expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333); +}); + +test('§5 odds publish archives prior version lists history and writes audit log', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count(); + + $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); + $this->putJson( + '/api/v1/admin/config/odds-versions/'.$draftId.'/items', + ['items' => oddsPutPayloadFromDetail($detail)], + $auth, + )->assertOk(); + + $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1); + expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1); + + $list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items'); + expect(count($list))->toBeGreaterThanOrEqual(2); + + expect( + AuditLog::query() + ->where('module_code', 'odds') + ->where('action_code', 'publish') + ->exists(), + )->toBeTrue(); +}); + +test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row']; + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'p1', + 'username' => 'u1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => 'ACC-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => DrawStatus::Open->value, + 'start_time' => null, + 'close_time' => null, + 'draw_time' => null, + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $now = now(); + $orderId = DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-ACC-001', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('ticket_items')->insert([ + 'ticket_no' => 'TICK-ACC-001', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => null, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => json_encode($snapshot), + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'confirmed', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); + $payload = oddsPutPayloadFromDetail($detail); + foreach ($payload as &$row) { + if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { + $row['odds_value'] = 9_999_999; + } + } + unset($row); + + $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + $stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json'); + expect(json_decode((string) $stored, true))->toBe($snapshot); + + $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); + expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999); +}); + +test('§12.6 PATCH play-types controls master_enabled on public catalog without code deploy', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)->assertOk(); + + $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); + expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse(); + + $this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => true], $auth)->assertOk(); +}); + +test('§5 risk cap publish is audited and version history exists', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count(); + + $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items'); + $payload = collect($rows)->map(fn (array $r) => [ + 'draw_id' => $r['draw_id'], + 'normalized_number' => $r['normalized_number'], + 'cap_amount' => (int) $r['cap_amount'], + 'cap_type' => $r['cap_type'], + ])->all(); + + $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1); + + expect( + AuditLog::query() + ->where('module_code', 'risk_cap') + ->where('action_code', 'publish') + ->exists(), + )->toBeTrue(); + + $list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items'); + expect(count($list))->toBeGreaterThanOrEqual(2); +}); + +test('§5 play_config publish is audited', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $itemPayload = []; + foreach (PlayType::query()->orderBy('play_code')->get() as $t) { + $itemPayload[] = [ + 'play_code' => $t->play_code, + 'is_enabled' => true, + 'min_bet_amount' => 100, + 'max_bet_amount' => 500_000_000, + 'display_order' => (int) $t->sort_order, + ]; + } + + $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + expect( + AuditLog::query() + ->where('module_code', 'play_config') + ->where('action_code', 'publish') + ->exists(), + )->toBeTrue(); +}); diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php new file mode 100644 index 0000000..87eafb3 --- /dev/null +++ b/tests/Feature/OperationalConfigApiTest.php @@ -0,0 +1,87 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); +}); + +function mintConfigAdminToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'config_admin', + 'name' => 'Config QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('play effective catalog is public and merged', function (): void { + $resp = $this->getJson('/api/v1/play/effective?currency=NPR'); + $resp->assertOk()->assertJsonPath('data.currency_code', 'NPR'); + + $plays = $resp->json('data.plays'); + expect($plays)->toBeArray()->not->toBeEmpty(); + expect($plays[0])->toHaveKeys(['play_code', 'config', 'odds', 'master_enabled']); +}); + +test('admin play config draft publish flow', function (): void { + $token = mintConfigAdminToken(); + $active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail(); + + $create = $this->postJson('/api/v1/admin/config/play-versions', [ + 'reason' => 'test draft', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + expect($draftId)->toBeGreaterThan(0); + expect($create->json('data.status'))->toBe(ConfigVersionStatus::Draft->value); + + $types = PlayType::query()->orderBy('play_code')->get(); + $itemPayload = []; + foreach ($types as $t) { + $itemPayload[] = [ + 'play_code' => $t->play_code, + 'is_enabled' => true, + 'min_bet_amount' => 200, + 'max_bet_amount' => 400_000_000, + 'display_order' => (int) $t->sort_order, + ]; + } + + $this->putJson( + '/api/v1/admin/config/play-versions/'.$draftId.'/items', + ['items' => $itemPayload], + ['Authorization' => 'Bearer '.$token], + )->assertOk()->assertJsonPath('data.items.0.min_bet_amount', 200); + + $this->postJson( + '/api/v1/admin/config/play-versions/'.$draftId.'/publish', + [], + ['Authorization' => 'Bearer '.$token], + )->assertOk()->assertJsonPath('data.status', ConfigVersionStatus::Active->value); + + $active->refresh(); + expect($active->status)->toBe(ConfigVersionStatus::Archived->value); + + expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1); +}); + +test('admin play-types requires authentication', function (): void { + $this->getJson('/api/v1/admin/play-types')->assertUnauthorized(); +});