diff --git a/AGENTS.md b/AGENTS.md index 2852fd9..c0316ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ - 期号 `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`)。 +- 站点管理员(`admin_user_site_roles` + `slug=site_admin|site_finance|site_cs`,且**未**绑 `admin_user_agents`)定位单站运营;`site_admin` 含代理树/玩家/信用结算/注单 + 本站钱包流水·对账·经营报表(可导出)·期号只读;`site_finance` 财务工作台 + 对账/报表/结算收付;`site_cs` 客服工作台 + 单玩家查询。数据范围仅绑定站点;不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 - 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`;登记收付/确认仅可操作 **收款方**。 - 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 - 线上生产:已有库用 `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 再前端。 diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php index 49a6261..9299d7d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php @@ -2,12 +2,15 @@ namespace App\Http\Controllers\Api\V1\Admin\Reconcile; +use App\Models\AdminSite; use App\Models\AdminUser; use App\Models\TransferOrder; use App\Models\ReconcileJob; use Illuminate\Http\Request; use App\Models\ReconcileItem; +use App\Support\AdminAgentScope; use App\Support\AdminApiList; +use App\Support\AdminReconcileScope; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminTransferOrderCapabilities; @@ -28,11 +31,27 @@ final class ReconcileItemIndexController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); + if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) { + return $denied; + } + $p = AdminApiList::readPaging($request, 50, 200); - $paginator = $reconcile_job->items() - ->orderBy('id') - ->paginate($p['perPage'], ['*'], 'page', $p['page']); + $itemsQuery = $reconcile_job->items()->orderBy('id'); + if (! $admin->isSuperAdmin()) { + $siteIds = $admin->accessibleAdminSiteIds() ?? []; + $siteCodes = $siteIds === [] + ? [] + : AdminSite::query() + ->whereIn('id', $siteIds) + ->pluck('code') + ->map(static fn ($code): string => (string) $code) + ->all(); + $agent = AdminAgentScope::primaryAgentNode($admin); + AdminReconcileScope::applyItemsTransferPlayerScope($itemsQuery, $siteCodes, $agent); + } + + $paginator = $itemsQuery->paginate($p['perPage'], ['*'], 'page', $p['page']); $transferNos = collect($paginator->items()) ->map(fn (ReconcileItem $item) => $item->side_a_ref) diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php index d70f1de..433c0ff 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Reconcile; use App\Models\ReconcileJob; +use App\Support\AdminReconcileScope; use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; @@ -13,6 +14,9 @@ final class ReconcileJobIndexController extends Controller { public function __invoke(Request $request): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request); $type = trim((string) $request->query('reconcile_type', '')); @@ -21,6 +25,8 @@ final class ReconcileJobIndexController extends Controller $q->where('reconcile_type', $type); } + AdminReconcileScope::applyToJobsQuery($q, $admin); + $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); return AdminApiList::json($paginator, fn (ReconcileJob $j) => $this->row($j)); diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php index d2144ec..549c49e 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php @@ -3,15 +3,24 @@ namespace App\Http\Controllers\Api\V1\Admin\Reconcile; use App\Models\ReconcileJob; +use App\Support\AdminReconcileScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use App\Http\Controllers\Controller; /** GET /api/v1/admin/reconcile-jobs/{reconcile_job} */ final class ReconcileJobShowController extends Controller { - public function __invoke(ReconcileJob $reconcile_job): JsonResponse + public function __invoke(Request $request, ReconcileJob $reconcile_job): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + + if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) { + return $denied; + } + return ApiResponse::success([ 'id' => (int) $reconcile_job->id, 'job_no' => $reconcile_job->job_no, diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php deleted file mode 100644 index e965fe2..0000000 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php +++ /dev/null @@ -1,47 +0,0 @@ -lotteryAdmin(); - abort_if($admin === null, 401); - - $validated = $request->validated(); - $p = AdminApiList::readPaging($request); - $range = $service->resolveDateRange($validated); - $playCode = isset($validated['play_code']) ? trim((string) $validated['play_code']) : null; - $scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id'); - - $paginator = $service->rebateCommissionPaginated( - $playCode !== '' ? $playCode : null, - $range['date_from'], - $range['date_to'], - $p['page'], - $p['perPage'], - $scope, - ); - - return AdminApiList::jsonWith($paginator, static function (object $row): array { - return [ - 'play_code' => (string) $row->play_code, - 'total_rebate_minor' => (int) $row->total_rebate_minor, - 'order_count' => (int) $row->order_count, - 'ticket_item_count' => (int) $row->ticket_item_count, - ]; - }, [ - 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), - 'disclaimer' => 'wallet_instant_rebate_not_agent_period_settlement', - ]); - } -} diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php index d0a791c..d87c2f9 100644 --- a/app/Http/Requests/Admin/ReportJobStoreRequest.php +++ b/app/Http/Requests/Admin/ReportJobStoreRequest.php @@ -48,7 +48,6 @@ final class ReportJobStoreRequest extends ApiFormRequest 'hot_number_risk_report', 'play_dimension_report', 'sold_out_number_report', - 'rebate_commission_report', 'audit_operation_report', 'wallet_txns_daily', 'transfer_orders_daily', diff --git a/app/Models/ReconcileJob.php b/app/Models/ReconcileJob.php index 6eb5c22..542ec59 100644 --- a/app/Models/ReconcileJob.php +++ b/app/Models/ReconcileJob.php @@ -13,6 +13,7 @@ final class ReconcileJob extends Model protected $fillable = [ 'job_no', 'admin_user_id', + 'admin_site_id', 'reconcile_type', 'status', 'period_start', diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index e1a58da..13bcdf0 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -17,7 +17,7 @@ use App\Support\AdminDataScope; use App\Support\AdminScopeContext; use App\Support\AdminAgentScope; use App\Support\AdminScopeContextResolver; -use App\Support\SitePlatformRole; +use App\Support\SiteOperatorRoles; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; /** @@ -32,6 +32,8 @@ final class AdminDashboardSnapshotBuilder private readonly AdminReportQueryService $reportQuery, private readonly AgentDashboardOverviewBuilder $agentOverview, private readonly SiteDashboardOverviewBuilder $siteOverview, + private readonly SiteFinanceDashboardOverviewBuilder $siteFinanceOverview, + private readonly SiteCsDashboardOverviewBuilder $siteCsOverview, ) {} /** @return array */ @@ -60,12 +62,19 @@ final class AdminDashboardSnapshotBuilder ], 'agent_overview' => null, 'site_overview' => null, + 'site_finance_overview' => null, + 'site_cs_overview' => null, ]; if ($admin->primaryAgentNode() !== null) { $out['agent_overview'] = $this->agentOverview->build($admin); - } elseif (SitePlatformRole::userHasSiteAdminRole($admin)) { - $out['site_overview'] = $this->siteOverview->build($admin, $scope); + } elseif (SiteOperatorRoles::userHasSiteOperatorRole($admin)) { + $operatorSlug = SiteOperatorRoles::primarySiteOperatorSlug($admin); + match ($operatorSlug) { + SiteOperatorRoles::SLUG_SITE_FINANCE => $out['site_finance_overview'] = $this->siteFinanceOverview->build($admin, $scope), + SiteOperatorRoles::SLUG_SITE_CS => $out['site_cs_overview'] = $this->siteCsOverview->build($admin), + default => $out['site_overview'] = $this->siteOverview->build($admin, $scope), + }; } if ($canDraw) { diff --git a/app/Services/Admin/AdminReconcileJobService.php b/app/Services/Admin/AdminReconcileJobService.php index d2a9abf..82e324f 100644 --- a/app/Services/Admin/AdminReconcileJobService.php +++ b/app/Services/Admin/AdminReconcileJobService.php @@ -4,11 +4,17 @@ namespace App\Services\Admin; use Carbon\Carbon; use App\Models\AdminUser; +use App\Models\Player; use Illuminate\Support\Str; use App\Models\ReconcileJob; use Illuminate\Http\Request; use App\Models\ReconcileItem; +use App\Lottery\ErrorCode; use App\Services\AuditLogger; +use App\Support\AdminReconcileScope; +use App\Support\AdminScopePolicy; +use App\Support\AdminSiteScope; +use App\Support\ApiMessage; use Illuminate\Support\Facades\DB; use App\Services\Wallet\WalletTransferReconcileDetector; @@ -102,6 +108,23 @@ final class AdminReconcileJobService $periodEnd ??= now()->endOfDay(); $periodStart ??= $periodEnd->copy()->startOfDay(); + $player = null; + if ($playerId !== null) { + $player = Player::query()->find($playerId); + if ($player === null || ! AdminSiteScope::playerAccessible($admin, $player)) { + abort(ApiMessage::errorResponse( + $request, + 'admin.site_player_access_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + )); + } + } + + $scope = AdminScopePolicy::resolveContext($request, $admin); + $adminSiteId = AdminReconcileScope::resolveAdminSiteIdForJob($admin, $player); + [$items] = $this->detector->scanItems( $periodStart, $periodEnd, @@ -109,9 +132,17 @@ final class AdminReconcileJobService staleMinutes: 15, playerId: $playerId, includeMainSiteCheck: true, + scope: $scope, ); - $job = $this->detector->persistJob($items, $periodStart, $periodEnd, (int) $admin->getKey()); + $job = $this->detector->persistJob( + $items, + $periodStart, + $periodEnd, + (int) $admin->getKey(), + $adminSiteId, + $playerId, + ); AuditLogger::recordForAdmin( $admin, diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index a57a466..3d5d6f8 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -478,19 +478,6 @@ final class AdminReportQueryService return $query->paginate($perPage, ['*'], 'page', $page); } - public function rebateCommissionPaginated( - ?string $playCode, - string $dateFrom, - string $dateTo, - int $page, - int $perPage, - AdminUser|AdminScopeContext|null $scope = null, - ): LengthAwarePaginator { - $query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scope); - - return $query->paginate($perPage, ['*'], 'page', $page); - } - /** * @return list> */ @@ -505,7 +492,6 @@ final class AdminReportQueryService 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope), 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope), 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope), - 'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scope), 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo), 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), @@ -546,7 +532,6 @@ final class AdminReportQueryService 'hot_number_risk_report' => '热门号码风险报表', 'play_dimension_report' => '玩法维度报表', 'sold_out_number_report' => '售罄号码报表', - 'rebate_commission_report' => '佣金回水报表', 'audit_operation_report' => '后台操作审计报表', default => $reportType, }; @@ -618,28 +603,6 @@ final class AdminReportQueryService return $rows; } - /** - * @return list> - */ - private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array - { - $playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null; - $rows = [ - ['玩法', '回水', '订单数', '注单数'], - ]; - $items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scope)->get(); - foreach ($items as $row) { - $rows[] = [ - (string) $row->play_code, - (int) $row->total_rebate_minor, - (int) $row->order_count, - (int) $row->ticket_item_count, - ]; - } - - return $rows; - } - /** * @return list> */ @@ -739,31 +702,6 @@ final class AdminReportQueryService return $query; } - /** @return \Illuminate\Database\Query\Builder */ - private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null) - { - $context = $this->normalizeScope($scope); - $query = DB::table('ticket_items as ti') - ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') - ->join('draws as d', 'd.id', '=', 'o.draw_id') - ->selectRaw('ti.play_code') - ->selectRaw('SUM(ti.total_bet_amount - ti.actual_deduct_amount) as total_rebate_minor') - ->selectRaw('COUNT(DISTINCT o.id) as order_count') - ->selectRaw('COUNT(ti.id) as ticket_item_count') - ->whereDate('d.business_date', '>=', $dateFrom) - ->whereDate('d.business_date', '<=', $dateTo) - ->groupBy('ti.play_code') - ->orderBy('ti.play_code'); - - if ($playCode !== null && $playCode !== '') { - $query->where('ti.play_code', $playCode); - } - - AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o'); - - return $query; - } - /** * @return list> */ diff --git a/app/Services/Admin/SiteCsDashboardOverviewBuilder.php b/app/Services/Admin/SiteCsDashboardOverviewBuilder.php new file mode 100644 index 0000000..299d000 --- /dev/null +++ b/app/Services/Admin/SiteCsDashboardOverviewBuilder.php @@ -0,0 +1,62 @@ +|null + */ + public function build(AdminUser $admin): ?array + { + if (! $admin->hasPermissionCode('dashboard.view')) { + return null; + } + + $site = SiteDashboardSiteResolver::resolvePrimarySite($admin); + if ($site === null) { + return null; + } + + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + $today = now()->toDateString(); + $activity = $this->todayTicketStats($siteCode, $today); + + return [ + 'admin_site_id' => $siteId, + 'site_code' => $siteCode, + 'site_name' => (string) $site->name, + 'player_count' => (int) Player::query()->where('site_code', $siteCode)->count(), + 'ticket_order_count_today' => $activity['order_count'], + 'active_player_count_today' => $activity['player_count'], + 'latest_ticket_at' => $activity['latest_at'], + ]; + } + + /** + * @return array{player_count: int, order_count: int, latest_at: ?string} + */ + private function todayTicketStats(string $siteCode, string $today): array + { + $base = DB::table('ticket_orders as o') + ->join('draws as d', 'd.id', '=', 'o.draw_id') + ->join('players as p', 'p.id', '=', 'o.player_id') + ->where('p.site_code', $siteCode) + ->whereDate('d.business_date', $today); + + $latestAt = (clone $base)->max('o.created_at'); + + return [ + 'player_count' => (int) (clone $base)->distinct('o.player_id')->count('o.player_id'), + 'order_count' => (int) (clone $base)->count(), + 'latest_at' => $latestAt !== null ? Carbon::parse((string) $latestAt)->toIso8601String() : null, + ]; + } +} diff --git a/app/Services/Admin/SiteDashboardOverviewBuilder.php b/app/Services/Admin/SiteDashboardOverviewBuilder.php index daeb93b..822e686 100644 --- a/app/Services/Admin/SiteDashboardOverviewBuilder.php +++ b/app/Services/Admin/SiteDashboardOverviewBuilder.php @@ -2,7 +2,6 @@ namespace App\Services\Admin; -use App\Models\AdminSite; use App\Models\AdminUser; use App\Models\AgentNode; use App\Models\Player; @@ -27,7 +26,7 @@ final class SiteDashboardOverviewBuilder return null; } - $site = $this->resolvePrimarySite($admin); + $site = SiteDashboardSiteResolver::resolvePrimarySite($admin); if ($site === null) { return null; } @@ -52,7 +51,11 @@ final class SiteDashboardOverviewBuilder $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped) ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped); $todayActivity = $this->todayActivityStats($siteCode, $today); - $pendingBills = $this->pendingBillStats($siteId); + $billStats = SiteSettlementBillStats::forSite($siteId); + $pendingBills = [ + 'count' => $billStats['pending_confirm_count'] + $billStats['payable_count'], + 'unpaid_minor' => $billStats['payable_unpaid_minor'], + ]; $topAgentToday = $this->topAgentToday($scoped, $today); return [ @@ -78,18 +81,6 @@ final class SiteDashboardOverviewBuilder ]; } - private function resolvePrimarySite(AdminUser $admin): ?AdminSite - { - $siteIds = $admin->accessibleAdminSiteIds(); - if ($siteIds === null || $siteIds === []) { - return null; - } - - return AdminSite::query() - ->where('id', (int) $siteIds[0]) - ->first(); - } - /** * @return array{player_count: int, order_count: int, latest_bet_at: ?string} */ @@ -110,22 +101,6 @@ final class SiteDashboardOverviewBuilder ]; } - /** - * @return array{count: int, unpaid_minor: int} - */ - private function pendingBillStats(int $siteId): array - { - $query = DB::table('settlement_bills as sb') - ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') - ->where('sp.admin_site_id', $siteId) - ->whereIn('sb.status', ['pending', 'pending_confirm', 'partial']); - - return [ - 'count' => (int) $query->count(), - 'unpaid_minor' => (int) $query->sum('sb.unpaid_amount'), - ]; - } - /** * @return array|null */ diff --git a/app/Services/Admin/SiteDashboardSiteResolver.php b/app/Services/Admin/SiteDashboardSiteResolver.php new file mode 100644 index 0000000..a033c45 --- /dev/null +++ b/app/Services/Admin/SiteDashboardSiteResolver.php @@ -0,0 +1,22 @@ +accessibleAdminSiteIds(); + if ($siteIds === null || $siteIds === []) { + return null; + } + + return AdminSite::query() + ->where('id', (int) $siteIds[0]) + ->first(); + } +} diff --git a/app/Services/Admin/SiteFinanceDashboardOverviewBuilder.php b/app/Services/Admin/SiteFinanceDashboardOverviewBuilder.php new file mode 100644 index 0000000..2dc2c08 --- /dev/null +++ b/app/Services/Admin/SiteFinanceDashboardOverviewBuilder.php @@ -0,0 +1,92 @@ +|null + */ + public function build(AdminUser $admin, AdminScopeContext $scope): ?array + { + if (! $admin->hasPermissionCode('dashboard.view')) { + return null; + } + + $site = SiteDashboardSiteResolver::resolvePrimarySite($admin); + if ($site === null) { + return null; + } + + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + $billStats = SiteSettlementBillStats::forSite($siteId); + $currencyCode = Player::query() + ->where('site_code', $siteCode) + ->whereNotNull('default_currency') + ->value('default_currency'); + + return [ + 'admin_site_id' => $siteId, + 'site_code' => $siteCode, + 'site_name' => (string) $site->name, + 'wallet_player_count' => (int) Player::query() + ->where('site_code', $siteCode) + ->where('funding_mode', 'wallet') + ->count(), + 'credit_player_count' => (int) Player::query() + ->where('site_code', $siteCode) + ->where('funding_mode', 'credit') + ->count(), + 'pending_confirm_bill_count' => $billStats['pending_confirm_count'], + 'payable_bill_count' => $billStats['payable_count'], + 'payable_unpaid_minor' => $billStats['payable_unpaid_minor'], + 'pending_bill_count' => $billStats['pending_confirm_count'] + $billStats['payable_count'], + 'pending_unpaid_minor' => $billStats['payable_unpaid_minor'], + 'abnormal_transfer_count' => $this->abnormalTransferCount($admin, $scope), + 'currency_code' => is_string($currencyCode) && $currencyCode !== '' ? $currencyCode : null, + ]; + } + + private function abnormalTransferCount(AdminUser $admin, AdminScopeContext $scope): int + { + $query = TransferOrder::query() + ->whereIn('status', ['processing', 'failed', 'pending_reconcile']); + AdminDataScope::applyEloquentViaPlayer($query, $admin); + $this->applyRequestedScopeViaPlayer($query, $scope); + + return (int) $query->count(); + } + + /** + * @param EloquentBuilder $query + */ + private function applyRequestedScopeViaPlayer(EloquentBuilder $query, AdminScopeContext $scope): void + { + $siteCode = $scope->effectiveRequestedSiteCode(); + $agentNodeId = $scope->effectiveRequestedAgentNodeId(); + if ($siteCode === null && $agentNodeId === null) { + return; + } + + $admin = $scope->admin; + $query->whereHas('player', static function (EloquentBuilder $playerQuery) use ($admin, $siteCode, $agentNodeId): void { + if ($siteCode !== null) { + $playerQuery->where('site_code', $siteCode); + } + + if ($agentNodeId !== null) { + AdminAgentScope::applyRequestedAgentNodeFilter($playerQuery, $admin, $agentNodeId); + } + }); + } +} diff --git a/app/Services/Admin/SiteSettlementBillStats.php b/app/Services/Admin/SiteSettlementBillStats.php new file mode 100644 index 0000000..86ce79d --- /dev/null +++ b/app/Services/Admin/SiteSettlementBillStats.php @@ -0,0 +1,36 @@ +join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sp.admin_site_id', $siteId) + ->where('sb.status', 'pending_confirm'); + + $payableQuery = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sp.admin_site_id', $siteId) + ->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue']) + ->where('sb.unpaid_amount', '>', 0); + + return [ + 'pending_confirm_count' => (int) (clone $pendingConfirmQuery)->count(), + 'payable_count' => (int) (clone $payableQuery)->count(), + 'payable_unpaid_minor' => (int) (clone $payableQuery)->sum('sb.unpaid_amount'), + ]; + } +} diff --git a/app/Services/Wallet/WalletTransferReconcileDetector.php b/app/Services/Wallet/WalletTransferReconcileDetector.php index 5fd8d27..823a32c 100644 --- a/app/Services/Wallet/WalletTransferReconcileDetector.php +++ b/app/Services/Wallet/WalletTransferReconcileDetector.php @@ -7,6 +7,8 @@ use App\Models\WalletTxn; use App\Models\TransferOrder; use App\Models\ReconcileJob; use App\Models\ReconcileItem; +use App\Support\AdminScopeContext; +use App\Support\AdminScopePolicy; use Illuminate\Support\Str; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -56,6 +58,7 @@ final class WalletTransferReconcileDetector int $staleMinutes = 15, ?int $playerId = null, bool $includeMainSiteCheck = true, + ?AdminScopeContext $scope = null, ): array { $limit = max(1, $limit); $staleMinutes = max(1, $staleMinutes); @@ -77,6 +80,10 @@ final class WalletTransferReconcileDetector $query->where('player_id', $playerId); } + if ($scope !== null && ! $scope->isSuperAdmin()) { + AdminScopePolicy::applyViaPlayer($query, $scope); + } + $orders = $query->get(); if ($orders->isEmpty()) { @@ -118,13 +125,16 @@ final class WalletTransferReconcileDetector Carbon $periodStart, Carbon $periodEnd, ?int $adminUserId, + ?int $adminSiteId = null, + ?int $playerId = null, ): ReconcileJob { - return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId): ReconcileJob { + return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId, $adminSiteId, $playerId): ReconcileJob { $jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString())); $job = ReconcileJob::query()->create([ 'job_no' => $jobNo, 'admin_user_id' => $adminUserId, + 'admin_site_id' => $adminSiteId, 'reconcile_type' => self::RECONCILE_TYPE, 'status' => 'completed', 'period_start' => $periodStart, @@ -132,6 +142,7 @@ final class WalletTransferReconcileDetector 'summary_json' => [ 'item_count' => count($items), 'mismatch_count' => count($items), + 'player_id' => $playerId, ], 'finished_at' => now(), ]); diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 986130b..7b032fc 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -40,6 +40,7 @@ final class AdminAuthProfile * }, * site: ?array{id: int, code: string, name: string}, * is_super_admin: bool, + * account_kind: 'super_admin'|'site_admin'|'site_finance'|'site_cs'|'agent_operator'|'platform_account', * operational_permissions: list, * delegation_ceiling: list, * accessible_sites?: list @@ -49,6 +50,7 @@ final class AdminAuthProfile { $fresh = $admin->fresh(); $permissionSlugs = $fresh->adminPermissionSlugs(); + $operationalCodes = $fresh->effectiveMenuActionPermissionCodes(); $agent = self::agentContext($fresh); $payload = [ @@ -61,7 +63,8 @@ final class AdminAuthProfile 'agent' => $agent, 'site' => self::siteContext($fresh), 'is_super_admin' => $fresh->isSuperAdmin(), - 'operational_permissions' => $permissionSlugs, + 'account_kind' => self::accountKind($fresh), + 'operational_permissions' => $operationalCodes, 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), ]; @@ -81,7 +84,7 @@ final class AdminAuthProfile return null; } - if (! SitePlatformRole::userHasSiteAdminRole($admin)) { + if (! SiteOperatorRoles::userHasSiteOperatorRole($admin)) { return null; } @@ -130,4 +133,22 @@ final class AdminAuthProfile 'can_create_player' => (bool) ($profile?->can_create_player ?? false), ]; } + + private static function accountKind(AdminUser $admin): string + { + if ($admin->isSuperAdmin()) { + return 'super_admin'; + } + + if ($admin->hasPrimaryAgentBinding()) { + return 'agent_operator'; + } + + $siteRole = SiteOperatorRoles::primarySiteOperatorSlug($admin); + if ($siteRole !== null) { + return $siteRole; + } + + return 'platform_account'; + } } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 5fe7f2a..b6c2f4b 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -565,7 +565,6 @@ final class AdminAuthorizationRegistry ['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], - ['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']], ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']], diff --git a/app/Support/AdminReconcileScope.php b/app/Support/AdminReconcileScope.php new file mode 100644 index 0000000..37829c7 --- /dev/null +++ b/app/Support/AdminReconcileScope.php @@ -0,0 +1,206 @@ + $query + */ + public static function applyToJobsQuery(Builder $query, AdminUser $admin): void + { + if ($admin->isSuperAdmin()) { + return; + } + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return; + } + + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $siteCodes = AdminSite::query() + ->whereIn('id', $siteIds) + ->pluck('code') + ->map(static fn ($code): string => (string) $code) + ->all(); + + $agent = AdminAgentScope::primaryAgentNode($admin); + + $query->where(function (Builder $outer) use ($siteIds, $siteCodes, $agent): void { + $outer->whereIn('admin_site_id', $siteIds); + + if ($siteCodes !== []) { + $outer->orWhere(function (Builder $legacy) use ($siteCodes, $agent): void { + $legacy->whereNull('admin_site_id') + ->whereHas('items', function (Builder $items) use ($siteCodes, $agent): void { + self::applyItemsTransferPlayerScope($items, $siteCodes, $agent); + }); + }); + } + }); + + if ($agent instanceof AgentNode) { + $query->whereHas('items', function (Builder $items) use ($agent): void { + self::applyItemsAgentSubtree($items, $agent); + }); + } + } + + public static function jobAccessible(AdminUser $admin, ReconcileJob $job): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return true; + } + + if ($siteIds === []) { + return false; + } + + $jobSiteId = $job->admin_site_id !== null ? (int) $job->admin_site_id : null; + if ($jobSiteId !== null) { + if (! in_array($jobSiteId, $siteIds, true)) { + return false; + } + } else { + $siteCodes = AdminSite::query() + ->whereIn('id', $siteIds) + ->pluck('code') + ->map(static fn ($code): string => (string) $code) + ->all(); + + $hasScopedItem = $job->items() + ->where(function (Builder $items) use ($siteCodes, $admin): void { + $agent = AdminAgentScope::primaryAgentNode($admin); + self::applyItemsTransferPlayerScope($items, $siteCodes, $agent); + }) + ->exists(); + + if (! $hasScopedItem) { + return false; + } + } + + $agent = AdminAgentScope::primaryAgentNode($admin); + if ($agent === null) { + return true; + } + + return $job->items() + ->where(function (Builder $items) use ($agent): void { + self::applyItemsAgentSubtree($items, $agent); + }) + ->exists(); + } + + public static function resolveAdminSiteIdForJob(AdminUser $admin, ?Player $player = null): ?int + { + if ($player !== null) { + $siteId = AdminSite::query() + ->where('code', (string) $player->site_code) + ->value('id'); + + return $siteId !== null ? (int) $siteId : null; + } + + if ($admin->isSuperAdmin()) { + return null; + } + + $agent = AdminAgentScope::primaryAgentNode($admin); + if ($agent !== null) { + return (int) $agent->admin_site_id; + } + + $siteIds = $admin->accessibleAdminSiteIds(); + + return $siteIds[0] ?? null; + } + + public static function denyUnlessJobAccessible(AdminUser $admin, ReconcileJob $job): ?JsonResponse + { + if (! self::jobAccessible($admin, $job)) { + return ApiMessage::errorResponse( + request(), + 'admin.reconcile_job_access_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + return null; + } + + /** + * @param Builder<\App\Models\ReconcileItem> $query + * @param list $siteCodes + */ + public static function applyItemsTransferPlayerScope( + Builder $query, + array $siteCodes, + ?AgentNode $agent = null, + ): void { + if ($siteCodes === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereExists(function ($sub) use ($siteCodes, $agent): void { + $sub->selectRaw('1') + ->from('transfer_orders') + ->join('players', 'players.id', '=', 'transfer_orders.player_id') + ->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref'); + + $sub->whereIn('players.site_code', $siteCodes); + + if ($agent instanceof AgentNode) { + $sub->whereIn('players.agent_node_id', function ($nodes) use ($agent): void { + $nodes->select('id') + ->from('agent_nodes') + ->where('path', 'like', $agent->path.'%'); + }); + } + }); + } + + /** + * @param Builder<\App\Models\ReconcileItem> $query + */ + private static function applyItemsAgentSubtree(Builder $query, AgentNode $agent): void + { + $query->whereExists(function ($sub) use ($agent): void { + $sub->selectRaw('1') + ->from('transfer_orders') + ->join('players', 'players.id', '=', 'transfer_orders.player_id') + ->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref') + ->whereIn('players.agent_node_id', function ($nodes) use ($agent): void { + $nodes->select('id') + ->from('agent_nodes') + ->where('path', 'like', $agent->path.'%'); + }); + }); + } +} diff --git a/app/Support/PlatformSystemRoles.php b/app/Support/PlatformSystemRoles.php index 4903620..80ffb3d 100644 --- a/app/Support/PlatformSystemRoles.php +++ b/app/Support/PlatformSystemRoles.php @@ -4,19 +4,29 @@ namespace App\Support; use App\Models\AdminRole; -/** 平台角色管理仅维护的两个内置系统角色。 */ +/** 平台内置系统角色(超管 / 站点运营 / 代理)。 */ final class PlatformSystemRoles { public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN; public const SLUG_AGENT = 'agent'; - public const SLUG_SITE_ADMIN = SitePlatformRole::SLUG; + public const SLUG_SITE_ADMIN = SiteOperatorRoles::SLUG_SITE_ADMIN; + + public const SLUG_SITE_FINANCE = SiteOperatorRoles::SLUG_SITE_FINANCE; + + public const SLUG_SITE_CS = SiteOperatorRoles::SLUG_SITE_CS; /** @return list */ public static function fixedSlugs(): array { - return [self::SLUG_SUPER_ADMIN, self::SLUG_SITE_ADMIN, self::SLUG_AGENT]; + return [ + self::SLUG_SUPER_ADMIN, + self::SLUG_SITE_ADMIN, + self::SLUG_SITE_FINANCE, + self::SLUG_SITE_CS, + self::SLUG_AGENT, + ]; } public static function isFixedSlug(string $slug): bool @@ -52,6 +62,8 @@ final class PlatformSystemRoles { self::ensureSuperAdminRole(); SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole(); + SiteFinanceDefaultRolePermissions::ensurePlatformSiteFinanceRole(); + SiteCsDefaultRolePermissions::ensurePlatformSiteCsRole(); AgentDefaultRolePermissions::ensurePlatformAgentRole(); } } diff --git a/app/Support/SiteAdminDefaultRolePermissions.php b/app/Support/SiteAdminDefaultRolePermissions.php index b0cf2ba..9b7adb9 100644 --- a/app/Support/SiteAdminDefaultRolePermissions.php +++ b/app/Support/SiteAdminDefaultRolePermissions.php @@ -23,9 +23,11 @@ final class SiteAdminDefaultRolePermissions 'prd.users.manage', 'prd.tickets.view', 'prd.report.view', + 'prd.report.export', + 'prd.draw_result.view', + 'prd.wallet_reconcile.manage', 'prd.settlement.agent.view', 'prd.settlement.agent.manage', - 'prd.integration.view', ]; /** @@ -46,7 +48,7 @@ final class SiteAdminDefaultRolePermissions [ 'code' => SitePlatformRole::SLUG, 'name' => '站点管理员', - 'description' => '接入站点后台默认权限(代理/玩家/结算运营 + 站点仪表盘)', + 'description' => '接入站点后台默认权限(本站代理/玩家/信用结算 + 钱包流水与经营报表 + 期号只读)', 'status' => 1, 'is_system' => true, 'sort_order' => 40, diff --git a/app/Support/SiteCsDefaultRolePermissions.php b/app/Support/SiteCsDefaultRolePermissions.php new file mode 100644 index 0000000..1e6ae5c --- /dev/null +++ b/app/Support/SiteCsDefaultRolePermissions.php @@ -0,0 +1,49 @@ + */ + private const TEMPLATE_SLUGS = [ + 'prd.dashboard.view', + 'prd.users.view_cs', + 'prd.tickets.view', + 'prd.wallet_reconcile.view_cs', + ]; + + /** @return list */ + public static function templateSlugs(): array + { + return self::TEMPLATE_SLUGS; + } + + public static function ensurePlatformSiteCsRole(): AdminRole + { + $role = AdminRole::query()->updateOrCreate( + [ + 'slug' => SiteOperatorRoles::SLUG_SITE_CS, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ], + [ + 'code' => SiteOperatorRoles::SLUG_SITE_CS, + 'name' => '站点客服', + 'description' => '单站客服:玩家/注单/单用户钱包查询工作台,不含全局报表与代理经营', + 'status' => 1, + 'is_system' => true, + 'sort_order' => 42, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ], + ); + + $role->syncLegacyPermissionSlugs( + AdminPermissionInheritance::expand(self::TEMPLATE_SLUGS), + ); + + return $role->fresh() ?? $role; + } +} diff --git a/app/Support/SiteFinanceDefaultRolePermissions.php b/app/Support/SiteFinanceDefaultRolePermissions.php new file mode 100644 index 0000000..6d03980 --- /dev/null +++ b/app/Support/SiteFinanceDefaultRolePermissions.php @@ -0,0 +1,55 @@ + */ + private const TEMPLATE_SLUGS = [ + 'prd.dashboard.view', + 'prd.draw_result.view', + 'prd.users.view_finance', + 'prd.tickets.view', + 'prd.report.view', + 'prd.report.export', + 'prd.wallet_reconcile.manage', + 'prd.wallet_adjust.manage', + 'prd.settlement.agent.view', + 'prd.settlement.agent.manage', + ]; + + /** @return list */ + public static function templateSlugs(): array + { + return self::TEMPLATE_SLUGS; + } + + public static function ensurePlatformSiteFinanceRole(): AdminRole + { + $role = AdminRole::query()->updateOrCreate( + [ + 'slug' => SiteOperatorRoles::SLUG_SITE_FINANCE, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ], + [ + 'code' => SiteOperatorRoles::SLUG_SITE_FINANCE, + 'name' => '站点财务', + 'description' => '单站财务/对账:钱包流水、对账、经营报表导出、信用结算收付', + 'status' => 1, + 'is_system' => true, + 'sort_order' => 41, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ], + ); + + $role->syncLegacyPermissionSlugs( + AdminPermissionInheritance::expand(self::TEMPLATE_SLUGS), + ); + + return $role->fresh() ?? $role; + } +} diff --git a/app/Support/SiteOperatorRoles.php b/app/Support/SiteOperatorRoles.php new file mode 100644 index 0000000..f2b4397 --- /dev/null +++ b/app/Support/SiteOperatorRoles.php @@ -0,0 +1,62 @@ + */ + public static function slugs(): array + { + return [ + self::SLUG_SITE_ADMIN, + self::SLUG_SITE_FINANCE, + self::SLUG_SITE_CS, + ]; + } + + public static function isSiteOperatorSlug(string $slug): bool + { + return in_array($slug, self::slugs(), true); + } + + public static function userHasSiteOperatorRole(AdminUser $user): bool + { + if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) { + return false; + } + + return self::primarySiteOperatorSlug($user) !== null; + } + + public static function userHasSiteAdminRole(AdminUser $user): bool + { + return self::primarySiteOperatorSlug($user) === self::SLUG_SITE_ADMIN; + } + + public static function primarySiteOperatorSlug(AdminUser $user): ?string + { + if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) { + return null; + } + + $slug = DB::table('admin_user_site_roles as usr') + ->join('admin_roles as r', 'r.id', '=', 'usr.role_id') + ->where('usr.admin_user_id', $user->id) + ->whereIn('r.slug', self::slugs()) + ->orderBy('usr.site_id') + ->orderBy('r.sort_order') + ->value('r.slug'); + + return is_string($slug) && $slug !== '' ? $slug : null; + } +} diff --git a/app/Support/SitePlatformRole.php b/app/Support/SitePlatformRole.php index 59b48a5..4a8a60b 100644 --- a/app/Support/SitePlatformRole.php +++ b/app/Support/SitePlatformRole.php @@ -7,10 +7,10 @@ use App\Models\AdminUser; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; -/** 接入站点后台账号统一使用平台系统角色 slug=site_admin。 */ +/** 接入站点后台主运营角色 slug=site_admin。 */ final class SitePlatformRole { - public const SLUG = 'site_admin'; + public const SLUG = SiteOperatorRoles::SLUG_SITE_ADMIN; public static function resolve(): AdminRole { @@ -41,14 +41,6 @@ final class SitePlatformRole public static function userHasSiteAdminRole(AdminUser $user): bool { - if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) { - return false; - } - - return DB::table('admin_user_site_roles as usr') - ->join('admin_roles as r', 'r.id', '=', 'usr.role_id') - ->where('usr.admin_user_id', $user->id) - ->where('r.slug', self::SLUG) - ->exists(); + return SiteOperatorRoles::userHasSiteAdminRole($user); } } diff --git a/database/migrations/2026_06_16_120000_add_admin_site_id_to_reconcile_jobs_table.php b/database/migrations/2026_06_16_120000_add_admin_site_id_to_reconcile_jobs_table.php new file mode 100644 index 0000000..82b9b77 --- /dev/null +++ b/database/migrations/2026_06_16_120000_add_admin_site_id_to_reconcile_jobs_table.php @@ -0,0 +1,44 @@ +foreignId('admin_site_id') + ->nullable() + ->after('admin_user_id') + ->constrained('admin_sites') + ->nullOnDelete(); + }); + + if (DB::getDriverName() === 'pgsql') { + DB::statement(<<<'SQL' + UPDATE reconcile_jobs AS rj + SET admin_site_id = sub.admin_site_id + FROM ( + SELECT ri.reconcile_job_id, MIN(asite.id) AS admin_site_id + FROM reconcile_items AS ri + INNER JOIN transfer_orders AS tord ON tord.transfer_no = ri.side_a_ref + INNER JOIN players AS p ON p.id = tord.player_id + INNER JOIN admin_sites AS asite ON asite.code = p.site_code + GROUP BY ri.reconcile_job_id + ) AS sub + WHERE rj.id = sub.reconcile_job_id + AND rj.admin_site_id IS NULL + SQL); + } + } + + public function down(): void + { + Schema::table('reconcile_jobs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('admin_site_id'); + }); + } +}; diff --git a/database/migrations/2026_06_16_140000_seed_platform_site_finance_and_cs_roles.php b/database/migrations/2026_06_16_140000_seed_platform_site_finance_and_cs_roles.php new file mode 100644 index 0000000..3082ea2 --- /dev/null +++ b/database/migrations/2026_06_16_140000_seed_platform_site_finance_and_cs_roles.php @@ -0,0 +1,19 @@ +group(function (): void { ->name('api.v1.admin.reports.player-win-loss'); Route::get('reports/play-dimension', AdminReportPlayDimensionController::class) ->name('api.v1.admin.reports.play-dimension'); - Route::get('reports/rebate-commission', AdminReportRebateCommissionController::class) - ->name('api.v1.admin.reports.rebate-commission'); Route::get('report-jobs', ReportJobIndexController::class) ->name('api.v1.admin.report-jobs.index'); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index c83c4c9..c8662d2 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -15,6 +15,8 @@ test('admin ping requires authentication', function () { }); test('admin auth me returns current admin profile', function () { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + $admin = AdminUser::query()->create([ 'username' => 'admin_me', 'name' => '管理员本人', @@ -26,13 +28,21 @@ test('admin auth me returns current admin profile', function () { $token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken; - $this->withHeader('Authorization', 'Bearer '.$token) + $resp = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/auth/me') ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.admin.username', 'admin_me') + ->assertJsonPath('data.admin.account_kind', 'super_admin') ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard') ->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]); + + $permissions = $resp->json('data.admin.permissions'); + $operational = $resp->json('data.admin.operational_permissions'); + expect($permissions)->toBeArray()->not->toBeEmpty() + ->and($operational)->toBeArray()->not->toBeEmpty() + ->and($permissions[0])->toStartWith('prd.') + ->and($operational[0])->not->toStartWith('prd.'); }); test('admin login returns bearer token when captcha passes validation', function () { diff --git a/tests/Feature/AdminReconcileJobScopeTest.php b/tests/Feature/AdminReconcileJobScopeTest.php new file mode 100644 index 0000000..2512792 --- /dev/null +++ b/tests/Feature/AdminReconcileJobScopeTest.php @@ -0,0 +1,211 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function reconcileScopeSuperToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'reconcile_scope_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('reconcile job list is scoped to site admin site', function (): void { + $superToken = reconcileScopeSuperToken(); + + $siteA = $this->withHeader('Authorization', 'Bearer '.$superToken) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'reconcile-a', + 'name' => 'Reconcile A', + 'admin_account' => [ + 'username' => 'reconcile_a_admin', + 'nickname' => 'A Admin', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated() + ->json('data'); + + $siteB = $this->withHeader('Authorization', 'Bearer '.$superToken) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'reconcile-b', + 'name' => 'Reconcile B', + 'admin_account' => [ + 'username' => 'reconcile_b_admin', + 'nickname' => 'B Admin', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated() + ->json('data'); + + $playerA = Player::query()->create([ + 'site_code' => 'reconcile-a', + 'site_player_id' => 'scope-a-1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $playerB = Player::query()->create([ + 'site_code' => 'reconcile-b', + 'site_player_id' => 'scope-b-1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + foreach ([ + ['TO_scope_a', $playerA->id, (int) $siteA['id']], + ['TO_scope_b', $playerB->id, (int) $siteB['id']], + ] as [$transferNo, $playerId, $siteId]) { + TransferOrder::query()->create([ + 'transfer_no' => $transferNo, + 'player_id' => $playerId, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 100, + 'idempotent_key' => $transferNo.'-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + 'created_at' => now()->subHours(2), + ]); + + $this->withHeader('Authorization', 'Bearer '.$superToken) + ->postJson('/api/v1/admin/reconcile-jobs', [ + 'reconcile_type' => 'wallet_transfer', + 'date_from' => now()->subDay()->toDateString(), + 'date_to' => now()->toDateString(), + 'player_id' => $playerId, + ]) + ->assertOk(); + } + + $jobA = ReconcileJob::query()->where('admin_site_id', (int) $siteA['id'])->latest('id')->first(); + $jobB = ReconcileJob::query()->where('admin_site_id', (int) $siteB['id'])->latest('id')->first(); + expect($jobA)->not->toBeNull() + ->and($jobB)->not->toBeNull() + ->and((int) $jobA->admin_site_id)->toBe((int) $siteA['id']) + ->and((int) $jobB->admin_site_id)->toBe((int) $siteB['id']); + + $siteAdminA = AdminUser::query()->where('username', 'reconcile_a_admin')->firstOrFail(); + $siteAdminB = AdminUser::query()->where('username', 'reconcile_b_admin')->firstOrFail(); + expect(SitePlatformRole::userHasSiteAdminRole($siteAdminA))->toBeTrue() + ->and($siteAdminA->isSuperAdmin())->toBeFalse() + ->and($siteAdminA->accessibleAdminSiteIds())->toBe([(int) $siteA['id']]) + ->and($siteAdminA->primaryAgentNode())->toBeNull() + ->and($siteAdminB->accessibleAdminSiteIds())->toBe([(int) $siteB['id']]); + + Sanctum::actingAs($siteAdminA, ['*']); + $idsA = collect($this->getJson('/api/v1/admin/reconcile-jobs') + ->assertOk() + ->json('data.items')) + ->pluck('id') + ->all(); + + Sanctum::actingAs($siteAdminB, ['*']); + $idsB = collect($this->getJson('/api/v1/admin/reconcile-jobs') + ->assertOk() + ->json('data.items')) + ->pluck('id') + ->all(); + + expect($idsA)->toEqual([(int) $jobA->id]) + ->and($idsB)->toEqual([(int) $jobB->id]); + + Sanctum::actingAs($siteAdminA, ['*']); + $this->getJson('/api/v1/admin/reconcile-jobs/'.$jobB->id.'/items') + ->assertForbidden(); +}); + +test('site admin reconcile scan only includes own site transfer orders', function (): void { + $superToken = reconcileScopeSuperToken(); + + $this->withHeader('Authorization', 'Bearer '.$superToken) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'scan-scope-a', + 'name' => 'Scan Scope A', + 'admin_account' => [ + 'username' => 'scan_scope_a_admin', + 'nickname' => 'Scan A', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated(); + + $defaultSiteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code'); + + $ownPlayer = Player::query()->create([ + 'site_code' => 'scan-scope-a', + 'site_player_id' => 'scan-own', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $otherPlayer = Player::query()->create([ + 'site_code' => $defaultSiteCode, + 'site_player_id' => 'scan-other', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + foreach ([['TO_scan_own', $ownPlayer->id], ['TO_scan_other', $otherPlayer->id]] as [$transferNo, $playerId]) { + TransferOrder::query()->create([ + 'transfer_no' => $transferNo, + 'player_id' => $playerId, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 200, + 'idempotent_key' => $transferNo.'-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + 'created_at' => now()->subHours(2), + ]); + } + + $siteAdmin = AdminUser::query()->where('username', 'scan_scope_a_admin')->firstOrFail(); + Sanctum::actingAs($siteAdmin, ['*']); + + $this->postJson('/api/v1/admin/reconcile-jobs', [ + 'reconcile_type' => 'wallet_transfer', + 'date_from' => now()->subDay()->toDateString(), + 'date_to' => now()->toDateString(), + ]) + ->assertOk() + ->assertJsonPath('data.item_count', 1); + + $job = ReconcileJob::query()->latest('id')->firstOrFail(); + expect($job->items()->value('side_a_ref'))->toBe('TO_scan_own'); +}); diff --git a/tests/Feature/PlatformSystemRolesTest.php b/tests/Feature/PlatformSystemRolesTest.php index 27af314..0e7158f 100644 --- a/tests/Feature/PlatformSystemRolesTest.php +++ b/tests/Feature/PlatformSystemRolesTest.php @@ -49,7 +49,7 @@ test('platform role index lists built-in roles and custom system roles', functio ->pluck('slug') ->all(); - expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops'); + expect($slugs)->toContain('super_admin', 'site_admin', 'site_finance', 'site_cs', 'agent', 'legacy_custom_ops'); }); test('admin can create custom platform role but not reserved slugs', function (): void { diff --git a/tests/Feature/SiteOperatorDashboardTest.php b/tests/Feature/SiteOperatorDashboardTest.php new file mode 100644 index 0000000..afa761f --- /dev/null +++ b/tests/Feature/SiteOperatorDashboardTest.php @@ -0,0 +1,92 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function dashboardSiteOperatorToken(string $username, string $roleSlug): string +{ + PlatformSystemRoles::ensureAll(); + + $user = AdminUser::query()->create([ + 'username' => $username, + 'name' => ucfirst(str_replace('_', ' ', $username)), + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id'); + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $user->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => now(), + ]); + + return $user->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('site finance dashboard returns finance overview without site admin overview', function (): void { + $token = dashboardSiteOperatorToken('dash_site_finance', SiteOperatorRoles::SLUG_SITE_FINANCE); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.site_overview', null) + ->assertJsonPath('data.site_cs_overview', null) + ->assertJsonPath('data.site_finance_overview.site_code', fn ($code) => is_string($code) && $code !== '') + ->assertJsonStructure([ + 'data' => [ + 'site_finance_overview' => [ + 'wallet_player_count', + 'credit_player_count', + 'pending_confirm_bill_count', + 'payable_bill_count', + 'payable_unpaid_minor', + 'abnormal_transfer_count', + ], + ], + ]); +}); + +test('site cs dashboard returns cs overview without finance or admin overview', function (): void { + $token = dashboardSiteOperatorToken('dash_site_cs', SiteOperatorRoles::SLUG_SITE_CS); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.site_overview', null) + ->assertJsonPath('data.site_finance_overview', null) + ->assertJsonPath('data.site_cs_overview.site_code', fn ($code) => is_string($code) && $code !== '') + ->assertJsonStructure([ + 'data' => [ + 'site_cs_overview' => [ + 'player_count', + 'ticket_order_count_today', + 'active_player_count_today', + ], + ], + ]); +}); + +test('site admin dashboard still returns site overview only', function (): void { + $token = dashboardSiteOperatorToken('dash_site_admin', SiteOperatorRoles::SLUG_SITE_ADMIN); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.site_finance_overview', null) + ->assertJsonPath('data.site_cs_overview', null) + ->assertJsonPath('data.site_overview.site_code', fn ($code) => is_string($code) && $code !== ''); +}); diff --git a/tests/Feature/SiteOperatorRolesAuthTest.php b/tests/Feature/SiteOperatorRolesAuthTest.php new file mode 100644 index 0000000..2985759 --- /dev/null +++ b/tests/Feature/SiteOperatorRolesAuthTest.php @@ -0,0 +1,75 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function bindSiteOperator(AdminUser $user, string $roleSlug): void +{ + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id'); + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $user->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => now(), + ]); +} + +test('auth me exposes site role specific account_kind for site operators', function (): void { + PlatformSystemRoles::ensureAll(); + + $finance = AdminUser::query()->create([ + 'username' => 'site_finance_ops', + 'name' => 'Site Finance', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + bindSiteOperator($finance, SiteOperatorRoles::SLUG_SITE_FINANCE); + + $token = $finance->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.account_kind', 'site_finance') + ->assertJsonPath('data.admin.site.code', fn ($code) => is_string($code) && $code !== ''); + + expect($finance->fresh()->adminPermissionSlugs()) + ->toContain('prd.report.export') + ->not->toContain('prd.agent.manage'); +}); + +test('site cs auth me has cs account kind without report permissions', function (): void { + PlatformSystemRoles::ensureAll(); + + $cs = AdminUser::query()->create([ + 'username' => 'site_cs_ops', + 'name' => 'Site CS', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + bindSiteOperator($cs, SiteOperatorRoles::SLUG_SITE_CS); + + $token = $cs->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.account_kind', 'site_cs'); + + expect($cs->fresh()->adminPermissionSlugs()) + ->toContain('prd.users.view_cs') + ->not->toContain('prd.report.view'); +}); diff --git a/tests/Unit/SiteAdminDefaultRolePermissionsTest.php b/tests/Unit/SiteAdminDefaultRolePermissionsTest.php index cac4be8..1727cf1 100644 --- a/tests/Unit/SiteAdminDefaultRolePermissionsTest.php +++ b/tests/Unit/SiteAdminDefaultRolePermissionsTest.php @@ -1,13 +1,44 @@ toContain('prd.dashboard.view') ->toContain('prd.agent.manage') ->toContain('prd.settlement.agent.manage') - ->toContain('prd.report.view'); + ->toContain('prd.report.view') + ->toContain('prd.report.export') + ->toContain('prd.draw_result.view') + ->toContain('prd.wallet_reconcile.manage') + ->not->toContain('prd.integration.view') + ->not->toContain('prd.wallet_adjust.manage'); +}); + +test('site finance template focuses on reconcile reports and settlement without agent manage', function (): void { + $slugs = SiteFinanceDefaultRolePermissions::templateSlugs(); + + expect($slugs) + ->toContain('prd.report.export') + ->toContain('prd.wallet_adjust.manage') + ->toContain('prd.settlement.agent.manage') + ->toContain('prd.draw_result.view') + ->not->toContain('prd.agent.manage') + ->not->toContain('prd.users.manage'); +}); + +test('site cs template is single-player scoped without global reports', function (): void { + $slugs = SiteCsDefaultRolePermissions::templateSlugs(); + + expect($slugs) + ->toContain('prd.dashboard.view') + ->toContain('prd.users.view_cs') + ->toContain('prd.tickets.view') + ->toContain('prd.wallet_reconcile.view_cs') + ->not->toContain('prd.report.view') + ->not->toContain('prd.agent.view'); });