Files
portal/app/app/Http/Controllers/Api/AdminTenantsController.php
T

481 lines
20 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=&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),
]);
}
/**
* 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,
];
}
/**
* 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,
];
}
}