query('status', ''); $search = trim((string) $request->query('search', '')); $limit = max(1, min(500, (int) $request->query('limit', '100'))); $offset = max(0, (int) $request->query('offset', '0')); $query = Tenant::query() ->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id') ->select([ 'tenants.id', 'tenants.subdomain', 'tenants.organization_name', 'tenants.contact_email', 'tenants.status', 'tenants.balance_rub', 'tenants.balance_leads', 'tenants.is_trial', 'tenants.last_activity_at', 'tenants.current_tariff_id', 'tenants.desired_daily_numbers', 'tenants.chargeback_unrecovered_rub', 'tenants.created_at', 'tariff_plans.name as tariff_name', 'tariff_plans.price_monthly as tariff_price_monthly', ]) ->whereNull('tenants.deleted_at'); if ($status !== '') { $query->where('tenants.status', $status); } if ($search !== '') { $like = '%'.$search.'%'; $query->where(function ($q) use ($like) { $q->where('tenants.organization_name', 'ilike', $like) ->orWhere('tenants.subdomain', 'ilike', $like) ->orWhere('tenants.contact_email', 'ilike', $like); }); } $total = (clone $query)->count('tenants.id'); $rows = $query ->orderByDesc('tenants.last_activity_at') ->orderBy('tenants.id') ->limit($limit) ->offset($offset) ->get(); return response()->json([ 'tenants' => $rows->map(fn ($t) => [ 'id' => (int) $t->id, 'subdomain' => $t->subdomain, 'organization_name' => $t->organization_name, 'contact_email' => $t->contact_email, 'status' => $t->status, 'balance_rub' => (string) $t->balance_rub, 'balance_leads' => (int) $t->balance_leads, 'is_trial' => (bool) $t->is_trial, 'last_activity_at' => $t->last_activity_at !== null ? CarbonImmutable::parse($t->last_activity_at)->toIso8601String() : null, 'tariff_id' => $t->current_tariff_id !== null ? (int) $t->current_tariff_id : null, 'tariff_name' => $t->tariff_name, // mrr_rub = price_monthly если активный тариф + не-trial; иначе null. // Aggregate-формат как у /admin/billing — string, чтобы decimal не терял точность. 'mrr_rub' => $t->tariff_price_monthly !== null && ! $t->is_trial ? (string) $t->tariff_price_monthly : null, 'desired_daily_numbers' => $t->desired_daily_numbers !== null ? (int) $t->desired_daily_numbers : null, 'chargeback_unrecovered_rub' => (string) $t->chargeback_unrecovered_rub, 'created_at' => $t->created_at !== null ? CarbonImmutable::parse($t->created_at)->toIso8601String() : null, ]), 'total' => $total, 'limit' => $limit, 'offset' => $offset, 'stats' => $this->computeStats(), ]); } /** * GET /api/admin/tenants/{subdomain} — карточка тенанта для AdminTenantDetailView. * * Возвращает: tenant base + 4 секции (users / projects / balance_history / * activity) + computed metrics (leads_today/week/month, avg_lead_cost, runway_days). * * Saas-уровневый endpoint: НЕ tenant-aware, RLS не применяется (admin * читает чужие данные через crm_admin_user/BYPASSRLS на prod). На MVP * без auth-middleware — saas-admin SSO ⏸ Б-1. */ public function show(Request $request, string $subdomain): JsonResponse { $tenant = DB::table('tenants') ->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id') ->where('tenants.subdomain', $subdomain) ->whereNull('tenants.deleted_at') ->select([ 'tenants.id', 'tenants.subdomain', 'tenants.organization_name', 'tenants.contact_email', 'tenants.status', 'tenants.balance_rub', 'tenants.balance_leads', 'tenants.is_trial', 'tenants.last_activity_at', 'tenants.current_tariff_id', 'tenants.desired_daily_numbers', 'tenants.chargeback_unrecovered_rub', 'tenants.created_at', 'tariff_plans.name as tariff_name', 'tariff_plans.price_monthly as tariff_price_monthly', ]) ->first(); if ($tenant === null) { return response()->json(['message' => 'Тенант не найден.'], 404); } $tenantId = (int) $tenant->id; return response()->json([ 'tenant' => [ 'id' => $tenantId, 'subdomain' => $tenant->subdomain, 'organization_name' => $tenant->organization_name, 'contact_email' => $tenant->contact_email, 'status' => $tenant->status, 'balance_rub' => (string) $tenant->balance_rub, 'balance_leads' => (int) $tenant->balance_leads, 'is_trial' => (bool) $tenant->is_trial, 'last_activity_at' => $tenant->last_activity_at !== null ? CarbonImmutable::parse($tenant->last_activity_at)->toIso8601String() : null, 'tariff_id' => $tenant->current_tariff_id !== null ? (int) $tenant->current_tariff_id : null, 'tariff_name' => $tenant->tariff_name, 'mrr_rub' => $tenant->tariff_price_monthly !== null && ! $tenant->is_trial ? (string) $tenant->tariff_price_monthly : null, 'desired_daily_numbers' => $tenant->desired_daily_numbers !== null ? (int) $tenant->desired_daily_numbers : null, 'chargeback_unrecovered_rub' => (string) $tenant->chargeback_unrecovered_rub, 'created_at' => $tenant->created_at !== null ? CarbonImmutable::parse($tenant->created_at)->toIso8601String() : null, ], 'users' => $this->fetchUsers($tenantId), 'projects' => $this->fetchProjects($tenantId), 'balance_history' => $this->fetchBalanceHistory($tenantId), 'activity' => $this->fetchActivity($tenantId), 'metrics' => $this->computeMetrics($tenantId, $tenant), ]); } /** * PATCH /api/admin/tenants/{id}/balance — установить точный ₽-баланс тенанта. * * Семантика «set absolute»: админ передаёт целевой balance_rub, сервер * считает знаковую дельту (target − current) и пишет её append-only строкой * balance_transactions(type='manual_adjustment') + saas_admin_audit_log. * * SaaS-уровневый: НЕ tenant-aware. Money — bcmath, lockForUpdate (конвенция * LedgerService / AdminBillingController::refund). balance_leads не трогаем * (Billing v2 Spec A — лиды vestigial, удаляются в Phase B). */ public function updateBalance(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'balance_rub' => ['required', 'string', 'regex:/^-?\d+(\.\d{1,2})?$/'], 'reason' => ['nullable', 'string', 'max:500'], ]); $target = bcadd((string) $validated['balance_rub'], '0', 2); $reason = isset($validated['reason']) && trim((string) $validated['reason']) !== '' ? trim((string) $validated['reason']) : 'Ручная корректировка баланса (админ)'; $adminUserId = $this->resolveAdminUserId($request, 'system-balance@liderra.local', 'System Balance Bot'); /** @var array{balance_rub:string, delta:string, transaction_id:int} $result */ $result = DB::transaction(function () use ($id, $target, $reason, $adminUserId, $request): array { DB::statement('SET LOCAL app.current_tenant_id = '.$id); $tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at') ->lockForUpdate()->first(); if ($tenant === null) { abort(404, 'tenant not found'); } $current = (string) $tenant->balance_rub; $delta = bcsub($target, $current, 2); if (bccomp($delta, '0', 2) === 0) { abort(422, 'balance unchanged'); } DB::table('tenants')->where('id', $id)->update([ 'balance_rub' => $target, 'updated_at' => now(), ]); $tx = BalanceTransaction::create([ 'tenant_id' => $id, 'type' => BalanceTransaction::TYPE_MANUAL_ADJUSTMENT, 'amount_rub' => $delta, 'amount_leads' => null, 'balance_rub_after' => $target, 'balance_leads_after' => null, 'description' => $reason, 'admin_user_id' => $adminUserId, 'created_at' => now(), ]); SaasAdminAuditLog::create([ 'admin_user_id' => $adminUserId, 'action' => 'tenant.balance_adjusted', 'target_type' => 'tenant', 'target_id' => $id, 'target_tenant_id' => $id, 'payload_before' => ['balance_rub' => $current], 'payload_after' => ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => $tx->id], 'reason' => $reason, 'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(), ]); return ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => (int) $tx->id]; }); return response()->json([ 'id' => $id, 'balance_rub' => $result['balance_rub'], 'delta' => $result['delta'], 'transaction_id' => $result['transaction_id'], ]); } /** @return array> */ private function fetchUsers(int $tenantId): array { return DB::table('users') ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->orderByDesc('last_active_at') ->orderBy('id') ->limit(50) ->get() ->map(fn ($u) => [ 'id' => (int) $u->id, 'email' => $u->email, 'first_name' => $u->first_name, 'last_name' => $u->last_name, 'is_active' => (bool) $u->is_active, 'totp_enabled' => (bool) $u->totp_enabled, 'last_active_at' => $u->last_active_at !== null ? CarbonImmutable::parse($u->last_active_at)->toIso8601String() : null, 'last_login_at' => $u->last_login_at !== null ? CarbonImmutable::parse($u->last_login_at)->toIso8601String() : null, ]) ->all(); } /** @return array> */ private function fetchProjects(int $tenantId): array { $today = CarbonImmutable::now()->startOfDay(); // Subquery suppliers count per project_id. $suppliers = DB::table('project_suppliers') ->select('project_id', DB::raw('COUNT(*) as cnt')) ->groupBy('project_id'); // Subquery leads-today count per project_id (deals в текущем дне). $leadsToday = DB::table('deals') ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->where('received_at', '>=', $today) ->select('project_id', DB::raw('COUNT(*) as cnt')) ->groupBy('project_id'); return DB::table('projects') ->leftJoinSub($suppliers, 'sp', 'sp.project_id', '=', 'projects.id') ->leftJoinSub($leadsToday, 'lt', 'lt.project_id', '=', 'projects.id') ->where('projects.tenant_id', $tenantId) ->orderBy('projects.id') ->select([ 'projects.id', 'projects.name', 'projects.tag', 'projects.is_active', 'projects.daily_limit_target', DB::raw('COALESCE(sp.cnt, 0) as suppliers_count'), DB::raw('COALESCE(lt.cnt, 0) as leads_today'), ]) ->limit(100) ->get() ->map(fn ($p) => [ 'id' => (int) $p->id, 'name' => $p->name, 'tag' => $p->tag, 'is_active' => (bool) $p->is_active, 'daily_limit_target' => (int) $p->daily_limit_target, 'suppliers_count' => (int) $p->suppliers_count, 'leads_today' => (int) $p->leads_today, ]) ->all(); } /** @return array> */ private function fetchBalanceHistory(int $tenantId): array { return DB::table('balance_transactions') ->where('tenant_id', $tenantId) ->orderByDesc('created_at') ->orderByDesc('id') ->limit(30) ->get() ->map(fn ($tx) => [ 'id' => (int) $tx->id, 'type' => $tx->type, 'amount_rub' => (string) $tx->amount_rub, 'amount_leads' => (int) $tx->amount_leads, 'balance_rub_after' => $tx->balance_rub_after !== null ? (string) $tx->balance_rub_after : null, 'description' => $tx->description, 'created_at' => CarbonImmutable::parse($tx->created_at)->toIso8601String(), ]) ->all(); } /** @return array> */ private function fetchActivity(int $tenantId): array { return DB::table('activity_log') ->leftJoin('users', 'users.id', '=', 'activity_log.user_id') ->where('activity_log.tenant_id', $tenantId) ->orderByDesc('activity_log.created_at') ->orderByDesc('activity_log.id') ->limit(20) ->select([ 'activity_log.id', 'activity_log.event', 'activity_log.deal_id', 'activity_log.context', 'activity_log.created_at', 'users.email as actor_email', ]) ->get() ->map(fn ($ev) => [ 'id' => (int) $ev->id, 'event' => $ev->event, 'deal_id' => (int) $ev->deal_id, 'actor_email' => $ev->actor_email, 'context' => $ev->context !== null ? json_decode($ev->context, true) : null, 'created_at' => CarbonImmutable::parse($ev->created_at)->toIso8601String(), ]) ->all(); } /** * @param object $tenantRow Row из основного select'а в show(). * @return array */ private function computeMetrics(int $tenantId, object $tenantRow): array { $now = CarbonImmutable::now(); $today = $now->startOfDay(); $weekAgo = $now->subDays(7)->startOfDay(); $monthAgo = $now->subDays(30)->startOfDay(); // Counts по периодам — один SELECT с FILTER. $leadsRow = DB::table('deals') ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->where('received_at', '>=', $monthAgo) ->selectRaw(' COUNT(*) FILTER (WHERE received_at >= ?) as today_count, COUNT(*) FILTER (WHERE received_at >= ?) as week_count, COUNT(*) as month_count ', [$today, $weekAgo]) ->first(); // Средняя цена за 30 дней (LEFT JOIN supplier_lead_costs). $avgRow = DB::table('deals') ->leftJoin('supplier_lead_costs', 'deals.id', '=', 'supplier_lead_costs.deal_id') ->where('deals.tenant_id', $tenantId) ->whereNull('deals.deleted_at') ->where('deals.received_at', '>=', $monthAgo) ->selectRaw('AVG(supplier_lead_costs.cost_rub) as avg_cost') ->first(); $avgCost = $avgRow !== null && $avgRow->avg_cost !== null ? round((float) $avgRow->avg_cost, 2) : null; // runway_days = balance_rub / (среднее списание/день за 30 дней). // Простая аппроксимация: month_spend / 30 = avg_daily_spend. $balance = (float) $tenantRow->balance_rub; $monthSpendRow = DB::table('balance_transactions') ->where('tenant_id', $tenantId) ->where('created_at', '>=', $monthAgo) ->where('amount_rub', '<', 0) ->selectRaw('SUM(ABS(amount_rub)) as month_spend') ->first(); $runwayDays = null; if ($monthSpendRow !== null && $monthSpendRow->month_spend !== null) { $avgDailySpend = (float) $monthSpendRow->month_spend / 30; if ($avgDailySpend > 0 && $balance > 0) { $runwayDays = (int) floor($balance / $avgDailySpend); } } return [ 'leads_today' => $leadsRow !== null ? (int) $leadsRow->today_count : 0, 'leads_this_week' => $leadsRow !== null ? (int) $leadsRow->week_count : 0, 'leads_this_month' => $leadsRow !== null ? (int) $leadsRow->month_count : 0, 'avg_lead_cost_rub' => $avgCost, 'runway_days' => $runwayDays, ]; } /** * Aggregate-stats для page-head: total / active / trial / overdue / revenue. * Считается отдельным запросом без фильтров (показывает глобальную картину * по всем тенантам). * * @return array */ private function computeStats(): array { $row = DB::table('tenants') ->whereNull('deleted_at') ->selectRaw(' COUNT(*) as total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as active, SUM(CASE WHEN is_trial = TRUE THEN 1 ELSE 0 END) as trial, SUM(CASE WHEN chargeback_unrecovered_rub > 0 OR balance_rub < 0 THEN 1 ELSE 0 END) as overdue ', ['active']) ->first(); return [ 'total' => $row !== null ? (int) $row->total : 0, 'active' => $row !== null ? (int) $row->active : 0, 'trial' => $row !== null ? (int) $row->trial : 0, 'overdue' => $row !== null ? (int) $row->overdue : 0, ]; } }