Files
portal/app/app/Http/Controllers/Api/AdminDashboardController.php
T
Дмитрий 1fef2571e8
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(y360): баланс почты Яндекс 360 — ручной ввод + кнопка Пополнить
email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore),
светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products.
Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:34:13 +03:00

582 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Dashboard\SupplyReconciliation;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Командный центр» — read-only агрегаты для дашборда.
* Под группой ['saas-admin','admin-db'] → cross-tenant через pgsql_admin.
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
*/
class AdminDashboardController extends Controller
{
/**
* Диапазон периода из query: либо date_from/date_to (свой период, приоритет),
* либо preset period=today|7d|30d|60d|90d (дефолт 7d). Возвращает [from, to]:
* to — верхняя граница (конец дня date_to при своём периоде, иначе now).
*
* @return array{0:Carbon,1:Carbon}
*/
private function periodRange(Request $request): array
{
$df = (string) $request->query('date_from', '');
$dt = (string) $request->query('date_to', '');
if ($df !== '' && $dt !== '') {
try {
return [Carbon::parse($df)->startOfDay(), Carbon::parse($dt)->endOfDay()];
} catch (\Throwable) {
// невалидные даты → падаем на preset ниже
}
}
$from = match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'30d' => now()->subDays(30),
'60d' => now()->subDays(60),
'90d' => now()->subDays(90),
default => now()->subDays(7),
};
return [$from, now()];
}
/** GET /api/admin/dashboard — сводка L1 (все плитки). */
public function summary(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'date_from' => $request->query('date_from'),
'date_to' => $request->query('date_to'),
'finance' => $this->financeTile($from, $to),
'health' => $this->healthTile(),
'leads' => $this->leadsTile(),
'supply' => $this->supplyTile(),
'balances' => $this->balancesTile(),
'clients' => $this->clientsTile($from, $to),
]);
}
/** @return array<string,mixed> */
private function financeTile(Carbon $from, Carbon $to): array
{
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = (float) DB::table('balance_transactions')
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
$newClients = DB::table('tenants')->whereBetween('created_at', [$from, $to])->whereNull('deleted_at')->count();
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
return [
'topups_rub' => (string) $topups,
'charges_rub' => (string) abs($charges),
'active_clients' => $active,
'new_clients' => $newClients,
'negative_balance_count' => $negative,
'light' => $negative > 0 ? 'red' : 'green',
];
}
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
public function finance(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = abs((float) DB::table('balance_transactions')
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub'));
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
$attention = DB::table('tenants')->whereNull('deleted_at')
->where('balance_rub', '<', 0)
->orderBy('balance_rub')
->limit(20)
->get(['id', 'subdomain', 'organization_name', 'balance_rub', 'balance_leads'])
->map(fn ($t) => [
'id' => (int) $t->id,
'subdomain' => $t->subdomain,
'organization_name' => $t->organization_name,
'balance_rub' => (string) $t->balance_rub,
'state' => 'negative',
]);
// Топ по обороту: сумма пополнений за период.
$top = DB::table('balance_transactions')
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
->where('balance_transactions.type', 'topup')
->whereBetween('balance_transactions.created_at', [$from, $to])
->whereNull('tenants.deleted_at')
->groupBy('tenants.id', 'tenants.organization_name')
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
->limit(10)
->get([
'tenants.id',
'tenants.organization_name',
DB::raw('SUM(balance_transactions.amount_rub) AS topped_rub'),
])
->map(fn ($r) => [
'id' => (int) $r->id,
'organization_name' => $r->organization_name,
'topped_rub' => (string) $r->topped_rub,
]);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'kpi' => [
'topups_rub' => (string) $topups,
'charges_rub' => (string) $charges,
'net_inflow_rub' => (string) ($topups - $charges),
'negative_balance_count' => $attention->count(),
],
'attention' => $attention,
'top_by_turnover' => $top,
]);
}
/** GET /api/admin/dashboard/health — 6 подсистем эксплуатации (L2). */
public function health(): JsonResponse
{
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$lastReconcile = DB::table('supplier_csv_reconcile_log')->orderByDesc('id')->first();
$unresolvedWebhooks = DB::table('failed_webhook_jobs')->whereNull('resolved_at')->count();
$inc = $this->incidentCounts();
$staleHeartbeat = DB::table('scheduler_heartbeats')->where('consecutive_failures', '>', 0)->count();
$jobsLight = ($failedJobs > 0 || $inc['auto_job_24h'] > 0) ? 'red' : 'green';
$jobsDetail = $inc['auto_job_24h'] > 0
? $inc['auto_job_24h'].' повторяющихся ошибок джоб за сутки'
: $failedJobs.' упавших за сутки';
$subsystems = [
['key' => 'queues', 'light' => $jobsLight, 'detail' => $jobsDetail],
['key' => 'scheduler', 'light' => $staleHeartbeat > 0 ? 'red' : 'green',
'detail' => $staleHeartbeat > 0 ? $staleHeartbeat.' задач с пропусками' : 'по расписанию'],
['key' => 'supplier_sync', 'light' => ($lastSync && in_array($lastSync->status, ['failed', 'aborted'], true)) ? 'red' : 'green',
'detail' => 'последний: '.($lastSync->status ?? 'нет')],
['key' => 'csv_drift', 'light' => ($lastReconcile && $lastReconcile->status === 'drift_alert') ? 'red' : 'green',
'detail' => 'статус: '.($lastReconcile->status ?? 'нет')],
['key' => 'webhooks', 'light' => $unresolvedWebhooks > 0 ? 'amber' : 'green',
'detail' => $unresolvedWebhooks.' неразобранных'],
['key' => 'incidents', 'light' => $inc['real'] > 0 ? 'red' : 'green',
'detail' => $inc['real'].' открытых (реальных)'],
];
$order = ['green' => 0, 'amber' => 1, 'red' => 2];
$overall = collect($subsystems)->sortByDesc(fn ($s) => $order[$s['light']])->first()['light'];
return response()->json(['subsystems' => $subsystems, 'overall_light' => $overall]);
}
/**
* Счётчики инцидентов с разделением: РЕАЛЬНЫЕ (заведённые человеком/РКН) vs
* АВТО-ошибки джоб ('Автоматически: persistent exception job=…'), которые
* копятся и сами не закрываются. Для здоровья считаем реальные + свежие авто.
*
* @return array{real:int,auto_job_24h:int}
*/
private function incidentCounts(): array
{
$real = DB::table('incidents_log')->whereNull('resolved_at')
->where(function ($q) {
$q->whereNull('summary')->orWhere('summary', 'not like', 'Автоматически:%');
})
->count();
$autoJob24h = DB::table('incidents_log')->whereNull('resolved_at')
->where('summary', 'like', 'Автоматически:%')
->where('detected_at', '>=', now()->subDay())
->count();
return ['real' => $real, 'auto_job_24h' => $autoJob24h];
}
/** @return array<string,mixed> */
private function healthTile(): array
{
$inc = $this->incidentCounts();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$light = 'green';
if ($inc['real'] > 0 || $failedJobs > 0 || $inc['auto_job_24h'] > 0
|| ($lastSync !== null && in_array($lastSync->status, ['failed', 'aborted'], true))) {
$light = 'red';
}
return [
'light' => $light,
'open_incidents' => $inc['real'],
'job_errors_24h' => $inc['auto_job_24h'],
'failed_jobs_24h' => $failedJobs,
'last_sync_status' => $lastSync->status ?? 'none',
'last_sync_at' => $lastSync->finished_at ?? null,
];
}
// === Этап 2: Лиды ===
/** @return array<string,mixed> */
private function leadsMetrics(): array
{
$todayStart = now('Europe/Moscow')->startOfDay();
// Доставлено = реально созданные сегодня сделки у клиентов (не тест, не удал.).
$deliveredToday = DB::table('deals')
->where('received_at', '>=', $todayStart)
->where('is_test', false)
->whereNull('deleted_at')
->count();
// Получено от поставщика сегодня.
$receivedToday = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count();
// В очереди на распределение прямо сейчас.
$unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count();
// Зависшие = не распределены дольше 4 часов (порог cron leads:escalate-stale).
$stuck = DB::table('supplier_leads')
->whereNull('processed_at')
->where('received_at', '<', now()->subHours(4))
->count();
$light = 'green';
if ($stuck > 0) {
$light = 'red';
} elseif ($unrouted > 0) {
$light = 'amber';
}
return [
'light' => $light,
'delivered_today' => $deliveredToday,
'received_today' => $receivedToday,
'stuck' => $stuck,
'unrouted' => $unrouted,
];
}
/** @return array<string,mixed> */
private function leadsTile(): array
{
$m = $this->leadsMetrics();
return [
'light' => $m['light'],
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
];
}
/** GET /api/admin/dashboard/leads — KPI распределения лидов + топ-10 последних (L2). */
public function leads(): JsonResponse
{
$m = $this->leadsMetrics();
// Топ-10 последних лидов для drill (полный список — на экране /admin/leads).
$recent = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
->orderByDesc('sl.received_at')
->limit(10)
->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at',
'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key'])
->map(fn ($r) => [
'id' => (int) $r->id,
'received_at' => $r->received_at,
'platform' => $r->platform,
'channel' => $r->channel,
'source' => $r->unique_key,
'phone_masked' => $this->maskPhoneShort($r->phone),
'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0,
'processed' => $r->processed_at !== null,
]);
return response()->json([
'light' => $m['light'],
'kpi' => [
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
],
'recent' => $recent,
]);
}
/** Короткая маска телефона для drill (152-ФЗ). */
private function maskPhoneShort(?string $phone): string
{
if (! $phone) {
return '—';
}
$d = preg_replace('/\D/', '', $phone);
return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***';
}
// === Этап 2: Заказ у поставщика ===
/**
* Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects).
* Плюс ПОЛНАЯ картина у поставщика (все активные заказы), чтобы не выглядело
* занижено: сверка идёт только по группам последнего снимка, а заказов больше.
*
* @return array{snapshot_date:?string,total_orders:int,total_limit:int,result:array{groups:list<array<string,mixed>>,totals:array<string,int>}}
*/
private function supplyReconciliation(): array
{
/** @var string|null $latest */
$latest = DB::table('project_routing_snapshots')->max('snapshot_date');
$demand = [];
if ($latest !== null) {
$rows = DB::table('project_routing_snapshots')
->where('snapshot_date', $latest)
->groupBy('signal_type', 'signal_identifier')
->select(
'signal_type',
'signal_identifier',
DB::raw('SUM(daily_limit) AS demand'),
DB::raw('MAX(daily_limit) AS max_limit'),
)
->get();
foreach ($rows as $r) {
$demand[] = [
'signal_type' => (string) $r->signal_type,
'identifier' => (string) $r->signal_identifier,
'demand' => (int) $r->demand,
'max_limit' => (int) $r->max_limit,
];
}
}
/** @var array<string,int> $orderedByKey */
$orderedByKey = DB::table('supplier_projects')
->groupBy('signal_type', 'unique_key')
->select('signal_type', 'unique_key', DB::raw('SUM(current_limit) AS ordered'))
->get()
->mapWithKeys(fn ($r) => [$r->signal_type.'|'.$r->unique_key => (int) $r->ordered])
->all();
return [
'snapshot_date' => $latest,
'total_orders' => (int) DB::table('supplier_projects')->where('current_limit', '>', 0)->count(),
'total_limit' => (int) DB::table('supplier_projects')->sum('current_limit'),
'result' => SupplyReconciliation::build($demand, $orderedByKey),
];
}
/** @return array<string,mixed> */
private function supplyTile(): array
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return [
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'demand' => $totals['demand'],
'formula' => $totals['formula'],
'ordered' => $totals['ordered'],
'mismatches' => $totals['mismatches'],
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'snapshot_date' => $rec['snapshot_date'],
];
}
// === Балансы внешних сервисов (28.06) ===
/** Порядок «опасности» светофора: больше = хуже. */
private const LIGHT_ORDER = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
/**
* Прямая ссылка «Пополнить» для сервиса (статика из конфига; в БД не хранится).
* Владелец с планшета: увидел минус → ткнул → попал на страницу оплаты.
*/
private function topupUrl(string $key): ?string
{
return match ($key) {
'dadata' => (string) config('services.dadata.topup_url') ?: null,
'supplier' => (string) config('services.supplier.topup_url') ?: null,
'yandex_cloud' => $this->ycTopupUrl(),
'email' => (string) config('services.yandex360.topup_url') ?: null,
default => null,
};
}
private function ycTopupUrl(): ?string
{
$base = (string) config('services.yandex_cloud.console_billing_url');
$acc = (string) config('services.yandex_cloud.billing_account_id');
if ($base === '' || $acc === '') {
return null;
}
return rtrim($base, '/').'/'.$acc.'/payments';
}
/** @return array<string,mixed> */
private function balancesTile(): array
{
$rows = DB::table('external_service_balances')->get();
$light = $rows->isEmpty() ? 'grey'
: $rows->map(fn ($r) => $r->ok ? $r->light : 'grey')
->sortByDesc(fn ($l) => self::LIGHT_ORDER[$l] ?? 0)->first();
return [
'light' => $light,
'count' => $rows->count(),
'red' => $rows->where('ok', true)->where('light', 'red')->count(),
];
}
/** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */
public function balances(): JsonResponse
{
$rows = DB::table('external_service_balances')->get()->map(fn ($r) => [
'service_key' => $r->service_key,
'balance_amount' => $r->balance_amount,
'currency' => $r->currency,
'daily_spend_estimate' => $r->daily_spend_estimate,
'days_left' => $r->days_left,
'light' => $r->ok ? $r->light : 'grey',
'ok' => (bool) $r->ok,
'error' => $r->error,
'checked_at' => $r->checked_at,
'topup_url' => $this->topupUrl($r->service_key),
])->values();
$light = $rows->isEmpty() ? 'grey'
: $rows->sortByDesc(fn ($s) => self::LIGHT_ORDER[$s['light']] ?? 0)->first()['light'];
return response()->json(['light' => $light, 'services' => $rows]);
}
// === Клиенты (активность) ===
/** Клиент «спит», если его тенант не заходил дольше этого срока (или ни разу). */
private const DORMANT_DAYS = 14;
/** @return array{total_active:int,new_count:int,logged_in:int,got_leads:int,paid:int} */
private function clientActivityKpi(Carbon $from, Carbon $to): array
{
return [
'total_active' => DB::table('tenants')->whereNull('deleted_at')->where('status', 'active')->count(),
'new_count' => DB::table('tenants')->whereNull('deleted_at')->whereBetween('created_at', [$from, $to])->count(),
'logged_in' => DB::table('users')->whereBetween('last_login_at', [$from, $to])->distinct()->count('tenant_id'),
'got_leads' => DB::table('deals')->whereBetween('received_at', [$from, $to])->where('is_test', false)
->whereNull('deleted_at')->distinct()->count('tenant_id'),
'paid' => DB::table('balance_transactions')->where('type', 'topup')->whereBetween('created_at', [$from, $to])
->distinct()->count('tenant_id'),
];
}
/** Активные тенанты без входа дольше DORMANT_DAYS (или ни разу) — «спящие». */
private function dormantQuery(): Builder
{
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
->groupBy('tenant_id');
return DB::table('tenants')
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
->whereNull('tenants.deleted_at')
->where('tenants.status', 'active')
->where(function ($q) {
$q->whereNull('ll.last_login_at')
->orWhere('ll.last_login_at', '<', now()->subDays(self::DORMANT_DAYS));
});
}
/** @return array<string,mixed> */
private function clientsTile(Carbon $from, Carbon $to): array
{
$kpi = $this->clientActivityKpi($from, $to);
$dormant = (clone $this->dormantQuery())->count();
return [
'light' => $dormant > 0 ? 'amber' : 'green',
'total_active' => $kpi['total_active'],
'new_count' => $kpi['new_count'],
'logged_in' => $kpi['logged_in'],
'dormant' => $dormant,
];
}
/** GET /api/admin/dashboard/clients — активность клиентов + новые + спящие (L2). */
public function clients(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$kpi = $this->clientActivityKpi($from, $to);
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
->groupBy('tenant_id');
$newClients = DB::table('tenants')
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
->whereNull('tenants.deleted_at')
->whereBetween('tenants.created_at', [$from, $to])
->orderByDesc('tenants.created_at')
->limit(50)
->get([
'tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.status',
'tenants.created_at', 'tenants.balance_rub', 'tenants.delivered_in_month', 'll.last_login_at',
])
->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name ?: $t->subdomain,
'subdomain' => $t->subdomain,
'status' => $t->status,
'created_at' => $t->created_at,
'last_login_at' => $t->last_login_at,
'delivered_in_month' => (int) $t->delivered_in_month,
'balance_rub' => (string) $t->balance_rub,
]);
$dormant = (clone $this->dormantQuery())
->orderByRaw('ll.last_login_at ASC NULLS FIRST')
->limit(50)
->get(['tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.balance_rub', 'll.last_login_at'])
->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name ?: $t->subdomain,
'subdomain' => $t->subdomain,
'last_login_at' => $t->last_login_at,
'balance_rub' => (string) $t->balance_rub,
]);
return response()->json([
'kpi' => $kpi,
'new_clients' => $newClients,
'dormant' => $dormant,
]);
}
/** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
public function supply(): JsonResponse
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return response()->json([
'snapshot_date' => $rec['snapshot_date'],
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'totals' => $totals,
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'groups' => $rec['result']['groups'],
]);
}
}