1fd56e205b
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Иерархический дашборд (3 уровня, drill-down). Этап 1: Командный центр + Финансы + Здоровье (переиспользуют существующие экраны как L3). Этап 2: Лиды + Заказ у поставщика. Механизм заказа задокументирован по коду (формула SupplierQuotaAllocator: max(max_спрос, ceil(Σ/3))), без маржи (по решению владельца). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
11 KiB
Markdown
155 lines
11 KiB
Markdown
# Дизайн: SaaS-админка «Командный центр» Лидерры (иерархический дашборд)
|
||
|
||
**Дата:** 2026-06-27
|
||
**Авторы:** Дмитрий (владелец) + Claude
|
||
**Статус:** концепция одобрена («в принципе пойдёт»), детализация для плана
|
||
**Макет:** `web/admin-dashboard-mockup.html` (кликабельный, фирменные цвета Forest)
|
||
|
||
## Проблема / цель
|
||
|
||
Текущая админка — плоский список разделов (Тенанты, Биллинг, Инциденты…).
|
||
Владельцу нужен **командный центр**: верхний экран с главными цифрами и
|
||
светофорами, с которого можно **проваливаться в детали** (иерархия + вложенность).
|
||
|
||
## Иерархия (3 уровня)
|
||
|
||
- **Уровень 1 — Командный центр:** landing-экран, 4 плитки со светофором
|
||
(🟢/🟡/🔴) и 3–4 главными числами. Период: Сегодня / 7 дней / 30 дней.
|
||
- **Уровень 2 — Область:** клик по плитке → разворот области (KPI + графики +
|
||
таблицы «требует внимания»).
|
||
- **Уровень 3 — Карточка:** клик по строке → существующий экран
|
||
(карточка тенанта, инцидент, проект поставщика). **Переиспользуем, не пишем заново.**
|
||
|
||
## Фазы (по решению владельца — этапами)
|
||
|
||
- **Этап 1:** Командный центр (L1 каркас) + области **Финансы** и **Здоровье** (L2),
|
||
с проваливанием в существующие экраны (L3).
|
||
- **Этап 2:** области **Лиды** и **Заказ у поставщика** (L2) — там есть новые расчёты.
|
||
|
||
## Не делаем (YAGNI)
|
||
|
||
- **Маржа/себестоимость** — владелец явно отказался. Прибыль/cost не показываем.
|
||
- Не трогаем RLS/роли/схему для Этапа 1 — все цифры читаются из существующих таблиц.
|
||
- Не переписываем существующие detail-экраны — они служат Уровнем 3.
|
||
|
||
---
|
||
|
||
## Архитектура
|
||
|
||
- **Frontend:** новый Vue-роут `/admin` (или `/admin/dashboard`) — landing
|
||
«Командный центр» (компонент `AdminDashboardView.vue`), layout `admin`.
|
||
Плитки — компоненты; клик меняет активную область (как в макете) или ведёт на
|
||
существующий экран. Стек — Vue 3 + Vuetify 3 + Forest (Brandbook).
|
||
- **Backend:** новый `AdminDashboardController` с эндпоинтами агрегатов:
|
||
- `GET /api/admin/dashboard` — сводка для L1 (все 4 плитки, по периоду).
|
||
- `GET /api/admin/dashboard/finance?period=` — L2 Финансы.
|
||
- `GET /api/admin/dashboard/health` — L2 Здоровье.
|
||
- (Этап 2) `GET /api/admin/dashboard/leads`, `GET /api/admin/dashboard/supplier-order`.
|
||
- Эндпоинты — в группе `Route::middleware(['saas-admin','admin-db'])` (та же
|
||
admin-группа; cross-tenant доступ через подключение `pgsql_admin`, уже введено
|
||
фиксом 27.06 — см. spec `2026-06-27-admin-db-connection-path-a-design.md`).
|
||
- **Свежесть данных:** запрос при открытии + кнопка «обновить»; период-тумблер
|
||
пересчитывает агрегаты. Не realtime-push (не нужно).
|
||
|
||
---
|
||
|
||
## Этап 1 — детальные требования
|
||
|
||
### Плитка 💰 Финансы
|
||
|
||
**L1 (сводка за период):**
|
||
| Метрика | Источник |
|
||
|---|---|
|
||
| Пополнения за период | `SUM(balance_transactions.amount_rub) WHERE type='topup'` |
|
||
| Списано за лиды | `SUM(ABS(amount_rub)) WHERE type='lead_charge'` |
|
||
| Активных клиентов | `tenants WHERE status='active' AND deleted_at IS NULL` |
|
||
| Новых за период | `COUNT(tenants) WHERE created_at IN period` |
|
||
| Светофор | 🔴 если есть клиенты с `balance_rub < 0`; счётчик «N в минусе» |
|
||
|
||
**L2 (детали):** KPI (пополнения / списано / чистый приток = пополнения−списания /
|
||
клиентов в минусе); график пополнений vs списаний по дням (из
|
||
`balance_transactions` group by день); таблицы:
|
||
- «🔴 Требуют внимания» — `balance_rub < 0` ИЛИ остаток баланса < 3 дней по
|
||
среднему 7-дневному списанию (runway). Клик → карточка тенанта (L3).
|
||
- «Топ по обороту» — клиенты по сумме пополнений за период.
|
||
- «Последние пополнения».
|
||
|
||
**Пороги светофора:** 🟢 нет минусов и ни у кого runway<3д; 🟡 есть runway<3д;
|
||
🔴 есть `balance_rub<0`.
|
||
|
||
### Плитка ❤️ Здоровье портала
|
||
|
||
**L1:** общий светофор (худший из подсистем) + «открытых инцидентов N» +
|
||
«последний сбой».
|
||
|
||
**L2 — 6 подсистем, у каждой 🟢/🟡/🔴 + число + «когда»:**
|
||
| Подсистема | Источник | 🔴 условие |
|
||
|---|---|---|
|
||
| Очереди / джобы | `failed_jobs` за сутки | >0 упавших (🟡), spike (🔴) |
|
||
| Планировщик | `scheduler_heartbeats.last_run_at` vs ожидаемый интервал | просрочка heartbeat |
|
||
| Синхрон с поставщиком | `supplier_sync_runs` последний `status` | `failed`/`aborted` |
|
||
| Сверка CSV (дрейф) | `supplier_csv_reconcile_log` последний `status`/`drift_ratio` | `drift_alert` |
|
||
| Вебхуки | `failed_webhook_jobs WHERE resolved_at IS NULL` | unresolved > порога |
|
||
| Инциденты | `incidents_log` открытые (`resolved_at IS NULL`) | есть high-severity |
|
||
+ лента «последние события эксплуатации» (из supplier_sync_runs / reconcile / backups).
|
||
Клик по подсистеме → существующие экраны Инциденты / Интеграция с поставщиком (L3).
|
||
|
||
---
|
||
|
||
## Этап 2 — детальные требования
|
||
|
||
### Плитка 🎯 Лиды
|
||
|
||
**L1:** доставлено сегодня; зависших; нераспределённых; % доставки.
|
||
**Источники:** `project_routing_snapshots` (`expected_volume` vs `delivered_count`),
|
||
`supplier_leads` (`processed_at IS NULL` = необработанные; `error IS NOT NULL`),
|
||
`supplier_lead_deliveries`, `supplier_manual_sync_queue` (pending).
|
||
**🔴 условие:** есть зависшие (необработанные supplier_leads старше N часов) или
|
||
нераспределённые (доставлены поставщиком, но не легли клиенту).
|
||
«Утечка» = закуплено у поставщика − доставлено клиентам (требует нового расчёта).
|
||
|
||
### Плитка 📦 Заказ у поставщика
|
||
|
||
**РЕАЛЬНЫЙ механизм (по коду, не выдумка):**
|
||
- Спрос клиента по проекту = `COALESCE(projects.effective_daily_limit_today,
|
||
daily_limit_target)`, замораживается в `project_routing_snapshots.daily_limit`
|
||
слепком в **18:02 МСК**.
|
||
- В **18:05 МСК** `SyncSupplierProjectsJob` группирует проекты по
|
||
(signal_type, identifier) и считает заказ:
|
||
**`order = max( max(спрос в группе) , ⌈ Σ(спрос) / 3 ⌉ )`**
|
||
(`SupplierQuotaAllocator::computeOrder`) — потому что лид перепродаётся до 3 клиентов.
|
||
- Заказ делится между площадками B1/B2/B3 (`distributeForPlatform`) и пишется в
|
||
`supplier_projects.current_limit` (это «заказали по факту»), отправляется поставщику.
|
||
- Итог прогона — в `supplier_sync_runs` (groups_total / synced_ok / failed / …).
|
||
|
||
**L1:** Клиенты просят (Σ спроса/день); Надо по формуле; Заказали по факту;
|
||
Групп с рассинхроном.
|
||
**L2 — таблица по группам/проектам:** Клиенты просят (Σ snapshot.daily_limit по
|
||
группе) → Надо по формуле (`computeOrder`) → Заказали по факту (Σ
|
||
`supplier_projects.current_limit` по группе) → **Совпадает?** (🟢 факт=формула /
|
||
🔴 рассинхрон).
|
||
**🔴 рассинхрон** = «по факту» ≠ «по формуле» (лимит правили руками, или синхрон
|
||
18:05 не прошёл — `supplier_sync_runs.status != ok` / `supplier_projects.sync_status='failed'`).
|
||
|
||
**Новое:** сейчас нет готовой сверки «заказ-по-формуле vs заказ-по-факту»
|
||
(существующая CsvReconcile — про доставленные лиды, не про заказ). Этот расчёт
|
||
делаем на лету (snapshot.daily_limit сгруппировать + computeOrder + сравнить с
|
||
current_limit). Новой таблицы не требуется.
|
||
|
||
---
|
||
|
||
## Тестирование
|
||
|
||
- Feature-тесты эндпоинтов `AdminDashboardController` (под admin-группой,
|
||
trait `SharesAdminPdo` уже глобальный) — корректные агрегаты на сидов.
|
||
- Тесты порогов светофоров (минус баланса → 🔴; runway<3д → 🟡; sync failed → 🔴).
|
||
- Этап 2: тест расчёта «формула vs факт» для группы (mirror `SupplierQuotaAllocator`).
|
||
- Frontend (Vitest) — рендер плиток и переключение области (drill-down).
|
||
|
||
## Открытые вопросы
|
||
|
||
- Кто ещё пользуется дашбордом, кроме владельца (роли)? — пока считаем только
|
||
владелец/ops; отдельных ролей не вводим (saas-admin SSO ⏸ Б-1).
|
||
- Точные числовые пороги (runway дни, webhook unresolved, «зависший» возраст) —
|
||
уточним при реализации; дефолты: runway<3д 🟡, webhook>0 🟡, зависший >2ч 🔴.
|