Files
portal/app/app/Http/Controllers/Api/AdminTenantsController.php
T
Дмитрий c92d498b57
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:56 +03:00

512 lines
22 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\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
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
{
use ResolvesAdminUserId;
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', '');
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
$statuses = $this->csvParam($request, 'statuses');
$tariffs = $this->csvParam($request, 'tariffs');
$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');
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
if ($statuses !== []) {
$query->whereIn(DB::raw("(CASE
WHEN tenants.is_trial THEN 'trial'
WHEN tenants.status = 'suspended' THEN 'suspended'
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
WHEN tenants.status = 'active' THEN 'active'
ELSE 'suspended'
END)"), $statuses);
} elseif ($status !== '') {
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
}
if ($tariffs !== []) {
$query->whereIn('tariff_plans.name', $tariffs);
}
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<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,
];
}
/**
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
*
* @return list<string>
*/
private function csvParam(Request $request, string $key): array
{
return array_values(array_filter(array_map(
'trim',
explode(',', (string) $request->query($key, '')),
)));
}
/**
* 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,
];
}
}