# Командный центр — Этап 2 (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Наполнить живыми данными две оставшиеся плитки дашборда «Командный центр» — 🎯 **Лиды** (доставлено/зависшие/нераспределённые/% доставки) и 📦 **Заказ у поставщика** (спрос клиентов → надо по формуле → заказали по факту → совпадает ли механизму заказа).

**Architecture:** Backend — расширяем существующий `AdminDashboardController` двумя эндпоинтами (`leads`, `supply`) + дополняем `summary` плитками leads/supply; логику сверки заказа выносим в чистый сервис `App\Services\Dashboard\SupplyReconciliation` (unit-тестируем без БД — таблица снимков партиционирована, сеять в тестах больно). Frontend — оживляем плитки Лиды/Заказ в `AdminDashboardView.vue` (убираем заглушки «Этап 2»), добавляем drill-панели.

**Tech Stack:** Laravel 13, PostgreSQL 16, Pest 4 (backend), Vue 3 + Vuetify 3 + Vitest (frontend), Forest-палитра.

**Spec:** `docs/superpowers/specs/2026-06-27-admin-command-center-design.md`
**Этап 1 (база):** `docs/superpowers/plans/2026-06-27-admin-command-center-stage1.md` (backend `9eaa9322` + frontend `67ea5d32`).

**Базовая ветка:** `feat/admin-command-center`. Коммитить ТОЛЬКО файлы из задач ниже по явным путям (в репо активны параллельные правки — не трогать чужое).

---

## Проверенная модель данных (по `db/schema.sql`, сверено дословно 27.06)

**Заказ у поставщика** — группа = `(signal_type, signal_identifier)`:
- **Просят клиенты** = `SUM(project_routing_snapshots.daily_limit)` по группе за последний `snapshot_date`. Колонки: `snapshot_date DATE`, `daily_limit INT`, `signal_type TEXT`, `signal_identifier TEXT` (nullable). Снимок партиционирован по `snapshot_date`, фиксируется в 18:02 МСК на завтра и уже содержит только eligible-проекты.
- **Надо по формуле** = `max( MAX(daily_limit), ⌈SUM(daily_limit)/3⌉ )` по группе — ровно `SupplierQuotaAllocator::computeOrder` (`app/app/Services/Supplier/SupplierQuotaAllocator.php`).
- **Заказали по факту** = `SUM(supplier_projects.current_limit)` по `(signal_type, unique_key)` (сумма по платформам B1/B2/B3). Колонки: `signal_type VARCHAR(16)`, `unique_key TEXT`, `current_limit INTEGER`.
- **Совпадает** = `формула === факт`; рассинхрон если нет.
- Сопоставление групп: `snapshots.signal_identifier` ↔ `supplier_projects.unique_key` (для SMS-групп с keyword возможно расхождение ключа — известное упрощение v1, см. Notes).

**Лиды** (cross-tenant через `pgsql_admin`, как все эндпоинты дашборда):
- **Доставлено сегодня** = `SUM(projects.delivered_today)` (атомарный счётчик, сбрасывается 00:00 МСК; у `projects` НЕТ `deleted_at`).
- **Получено сегодня** = `COUNT(supplier_leads WHERE received_at >= началоДняМСК)`; **обработано сегодня** = те же + `processed_at IS NOT NULL`.
- **Нераспределённых** (в очереди сейчас) = `COUNT(supplier_leads WHERE processed_at IS NULL)`.
- **Зависших** (тревога) = `COUNT(supplier_leads WHERE processed_at IS NULL AND received_at < now()-4h)` (порог 4ч — из реального cron `leads:escalate-stale`).
- **Нераспределённых сделок по менеджерам** = `COUNT(deals WHERE manager_id IS NULL AND status='new' AND deleted_at IS NULL AND is_test=false)`. Колонки `deals`: `manager_id BIGINT` (null), `status VARCHAR(50)`, `deleted_at TIMESTAMPTZ`, `is_test BOOLEAN`.
- **% доставки** = `обработано_сегодня / получено_сегодня * 100` (≤100%; если получено 0 → 100%).
- Светофор Лиды: 🔴 если зависших>0; 🟡 если нераспределённых>0; иначе 🟢.

---

## File Structure

