1fef2571e8
email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore), светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products. Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
582 lines
25 KiB
PHP
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'],
|
|
]);
|
|
}
|
|
}
|