Files
portal/app/app/Http/Controllers/Api/AdminTenantsController.php
T
Дмитрий f9d8926945 phase2(admin-tenant-detail-backend): GET /api/admin/tenants/{subdomain} с 4 секциями
- AdminTenantsController +show($subdomain): возвращает tenant base + users +
  projects + balance_history + activity + computed metrics (leads_today/week/
  month, avg_lead_cost_rub, runway_days). Lookup по subdomain (естественный
  URL slug) + whereNull deleted_at. Без auth-middleware (saas-admin SSO ⏸ Б-1).
- 4 private fetch'ера + computeMetrics:
  - fetchUsers: ORDER last_active_at DESC, LIMIT 50, поля email/first/last/
    is_active/totp_enabled/last_active_at/last_login_at.
  - fetchProjects: LEFT JOIN sub-queries для suppliers_count + leads_today
    (deals в текущем дне). Поля name/tag/is_active/daily_limit_target.
  - fetchBalanceHistory: ORDER created_at DESC, LIMIT 30. Поля type/amount_rub/
    amount_leads/balance_rub_after/description/created_at.
  - fetchActivity: LEFT JOIN users (actor_email), LIMIT 20. context json_decode.
  - computeMetrics: один SELECT с FILTER для leads counts; AVG cost_rub за
    30 дней; runway_days = balance / (month_spend / 30).
- routes/web.php: GET /api/admin/tenants/{subdomain} where [a-z0-9_-]+.
- Pest +13 в AdminTenantShowTest.php (всего 416/416, +13 от 403, 1388 assertions):
  404 unknown / 404 soft-deleted / базовые поля / 4 секции + metrics keys в response /
  users изоляция / projects suppliers_count + leads_today / balance_history ORDER+LIMIT 30 /
  balance_history изоляция / activity actor_email LEFT JOIN (user + system events) /
  metrics leads_today/week/month / metrics runway_days computed / tariff_name+mrr_rub /
  mrr_rub null для trial.
- phpstan-baseline регенерирован.

Этап A эпика AdminTenantDetailView (backend) закрыт. Этап B: frontend
integration + Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:24 +03:00

395 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin lookup тенантов для AdminTenantsView.
*
* Saas-уровневый endpoint (НЕ tenant-aware), на MVP без auth-middleware
* (saas-admin SSO ⏸ Б-1). Production: middleware('auth:saas-admin').
*
* Возвращает список тенантов с current_tariff_id+tariff_name (left-join),
* базовыми финансами и активностью. Не включает агрегаты типа MRR /
* actualDailyLeads — это требует partial JOIN'ов на balance_transactions
* и deals и значительно усложнит запрос. Добавим отдельным endpoint'ом
* по факту необходимости.
*/
class AdminTenantsController extends Controller
{
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
$status = (string) $request->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),
]);
}
/** @return array<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<string, mixed>
*/
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<string, int|string>
*/
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,
];
}
}