- Create: `app/app/Services/Dashboard/SupplyReconciliation.php` — чистая логика сверки заказа (спрос+формула+факт → группы+итоги). Одна ответственность, без БД.
- Modify: `app/app/Http/Controllers/Api/AdminDashboardController.php` — +`leads()`, +`supply()`, +`leadsTile()`, +`supplyTile()`, расширить `summary()`.
- Modify: `app/routes/web.php` — +2 маршрута в группе `['saas-admin','admin-db']`.
- Create test: `app/tests/Unit/Dashboard/SupplyReconciliationTest.php` — unit формулы/сверки.
- Create test: `app/tests/Feature/Admin/AdminDashboardLeadsTest.php`.
- Create test: `app/tests/Feature/Admin/AdminDashboardSupplyTest.php`.
- Modify: `app/resources/js/api/adminDashboard.ts` — +типы `LeadsDetail`,`SupplyDetail`; расширить `DashboardSummary`; +2 клиента.
- Modify: `app/resources/js/views/admin/AdminDashboardView.vue` — оживить плитки Лиды/Заказ + drill-панели.
- Modify: `app/tests/Frontend/AdminDashboardView.spec.ts` — обновить моки/проверки под живые плитки.

Все команды — из каталога `app/`.

---

## ЧАСТЬ A — BACKEND

### Task 1: Чистый сервис сверки заказа `SupplyReconciliation`

**Files:**
- Create: `app/app/Services/Dashboard/SupplyReconciliation.php`
- Test: `app/tests/Unit/Dashboard/SupplyReconciliationTest.php`

- [ ] **Step 1: Write the failing test**

Create `app/tests/Unit/Dashboard/SupplyReconciliationTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Dashboard\SupplyReconciliation;

it('считает формулу max(max, ceil(sum/3)) и помечает рассинхрон', function () {
    // Группа A: лимиты 100 (один проект) → спрос 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]);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test tests/Unit/Dashboard/SupplyReconciliationTest.php`
Expected: FAIL — класс `SupplyReconciliation` не существует.

- [ ] **Step 3: Create the service**

Create `app/app/Services/Dashboard/SupplyReconciliation.php`:

```php
<?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,
            ],
        ];
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test tests/Unit/Dashboard/SupplyReconciliationTest.php`
Expected: PASS (3 теста).

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Dashboard/SupplyReconciliation.php app/tests/Unit/Dashboard/SupplyReconciliationTest.php
git commit -F <msg-файл>
```
Сообщение: `feat дашборд: сервис сверки заказа у поставщика спрос-формула-факт`

---

### Task 2: Эндпоинт «Заказ у поставщика» — `AdminDashboardController::supply`

**Files:**
- Modify: `app/app/Http/Controllers/Api/AdminDashboardController.php`
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/Admin/AdminDashboardSupplyTest.php`

- [ ] **Step 1: Write the failing test**

Create `app/tests/Feature/Admin/AdminDashboardSupplyTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;

it('GET /api/admin/dashboard/supply возвращает группы и итоги', function () {
    // supplier_projects не партиционирован — сеем напрямую. Снимки оставляем пустыми
    // (партиция по дате), поэтому groups может быть пуст — проверяем контракт ответа.
    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']);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test tests/Feature/Admin/AdminDashboardSupplyTest.php`
Expected: FAIL — маршрут/метод отсутствует (404).

- [ ] **Step 3: Add `supply()` + `supplyTile()` (use the service)**

В `AdminDashboardController.php` добавь `use App\Services\Dashboard\SupplyReconciliation;` в шапку и методы:

```php
    /** Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects). */
    private function supplyReconciliation(): array
    {
        $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,
                ];
            }
        }

        $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
    {
        $rec = $this->supplyReconciliation();
        $t = $rec['result']['totals'];

        return [
            'light' => $t['mismatches'] > 0 ? 'red' : 'green',
            'demand' => $t['demand'],
            'formula' => $t['formula'],
            'ordered' => $t['ordered'],
            'mismatches' => $t['mismatches'],
        ];
    }

    /** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
    public function supply(): JsonResponse
    {
        $rec = $this->supplyReconciliation();
        $t = $rec['result']['totals'];

        return response()->json([
            'snapshot_date' => $rec['snapshot_date'],
            'light' => $t['mismatches'] > 0 ? 'red' : 'green',
            'totals' => $t,
            'groups' => $rec['result']['groups'],
        ]);
    }
```

- [ ] **Step 4: Register the route**

В `app/routes/web.php`, в группе `['saas-admin','admin-db']`, после health-роута:

```php
    Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
```

- [ ] **Step 5: Run test to verify it passes**

