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:
Дмитрий
2026-06-27 14:32:31 +03:00
parent 67ea5d32b4
commit 02a8a90e4d
12 changed files with 1643 additions and 27 deletions
@@ -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,
],
];
}
}
+12
View File
@@ -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
+49
View File
@@ -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>
+2
View File
@@ -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