feat дашборд: Этап 2 — живые плитки Лиды и Заказ у поставщика
Backend: AdminDashboardController +leads/+supply эндпоинты, summary дополнен плитками leads/supply; сверка заказа вынесена в чистый сервис SupplyReconciliation (спрос → формула computeOrder=max(max,⌈Σ/3⌉) → факт → рассинхрон). Лиды: доставлено сегодня / зависшие 4ч+ / нераспределённые / % доставки — cross-tenant под pgsql_admin. Frontend: плитки Лиды и Заказ оживлены (убраны заглушки «Этап 2»), drill с KPI и таблицей групп спрос→формула→факт→совпадает. Тесты: SupplyReconciliation unit 3/3, Leads/Supply/Summary feature, admin-срез 87 зелёных, фронт 10/10. stan 0, pint/eslint/type-check/build чисто. phpstan-baseline перегенерирован (getJson false-positive на новых тестах). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Dashboard\SupplyReconciliation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -36,6 +37,8 @@ class AdminDashboardController extends Controller
|
||||
'period' => (string) $request->query('period', '7d'),
|
||||
'finance' => $this->financeTile($from),
|
||||
'health' => $this->healthTile(),
|
||||
'leads' => $this->leadsTile(),
|
||||
'supply' => $this->supplyTile(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -169,4 +172,154 @@ class AdminDashboardController extends Controller
|
||||
'last_sync_at' => $lastSync->finished_at ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// === Этап 2: Лиды ===
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function leadsMetrics(): array
|
||||
{
|
||||
$todayStart = now('Europe/Moscow')->startOfDay();
|
||||
|
||||
$deliveredToday = (int) DB::table('projects')->sum('delivered_today');
|
||||
$received = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count();
|
||||
$processed = DB::table('supplier_leads')
|
||||
->where('received_at', '>=', $todayStart)
|
||||
->whereNotNull('processed_at')
|
||||
->count();
|
||||
$unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count();
|
||||
$stuck = DB::table('supplier_leads')
|
||||
->whereNull('processed_at')
|
||||
->where('received_at', '<', now()->subHours(4))
|
||||
->count();
|
||||
$unassigned = DB::table('deals')
|
||||
->whereNull('manager_id')->where('status', 'new')
|
||||
->whereNull('deleted_at')->where('is_test', false)
|
||||
->count();
|
||||
|
||||
$deliveryPct = $received > 0 ? round($processed / $received * 100, 1) : 100.0;
|
||||
|
||||
$light = 'green';
|
||||
if ($stuck > 0) {
|
||||
$light = 'red';
|
||||
} elseif ($unrouted > 0) {
|
||||
$light = 'amber';
|
||||
}
|
||||
|
||||
return [
|
||||
'light' => $light,
|
||||
'delivered_today' => $deliveredToday,
|
||||
'stuck' => $stuck,
|
||||
'unrouted' => $unrouted,
|
||||
'unassigned_deals' => $unassigned,
|
||||
'delivery_pct' => $deliveryPct,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function leadsTile(): array
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
return [
|
||||
'light' => $m['light'],
|
||||
'delivered_today' => $m['delivered_today'],
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
'delivery_pct' => $m['delivery_pct'],
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
|
||||
public function leads(): JsonResponse
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
return response()->json([
|
||||
'light' => $m['light'],
|
||||
'kpi' => [
|
||||
'delivered_today' => $m['delivered_today'],
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
'unassigned_deals' => $m['unassigned_deals'],
|
||||
'delivery_pct' => $m['delivery_pct'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// === Этап 2: Заказ у поставщика ===
|
||||
|
||||
/**
|
||||
* Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects).
|
||||
*
|
||||
* @return array{snapshot_date:?string,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,
|
||||
'result' => SupplyReconciliation::build($demand, $orderedByKey),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function supplyTile(): array
|
||||
{
|
||||
$totals = $this->supplyReconciliation()['result']['totals'];
|
||||
|
||||
return [
|
||||
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
|
||||
'demand' => $totals['demand'],
|
||||
'formula' => $totals['formula'],
|
||||
'ordered' => $totals['ordered'],
|
||||
'mismatches' => $totals['mismatches'],
|
||||
];
|
||||
}
|
||||
|
||||
/** 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,
|
||||
'groups' => $rec['result']['groups'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dashboard;
|
||||
|
||||
/**
|
||||
* Сверка заказа у поставщика для дашборда: спрос клиентов → надо по формуле
|
||||
* → заказали по факту → совпадает ли. Чистая логика (без БД), тестируема.
|
||||
*
|
||||
* Формула = SupplierQuotaAllocator::computeOrder = max(max(лимитов), ceil(сумма/3)).
|
||||
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
*/
|
||||
class SupplyReconciliation
|
||||
{
|
||||
/**
|
||||
* @param list<array{signal_type:string,identifier:string,demand:int,max_limit:int}> $demand
|
||||
* @param array<string,int> $orderedByKey ключ "signal_type|identifier" => SUM(current_limit)
|
||||
* @return array{groups:list<array{signal_type:string,identifier:string,demand:int,formula:int,ordered:int,in_sync:bool}>,totals:array{demand:int,formula:int,ordered:int,mismatches:int}}
|
||||
*/
|
||||
public static function build(array $demand, array $orderedByKey): array
|
||||
{
|
||||
$groups = [];
|
||||
$sumDemand = 0;
|
||||
$sumFormula = 0;
|
||||
$sumOrdered = 0;
|
||||
$mismatches = 0;
|
||||
|
||||
foreach ($demand as $d) {
|
||||
$formula = max((int) $d['max_limit'], (int) ceil($d['demand'] / 3));
|
||||
$key = $d['signal_type'].'|'.$d['identifier'];
|
||||
$ordered = (int) ($orderedByKey[$key] ?? 0);
|
||||
$inSync = $formula === $ordered;
|
||||
|
||||
$groups[] = [
|
||||
'signal_type' => (string) $d['signal_type'],
|
||||
'identifier' => (string) $d['identifier'],
|
||||
'demand' => (int) $d['demand'],
|
||||
'formula' => $formula,
|
||||
'ordered' => $ordered,
|
||||
'in_sync' => $inSync,
|
||||
];
|
||||
|
||||
$sumDemand += (int) $d['demand'];
|
||||
$sumFormula += $formula;
|
||||
$sumOrdered += $ordered;
|
||||
if (! $inSync) {
|
||||
$mismatches++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'groups' => $groups,
|
||||
'totals' => [
|
||||
'demand' => $sumDemand,
|
||||
'formula' => $sumFormula,
|
||||
'ordered' => $sumOrdered,
|
||||
'mismatches' => $mismatches,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -304,8 +304,20 @@ parameters:
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardLeadsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/AdminDashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardSupplyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
|
||||
@@ -28,6 +28,45 @@ export interface DashboardSummary {
|
||||
last_sync_status: string;
|
||||
last_sync_at: string | null;
|
||||
};
|
||||
leads: {
|
||||
light: Light;
|
||||
delivered_today: number;
|
||||
stuck: number;
|
||||
unrouted: number;
|
||||
delivery_pct: number;
|
||||
};
|
||||
supply: {
|
||||
light: Light;
|
||||
demand: number;
|
||||
formula: number;
|
||||
ordered: number;
|
||||
mismatches: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeadsDetail {
|
||||
light: Light;
|
||||
kpi: {
|
||||
delivered_today: number;
|
||||
stuck: number;
|
||||
unrouted: number;
|
||||
unassigned_deals: number;
|
||||
delivery_pct: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SupplyDetail {
|
||||
snapshot_date: string | null;
|
||||
light: Light;
|
||||
totals: { demand: number; formula: number; ordered: number; mismatches: number };
|
||||
groups: Array<{
|
||||
signal_type: string;
|
||||
identifier: string;
|
||||
demand: number;
|
||||
formula: number;
|
||||
ordered: number;
|
||||
in_sync: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FinanceDetail {
|
||||
@@ -71,3 +110,13 @@ export async function getDashboardHealth(): Promise<HealthDetail> {
|
||||
const { data } = await apiClient.get<HealthDetail>('/api/admin/dashboard/health');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardLeads(): Promise<LeadsDetail> {
|
||||
const { data } = await apiClient.get<LeadsDetail>('/api/admin/dashboard/leads');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardSupply(): Promise<SupplyDetail> {
|
||||
const { data } = await apiClient.get<SupplyDetail>('/api/admin/dashboard/supply');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка → Командный центр (дашборд). Landing SaaS-админки: 4 плитки-светофора
|
||||
* с проваливанием в детали (Уровень 2). Этап 1 наполняет области Финансы и
|
||||
* Здоровье живыми данными; Лиды и Заказ у поставщика — заглушки «Этап 2».
|
||||
* с проваливанием в детали (Уровень 2). Все 4 области — Финансы, Здоровье, Лиды,
|
||||
* Заказ у поставщика — наполнены живыми данными (Этапы 1 + 2).
|
||||
*
|
||||
* Источник дизайна: web/admin-dashboard-mockup.html (Forest-палитра).
|
||||
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
@@ -16,9 +16,13 @@ import {
|
||||
getDashboardSummary,
|
||||
getDashboardFinance,
|
||||
getDashboardHealth,
|
||||
getDashboardLeads,
|
||||
getDashboardSupply,
|
||||
type DashboardSummary,
|
||||
type FinanceDetail,
|
||||
type HealthDetail,
|
||||
type LeadsDetail,
|
||||
type SupplyDetail,
|
||||
type Light,
|
||||
} from '../../api/adminDashboard';
|
||||
|
||||
@@ -32,6 +36,8 @@ const selected = ref<Area>('fin');
|
||||
const summary = ref<DashboardSummary | null>(null);
|
||||
const finance = ref<FinanceDetail | null>(null);
|
||||
const health = ref<HealthDetail | null>(null);
|
||||
const leads = ref<LeadsDetail | null>(null);
|
||||
const supply = ref<SupplyDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
@@ -57,6 +63,18 @@ function healthLightLabel(): string {
|
||||
return summary.value?.health.light === 'green' ? 'OK' : 'есть проблемы';
|
||||
}
|
||||
|
||||
/** Подпись светофора Лидов на плитке. */
|
||||
function leadsLightLabel(): string {
|
||||
const st = summary.value?.leads.stuck ?? 0;
|
||||
return st > 0 ? `${st} зависших` : 'чисто';
|
||||
}
|
||||
|
||||
/** Подпись светофора Заказа на плитке. */
|
||||
function supplyLightLabel(): string {
|
||||
const m = summary.value?.supply.mismatches ?? 0;
|
||||
return m > 0 ? `${m} рассинхрон` : 'ровно';
|
||||
}
|
||||
|
||||
/** Человеческие названия подсистем здоровья. */
|
||||
const SUBSYSTEM_LABELS: Record<string, string> = {
|
||||
queues: 'Очереди / джобы',
|
||||
@@ -85,14 +103,18 @@ async function load() {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const [s, f, h] = await Promise.all([
|
||||
const [s, f, h, l, sup] = await Promise.all([
|
||||
getDashboardSummary(period.value),
|
||||
getDashboardFinance(period.value),
|
||||
getDashboardHealth(),
|
||||
getDashboardLeads(),
|
||||
getDashboardSupply(),
|
||||
]);
|
||||
summary.value = s;
|
||||
finance.value = f;
|
||||
health.value = h;
|
||||
leads.value = l;
|
||||
supply.value = sup;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
@@ -115,7 +137,7 @@ function openTenant(subdomain: string) {
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ period, selected, summary, finance, health, loading, fetchError, load });
|
||||
defineExpose({ period, selected, summary, finance, health, leads, supply, loading, fetchError, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -251,12 +273,32 @@ defineExpose({ period, selected, summary, finance, health, loading, fetchError,
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">🎯</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Лиды</span>
|
||||
<v-chip size="small" variant="tonal" class="ml-auto">Этап 2</v-chip>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.leads.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ leadsLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<p class="text-medium-emphasis text-body-2">
|
||||
Распределение лидов по клиентам, поиск зависших и нераспределённых, % доставки.
|
||||
</p>
|
||||
<div class="tile__more">Скоро →</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Доставлено сегодня</span>
|
||||
<span class="num text-h6 font-weight-bold">{{ summary?.leads.delivered_today ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Зависших</span>
|
||||
<span class="num font-weight-bold">{{ summary?.leads.stuck ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Нераспределённых</span>
|
||||
<span class="num font-weight-bold">{{ summary?.leads.unrouted ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">% доставки</span>
|
||||
<span class="num font-weight-bold">{{ summary?.leads.delivery_pct ?? '—' }}%</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть лиды →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -274,12 +316,32 @@ defineExpose({ period, selected, summary, finance, health, loading, fetchError,
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">📦</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Заказ у поставщика</span>
|
||||
<v-chip size="small" variant="tonal" class="ml-auto">Этап 2</v-chip>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.supply.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ supplyLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<p class="text-medium-emphasis text-body-2">
|
||||
Спрос клиентов → надо по формуле → заказали по факту → совпадает ли механизму заказа.
|
||||
</p>
|
||||
<div class="tile__more">Скоро →</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Просят клиенты (Σ/день)</span>
|
||||
<span class="num text-h6 font-weight-bold">{{ summary?.supply.demand ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Надо по формуле</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.formula ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Заказали по факту</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.ordered ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">Групп с рассинхроном</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.mismatches ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть заказ →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -389,24 +451,111 @@ defineExpose({ period, selected, summary, finance, health, loading, fetchError,
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- DRILL: ЛИДЫ (Этап 2) -->
|
||||
<!-- DRILL: ЛИДЫ -->
|
||||
<v-card v-else-if="selected === 'leads'" variant="outlined" class="drill mt-5" data-testid="drill-leads">
|
||||
<v-card-title class="drill__head">🎯 Лиды — детали <v-chip size="x-small" class="ml-2">Этап 2</v-chip></v-card-title>
|
||||
<v-card-text class="text-medium-emphasis text-body-2">
|
||||
Здесь будет: распределение лидов по клиентам, поиск зависших и потерянных, % доставки по дням.
|
||||
Делаем на Этапе 2.
|
||||
<v-card-title class="drill__head">🎯 Лиды — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row dense>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Доставлено сегодня</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.delivered_today ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Зависших</div>
|
||||
<div class="kpi__val num" :class="{ 'text-error': (leads?.kpi.stuck ?? 0) > 0 }">
|
||||
{{ leads?.kpi.stuck ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Нераспределённых</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.unrouted ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">% доставки</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.delivery_pct ?? 0 }}%</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p class="text-medium-emphasis text-body-2 mt-2">
|
||||
Нераспределённых сделок по менеджерам: {{ leads?.kpi.unassigned_deals ?? 0 }}.
|
||||
«Зависшие» — лиды от поставщика без распределения дольше 4 часов.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА (Этап 2) -->
|
||||
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
|
||||
<v-card v-else variant="outlined" class="drill mt-5" data-testid="drill-supply">
|
||||
<v-card-title class="drill__head">
|
||||
📦 Заказ у поставщика — детали <v-chip size="x-small" class="ml-2">Этап 2</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-medium-emphasis text-body-2">
|
||||
Здесь будет, по каждой группе: <b>сколько лидов просят клиенты</b> (Σ спроса) →
|
||||
<b>сколько надо по формуле</b> (max самого крупного клиента и ⌈сумма ÷ 3⌉, т.к. лид перепродаётся
|
||||
до 3 клиентов) → <b>сколько заказали по факту</b> → совпадает ли. Делаем на Этапе 2.
|
||||
<v-card-title class="drill__head">📦 Заказ у поставщика — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Просят клиенты</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.demand ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Надо по формуле</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.formula ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Заказали по факту</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.ordered ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Рассинхронов</div>
|
||||
<div class="kpi__val num" :class="{ 'text-error': (supply?.totals.mismatches ?? 0) > 0 }">
|
||||
{{ supply?.totals.mismatches ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<h4 class="panel__h4">По группам: спрос → формула → факт</h4>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Группа</th>
|
||||
<th class="text-right">Просят</th>
|
||||
<th class="text-right">Формула</th>
|
||||
<th class="text-right">Факт</th>
|
||||
<th class="text-right">Совпадает?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier">
|
||||
<td>{{ g.identifier }} <span class="text-medium-emphasis">({{ g.signal_type }})</span></td>
|
||||
<td class="text-right num">{{ g.demand }}</td>
|
||||
<td class="text-right num">{{ g.formula }}</td>
|
||||
<td class="text-right num">{{ g.ordered }}</td>
|
||||
<td class="text-right">
|
||||
<v-chip :color="g.in_sync ? 'success' : 'error'" size="x-small" variant="tonal">
|
||||
{{ g.in_sync ? 'да' : 'рассинхрон' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="(supply?.groups?.length ?? 0) === 0">
|
||||
<td colspan="5" class="text-medium-emphasis text-center">
|
||||
Нет данных снимка маршрутизации (снимок делается в 18:02 МСК).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p class="text-medium-emphasis text-body-2 mt-2">
|
||||
Формула заказа: max самого крупного клиента и ⌈сумма спроса ÷ 3⌉ — лид перепродаётся до 3 клиентов.
|
||||
Рассинхрон = факт ≠ формула.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
@@ -113,6 +113,8 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
Route::get('/api/admin/dashboard', 'App\Http\Controllers\Api\AdminDashboardController@summary');
|
||||
Route::get('/api/admin/dashboard/finance', 'App\Http\Controllers\Api\AdminDashboardController@finance');
|
||||
Route::get('/api/admin/dashboard/health', 'App\Http\Controllers\Api\AdminDashboardController@health');
|
||||
Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads');
|
||||
Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
|
||||
|
||||
// SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('GET /api/admin/dashboard/leads возвращает KPI лидов', function () {
|
||||
$tenant = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'leadsacme', 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru',
|
||||
'status' => 'active', 'is_trial' => false, 'balance_rub' => 0, 'balance_leads' => 0,
|
||||
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$supplierProjectId = DB::table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'leads-x.ru',
|
||||
'current_limit' => 10, 'sync_status' => 'ok', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('supplier_leads')->insert([
|
||||
['supplier_project_id' => $supplierProjectId, 'platform' => 'B1', 'raw_payload' => '{}',
|
||||
'phone' => '79990000001', 'received_at' => now(), 'processed_at' => now(), 'deals_created_count' => 1],
|
||||
['supplier_project_id' => $supplierProjectId, 'platform' => 'B1', 'raw_payload' => '{}',
|
||||
'phone' => '79990000002', 'received_at' => now()->subHours(6), 'processed_at' => null, 'deals_created_count' => null],
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard/leads');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'light',
|
||||
'kpi' => ['delivered_today', 'stuck', 'unrouted', 'unassigned_deals', 'delivery_pct'],
|
||||
]);
|
||||
expect($res->json('kpi.stuck'))->toBe(1); // 1 зависший (6ч, не обработан)
|
||||
expect($res->json('kpi.unrouted'))->toBe(1); // 1 в очереди
|
||||
expect($res->json('light'))->toBe('red'); // есть зависший
|
||||
});
|
||||
@@ -35,3 +35,16 @@ it('GET /api/admin/dashboard returns finance + health tiles', function () {
|
||||
expect($res->json('finance.negative_balance_count'))->toBe(1);
|
||||
expect($res->json('finance.light'))->toBe('red');
|
||||
});
|
||||
|
||||
it('summary включает плитки leads и supply', function () {
|
||||
$res = $this->getJson('/api/admin/dashboard?period=30d');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'finance' => ['light'],
|
||||
'health' => ['light'],
|
||||
'leads' => ['light', 'delivered_today', 'stuck', 'unrouted', 'delivery_pct'],
|
||||
'supply' => ['light', 'demand', 'formula', 'ordered', 'mismatches'],
|
||||
'period',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('GET /api/admin/dashboard/supply возвращает группы и итоги', function () {
|
||||
// supplier_projects не партиционирован — сеем напрямую. project_routing_snapshots
|
||||
// партиционирована по дате → в тесте не сеем (контракт ответа проверяем; формула
|
||||
// покрыта unit-тестом SupplyReconciliation).
|
||||
DB::table('supplier_projects')->insert([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'demo-x.ru',
|
||||
'current_limit' => 50, 'sync_status' => 'ok',
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard/supply');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'snapshot_date',
|
||||
'light',
|
||||
'totals' => ['demand', 'formula', 'ordered', 'mismatches'],
|
||||
'groups',
|
||||
]);
|
||||
expect($res->json('light'))->toBeIn(['green', 'red']);
|
||||
});
|
||||
@@ -23,6 +23,8 @@ vi.mock('../../resources/js/api/adminDashboard', () => ({
|
||||
last_sync_status: 'success',
|
||||
last_sync_at: null,
|
||||
},
|
||||
leads: { light: 'green', delivered_today: 71, stuck: 0, unrouted: 0, delivery_pct: 100 },
|
||||
supply: { light: 'red', demand: 250, formula: 160, ordered: 175, mismatches: 1 },
|
||||
}),
|
||||
getDashboardFinance: vi.fn().mockResolvedValue({
|
||||
period: '7d',
|
||||
@@ -37,6 +39,16 @@ vi.mock('../../resources/js/api/adminDashboard', () => ({
|
||||
{ key: 'incidents', light: 'green', detail: '0 открытых' },
|
||||
],
|
||||
}),
|
||||
getDashboardLeads: vi.fn().mockResolvedValue({
|
||||
light: 'green',
|
||||
kpi: { delivered_today: 71, stuck: 0, unrouted: 0, unassigned_deals: 2, delivery_pct: 100 },
|
||||
}),
|
||||
getDashboardSupply: vi.fn().mockResolvedValue({
|
||||
snapshot_date: '2026-06-28',
|
||||
light: 'red',
|
||||
totals: { demand: 250, formula: 160, ordered: 175, mismatches: 1 },
|
||||
groups: [{ signal_type: 'site', identifier: 'okna.ru', demand: 150, formula: 100, ordered: 100, in_sync: true }],
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,6 +88,23 @@ describe('AdminDashboardView.vue', () => {
|
||||
expect(text).toContain('Заказ у поставщика');
|
||||
});
|
||||
|
||||
it('плитки Лиды и Заказ показывают живые числа', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Доставлено сегодня');
|
||||
expect(text).toContain('71');
|
||||
expect(text).toContain('1 рассинхрон'); // светофор Заказа (mismatches=1)
|
||||
});
|
||||
|
||||
it('клик по плитке Заказ показывает таблицу групп', async () => {
|
||||
const { wrapper } = await factory();
|
||||
await wrapper.find('[data-testid="tile-supply"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="drill-supply"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('okna.ru');
|
||||
expect(wrapper.text()).toContain('По группам');
|
||||
});
|
||||
|
||||
it('Финансы и Здоровье показывают живые числа из API', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const text = wrapper.text();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Dashboard\SupplyReconciliation;
|
||||
|
||||
it('считает формулу max(max, ceil(sum/3)) и помечает рассинхрон', function () {
|
||||
// Группа A: спрос 100, max 100 → формула max(100, ceil(100/3)=34)=100, факт 100 → OK
|
||||
// Группа B: спрос 60, max 30 → формула max(30, ceil(60/3)=20)=30, факт 35 → рассинхрон
|
||||
$demand = [
|
||||
['signal_type' => 'site', 'identifier' => 'a.ru', 'demand' => 100, 'max_limit' => 100],
|
||||
['signal_type' => 'site', 'identifier' => 'b.ru', 'demand' => 60, 'max_limit' => 30],
|
||||
];
|
||||
$orderedByKey = ['site|a.ru' => 100, 'site|b.ru' => 35];
|
||||
|
||||
$result = SupplyReconciliation::build($demand, $orderedByKey);
|
||||
|
||||
expect($result['groups'])->toHaveCount(2);
|
||||
expect($result['groups'][0])->toMatchArray([
|
||||
'signal_type' => 'site', 'identifier' => 'a.ru',
|
||||
'demand' => 100, 'formula' => 100, 'ordered' => 100, 'in_sync' => true,
|
||||
]);
|
||||
expect($result['groups'][1])->toMatchArray([
|
||||
'identifier' => 'b.ru', 'demand' => 60, 'formula' => 30, 'ordered' => 35, 'in_sync' => false,
|
||||
]);
|
||||
expect($result['totals'])->toMatchArray([
|
||||
'demand' => 160, 'formula' => 130, 'ordered' => 135, 'mismatches' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('факт 0 когда группы нет в supplier_projects → рассинхрон', function () {
|
||||
$demand = [['signal_type' => 'call', 'identifier' => '79990001122', 'demand' => 10, 'max_limit' => 10]];
|
||||
$result = SupplyReconciliation::build($demand, []);
|
||||
expect($result['groups'][0]['ordered'])->toBe(0);
|
||||
expect($result['groups'][0]['in_sync'])->toBeFalse();
|
||||
expect($result['totals']['mismatches'])->toBe(1);
|
||||
});
|
||||
|
||||
it('пустой спрос → пустые группы и нулевые итоги', function () {
|
||||
$result = SupplyReconciliation::build([], []);
|
||||
expect($result['groups'])->toBe([]);
|
||||
expect($result['totals'])->toMatchArray(['demand' => 0, 'formula' => 0, 'ordered' => 0, 'mismatches' => 0]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user