Run: `php artisan test tests/Feature/Admin/AdminDashboardSupplyTest.php`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminDashboardController.php app/routes/web.php app/tests/Feature/Admin/AdminDashboardSupplyTest.php
git commit -F <msg-файл>
```
Сообщение: `feat дашборд: эндпоинт Заказ у поставщика L2`

---

### Task 3: Эндпоинт «Лиды» — `AdminDashboardController::leads`

**Files:**
- Modify: `app/app/Http/Controllers/Api/AdminDashboardController.php`
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/Admin/AdminDashboardLeadsTest.php`

- [ ] **Step 1: Write the failing test**

Create `app/tests/Feature/Admin/AdminDashboardLeadsTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;

it('GET /api/admin/dashboard/leads возвращает KPI лидов', function () {
    // нужен тенант + проект для delivered_today (FK tenant_id у projects)
    $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(),
    ]);

    // обработанный сегодня лид + зависший (старый, processed_at null)
    DB::table('supplier_leads')->insert([
        ['supplier_project_id' => $supplierProjectId, 'phone' => '79990000001',
         'received_at' => now(), 'processed_at' => now(), 'deals_created_count' => 1],
        ['supplier_project_id' => $supplierProjectId, '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');     // есть зависший
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test tests/Feature/Admin/AdminDashboardLeadsTest.php`
Expected: FAIL — маршрут/метод отсутствует.

- [ ] **Step 3: Add `leads()` + `leadsTile()`**

В `AdminDashboardController.php` добавь методы:

```php
    /** @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,
        ];
    }

    /** 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'],
            ],
        ]);
    }
```

- [ ] **Step 4: Register the route**

В `app/routes/web.php`, та же группа:

```php
    Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads');
```

- [ ] **Step 5: Run test to verify it passes**

