2026-05-09 09:19:53 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
|
2026-05-23 19:33:19 +03:00
|
|
|
|
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
2026-05-09 09:19:53 +03:00
|
|
|
|
use App\Http\Controllers\Controller;
|
2026-05-23 19:33:19 +03:00
|
|
|
|
use App\Models\BalanceTransaction;
|
|
|
|
|
|
use App\Models\SaasAdminAuditLog;
|
2026-05-09 09:19:53 +03:00
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-05-23 19:33:19 +03:00
|
|
|
|
use ResolvesAdminUserId;
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
|
2026-05-09 09:19:53 +03:00
|
|
|
|
public function index(Request $request): JsonResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
$status = (string) $request->query('status', '');
|
2026-06-28 12:05:31 +03:00
|
|
|
|
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
|
|
|
|
|
|
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
|
|
|
|
|
|
$statuses = $this->csvParam($request, 'statuses');
|
|
|
|
|
|
$tariffs = $this->csvParam($request, 'tariffs');
|
2026-05-09 09:19:53 +03:00
|
|
|
|
$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',
|
2026-05-09 10:08:12 +03:00
|
|
|
|
'tariff_plans.price_monthly as tariff_price_monthly',
|
2026-05-09 09:19:53 +03:00
|
|
|
|
])
|
|
|
|
|
|
->whereNull('tenants.deleted_at');
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
// Производный статус — зеркалит 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);
|
2026-05-09 09:19:53 +03:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-05-09 10:08:12 +03:00
|
|
|
|
// 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,
|
2026-05-09 09:19:53 +03:00
|
|
|
|
'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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 14:32:24 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 19:33:19 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 14:32:24 +03:00
|
|
|
|
/** @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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Разбирает 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, '')),
|
|
|
|
|
|
)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:19:53 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|