diff --git a/AGENTS.md b/AGENTS.md index 5df07f2..65a0a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,15 +42,14 @@ ## Learned Workspace Facts -- 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。 -- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`。 -- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。 +- 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`。 - `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。 - 站点管理员(`admin_user_site_roles` + `slug=site_admin`,且**未**绑 `admin_user_agents`)定位单站信用盘运营(代理树/玩家/结算/注单/报表);不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 -- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。 -- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。 -- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty;代理账单=按 net_amount 方向的 payee)。上级不能代登下级玩家收付,下级也不能代登向上级的代理账单。 +- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`;登记收付/确认仅可操作 **收款方**。 - 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 -- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表。 -- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`)。 -- **一级代理 profile**:每站唯一根节点(`depth=0`)的占成/授信/回水仅超管可改(`AdminAgentScope::nodeProfileEditableBy`);站点管理员与一级代理账号对根节点 profile API 为 403;下级仍由上级代理维护。 +- 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync);常驻 `schedule:work`、`queue:work redis --queue=broadcasts:countdown,broadcasts,default`、`reverb:start`;`CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis;先部署 lotterLaravel 再前端。 +- `AgentProfileService::upsertForNode` PATCH 缺省键会写 0(占成/回水等),须按 `array_key_exists` 合并既有 profile。 +- 代理仪表盘/账期「输赢」用本级 `share_profit`;开/关账期仅未绑定代理站点财务;根节点(`depth=0`)profile 仅超管可改。 +- SSO 钱包盘:首次有效 JWT 调 `GET /api/v1/player/me` 按 `(site_code, site_player_id)` 自动建档;`username`/`nickname` 彩票生成 `nlotto******`,不从主站同步。 +- 错误 8005(Account suspended):站点不存在/停用或玩家冻结;**生产**按 `admin_sites` 验站点,`.env` `MAIN_SITE_*` 兜底仅 `local/testing`。 +- 建站即生成 `sso_jwt_secret`/`wallet_api_key`;`main-site/` 须手动对齐 `MAIN_SITE_CODE` 与两把密钥(未设时默认 `main-site`),不会从后台自动同步;`wallet_api_url` 填主站钱包根 URL,勿填彩票 H5。 diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php index 644aa04..3769b65 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php @@ -23,6 +23,7 @@ final class AdminRiskPoolIndexController extends Controller $p = AdminApiList::readPaging($request); $soldOutOnly = $request->boolean('sold_out_only'); $highRiskOnly = $request->boolean('high_risk_only'); + $activeOnly = $request->boolean('active_only'); $number = trim((string) $request->query('normalized_number', '')); $sort = trim((string) $request->query('sort', 'usage_desc')); @@ -34,6 +35,13 @@ final class AdminRiskPoolIndexController extends Controller if ($highRiskOnly) { $q->whereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); } + if ($activeOnly) { + $q->where(function ($inner): void { + $inner->where('locked_amount', '>', 0) + ->orWhere('sold_out_status', 1) + ->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); + }); + } if ($number !== '') { $q->where('normalized_number', 'like', '%'.$number.'%'); } diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php index b35af32..f33d626 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php @@ -10,48 +10,51 @@ use App\Models\RiskPoolLockLog; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use App\Services\Admin\AdminRiskPoolLockLogQueryService; /** * GET /api/v1/admin/draws/{draw}/risk-pool-lock-logs — 风险池占用/释放流水(审计与监控)。 + * + * Query:`group_by=ticket`(默认,按注单聚合)| `entry`(按号码明细); + * `ticket_item_id` 可筛单注;`action_type`、`normalized_number` 同前。 */ final class AdminRiskPoolLockLogIndexController extends Controller { + public function __construct( + private readonly AdminRiskPoolLockLogQueryService $queryService, + ) {} + public function __invoke(Request $request, Draw $draw): JsonResponse { $p = AdminApiList::readPaging($request); - $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); + $groupBy = trim((string) $request->query('group_by', 'ticket')); + if (! in_array($groupBy, ['ticket', 'entry'], true)) { + $groupBy = 'ticket'; } /** @var LengthAwarePaginator $paginator */ - $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); + $paginator = $groupBy === 'entry' + ? $this->queryService->paginateEntries($draw, $request, $p['page'], $p['perPage']) + : $this->queryService->paginateByTicket($draw, $request, $p['page'], $p['perPage']); $currencyCode = (string) (TicketOrder::query() ->where('draw_id', $draw->id) ->value('currency_code') ?? ''); - return AdminApiList::jsonWith($paginator, fn (RiskPoolLockLog $log) => $this->row($log), [ + $rowMapper = $groupBy === 'entry' + ? fn (RiskPoolLockLog $log): array => $this->entryRow($log) + : fn (object $row): array => $this->ticketRow($row); + + return AdminApiList::jsonWith($paginator, $rowMapper, [ 'draw_id' => (int) $draw->id, 'draw_no' => $draw->draw_no, 'currency_code' => $currencyCode !== '' ? $currencyCode : null, + 'group_by' => $groupBy, ]); } /** @return array */ - private function row(RiskPoolLockLog $log): array + private function entryRow(RiskPoolLockLog $log): array { return [ 'id' => (int) $log->id, @@ -66,4 +69,25 @@ final class AdminRiskPoolLockLogIndexController extends Controller 'created_at' => $log->created_at?->toIso8601String(), ]; } + + /** @return array */ + private function ticketRow(object $row): array + { + $lastAt = $row->last_at ?? null; + + return [ + 'ticket_item_id' => (int) $row->ticket_item_id, + 'ticket_no' => (string) $row->ticket_no, + 'play_code' => (string) $row->play_code, + 'original_number' => (string) $row->original_number, + 'combination_count' => (int) $row->combination_count, + 'player_id' => (int) $row->player_id, + 'number_count' => (int) $row->number_count, + 'lock_entry_count' => (int) $row->lock_entry_count, + 'release_entry_count' => (int) $row->release_entry_count, + 'total_lock_amount' => (int) $row->total_lock_amount, + 'total_release_amount' => (int) $row->total_release_amount, + 'last_at' => is_string($lastAt) ? $lastAt : null, + ]; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php new file mode 100644 index 0000000..7e65277 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemShowController.php @@ -0,0 +1,100 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $ticketNo = trim($ticket_no); + $scope = AdminScopePolicy::resolveContext($request, $admin); + + $query = TicketItem::query() + ->where('ticket_no', $ticketNo) + ->with([ + 'combinations', + 'draw:id,draw_no,business_date', + 'order:id,order_no,currency_code,created_at,status', + 'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode', + 'player.agentNode:id,code,name', + ]); + + AdminScopePolicy::applyViaPlayerRelationWithContext($query, $scope, 'player'); + + $item = $query->first(); + if ($item === null) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + $totalBet = (int) $item->total_bet_amount; + $actualDeduct = (int) $item->actual_deduct_amount; + + return ApiResponse::success([ + 'id' => (int) $item->id, + 'ticket_no' => $item->ticket_no, + 'order_no' => $item->order?->order_no, + 'order_status' => $item->order?->status, + 'draw_id' => (int) $item->draw_id, + 'draw_no' => $item->draw?->draw_no, + 'currency_code' => $item->order?->currency_code, + 'player_id' => $item->player_id, + 'site_code' => $item->player?->site_code, + 'site_player_id' => $item->player?->site_player_id, + 'username' => $item->player?->username, + 'nickname' => $item->player?->nickname, + 'funding_mode' => $item->player?->funding_mode, + 'agent_node_id' => $item->player?->agent_node_id, + 'agent_code' => $item->player?->agentNode?->code, + 'agent_name' => $item->player?->agentNode?->name, + 'play_code' => $item->play_code, + 'dimension' => $item->dimension, + 'digit_slot' => $item->digit_slot, + 'original_number' => $item->original_number, + 'normalized_number' => $item->normalized_number, + 'combination_count' => (int) $item->combination_count, + 'unit_bet_amount' => (int) $item->unit_bet_amount, + 'total_bet_amount_minor' => $totalBet, + 'total_bet_amount_formatted' => CurrencyFormatter::fromMinor($totalBet), + 'actual_deduct_amount_minor' => $actualDeduct, + 'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct), + 'risk_locked_amount' => (int) $item->risk_locked_amount, + 'status' => $item->status, + 'fail_reason_code' => $item->fail_reason_code, + 'fail_reason_text' => $item->fail_reason_text, + 'win_amount_minor' => (int) $item->win_amount, + 'win_amount_formatted' => CurrencyFormatter::fromMinor((int) $item->win_amount), + 'placed_at' => $item->order?->created_at?->toIso8601String(), + 'updated_at' => $item->updated_at?->toIso8601String(), + 'combinations' => $item->combinations + ->sortBy('combination_no') + ->values() + ->map(fn ($combo): array => [ + 'combination_no' => (int) $combo->combination_no, + 'number_4d' => (string) $combo->number_4d, + 'bet_amount' => (int) $combo->bet_amount, + 'estimated_payout' => (int) $combo->estimated_payout, + ]) + ->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php index df4a014..6a7ac2c 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php @@ -7,15 +7,12 @@ use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminRoleApiPresenter; -use App\Support\PlatformSystemRoles; - final class AdminRoleIndexController extends Controller { public function __invoke(): JsonResponse { $roles = AdminRole::query() ->where('scope_type', AdminRole::SCOPE_SYSTEM) - ->whereIn('slug', PlatformSystemRoles::fixedSlugs()) ->orderBy('sort_order') ->orderBy('id') ->get(); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php index 0bd66dd..32f51a5 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php @@ -2,22 +2,71 @@ namespace App\Http\Controllers\Api\V1\Admin\User; +use App\Models\AdminRole; use App\Lottery\ErrorCode; use App\Support\ApiMessage; +use App\Support\ApiResponse; +use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminRoleApiPresenter; +use App\Support\PlatformSystemRoles; use App\Http\Requests\Admin\AdminRoleStoreRequest; final class AdminRoleStoreController extends Controller { public function __invoke(AdminRoleStoreRequest $request): JsonResponse { - return ApiMessage::errorResponse( + $slug = strtolower(trim((string) $request->validated('slug'))); + if (PlatformSystemRoles::isFixedSlug($slug)) { + return ApiMessage::errorResponse( + $request, + 'admin.role_slug_reserved', + ErrorCode::ValidationFailed->value, + ['slug' => $slug], + 422, + ); + } + + $role = DB::transaction(function () use ($request, $slug): AdminRole { + $maxSort = (int) AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->max('sort_order'); + + /** @var AdminRole $role */ + $role = AdminRole::query()->create([ + 'slug' => $slug, + 'code' => $slug, + 'name' => $request->validated('name'), + 'description' => $request->validated('description'), + 'status' => (int) ($request->validated('status') ?? 1), + 'is_system' => false, + 'sort_order' => max(100, $maxSort + 10), + 'scope_type' => AdminRole::SCOPE_SYSTEM, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ]); + + $permissionSlugs = $request->validated('permission_slugs', []); + if (is_array($permissionSlugs) && $permissionSlugs !== []) { + $role->syncLegacyPermissionSlugs(array_values($permissionSlugs)); + } + + return $role->refresh(); + }); + + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), $request, - 'admin.platform_roles_fixed', - ErrorCode::ValidationFailed->value, + 'system', + 'admin_role.create', + 'admin_role', + (string) $role->id, null, - 422, + AdminRoleApiPresenter::item($role), ); + + return ApiResponse::success(AdminRoleApiPresenter::item($role))->setStatusCode(201); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php index 06f92f9..3e5c700 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php @@ -39,7 +39,21 @@ final class AdminRoleUpdateController extends Controller } } if (isset($payload['slug'])) { - $payload['code'] = $payload['slug']; + $nextSlug = strtolower(trim((string) $payload['slug'])); + if ( + PlatformSystemRoles::isFixedSlug($nextSlug) + && $nextSlug !== (string) $admin_role->slug + ) { + return ApiMessage::errorResponse( + $request, + 'admin.role_slug_reserved', + ErrorCode::ValidationFailed->value, + ['slug' => $nextSlug], + 422, + ); + } + $payload['slug'] = $nextSlug; + $payload['code'] = $nextSlug; } $admin_role->fill($payload); diff --git a/app/Services/Admin/AdminRiskPoolLockLogQueryService.php b/app/Services/Admin/AdminRiskPoolLockLogQueryService.php new file mode 100644 index 0000000..4b5ea10 --- /dev/null +++ b/app/Services/Admin/AdminRiskPoolLockLogQueryService.php @@ -0,0 +1,126 @@ + + */ + public function paginateEntries(Draw $draw, Request $request, int $page, int $perPage): LengthAwarePaginator + { + $action = trim((string) $request->query('action_type', '')); + $number = trim((string) $request->query('normalized_number', '')); + $ticketItemId = (int) $request->integer('ticket_item_id', 0); + + $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); + } + + if ($ticketItemId > 0) { + $q->where('ticket_item_id', $ticketItemId); + } + + return $q->paginate($perPage, ['*'], 'page', $page); + } + + /** + * @return LengthAwarePaginator + */ + public function paginateByTicket(Draw $draw, Request $request, int $page, int $perPage): LengthAwarePaginator + { + $action = trim((string) $request->query('action_type', '')); + $number = trim((string) $request->query('normalized_number', '')); + $ticketItemId = (int) $request->integer('ticket_item_id', 0); + + $bindings = ['draw_id' => $draw->id]; + $filters = 'l.draw_id = :draw_id AND l.ticket_item_id IS NOT NULL'; + + if ($action !== '' && in_array($action, ['lock', 'release'], true)) { + $filters .= ' AND l.action_type = :action_type'; + $bindings['action_type'] = $action; + } + + if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) { + $filters .= ' AND l.normalized_number = :normalized_number'; + $bindings['normalized_number'] = $number; + } + + if ($ticketItemId > 0) { + $filters .= ' AND l.ticket_item_id = :ticket_item_id'; + $bindings['ticket_item_id'] = $ticketItemId; + } + + $driver = DB::connection()->getDriverName(); + $lockCountExpr = $driver === 'sqlite' + ? "SUM(CASE WHEN l.action_type = 'lock' THEN 1 ELSE 0 END)" + : "COUNT(*) FILTER (WHERE l.action_type = 'lock')"; + $releaseCountExpr = $driver === 'sqlite' + ? "SUM(CASE WHEN l.action_type = 'release' THEN 1 ELSE 0 END)" + : "COUNT(*) FILTER (WHERE l.action_type = 'release')"; + $lockAmountExpr = $driver === 'sqlite' + ? "COALESCE(SUM(CASE WHEN l.action_type = 'lock' THEN l.amount ELSE 0 END), 0)" + : "COALESCE(SUM(l.amount) FILTER (WHERE l.action_type = 'lock'), 0)"; + $releaseAmountExpr = $driver === 'sqlite' + ? "COALESCE(SUM(CASE WHEN l.action_type = 'release' THEN l.amount ELSE 0 END), 0)" + : "COALESCE(SUM(l.amount) FILTER (WHERE l.action_type = 'release'), 0)"; + + $aggregateSql = <<aggregate_count ?? 0); + $lastPage = max(1, (int) ceil($total / $perPage)); + $page = min(max($page, 1), $lastPage); + $offset = ($page - 1) * $perPage; + + $items = DB::select( + "{$aggregateSql} ORDER BY last_at DESC, ticket_item_id DESC LIMIT {$perPage} OFFSET {$offset}", + $bindings, + ); + + return new LengthAwarePaginator( + $items, + $total, + $perPage, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + } +} diff --git a/lang/en/admin.php b/lang/en/admin.php index 6e75bc5..3300ab6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -29,7 +29,7 @@ return [ 'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.', 'player_unpaid_settlement_blocks_delete' => 'Player still has unpaid settlement bills and cannot be deleted until they are settled or written off.', 'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.', - 'platform_roles_fixed' => 'Only the built-in Super Admin and Agent platform roles are supported; creating new roles is disabled.', + 'role_slug_reserved' => 'This role code is reserved for a built-in role. Choose another slug.', 'role_super_admin_permissions_fixed' => 'Super Admin always has full permissions. Run lottery:admin-auth-sync after the permission catalog changes.', 'role_super_admin_metadata_fixed' => 'Super Admin is a built-in role; name and status cannot be changed.', 'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index eed3d5e..855ae84 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -25,7 +25,7 @@ return [ 'player_has_tickets_blocks_delete' => 'खेलाडीसँग टिकट रेकर्ड छ, मेटाउन मिल्दैन।', 'player_unpaid_settlement_blocks_delete' => 'खेलाडीसँग अझै बाँकी सेटलमेन्ट बिलहरू छन्। पहिले सेटल वा राइट-अफ गरेपछि मात्र मेटाउन मिल्छ।', 'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।', - 'platform_roles_fixed' => 'प्लेटफर्ममा केवल सुपर एडमिन र एजेन्ट भूमिका छन्; नयाँ भूमिका थप्न मिल्दैन।', + 'role_slug_reserved' => 'यो भूमिका कोड बिल्ट-इन भूमिकाका लागि राखिएको छ। अर्को कोड प्रयोग गर्नुहोस्।', 'role_super_admin_permissions_fixed' => 'सुपर एडमिनसँग सबै अनुमति हुन्छ; क्याटलग परिवर्तनपछि lottery:admin-auth-sync चलाउनुहोस्।', 'role_super_admin_metadata_fixed' => 'सुपर एडमिन बिल्ट-इन भूमिका हो; नाम वा स्थिति परिवर्तन गर्न मिल्दैन।', 'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index d0bd11a..4bcaced 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -30,7 +30,7 @@ return [ 'player_unpaid_settlement_blocks_delete' => '该玩家仍有未结账单,请先结清或核销后再删除。', 'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。', 'role_cannot_delete_super_admin' => '不能删除超级管理员角色。', - 'platform_roles_fixed' => '平台仅保留「超级管理员」与「代理」两个内置角色,不支持新增。', + 'role_slug_reserved' => '该角色编码为系统保留,请使用其他标识。', 'role_super_admin_permissions_fixed' => '超级管理员拥有全部权限,请在权限目录变更后执行 lottery:admin-auth-sync。', 'role_super_admin_metadata_fixed' => '超级管理员为内置角色,不支持修改名称或状态。', 'role_builtin_cannot_delete' => '系统内置角色不允许删除。', diff --git a/routes/api/v1/admin/ticket.php b/routes/api/v1/admin/ticket.php index 99117de..bed0c66 100644 --- a/routes/api/v1/admin/ticket.php +++ b/routes/api/v1/admin/ticket.php @@ -1,6 +1,7 @@ group(function (): void { + Route::get('tickets/{ticket_no}', AdminTicketItemShowController::class) + ->name('api.v1.admin.tickets.show'); Route::get('tickets', AdminTicketItemIndexController::class) ->name('api.v1.admin.tickets.index'); }); diff --git a/tests/Feature/AdminRiskPoolApiTest.php b/tests/Feature/AdminRiskPoolApiTest.php index df9ac63..5ad0907 100644 --- a/tests/Feature/AdminRiskPoolApiTest.php +++ b/tests/Feature/AdminRiskPoolApiTest.php @@ -254,12 +254,63 @@ test('admin risk pool lock logs include ticket_no when linked', function (): voi $token = mintRiskAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) - ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs') + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs?group_by=entry') ->assertOk() + ->assertJsonPath('data.group_by', 'entry') ->assertJsonPath('data.meta.total', 1) ->assertJsonPath('data.items.0.amount', 50); }); +test('admin risk pool lock logs can group by ticket and filter active pools', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-007', + 'business_date' => '2026-05-12', + 'sequence_no' => 7, + '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' => '0043', + 'total_cap_amount' => 1_000_000, + 'locked_amount' => 0, + 'remaining_amount' => 1_000_000, + 'sold_out_status' => 0, + 'version' => 0, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1288', + 'total_cap_amount' => 1_000, + 'locked_amount' => 850, + 'remaining_amount' => 150, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?active_only=1') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.normalized_number', '1288'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs?group_by=entry') + ->assertOk() + ->assertJsonPath('data.group_by', 'entry'); +}); + test('admin risk pool show 404 when pool missing', function (): void { $draw = Draw::query()->create([ 'draw_no' => '20260512-003', diff --git a/tests/Feature/PlatformSystemRolesTest.php b/tests/Feature/PlatformSystemRolesTest.php index 5d6a786..27af314 100644 --- a/tests/Feature/PlatformSystemRolesTest.php +++ b/tests/Feature/PlatformSystemRolesTest.php @@ -27,7 +27,7 @@ function platformRolesApiToken(string $username): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } -test('platform role index only lists fixed super_admin and agent roles', function (): void { +test('platform role index lists built-in roles and custom system roles', function (): void { AdminRole::query()->create([ 'slug' => 'legacy_custom_ops', 'code' => 'legacy_custom_ops', @@ -49,10 +49,37 @@ test('platform role index only lists fixed super_admin and agent roles', functio ->pluck('slug') ->all(); - expect($slugs)->toBe(['super_admin', 'site_admin', 'agent']); + expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops'); }); -test('platform roles cannot be created and super_admin permissions are full catalog', function (): void { +test('admin can create custom platform role but not reserved slugs', function (): void { + PlatformSystemRoles::ensureAll(); + + $token = platformRolesApiToken('platform_role_create'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/admin-roles', [ + 'slug' => 'super_admin', + 'name' => 'Fake Super', + ]) + ->assertStatus(422); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/admin-roles', [ + 'slug' => 'new_ops', + 'name' => 'New Ops', + 'description' => 'Custom ops role', + 'permission_slugs' => ['prd.dashboard.view'], + ]) + ->assertCreated() + ->assertJsonPath('data.slug', 'new_ops') + ->assertJsonPath('data.name', 'New Ops') + ->assertJsonPath('data.is_system', false); + + expect(AdminRole::query()->where('slug', 'new_ops')->exists())->toBeTrue(); +}); + +test('super_admin built-in role remains protected', function (): void { PlatformSystemRoles::ensureAll(); $token = platformRolesApiToken('platform_role_guard'); @@ -64,13 +91,6 @@ test('platform roles cannot be created and super_admin permissions are full cata ->toBe($menuActionCount); expect($super->legacyPermissionSlugs())->not->toBeEmpty(); - $this->withHeader('Authorization', 'Bearer '.$token) - ->postJson('/api/v1/admin/admin-roles', [ - 'slug' => 'new_ops', - 'name' => 'New Ops', - ]) - ->assertStatus(422); - $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [ 'permission_slugs' => ['prd.dashboard.view'],