Run: `php artisan test tests/Feature/Admin/AdminDashboardLeadsTest.php`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminDashboardController.php app/routes/web.php app/tests/Feature/Admin/AdminDashboardLeadsTest.php
git commit -F <msg-файл>
```
Сообщение: `feat дашборд: эндпоинт Лиды L2`

---

### Task 4: Дополнить `summary()` плитками leads + supply

**Files:**
- Modify: `app/app/Http/Controllers/Api/AdminDashboardController.php`
- Test: `app/tests/Feature/Admin/AdminDashboardSummaryTest.php` (расширить существующий)

- [ ] **Step 1: Extend the summary test**

В `app/tests/Feature/Admin/AdminDashboardSummaryTest.php` добавь в конец `assertJsonStructure` ключи `leads` и `supply`. Добавь новый тест:

```php
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',
    ]);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test tests/Feature/Admin/AdminDashboardSummaryTest.php`
Expected: FAIL — ключей leads/supply нет.

- [ ] **Step 3: Extend `summary()` + add `leadsTile()`**

В `AdminDashboardController.php` добавь `leadsTile()`:

```php
    /** @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'],
        ];
    }
```

И измени `summary()` — добавь две строки в массив ответа:

```php
        return response()->json([
            'period' => (string) $request->query('period', '7d'),
            'finance' => $this->financeTile($from),
            'health' => $this->healthTile(),
            'leads' => $this->leadsTile(),
            'supply' => $this->supplyTile(),
        ]);
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test tests/Feature/Admin/AdminDashboardSummaryTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminDashboardController.php app/tests/Feature/Admin/AdminDashboardSummaryTest.php
git commit -F <msg-файл>
```
Сообщение: `feat дашборд: summary включает плитки Лиды и Заказ`

---

### Task 5: Backend self-review

- [ ] **Step 1:** `php artisan test tests/Feature/Admin tests/Unit/Dashboard` → PASS (все, включая старые admin-тесты).
- [ ] **Step 2:** `composer stan` → 0 ошибок в `AdminDashboardController.php` и `SupplyReconciliation.php` (типизация `@return array<string,mixed>` / точные шейпы из Task 1).
- [ ] **Step 3:** `composer pint -- app/Http/Controllers/Api/AdminDashboardController.php app/Services/Dashboard/SupplyReconciliation.php routes/web.php` → формат.
- [ ] **Step 4 (commit если pint менял):** `git add -A` по этим путям + `git commit -F <msg>` «chore дашборд: pint/stan backend Этап 2».

---

## ЧАСТЬ B — FRONTEND

### Task 6: Расширить API-клиент `adminDashboard.ts`

**Files:**
- Modify: `app/resources/js/api/adminDashboard.ts`

- [ ] **Step 1: Extend types + add clients**

В `app/resources/js/api/adminDashboard.ts`:

1. Расширь `DashboardSummary` — добавь поля `leads` и `supply`:

```ts
export interface DashboardSummary {
    period: string;
    finance: {
        topups_rub: string;
        charges_rub: string;
        active_clients: number;
        new_clients: number;
        negative_balance_count: number;
        light: Light;
    };
    health: {
        light: Light;
        open_incidents: number;
        failed_jobs_24h: number;
        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;
    };
}
```

2. Добавь два интерфейса деталей:

```ts
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;
    }>;
}
```

3. Добавь два клиента:

```ts
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;
}
```

- [ ] **Step 2: Commit**

```bash
git add app/resources/js/api/adminDashboard.ts
git commit -F <msg-файл>
```
Сообщение: `feat дашборд-fe: клиент Лиды и Заказ у поставщика`

---

### Task 7: Оживить плитки Лиды/Заказ в `AdminDashboardView.vue`

**Files:**
- Modify: `app/resources/js/views/admin/AdminDashboardView.vue`

- [ ] **Step 1: Update script — load leads/supply, drop stubs**

В `<script setup>` `AdminDashboardView.vue`:

1. В импорт из `../../api/adminDashboard` добавь `getDashboardLeads, getDashboardSupply, type LeadsDetail, type SupplyDetail`.
2. Добавь refs рядом с `finance`/`health`:

```ts
const leads = ref<LeadsDetail | null>(null);
const supply = ref<SupplyDetail | null>(null);
```

3. В функции `load()` расширь `Promise.all` до 5 вызовов:

```ts
        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;
```

4. Добавь подписи светофоров для плиток (рядом с `financeLightLabel`):

```ts
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} рассинхрон` : 'ровно';
}
```

5. Расширь `defineExpose` — добавь `leads, supply`.

- [ ] **Step 2: Update template — Лиды плитка живая**

Замени блок плитки Лиды (`data-testid="tile-leads"`) — вместо заглушки-параграфа поставь живые строки:

```html
            <v-col cols="12" md="6">
                <v-card
                    :variant="selected === 'leads' ? 'elevated' : 'outlined'"
                    :class="{ 'tile--sel': selected === 'leads' }"
                    class="tile"
                    data-testid="tile-leads"
                    @click="selectArea('leads')"
                >
                    <v-card-text>
                        <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
                                :color="lightColor(summary?.leads.light ?? 'green')"
                                size="small"
                                variant="tonal"
                                class="ml-auto"
                            >
                                {{ leadsLightLabel() }}
                            </v-chip>
                        </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>
```

- [ ] **Step 3: Update template — Заказ плитка живая**

Замени блок плитки Заказ (`data-testid="tile-supply"`):

```html
            <v-col cols="12" md="6">
                <v-card
                    :variant="selected === 'supply' ? 'elevated' : 'outlined'"
                    :class="{ 'tile--sel': selected === 'supply' }"
                    class="tile"
                    data-testid="tile-supply"
                    @click="selectArea('supply')"
                >
                    <v-card-text>
                        <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
                                :color="lightColor(summary?.supply.light ?? 'green')"
                                size="small"
                                variant="tonal"
                                class="ml-auto"
                            >
                                {{ supplyLightLabel() }}
                            </v-chip>
                        </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>
```

- [ ] **Step 4: Update template — drill Лиды живой**

Замени drill `data-testid="drill-leads"`:

```html
        <v-card v-else-if="selected === 'leads'" variant="outlined" class="drill mt-5" data-testid="drill-leads">
            <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>
```

- [ ] **Step 5: Update template — drill Заказ живой**

Замени drill `data-testid="drill-supply"`:

```html
        <v-card v-else variant="outlined" class="drill mt-5" data-testid="drill-supply">
            <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>
```

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/views/admin/AdminDashboardView.vue
git commit -F <msg-файл>
```
Сообщение: `feat дашборд-fe: живые плитки Лиды и Заказ у поставщика`

---

### Task 8: Обновить фронт-тест

**Files:**
- Modify: `app/tests/Frontend/AdminDashboardView.spec.ts`

- [ ] **Step 1: Extend the mock** — в `vi.mock('../../resources/js/api/adminDashboard')`:

1. В `getDashboardSummary` добавь в resolved-объект `leads` и `supply`:

```ts
        leads: { light: 'green', delivered_today: 71, stuck: 0, unrouted: 0, delivery_pct: 100 },
        supply: { light: 'red', demand: 250, formula: 160, ordered: 175, mismatches: 1 },
```

2. Добавь два мок-клиента в объект мока:

```ts
    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 }],
    }),
```

- [ ] **Step 2: Update assertions** — замени тест «4 плитки» и добавь живые проверки:

```ts
    it('плитки Лиды и Заказ показывают живые числа', async () => {
        const { wrapper } = await factory();
        const text = wrapper.text();
        expect(text).toContain('Доставлено сегодня');
        expect(text).toContain('71');
        expect(text).toContain('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('По группам');
    });
```

- [ ] **Step 3: Run** `npm run test:vue -- AdminDashboardView` → подгонять, пока PASS.
- [ ] **Step 4: Commit** `git add app/tests/Frontend/AdminDashboardView.spec.ts && git commit -F <msg>` «test дашборд-fe: живые плитки Этап 2».

---

## ЧАСТЬ C — ПРОВЕРКА И ВЫКАТ

### Task 9: Регрессия

- [ ] `php artisan test tests/Feature/Admin tests/Unit/Dashboard` → PASS.
- [ ] `npm run test:vue -- AdminDashboardView router` → PASS.
- [ ] `npm run type-check` → 0 ошибок в новых .ts/.vue.
- [ ] `npm run build` → без ошибок.
- [ ] `npx eslint resources/js/views/admin/AdminDashboardView.vue resources/js/api/adminDashboard.ts` → 0 (следить за U+202F: пробел перед ₽/символами — обычный ASCII).

### Task 10: Показ владельцу глазами (локально)

- [ ] Поднять `php artisan serve` + войти dev-юзером (`inessa.samojlova@example.org` / `Demo12345!`), открыть `/admin/dashboard`, кликнуть Лиды и Заказ. Скриншоты. (Снимков маршрутизации в dev может не быть → таблица Заказа покажет honest-заглушку «нет данных снимка» — это нормально.)

### Task 11: Выкат на боевой (по отдельному разрешению владельца)

> ⚠️ Необратимо. Только после явного «выкатывай» + `prod-deploy-validator` GO.
- [ ] Выкат включает фронт-сборку (`npm run build`). Влить ветку в gitea main, выкатить overlay (как `bin/deploy-source-edit.sh`: clone gitea → `npm ci && npm run build` → rsync → optimize → restart). БД не трогаем.
- [ ] Live-приёмка на liderra.ru: плитки Лиды/Заказ показывают реальные числа; Заказ — таблицу групп со сверкой.
- [ ] Обновить `ПИЛОТ.md` + память.

---

## Notes / Self-review

- **Spec coverage:** Лиды (Task 3,4,7) ✓; Заказ у поставщика спрос/формула/факт/совпадает (Task 1,2,4,7) ✓; реальный механизм заказа (`SupplierQuotaAllocator::computeOrder`, Task 1) ✓; плитки L1 (Task 4,7) ✓; drill L2 (Task 7) ✓; без маржи ✓; без новых таблиц ✓.
- **Известные упрощения v1 (честно показать, не выдавать за полноту):**
  - Сопоставление групп `snapshots.signal_identifier` ↔ `supplier_projects.unique_key` точное по строке; для SMS-групп с keyword ключи могут отличаться (`sender` vs `sender+keyword`) → возможен ложный рассинхрон на SMS. Если на проде увидим SMS-ложняки — отдельный follow-up с нормализацией ключа через `SupplierProjectGrouping::buildUniqueKeyAgnostic`.
  - `supplier_projects.subject_code` (региональные пулы «Вся РФ» vs субъект) суммируется в один факт по `unique_key` — для headline-сверки достаточно; пер-регион детализация — позже.
  - Снимок берём за последний `snapshot_date` (MAX) — до 18:02 МСК это «вчерашний на сегодня», после — «сегодняшний на завтра». Для мониторинга соответствия это корректно.
- **admin-db зависимость:** все эндпоинты в группе `['saas-admin','admin-db']` → cross-tenant под `pgsql_admin` (на проде роль `crm_supplier_worker`, srv_bypass). В тестах — глобальный `SharesAdminPdo`.
- **Партиции снимков:** `project_routing_snapshots` партиционирована по `snapshot_date` → в тестах НЕ сеем её напрямую (Supply Feature-тест проверяет контракт ответа; формула/сверка покрыта unit-тестом сервиса на массивах).
- **Не трогать** чужие незакоммиченные правки в рабочей папке; коммитить только перечисленные пути.
