Compare commits

...

65 Commits

Author SHA1 Message Date
Дмитрий 726c682d2e docs(tours): этап 3 построен — протокол и спека синхронизированы 2026-07-03 03:35:09 +03:00
Дмитрий 7ea084d01f test(tours): frontmatter tour статей сверяется с каталогом экскурсий 2026-07-03 03:31:08 +03:00
Дмитрий 85c7c9b53c feat(tours): data-tour якоря — кнопки создания проекта и пополнения 2026-07-03 03:29:34 +03:00
Дмитрий 4ad2c065fc feat(tours): запуск экскурсии по ?tour= из ссылки бота 2026-07-03 03:26:28 +03:00
Дмитрий 4afa228f15 feat(tours): GuidedTour — обобщённый раннер с ожиданием цели 2026-07-03 03:23:59 +03:00
Дмитрий 2f9d7743ec feat(tours): каталог экскурсий — 5 стартовых сценариев 2026-07-03 03:22:38 +03:00
Дмитрий 94e5828fbc docs(tours): план этапа 3 — экскурсии «Показать на портале» (6 задач TDD)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:19:57 +03:00
Дмитрий 6841492226 docs(bot): спека/протокол — FTS вместо pgvector, статьи в resources/help
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:17:05 +03:00
Дмитрий edf98d9ace style(bot): pint — неиспользуемые импорты в трёх bot-тестах
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:15:14 +03:00
Дмитрий c90f721978 test(bot): бюджет скорости — p95 нашей части тракта < 500мс 2026-07-02 21:11:52 +03:00
Дмитрий 726aeb716a feat(bot): JivoBotController — webhook /api/webhook/jivo/{secret}, мгновенный ack 2026-07-02 21:09:28 +03:00
Дмитрий 7a18dae0ca feat(bot): ProcessJivoMessageJob — оркестратор, очередь bot, журнал+latency 2026-07-02 21:07:41 +03:00
Дмитрий 335bf4c3a8 feat(bot): BotAnswerService — стоп-темы, строгий промпт, tour-ссылка под флагом 2026-07-02 21:00:40 +03:00
Дмитрий e2dfd22471 feat(bot): JivoBotClient — BOT_MESSAGE/INVITE_AGENT, dev-режим без URL 2026-07-02 20:58:24 +03:00
Дмитрий edbfd3e993 feat(bot): YandexGptClient — completion Lite, таймаут 8с, null при беде 2026-07-02 20:57:03 +03:00
Дмитрий 4cab703b82 feat(bot): KnowledgeSearch — FTS russian top-N с ранжированием 2026-07-02 20:55:27 +03:00
Дмитрий 3a724fb8ef feat(bot): schedule help:rebuild-knowledge — heartbeat + Europe/Moscow (стиль соседей)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:51:04 +03:00
Дмитрий 508d8cc1d5 feat(bot): help:rebuild-knowledge — индексация статей в knowledge_chunks + schedule 04:30 2026-07-02 20:49:11 +03:00
Дмитрий b04bb4ecf3 feat(bot): HelpArticleParser — frontmatter + чанки статей инструкции 2026-07-02 20:45:41 +03:00
Дмитрий e8e7332101 docs(help): три стартовые статьи клиентской инструкции (база знаний бота) 2026-07-02 20:43:13 +03:00
Дмитрий 9f8ded5b77 test(bot): SchemaDelta ожидания v8.58 — 76 таблиц / 130 индексов (+knowledge_chunks, +bot_dialogs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:40:52 +03:00
Дмитрий e01bcca751 feat(bot): таблица bot_dialogs — журнал диалогов бота 2026-07-02 20:37:47 +03:00
Дмитрий aa3bf3cbed feat(bot): таблица knowledge_chunks — база знаний бота (FTS russian + GIN) 2026-07-02 20:30:44 +03:00
Дмитрий e3b58f2c2c feat(bot): конфиг jivo_bot + yandexgpt (спека 2026-07-02) 2026-07-02 20:27:09 +03:00
Дмитрий f606a06155 docs(bot): план реализации ядра ИИ-бота Jivo (14 задач TDD)
По спеке 2026-07-02-jivo-ai-support-bot-design: webhook + FTS-поиск
по инструкции + YandexGPT Lite + эскалация + журнал + тест скорости.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:27:56 +03:00
Дмитрий b4ef5830e3 docs(support-bot): спека своего ИИ-бота в чате Jivo + протокол обсуждения
Дизайн согласован владельцем 02.07.2026: свой бот через Jivo Bot API,
YandexGPT Lite, база знаний = docs/help/ в репо, кнопка «Показать»
(экскурсии), скорость 2-5 сек, v1 только общие вопросы.
Словарь: +jivo/дживо/gigachat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:27:56 +03:00
Дмитрий 8e864bf96f merge: фиксы достоверности дашборда в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 18:58:33 +03:00
Дмитрий f30c6612c0 fix дашборд: достоверность метрик (здоровье/лиды/заказ) + периоды 60/90д
По сверке прод-данных с реальностью (часть чисел вводила в заблуждение):
- Финансы: +периоды 60 и 90 дней (крупные пополнения старше 30д теперь видны).
- Здоровье: «инциденты» больше не считают авто-лог ошибок джоб (summary
  'Автоматически:%') — раньше копилось 975 и держало красный ложно. Теперь:
  open_incidents = только реальные; добавлен job_errors_24h (повторяющиеся
  ошибки джоб за сутки) в подсистему queues.
- Лиды: убраны обманчивый «% доставки» (это было «обработано», не доставлено)
  и «нераспределённые по менеджерам» (менеджеры не используются). Добавлено
  «получено от поставщика сегодня»; доставлено = реально созданные сегодня сделки.
- Заказ: показаны дата снимка и полная картина (всего активных заказов /
  Σ лимита у поставщика) — сверка по снимку больше не выглядит занижено.

Тесты: admin-срез 87 зелёных, unit 3/3, фронт 10/10. stan 0, pint/eslint/
type-check/build чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:57:35 +03:00
Дмитрий 2ecc1d6115 merge: дашборд Командный центр Этапы 1+2 в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 14:49:49 +03:00
Дмитрий 02a8a90e4d 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>
2026-06-27 14:32:31 +03:00
Дмитрий 67ea5d32b4 feat дашборд-fe: экран Командного центра + API-клиент + роут /admin/dashboard
Этап 1 фронтенда дашборда «Командный центр»: плитки Финансы и Здоровье
с живыми данными, заглушки Лиды и Заказ у поставщика на Этап 2,
drill-детали, клик по клиенту ведёт в карточку тенанта.
Редирект /admin теперь на /admin/dashboard.

Тесты: AdminDashboardView 8/8, router.spec обновлён под новый редирект.
type-check / vite build / eslint — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:43:10 +03:00
Дмитрий fa7361364d feat: подсказка «Как увеличить количество сделок» в диалоге проекта
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:35:29 +03:00
Дмитрий 69f8614abe feat: подсказка «Как увеличить количество сделок» в диалоге проекта
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:32:09 +03:00
Дмитрий 9eaa9322dc feat(дашборд): backend Командного центра — summary/finance/health (Этап 1)
3 read-only эндпоинта под группой [saas-admin,admin-db] (cross-tenant через
pgsql_admin): L1 сводка (Финансы+Здоровье), L2 Финансы (KPI+внимание+топ),
L2 Здоровье (6 подсистем+светофор). TDD, 83 admin-теста зелёные. baseline:
+3 Pest getJson false-positive. Без маржи, без новых таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:58:58 +03:00
Дмитрий 1a92b70223 docs(дашборд): план реализации Этапа 1 (Командный центр + Финансы + Здоровье)
10 задач по TDD: 3 backend-эндпоинта (summary/finance/health) под admin-db
группой, Vue-экран AdminDashboardView + роут /admin/dashboard, тесты, выкат.
Без новых таблиц; переиспуют существующие detail-экраны как Уровень 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:49:34 +03:00
Дмитрий 7ac9af7c79 feat: убрать лимит по числу проектов — ограничение только по балансу/лидам
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Правило продукта: ограничений по количеству проектов нет, лимит только
по балансу и заказанным лидам. Убран гейт tenants.limits.max_projects
в ProjectService::create и показ лимита проектов на дашборде. Поле limits
оставлено как резерв; max_users и api_rps в коде не используются.

Заодно фикс типа в EditProjectDialog.spec: sampleProject типизирован
настоящим Project, source_locked больше не краснит vue-tsc.

Тесты: ProjectsStore 13/13, DashboardSummary 11/11, DashboardView 8/8,
EditProjectDialog 7/7; vue-tsc чисто; pint чисто; vite build ок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:47:49 +03:00
Дмитрий 1fd56e205b docs(админка): спецификация + кликабельный макет «Командного центра»
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>
2026-06-27 12:37:28 +03:00
Дмитрий c7e015a9ac refactor(fe): убрать мёртвый repositionMenuAfterOpen - ядро внутреннее
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Старый per-instance экспорт больше не используется (заменён глобальным
installMenuRepositionFix). Старый тест-файл удалён - механизм покрыт
installMenuRepositionFix.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:30:06 +03:00
Дмитрий 11dcd04173 refactor(fe): снять ручные обходы меню - заменены глобальным установщиком
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:27:46 +03:00
Дмитрий c78b69fcaf feat(fe): подключить installMenuRepositionFix при запуске SPA
Также: привести resizeSpy в тесте к EventListener (тип-чистота vue-tsc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:23:55 +03:00
Дмитрий 9f013ec591 feat(fe): глобальный installMenuRepositionFix + тест механизма
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:20:08 +03:00
Дмитрий 4fd4e390af docs(план): реализация глобального фикса позиционирования меню - 4 TDD-задачи
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:15:09 +03:00
Дмитрий 4044885c3e docs(спека): глобальный фикс позиционирования выпадающих меню - корневой обход вместо ручных пометок
Корень дефекта живого клиента 27.06: список Тип лица в окне создания
проекта уезжал за экран, реквизиты не сохранялись 422. Обход вешался
вручную на каждый список и забыт в 3 окнах. Решение - включать обход
автоматически глобально через MutationObserver, убрать ручные пометки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:09:50 +03:00
Дмитрий 9d0999d49a style(админка): pint — new UseAdminConnection без скобок в тесте
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:17:39 +03:00
Дмитрий b38fe0c875 feat(админка): admin-db middleware в группе saas-admin + SharesAdminPdo для тестов
bootstrap: alias admin-db=UseAdminConnection; web.php: группа saas-admin теперь
['saas-admin','admin-db'] (swap default→pgsql_admin после гейта). Тест: admin-db
в пайплайне /api/admin/tenants, saas-admin не потерян.

SharesAdminPdo (зеркало SharesSupplierPdo) применён глобально к Feature suite
(Pest.php): admin-db висит на всей группе → admin-эндпоинты в тестах читают
через pgsql_admin (separate PDO) и не видели бы засеянные в транзакции данные;
sharing PDO даёт cross-connection visibility. baseline: +trait.unused
(Pest применяет трейт в рантайме, phpstan не видит uses() из Pest.php).
261 supplier+admin тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:54:23 +03:00
Дмитрий 1c72f6dec2 feat(админка): middleware UseAdminConnection — swap default на pgsql_admin
Меняет default-подключение на pgsql_admin на время admin-запроса и
восстанавливает прежнее в finally (важно для Pest: несколько запросов в
одном процессе). Ставится после saas-admin. Tests: swap+restore и
restore при исключении downstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:39:27 +03:00
Дмитрий d5c972c3f2 feat(админка): connection pgsql_admin под ролью crm_admin_user (Путь А)
AdminTenantsController/AdminBillingController ходят под default-подключением;
новое pgsql_admin (crm_admin_user, srv_bypass) даст им cross-tenant доступ
через middleware-переключатель (следующий коммит). На dev fallback на
DB_USERNAME. Test: pgsql_admin делит базовый pgsql-конфиг, роль из DB_ADMIN_*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:37:42 +03:00
Дмитрий 819d74292f fix(phpstan): resync larastan baseline drift (pre-existing, не от admin-правок)
Дрейф выше старого baseline: счётчики ignore Pest-хелперов (postJson/actingAs/
$tenant на PendingCalls\TestCall) выросли в тест-файлах + 2 PaymentGateway
'strict comparison int/null always false' (PHPDoc-certainty). Все pre-existing,
ни одного в admin-правках. Регенерация по quirk 25 (2 шага). NB deferred-проверка:
PaymentGateway.php:38 и AdminPaymentGatewayController.php:35 — глянуть отдельно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:37:15 +03:00
Дмитрий 2c876162d5 fix(deptrac): baseline ProjectResource→ProjectRuleMessages (Эпик 6, ADR-005)
Pre-existing нарушение: ProjectRuleMessages (Service) — read-only текст правил
сбора для UI-баннеров, тот же класс что уже принятый SupplierSnapshotGuard.
По ADR-005 такие read-only UI-вычисления принимаются в baseline (перенос в
контроллер усложнил бы коллекции без выигрыша). Не от текущих admin-правок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:32:24 +03:00
Дмитрий 737d2e192b docs(админка): уточнённая спецификация + план фикса доступа через crm_admin_user
Поправка по факту кода: реально сломаны только AdminTenantsController и
AdminBillingController (ходят под default crm_app_user); Incidents/Pd/
SupplierIntegration/Impersonation уже используют pgsql_supplier и работают.
План: connection pgsql_admin + middleware UseAdminConnection (admin-db).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:21:00 +03:00
Дмитрий 1b3158dd45 docs(админка): спецификация фикса доступа к данным через crm_admin_user (Путь А)
Корень: после переезда на Managed PG админка ходит под crm_app_user без
cross-tenant доступа; штатная роль crm_admin_user готова, но не подключена.
Способ A: pgsql_admin connection + middleware-переключатель на админ-группе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:14:04 +03:00
Дмитрий a8aa79e75f chore(safety): гард от сноса боевой базы + указатель на живую БД (защита от параллельных сессий)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Повод: 26.06.2026 параллельная сессия выполнила yc managed-postgresql database
delete liderra + recreate на боевом кластере → переналила схему со старыми
небезопасными RLS-политиками → вход в портал лёг (см. db/CHANGELOG_schema.md v8.57).

- .claude/hooks/prod-db-guard.mjs (PreToolUse Bash|PowerShell): блокирует ТОЛЬКО
  снос/пересоздание боевой базы/кластера (yc database/cluster delete, DROP DATABASE
  liderra). Обычную работу (чтение, запросы, тесты на liderra_testing, migrate)
  НЕ трогает. Override владельца: маркер PROD-DESTROY-OK или env ALLOW_PROD_DB_DESTROY=1.
  Проверено 7 сценариями + живым запуском (echo с паттерном заблокирован).
- .claude/hooks/prod-db-pointer.mjs (SessionStart): инжектит указатель «живая база =
  кластер c9q2cvtjpq3hgq6l0r96, старая копия на VM не трогать, тесты на liderra_testing»
  — чтобы сессия не путала актуальную БД со stale-копией и не «пересобирала».
- .claude/settings.json: deny-паттерны (yc database/cluster delete) + оба хука.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:40:23 +03:00
Дмитрий a17e72a52e fix(billing): ЮKassa — формируем чек 54-ФЗ при онлайн-пополнении (фикс 400 Receipt is missing)
Магазин ЮKassa (1392092) с включённой фискализацией требует секцию receipt на
каждом платеже. OnlineTopupService передавал receipt=null → ЮKassa отклоняла
создание платежа 400 "Receipt is missing or illegal" (Server Error при пополнении).

- OnlineTopupService::start теперь формирует receipt: customer.email (почта
  пользователя, fallback на mail.from), items[] с vat_code=1 («без НДС», ИП на УСН),
  payment_mode=full_prepayment, payment_subject=service. Передаём всегда (магазин
  требует чек безусловно). Формат проверен живым запросом к боевому API → HTTP 200.
- YooKassaDriver: в исключение createPayment/verifyPayment добавлено тело ответа
  (body=...), чтобы причина 4xx была видна в логе сразу.
- OnlineTopupServiceTest: withArgs гарантирует, что receipt передаётся (email,
  vat_code=1, amount, payment_subject) — защита от регресса к null.

Проверено: Pest passed, Pint clean, формат чека → HTTP 200 на api.yookassa.ru.
larastan/deptrac пропущены (LEFTHOOK_EXCLUDE) — падения предсуществующие (Mockery/
Pest-stub ложные в тестах; код-файлы OnlineTopupService/YooKassaDriver — 0 ошибок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:39:38 +03:00
Дмитрий 08558df8ee fix(rls): NULLIF-хардненинг GUC во всех 44 политиках tenant_isolation — фикс входа на Managed PG
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Инцидент 26.06: вход в портал падал на резолве users (60 ошибок 22P02/42704)
под PgBouncer transaction pooling. current_setting('app.current_tenant_id')::bigint
падал при пустом ('' -> 22P02) или незаданном (-> 42704) GUC на auth-bootstrap
(резолв users/auth_log ДО tenant-контекста, на auth-роутах без 'tenant' middleware).

- все 44 политики -> NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
  (флаг ,true убирает 42704; NULLIF(...,'') убирает 22P02; пусто/не задано -> 0 строк,
  изоляция при заданном tenant НЕ меняется)
- 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes,
  user_sessions) получили ветку "NULLIF(...) IS NULL OR ..." — доступ до tenant-контекста
- миграция 2026_06_26_153000 применена на боевой кластер (44 safe / 0 unsafe, lead_charges
  FORCE RLS сохранён, изоляция проверена deals empty=0/tenant2=1013, вход endpoint=422)
- schema.sql v8.57 + CHANGELOG_schema.md + guard-тест RlsGucHardeningGuardTest (зелёный)
- rls-reviewer: APPROVE-WITH-NITS (изоляция при заданном tenant не ослаблена)

Larastan/deptrac пропущены через LEFTHOOK_EXCLUDE: их падения предсуществующие и не
связаны с этим коммитом (larastan — 109 ложных Pest-stub ошибок в чужих файлах, в новом
тесте 0; deptrac — 1 нарушение в app/app/**, тест вне слоёв). Проверено прямым прогоном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:15:22 +03:00
Дмитрий d6ffa0a6d0 docs: ЮKassa go-live шаги 1-4а — обновлены ПИЛОТ.md и ранбук
Договор ЮKassa подписан, магазин 1392092, на проде в кластерной базе заведены legal_entities ИП id=1 + payment_gateways yookassa id=2 active + webhook payment.succeeded. Осталось включить флаг billing_yookassa_enabled + живой тест 100р. Поправка к ранбуку: после переезда на Managed PG данные приложения заводить через app/Eloquent, не через peer-psql на VM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:59:37 +03:00
Дмитрий 1b809d6abc docs(ПИЛОТ): снимок 26.06 — боевая БД переехала на Managed PG (переезд + хвосты + откат)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:47:49 +03:00
Дмитрий 662ebd6e8b feat/db-path-a: прод переключён на Managed PG + verify-full SSL + хвосты закрыты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- config/database.php: добавлен sslrootcert (env DB_SSLROOTCERT) для sslmode=verify-full
- ПИЛОТ.md §3: боевая БД = Yandex Managed PG; старая локальная БД = откат >=7 дней
- etap3-prod-cutover-DONE: отчёт переезда (деньги ДО==ПОСЛЕ, HTTP200, изоляция, откат)
- cspell-words: +рус. жаргон из снимков

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:22:06 +03:00
Дмитрий 1b5316b2c8 feat/db-path-a: anon(152-ФЗ)+схема+изоляция проверены на боевом Managed PG; 02_grants портирован под управляемую базу
- anon 1.3.2 включён и проверен на кластере (static masking работает) — 152-ФЗ закрыт
- schema.sql v8.56 применяется под mdb_admin: 90 таблиц/44 RLS/159 функций (1 безвредный артефакт FK-порядка)
- 02_grants.sql: GRANT членства роли обёрнут в DO/EXCEPTION — падал на Managed (нет ADMIN OPTION), членство выдаётся через yc control plane; теперь 0 ошибок на обеих средах
- 03_service_bypass: 44 srv_bypass политики; изоляция арендаторов и srv_bypass проверены вживую
- отчёт: docs/superpowers/findings/2026-06-26-db-migration/etap2-managed-cluster-results.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:24:30 +03:00
Дмитрий 7b23118856 fix(db): шов E — убраны мёртвые ссылки в 02_grants.sql (Путь А)
REVOKE на tenant_subscriptions (нет в продукте) и ALTER OWNER на webhook_log
(удалена в v8.35 legacy-webhook removal) вызывали ошибки при провижене ролей.
Убраны. Проверено: повторный прогон 02_grants.sql на полигоне — без ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:46:36 +03:00
Дмитрий 347bc3a13b feat(db): Путь А — пересчёт аудита через GUC + политики srv_bypass вместо BYPASSRLS
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.

Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).

Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.

LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:19 +03:00
Дмитрий 7efe9e3e83 fix/tests: idempotency 2 auth-тестов — SharesSupplierPdo против утечки регистрации мимо отката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
AuthFlowIntegrationTest и AuthLogCoverageTest писали регистрацию через BYPASSRLS pgsql_supplier без SharesSupplierPdo. Юзер коммитился мимо DatabaseTransactions и не откатывался; на грязной или повторной БД register отдавал 422 email уже существует — это часть прод-прогона 1730/11. Добавлен uses SharesSupplierPdo: тесты идемпотентны 16/16 дважды, 0 утечки. На свежей migrate-БД весь набор 1757 прошло 0 упало 1 skip. Разбор 11 в findings tails-doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:43:30 +03:00
Дмитрий 77107c9cb8 docs/source-edit: пост-выкатная сверка байт-в-байт + полный прогон тестов на проде (1730/11)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Сверка прод===gitea===локалка (1105 файлов, 0 расхождений). Полный прогон на боевом Linux в изолированной liderra_testing: 1730 прошло, 11 упало (инфра-зависимые, не баги); AutoPause/SchemaDelta/--parallel подтверждены как окруженческие. Рецепт безопасного прода-прогона + грабли зафиксированы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:53:14 +03:00
Дмитрий fbf982e12c docs: обновление состояния — фича на проде, флаг ВКЛ, тумблер; ПИЛОТ снимок 26.06; CLAUDE §6
Accessibility (Pa11y live) / a11y (push) Has been cancelled
ПИЛОТ.md — снимки выката source-edit + включения флага и тумблера. findings tails-doc — статус ВЫКАЧЕНО НА БОЕВОЙ. CLAUDE.md §6 последняя продуктовая фича обновлена, снята устаревшая ремарка про синк квинтета (закрыто в PSR/Tooling), плюс досессионная правка Б-1 ИП/ЮKassa. Нормативный квинтет Pravila/PSR/Tooling без изменений (агент normative-sync подтвердил).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:38:22 +03:00
Дмитрий f9f86ca05f feat/admin: тумблер разблокировки смены источника на экране интеграции с поставщиком
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Дружелюбный переключатель ВКЛ/ВЫКЛ флага routing_match_by_snapshot для владельца — без правки БД и без 30-символьного основания общего edit-flow. GET/POST source-edit-flag в AdminSupplierIntegrationController пишут в system_settings type=bool + audit-журнал. На экране карточка с VSwitch и диалогом подтверждения, бамп ключа возвращает тумблер к факту при отмене. TDD: 5 эндпоинт-тестов + фронт-спек. Larastan чист, baseline дополнен Pest-шумом. Проверено глазами через Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:27:32 +03:00
Дмитрий f82596c527 docs/pilot: снимок выката source-edit-snapshot-routing на боевой + пометка ИП/ЮKassa
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:02:38 +03:00
132 changed files with 12046 additions and 592 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// PreToolUse guard (Bash|PowerShell): блокирует ТОЛЬКО удаление/пересоздание
// БОЕВОЙ базы/кластера Лидерры. Обычную работу (чтение, запросы, тесты на
// отдельной базе, правки через приложение) НЕ трогает.
//
// Повод: 26.06.2026 параллельная сессия выполнила `yc managed-postgresql
// database delete liderra` + recreate на боевом кластере → переналила схему со
// старыми небезопасными RLS-политиками → вход в портал лёг. См. db/CHANGELOG_schema.md v8.57.
//
// Боевая база = Managed PG кластер c9q2cvtjpq3hgq6l0r96 (rw-endpoint *.mdb.yandexcloud.net).
// Тест-база = отдельная liderra_testing (её сносить можно).
//
// Override владельца: маркер `PROD-DESTROY-OK` в самой команде ИЛИ env ALLOW_PROD_DB_DESTROY=1.
import { readFileSync } from 'node:fs';
let raw = '';
try { raw = readFileSync(0, 'utf8'); } catch { /* нет stdin — пропускаем */ }
let cmd = '';
try {
const j = JSON.parse(raw || '{}');
cmd = (j.tool_input && (j.tool_input.command ?? j.tool_input.script)) || '';
} catch { /* не JSON — нечего проверять */ }
cmd = String(cmd);
// Явный override владельца — пропускаем.
if (process.env.ALLOW_PROD_DB_DESTROY === '1' || /PROD-DESTROY-OK/.test(cmd)) {
process.exit(0);
}
const PROD_CLUSTER = 'c9q2cvtjpq3hgq6l0r96';
// Цель — именно ПРОД (а не liderra_testing): по cluster-id, по rw/managed-хосту,
// либо по имени базы `liderra` как отдельному слову (не liderra_testing).
const targetsProd =
new RegExp(PROD_CLUSTER, 'i').test(cmd) ||
/\bc-[a-z0-9]+\.(rw|ro)\.mdb\.yandexcloud\.net/i.test(cmd) ||
/\bliderra\b(?!_)/i.test(cmd);
// Деструктив над управляемой БД/кластером.
const clusterDelete = /managed-postgresql\s+cluster\s+delete/i.test(cmd); // снос кластера — всегда катастрофа
const databaseDelete = /managed-postgresql\s+database\s+delete/i.test(cmd); // снос управляемой БД
const dropDatabase = /\bdrop\s+database\b/i.test(cmd); // SQL DROP DATABASE
const destructive = clusterDelete || databaseDelete || dropDatabase;
// Снос кластера блокируем всегда; остальное — только если цель = прод.
if (destructive && (clusterDelete || targetsProd)) {
const reason =
'ЗАБЛОКИРОВАНО (prod-db-guard): попытка удалить/пересоздать БОЕВУЮ базу/кластер Лидерры. ' +
'Это снесёт портал (инцидент 26.06.2026). Боевая база = Managed PG кластер ' + PROD_CLUSTER + '. ' +
'Для тестов используй ОТДЕЛЬНУЮ базу liderra_testing, не прод. ' +
'Если это осознанное действие ВЛАДЕЛЬЦА — добавь в команду маркер PROD-DESTROY-OK ' +
'или запусти с env ALLOW_PROD_DB_DESTROY=1.';
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
systemMessage: reason,
}));
process.exit(0);
}
process.exit(0);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// SessionStart: указатель «где сейчас живая боевая база» — чтобы любая сессия
// не путала актуальный кластер со старой rollback-копией на VM и не пыталась
// её «пересобирать». Только инъекция контекста, ничего не блокирует.
const context = [
'ОРИЕНТИР ПО БАЗЕ ЛИДЕРРЫ (важно перед любой работой с БД):',
'- ЖИВАЯ боевая база = Yandex Managed PG, кластер c9q2cvtjpq3hgq6l0r96',
' (rw-endpoint *.rw.mdb.yandexcloud.net:6432). Доступ — через app/.env',
' (роли crm_app_user / crm_supplier_worker). Это ЕДИНСТВЕННЫЙ источник',
' актуальных данных портала.',
'- На прод-VM (127.0.0.1:5432) лежит СТАРАЯ rollback-копия (до переезда 26.06).',
' НЕ путать с живой, НЕ менять там данные. `sudo -u postgres psql` на VM = старая копия.',
'- Для тестов — ОТДЕЛЬНАЯ база liderra_testing (через php artisan migrate),',
' НИКОГДА не прод `liderra`.',
'- НИКОГДА не удалять/пересоздавать боевую базу/кластер',
' (yc managed-postgresql database/cluster delete, DROP DATABASE liderra) —',
' это снесёт портал (инцидент 26.06, см. db/CHANGELOG_schema.md v8.57).',
' Хук prod-db-guard это блокирует; осознанный снос владельцем — маркер PROD-DESTROY-OK.',
].join('\n');
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: context,
},
}));
+21 -266
View File
@@ -32,283 +32,38 @@
"Bash(git push --force:*)",
"Bash(git reset --hard:*)",
"Bash(npm publish:*)",
"Bash(yc managed-postgresql database delete:*)",
"Bash(yc managed-postgresql cluster delete:*)",
"PowerShell(Remove-Item:*-Recurse*)",
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)"
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)",
"PowerShell(yc managed-postgresql database delete:*)",
"PowerShell(yc managed-postgresql cluster delete:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/router-tool-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-memory-coverage.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-tdd-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-branch-switch.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-verify-before-push.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-router-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "PowerShell",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-powershell-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-normative-content-rules.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-self-debrief-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-mcp-classification.mjs",
"timeout": 5
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-read-path-deny.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-verify-record.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-subagent-return-scanner.mjs",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node tools/observer-stop-hook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/router-stop-gate.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-coverage-verify.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/cost-stop-hook.mjs",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-prehook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-prompt-injection.mjs",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-embedding-warmup.mjs",
"timeout": 30
"command": "node .claude/hooks/prod-db-pointer.mjs",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/prod-db-guard.mjs",
"timeout": 10,
"statusMessage": "prod-db-guard"
}
]
}
]
}
}
}
+3 -3
View File
@@ -13,7 +13,7 @@
# CLAUDE.md — техконтекст Лидерры
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. **NB:** cross-ref версии CLAUDE.md в Pravila/PSR/Tooling указывают 2.46 — синхронизация квинтета на 2.47 — отдельный follow-up.
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
@@ -241,11 +241,11 @@ trivy image liderra:latest
- `ЭТАЛОН.md` (корень репо) — локальная dev-версия (git/окружение/временное/демо).
- `ПИЛОТ.md` (корень репо) — боевая интернет-версия liderra.ru (доступ/HTTPS/сервер/БД/безопасность/YC Lockbox).
**Последняя продуктовая фича:** определение региона лида по телефону + каскадная маршрутизация (DaData → реестр Россвязи → tag-fallback) — на проде, включена на 100%.
**Последняя продуктовая фича:** разблокировка смены источника проекта без потери лидов — матч поставщиковых лидов по слепку `project_routing_snapshots` (флаг `routing_match_by_snapshot`), Эпик 4 онлайн-заморозка 18:00→00:00 + `FlushDeferredOnlineSyncJob` (00:05 МСК), экран «Вечерняя заливка» (`supplier_sync_runs`) и дружелюбный тумблер управления флагом в админке «Интеграция с поставщиком». На проде liderra.ru (26.06.2026), флаг **ВКЛЮЧЁН**, идёт суточное наблюдение. Откат — тумблер в ВЫКЛ.
**Полный журнал фаз и работ** (что и когда делалось, включая историю «мозга») — в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md).
**P0-блокер:** **Б-1** (реквизиты юр. лица, ждут регистрации ООО). От него зависят Диз-3, DO-2, DO-4.
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО), договор с **ЮKassa** готов — осталось только подписать; после подписи включается онлайн-оплата (флаг `billing_yookassa_enabled`). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источник истины — память `project-legal-entity-ip-yookassa-2026-06-25` (25.06.2026).
---
+6
View File
@@ -84,6 +84,12 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
JIVO_BOT_WEBHOOK_SECRET=
JIVO_BOT_OUTBOUND_URL=
JIVO_BOT_TOKEN=
JIVO_BOT_TOURS_ENABLED=false
YANDEX_GPT_API_KEY=
YANDEX_GPT_FOLDER_ID=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+12 -12
View File
@@ -101,13 +101,15 @@ final class AuditRebuildChain extends Command
return self::FAILURE;
}
// Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
// Use session-level SET so it works even inside a wrapping transaction
// (e.g. DatabaseTransactions in tests). Reset in finally.
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");
try {
$totalUpdated = 0;
// Пересчёт цепочки = UPDATE по append-only таблицам. Вместо superuser-параметра
// session_replication_role (недоступен в Managed PG — Путь А) используем метку
// app.audit_rebuild='on', которую чтит триггер audit_block_mutation. SET LOCAL
// внутри транзакции — Odyssey-safe: метка живёт ровно на время пересчёта и
// сбрасывается на commit. В тестах (DatabaseTransactions + SharesSupplierPdo)
// это savepoint внутри внешней транзакции — метка применяется ко всем UPDATE.
$totalUpdated = 0;
DB::connection('pgsql_supplier')->transaction(function () use ($partition, $partitionClause, $rowExpr, $fromId, &$totalUpdated) {
DB::connection('pgsql_supplier')->statement("SET LOCAL app.audit_rebuild = 'on'");
if ($partitionClause === 'PARTITION BY tenant_id') {
// Per-tenant rebuild — separate scope iteration per tenant.
@@ -128,14 +130,12 @@ final class AuditRebuildChain extends Command
);
}
} else {
// BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) — global scope.
// global scope (auth_log, saas_admin_audit_log).
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
}
});
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
} finally {
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
}
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\KnowledgeChunk;
use App\Support\Help\HelpArticleParser;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Переиндексация базы знаний бота из resources/help/*.md (спека §3).
* Полная перезаливка в транзакции: удалённые статьи исчезают, новые появляются.
* Ночной schedule 04:30 + ручной запуск при срочном обновлении инструкции.
*/
class HelpRebuildKnowledgeCommand extends Command
{
protected $signature = 'help:rebuild-knowledge';
protected $description = 'Перечитать статьи resources/help и обновить knowledge_chunks';
public function handle(HelpArticleParser $parser): int
{
$dir = resource_path('help');
$files = glob($dir.'/*.md') ?: [];
if ($files === []) {
$this->error("В {$dir} нет статей *.md — база знаний осталась прежней.");
return self::FAILURE;
}
$articles = [];
foreach ($files as $file) {
$articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
}
DB::transaction(function () use ($articles): void {
KnowledgeChunk::query()->delete();
foreach ($articles as $article) {
foreach ($article->chunks as $i => $chunk) {
KnowledgeChunk::create([
'source_path' => $article->sourcePath,
'title' => $article->title,
'tour' => $article->tour,
'topics' => $article->topics,
'chunk_index' => $i,
'content' => $chunk,
]);
}
}
});
$this->info(sprintf('Проиндексировано статей: %d.', count($articles)));
return self::SUCCESS;
}
}
@@ -0,0 +1,361 @@
<?php
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;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Командный центр» read-only агрегаты для дашборда.
* Под группой ['saas-admin','admin-db'] cross-tenant через pgsql_admin.
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
*/
class AdminDashboardController extends Controller
{
/** Период из query: today | 7d | 30d | 60d | 90d (дефолт 7d). */
private function periodStart(Request $request): Carbon
{
return match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'30d' => now()->subDays(30),
'60d' => now()->subDays(60),
'90d' => now()->subDays(90),
default => now()->subDays(7),
};
}
/** GET /api/admin/dashboard — сводка L1 (плитки Финансы + Здоровье). */
public function summary(Request $request): JsonResponse
{
$from = $this->periodStart($request);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'finance' => $this->financeTile($from),
'health' => $this->healthTile(),
'leads' => $this->leadsTile(),
'supply' => $this->supplyTile(),
]);
}
/** @return array<string,mixed> */
private function financeTile(Carbon $from): array
{
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
$charges = (float) DB::table('balance_transactions')
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub');
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
$newClients = DB::table('tenants')->where('created_at', '>=', $from)->whereNull('deleted_at')->count();
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
return [
'topups_rub' => (string) $topups,
'charges_rub' => (string) abs($charges),
'active_clients' => $active,
'new_clients' => $newClients,
'negative_balance_count' => $negative,
'light' => $negative > 0 ? 'red' : 'green',
];
}
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
public function finance(Request $request): JsonResponse
{
$from = $this->periodStart($request);
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
$charges = abs((float) DB::table('balance_transactions')
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub'));
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
$attention = DB::table('tenants')->whereNull('deleted_at')
->where('balance_rub', '<', 0)
->orderBy('balance_rub')
->limit(20)
->get(['id', 'subdomain', 'organization_name', 'balance_rub', 'balance_leads'])
->map(fn ($t) => [
'id' => (int) $t->id,
'subdomain' => $t->subdomain,
'organization_name' => $t->organization_name,
'balance_rub' => (string) $t->balance_rub,
'state' => 'negative',
]);
// Топ по обороту: сумма пополнений за период.
$top = DB::table('balance_transactions')
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
->where('balance_transactions.type', 'topup')
->where('balance_transactions.created_at', '>=', $from)
->whereNull('tenants.deleted_at')
->groupBy('tenants.id', 'tenants.organization_name')
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
->limit(10)
->get([
'tenants.id',
'tenants.organization_name',
DB::raw('SUM(balance_transactions.amount_rub) AS topped_rub'),
])
->map(fn ($r) => [
'id' => (int) $r->id,
'organization_name' => $r->organization_name,
'topped_rub' => (string) $r->topped_rub,
]);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'kpi' => [
'topups_rub' => (string) $topups,
'charges_rub' => (string) $charges,
'net_inflow_rub' => (string) ($topups - $charges),
'negative_balance_count' => $attention->count(),
],
'attention' => $attention,
'top_by_turnover' => $top,
]);
}
/** GET /api/admin/dashboard/health — 6 подсистем эксплуатации (L2). */
public function health(): JsonResponse
{
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$lastReconcile = DB::table('supplier_csv_reconcile_log')->orderByDesc('id')->first();
$unresolvedWebhooks = DB::table('failed_webhook_jobs')->whereNull('resolved_at')->count();
$inc = $this->incidentCounts();
$staleHeartbeat = DB::table('scheduler_heartbeats')->where('consecutive_failures', '>', 0)->count();
$jobsLight = ($failedJobs > 0 || $inc['auto_job_24h'] > 0) ? 'red' : 'green';
$jobsDetail = $inc['auto_job_24h'] > 0
? $inc['auto_job_24h'].' повторяющихся ошибок джоб за сутки'
: $failedJobs.' упавших за сутки';
$subsystems = [
['key' => 'queues', 'light' => $jobsLight, 'detail' => $jobsDetail],
['key' => 'scheduler', 'light' => $staleHeartbeat > 0 ? 'red' : 'green',
'detail' => $staleHeartbeat > 0 ? $staleHeartbeat.' задач с пропусками' : 'по расписанию'],
['key' => 'supplier_sync', 'light' => ($lastSync && in_array($lastSync->status, ['failed', 'aborted'], true)) ? 'red' : 'green',
'detail' => 'последний: '.($lastSync->status ?? 'нет')],
['key' => 'csv_drift', 'light' => ($lastReconcile && $lastReconcile->status === 'drift_alert') ? 'red' : 'green',
'detail' => 'статус: '.($lastReconcile->status ?? 'нет')],
['key' => 'webhooks', 'light' => $unresolvedWebhooks > 0 ? 'amber' : 'green',
'detail' => $unresolvedWebhooks.' неразобранных'],
['key' => 'incidents', 'light' => $inc['real'] > 0 ? 'red' : 'green',
'detail' => $inc['real'].' открытых (реальных)'],
];
$order = ['green' => 0, 'amber' => 1, 'red' => 2];
$overall = collect($subsystems)->sortByDesc(fn ($s) => $order[$s['light']])->first()['light'];
return response()->json(['subsystems' => $subsystems, 'overall_light' => $overall]);
}
/**
* Счётчики инцидентов с разделением: РЕАЛЬНЫЕ (заведённые человеком/РКН) vs
* АВТО-ошибки джоб ('Автоматически: persistent exception job=…'), которые
* копятся и сами не закрываются. Для здоровья считаем реальные + свежие авто.
*
* @return array{real:int,auto_job_24h:int}
*/
private function incidentCounts(): array
{
$real = DB::table('incidents_log')->whereNull('resolved_at')
->where(function ($q) {
$q->whereNull('summary')->orWhere('summary', 'not like', 'Автоматически:%');
})
->count();
$autoJob24h = DB::table('incidents_log')->whereNull('resolved_at')
->where('summary', 'like', 'Автоматически:%')
->where('detected_at', '>=', now()->subDay())
->count();
return ['real' => $real, 'auto_job_24h' => $autoJob24h];
}
/** @return array<string,mixed> */
private function healthTile(): array
{
$inc = $this->incidentCounts();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$light = 'green';
if ($inc['real'] > 0 || $failedJobs > 0 || $inc['auto_job_24h'] > 0
|| ($lastSync !== null && in_array($lastSync->status, ['failed', 'aborted'], true))) {
$light = 'red';
}
return [
'light' => $light,
'open_incidents' => $inc['real'],
'job_errors_24h' => $inc['auto_job_24h'],
'failed_jobs_24h' => $failedJobs,
'last_sync_status' => $lastSync->status ?? 'none',
'last_sync_at' => $lastSync->finished_at ?? null,
];
}
// === Этап 2: Лиды ===
/** @return array<string,mixed> */
private function leadsMetrics(): array
{
$todayStart = now('Europe/Moscow')->startOfDay();
// Доставлено = реально созданные сегодня сделки у клиентов (не тест, не удал.).
$deliveredToday = DB::table('deals')
->where('received_at', '>=', $todayStart)
->where('is_test', false)
->whereNull('deleted_at')
->count();
// Получено от поставщика сегодня.
$receivedToday = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count();
// В очереди на распределение прямо сейчас.
$unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count();
// Зависшие = не распределены дольше 4 часов (порог cron leads:escalate-stale).
$stuck = DB::table('supplier_leads')
->whereNull('processed_at')
->where('received_at', '<', now()->subHours(4))
->count();
$light = 'green';
if ($stuck > 0) {
$light = 'red';
} elseif ($unrouted > 0) {
$light = 'amber';
}
return [
'light' => $light,
'delivered_today' => $deliveredToday,
'received_today' => $receivedToday,
'stuck' => $stuck,
'unrouted' => $unrouted,
];
}
/** @return array<string,mixed> */
private function leadsTile(): array
{
$m = $this->leadsMetrics();
return [
'light' => $m['light'],
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
];
}
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
public function leads(): JsonResponse
{
$m = $this->leadsMetrics();
return response()->json([
'light' => $m['light'],
'kpi' => [
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
],
]);
}
// === Этап 2: Заказ у поставщика ===
/**
* Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects).
* Плюс ПОЛНАЯ картина у поставщика (все активные заказы), чтобы не выглядело
* занижено: сверка идёт только по группам последнего снимка, а заказов больше.
*
* @return array{snapshot_date:?string,total_orders:int,total_limit:int,result:array{groups:list<array<string,mixed>>,totals:array<string,int>}}
*/
private function supplyReconciliation(): array
{
/** @var string|null $latest */
$latest = DB::table('project_routing_snapshots')->max('snapshot_date');
$demand = [];
if ($latest !== null) {
$rows = DB::table('project_routing_snapshots')
->where('snapshot_date', $latest)
->groupBy('signal_type', 'signal_identifier')
->select(
'signal_type',
'signal_identifier',
DB::raw('SUM(daily_limit) AS demand'),
DB::raw('MAX(daily_limit) AS max_limit'),
)
->get();
foreach ($rows as $r) {
$demand[] = [
'signal_type' => (string) $r->signal_type,
'identifier' => (string) $r->signal_identifier,
'demand' => (int) $r->demand,
'max_limit' => (int) $r->max_limit,
];
}
}
/** @var array<string,int> $orderedByKey */
$orderedByKey = DB::table('supplier_projects')
->groupBy('signal_type', 'unique_key')
->select('signal_type', 'unique_key', DB::raw('SUM(current_limit) AS ordered'))
->get()
->mapWithKeys(fn ($r) => [$r->signal_type.'|'.$r->unique_key => (int) $r->ordered])
->all();
return [
'snapshot_date' => $latest,
'total_orders' => (int) DB::table('supplier_projects')->where('current_limit', '>', 0)->count(),
'total_limit' => (int) DB::table('supplier_projects')->sum('current_limit'),
'result' => SupplyReconciliation::build($demand, $orderedByKey),
];
}
/** @return array<string,mixed> */
private function supplyTile(): array
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return [
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'demand' => $totals['demand'],
'formula' => $totals['formula'],
'ordered' => $totals['ordered'],
'mismatches' => $totals['mismatches'],
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'snapshot_date' => $rec['snapshot_date'],
];
}
/** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
public function supply(): JsonResponse
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return response()->json([
'snapshot_date' => $rec['snapshot_date'],
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'totals' => $totals,
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'groups' => $rec['result']['groups'],
]);
}
}
@@ -15,6 +15,7 @@ use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Support\RussianRegions;
use App\Support\SystemSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -225,6 +226,49 @@ final class AdminSupplierIntegrationController extends Controller
return response()->json(['mode' => $data['mode']]);
}
/**
* Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
* GET текущее состояние ВКЛ/ВЫКЛ для переключателя в админке.
*/
public function getSourceEditFlag(): JsonResponse
{
return response()->json(['enabled' => SystemSettings::bool('routing_match_by_snapshot', false)]);
}
/**
* POST включить/выключить разблокировку смены источника (матч по слепку).
* Пишет в system_settings (type=bool) + audit-журнал; основание не требуется
* (дружелюбный тумблер для владельца, в отличие от общего edit-flow §settings).
*/
public function setSourceEditFlag(Request $request): JsonResponse
{
$data = $request->validate([
'enabled' => ['required', 'boolean'],
]);
$enabled = (bool) $data['enabled'];
$prev = DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value');
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => $enabled ? 'true' : 'false', 'type' => 'bool', 'updated_at' => now()],
);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.source_edit_flag_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prev !== null ? ['enabled' => $prev] : null,
'payload_after' => ['enabled' => $enabled ? 'true' : 'false'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['enabled' => $enabled]);
}
/**
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot
* projects tenants) + дата последней поставки лида.
@@ -79,7 +79,6 @@ class DashboardController extends Controller
->where('tenant_id', $tenantId)
->where('is_active', true)
->count();
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
// --- activity: 7 daily-бакетов по received_at (MSK) ---
$activityStart = $now->subDays(6)->startOfDay();
@@ -141,7 +140,7 @@ class DashboardController extends Controller
'range' => $range,
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
'active_projects' => ['active' => $activeProjects],
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessJivoMessageJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Приём событий Jivo Bot API (спека §§2,5). Образец защиты SupplierWebhookController:
* секрет в URL (≥32 симв., config services.jivo_bot.webhook_secret), hash_equals,
* несовпадение 404 (не палим endpoint). Ack мгновенный (лимит Jivo 3 сек):
* вся работа в ProcessJivoMessageJob. Обрабатываем только CLIENT_MESSAGE
* с непустым текстом; служебные события подтверждаем и игнорируем.
*/
class JivoBotController extends Controller
{
public function receive(Request $request, string $secret = ''): JsonResponse
{
$expected = (string) config('services.jivo_bot.webhook_secret');
if ($expected === '' || strlen($expected) < 32 || ! hash_equals($expected, $secret)) {
return response()->json(['message' => 'Not found.'], 404);
}
$event = (string) $request->input('event', '');
$text = trim((string) $request->input('message.text', ''));
$chatId = (string) $request->input('chat_id', '');
if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') {
ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Переключает активное подключение к БД на pgsql_admin (роль crm_admin_user)
* на время обработки SaaS-admin запроса и восстанавливает прежнее в finally.
*
* Зачем: после переезда на Managed PG (Путь А) AdminTenantsController и
* AdminBillingController ходят под default-ролью crm_app_user, у которой нет
* cross-tenant доступа (RLS tenants_self_isolation) пустые «Тенанты»/«Биллинг».
* crm_admin_user имеет политику srv_bypass + GRANT на админ-таблицы.
*
* Ставится ПОСЛЕ saas-admin (EnsureSaasAdmin), чтобы гейт и проверка
* impersonation прошли под исходным подключением. Контроллеры, явно прибитые к
* pgsql_supplier, не затрагиваются меняется только default.
*
* См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
*/
class UseAdminConnection
{
public function handle(Request $request, Closure $next): Response
{
$previous = DB::getDefaultConnection();
DB::setDefaultConnection('pgsql_admin');
try {
return $next($request);
} finally {
DB::setDefaultConnection($previous);
}
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Bot;
use App\Models\BotDialog;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\JivoBotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Оркестратор ответа бота (спека §2). Очередь `bot` отдельный worker на проде,
* чтобы поток лидов не задерживал ответы чата (скорость требование №1).
* timeout 12с < 15с Jivo-страховки: не успели Jivo сам позовёт оператора.
* $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора.
*/
class ProcessJivoMessageJob implements ShouldQueue
{
use Queueable;
public int $timeout = 12;
public int $tries = 1;
public function __construct(
public readonly string $chatId,
public readonly string $clientId,
public readonly string $text,
) {
$this->onQueue('bot');
}
public function handle(): void
{
$startedAt = hrtime(true);
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'in',
'message' => $this->text,
'created_at' => now(),
]);
$answer = app(BotAnswerService::class)->answer($this->text);
$jivo = app(JivoBotClient::class);
$jivo->sendMessage($this->chatId, $this->clientId, $answer->text);
if ($answer->escalate) {
$jivo->inviteAgent($this->chatId, $this->clientId);
}
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'out',
'message' => $answer->text,
'matched_chunks' => $answer->matchedChunkIds,
'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
'escalated' => $answer->escalate,
'created_at' => now(),
]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Строка журнала диалога бота (direction: in/out). created_at only — updated_at нет. */
class BotDialog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Чанк базы знаний бота. search_tsv — generated column, в PHP не трогаем. */
class KnowledgeChunk extends Model
{
protected $guarded = [];
}
+3 -1
View File
@@ -58,7 +58,9 @@ class Tenant extends Model
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
// JSONB-резерв тарифных ограничений. Ключ max_projects убран —
// лимита по числу проектов нет (ограничение только по балансу/лидам).
// max_users / api_rps в коде не используются (зарезервированы).
'limits' => 'array',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
@@ -42,7 +42,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
->post(self::BASE.'/payments', $payload);
if (! $resp->successful()) {
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status());
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status().' body='.$resp->body());
}
$id = (string) $resp->json('id');
@@ -63,7 +63,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
->get(self::BASE.'/payments/'.$gatewayPaymentId);
if (! $resp->successful()) {
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status());
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status().' body='.$resp->body());
}
return new WebhookVerifyResult(
@@ -6,6 +6,7 @@ namespace App\Services\Billing;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\User;
use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Support\Str;
@@ -41,7 +42,25 @@ final class OnlineTopupService
'created_at' => now(),
]);
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, null);
// Чек 54-ФЗ обязателен на стороне магазина ЮKassa (фискализация включена) —
// без секции receipt платёж отклоняется 400 "Receipt is missing". Формируем
// всегда. vat_code=1 = «без НДС» (ИП на УСН; проверено живым запросом 26.06.2026).
$email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null;
$email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru');
$receipt = [
'customer' => ['email' => $email],
'items' => [[
'description' => 'Пополнение баланса Лидерра',
'quantity' => '1.00',
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
'vat_code' => 1,
'payment_mode' => 'full_prepayment',
'payment_subject' => 'service',
]],
];
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, $receipt);
$tx->gateway_payment_id = $result->gatewayPaymentId;
$tx->save();
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/** Итог обработки вопроса. escalate=true → после текста зовём живого оператора. @param list<int> $matchedChunkIds */
class BotAnswer
{
public function __construct(
public readonly string $text,
public readonly bool $escalate,
public readonly array $matchedChunkIds = [],
) {}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/**
* Мозг ответа (спека §§45,7): стоп-темы эскалация до LLM; пустой поиск
* честное «не знаю»; иначе YandexGPT строго по найденным фрагментам инструкции.
* Tour-ссылка «Показать на портале» из frontmatter самой релевантной статьи,
* только под флагом tours_enabled (включается этапом 3).
*/
class BotAnswerService
{
/** Личные данные, деньги конкретного клиента, скидки, юр-темы, просьба человека. */
private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|счет|счёт|деньг|проект|заявк|сделк)|у меня (на )?(балансе|счете|счёте)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu';
private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.';
private const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Передаю живому специалисту, он ответит здесь же.';
public function __construct(
private readonly KnowledgeSearch $search,
private readonly YandexGptClient $gpt,
) {}
public function answer(string $question): BotAnswer
{
if (preg_match(self::STOP_PATTERN, $question) === 1) {
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
}
$chunks = $this->search->search($question, 3);
if ($chunks === []) {
return new BotAnswer(self::UNKNOWN_TEXT, escalate: true);
}
$context = implode("\n\n---\n\n", array_map(
fn ($c) => "### {$c->title}\n{$c->content}",
$chunks
));
$system = <<<PROMPT
Ты консультант техподдержки портала Лидерра (лиды для бизнеса). Отвечай кратко
(25 предложений), простым русским языком, дружелюбно и на «вы».
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
если ответа в них нет скажи честно «в инструкции этого нет». Ничего не выдумывай.
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
о данных конкретного клиента (баланс, его проекты) предложи позвать специалиста.
Фрагменты инструкции:
{$context}
PROMPT;
$text = $this->gpt->complete($system, $question);
if ($text === null) {
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
}
$tour = $chunks[0]->tour;
if ($tour !== null && (bool) config('services.jivo_bot.tours_enabled')) {
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
}
return new BotAnswer($text, escalate: false, matchedChunkIds: array_map(fn ($c) => (int) $c->id, $chunks));
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Исходящие события в Jivo Bot API. outbound_url выдаёт Jivo письмом при
* подключении бота (протокол, О-2); пустой URL = dev/CI, событие только в лог.
* Формат событий Jivo Bot API: BOT_MESSAGE (текст клиенту), INVITE_AGENT
* (позвать живого оператора).
*/
class JivoBotClient
{
public function sendMessage(string $chatId, string $clientId, string $text): void
{
$this->post([
'event' => 'BOT_MESSAGE',
'id' => (string) Str::uuid(),
'chat_id' => $chatId,
'client_id' => $clientId,
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => now()->getTimestamp()],
]);
}
public function inviteAgent(string $chatId, string $clientId): void
{
$this->post([
'event' => 'INVITE_AGENT',
'id' => (string) Str::uuid(),
'chat_id' => $chatId,
'client_id' => $clientId,
]);
}
/** @param array<string, mixed> $payload */
private function post(array $payload): void
{
$url = (string) config('services.jivo_bot.outbound_url');
if ($url === '') {
Log::info('JivoBot outbound skipped (no outbound_url)', ['event' => $payload['event']]);
return;
}
try {
Http::timeout(5)->post($url, $payload)->throw();
} catch (\Throwable $e) {
Log::warning('JivoBot outbound failure', ['event' => $payload['event'], 'error' => $e->getMessage()]);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use App\Models\KnowledgeChunk;
use Illuminate\Support\Collection;
/**
* Поиск по базе знаний бота: PostgreSQL FTS (russian) по generated-колонке
* search_tsv (title+topics+content), ранжирование ts_rank. websearch_to_tsquery
* терпим к пользовательскому вводу (спецсимволы не ломают запрос).
* Интерфейс намеренно узкий замена на pgvector позже не тронет вызывающих.
*
* @return list<KnowledgeChunk>
*/
class KnowledgeSearch
{
public function search(string $question, int $limit = 3): array
{
$question = trim($question);
if ($question === '') {
return [];
}
/** @var Collection<int, KnowledgeChunk> $hits */
$hits = KnowledgeChunk::query()
->selectRaw(
"knowledge_chunks.*, ts_rank(search_tsv, websearch_to_tsquery('russian', ?)) AS rank",
[$question]
)
->whereRaw("search_tsv @@ websearch_to_tsquery('russian', ?)", [$question])
->orderByDesc('rank')
->limit($limit)
->get();
return $hits->all();
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* YandexGPT Lite (Yandex Cloud Foundation Models) мозг бота (протокол, решение 8).
* Возвращает null при любой беде (нет ключа, таймаут, 5xx) решение об эскалации
* принимает вызывающий. Таймаут 8 сек бюджет скорости из спеки §6.
*/
class YandexGptClient
{
public function complete(string $systemPrompt, string $userText): ?string
{
$cfg = (array) config('services.yandexgpt');
if (($cfg['api_key'] ?? '') === '' || ($cfg['folder_id'] ?? '') === '') {
return null;
}
try {
$response = Http::timeout((int) ($cfg['timeout_seconds'] ?? 8))
->withHeaders(['Authorization' => 'Api-Key '.$cfg['api_key']])
->post((string) $cfg['endpoint'], [
'modelUri' => sprintf('gpt://%s/%s', $cfg['folder_id'], $cfg['model']),
'completionOptions' => ['stream' => false, 'temperature' => 0.2, 'maxTokens' => 500],
'messages' => [
['role' => 'system', 'text' => $systemPrompt],
['role' => 'user', 'text' => $userText],
],
]);
if (! $response->ok()) {
Log::warning('YandexGPT non-OK', ['status' => $response->status()]);
return null;
}
$text = $response->json('result.alternatives.0.message.text');
return is_string($text) && $text !== '' ? $text : null;
} catch (\Throwable $e) {
Log::warning('YandexGPT failure', ['error' => $e->getMessage()]);
return null;
}
}
}
@@ -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,
],
];
}
}
+3 -8
View File
@@ -619,14 +619,9 @@ class ProjectService
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->count();
if ($current >= $limit) {
throw new HttpResponseException(response()->json([
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
], 403));
}
// Лимита по числу проектов нет — ограничение только по балансу/заказанным
// лидам (балансовый префлайт в ProjectController::store). Прежний гейт
// tenants.limits['max_projects'] убран как противоречащий правилу продукта.
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$data['regions'] = $data['regions'] ?? [];
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\Help;
/** Разобранная статья инструкции. @param list<string> $chunks */
class HelpArticle
{
public function __construct(
public readonly string $sourcePath,
public readonly string $title,
public readonly ?string $tour,
public readonly string $topics,
public readonly array $chunks,
) {}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\Help;
use InvalidArgumentException;
/**
* Разбор статьи клиентской инструкции (resources/help/*.md):
* frontmatter (title/tour/topics, простые key: value между «---») + тело,
* порезанное на чанки ~1200 симв. по границам абзацев (для FTS-поиска).
* Без YAML-зависимости формат статей намеренно плоский.
*/
class HelpArticleParser
{
private const CHUNK_TARGET_CHARS = 1200;
public function parse(string $sourcePath, string $markdown): HelpArticle
{
if (! preg_match('/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/su', trim($markdown), $m)) {
throw new InvalidArgumentException("Статья {$sourcePath}: нет frontmatter (--- title/topics ---).");
}
$meta = [];
foreach (preg_split('/\r?\n/', $m[1]) as $line) {
if (preg_match('/^(\w+):\s*(.*)$/u', trim($line), $kv)) {
$meta[$kv[1]] = trim($kv[2]);
}
}
if (($meta['title'] ?? '') === '') {
throw new InvalidArgumentException("Статья {$sourcePath}: пустой title во frontmatter.");
}
$paragraphs = array_values(array_filter(
array_map('trim', preg_split('/\r?\n\r?\n+/', trim($m[2]))),
fn (string $p) => $p !== ''
));
$chunks = [];
$current = '';
foreach ($paragraphs as $p) {
if ($current !== '' && mb_strlen($current) + mb_strlen($p) > self::CHUNK_TARGET_CHARS) {
$chunks[] = $current;
$current = $p;
} else {
$current = $current === '' ? $p : $current."\n\n".$p;
}
}
if ($current !== '') {
$chunks[] = $current;
}
return new HelpArticle(
sourcePath: $sourcePath,
title: $meta['title'],
tour: ($meta['tour'] ?? '') !== '' ? $meta['tour'] : null,
topics: $meta['topics'] ?? '',
chunks: $chunks,
);
}
}
+2
View File
@@ -4,6 +4,7 @@ use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SetTenantContext;
use App\Http\Middleware\UseAdminConnection;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
@@ -27,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
'admin-db' => UseAdminConnection::class,
'apikey' => ApiKeyAuth::class,
]);
+20
View File
@@ -20,6 +20,10 @@ $pgsqlConnection = [
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
// Managed PG (Путь А, 26.06.2026): CA-файл для sslmode=verify-full. Если DB_SSLROOTCERT
// не задан (dev/локально) — env() вернёт null, Laravel-коннектор ключ пропустит (isset=false),
// поведение не меняется. На проде: DB_SSLMODE=verify-full + DB_SSLROOTCERT=<путь к CA>.
'sslrootcert' => env('DB_SSLROOTCERT'),
// PG session timezone = UTC. Без этого TIMESTAMPTZ возвращается с локальным offset
// (+03), а Carbon::parse теряет offset → password reset token expiry-check
// и аналогичные TZ-чувствительные сравнения ломаются.
@@ -140,6 +144,22 @@ return [
]
),
// Путь А (27.06.2026): dedicated PG connection для SaaS-admin зоны под
// ролью crm_admin_user (политика srv_bypass = видит все тенанты + GRANT на
// админ-таблицы). Используется через middleware UseAdminConnection (alias
// admin-db) на группе saas-admin: AdminTenantsController / AdminBillingController
// ходят под default → получают cross-tenant доступ. На dev fallback на
// DB_USERNAME/DB_PASSWORD (postgres superuser). На prod ОБЯЗАТЕЛЬНО задать
// DB_ADMIN_USERNAME=crm_admin_user + DB_ADMIN_PASSWORD.
// См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
'pgsql_admin' => array_merge(
$pgsqlConnection,
[
'username' => env('DB_ADMIN_USERNAME', env('DB_USERNAME', 'root')),
'password' => env('DB_ADMIN_PASSWORD', env('DB_PASSWORD', '')),
]
),
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
+19
View File
@@ -75,6 +75,25 @@ return [
'widget_id' => env('JIVO_WIDGET_ID'),
],
// ИИ-бот техподдержки в чате Jivo (спека 2026-07-02-jivo-ai-support-bot-design).
// webhook_secret — входящий секрет в URL (≥32 симв., по образцу supplier webhook).
// outbound_url/token — выдаёт Jivo письмом при подключении Bot API; пусто → отправка
// событий отключена (dev/CI), бот пишет только в журнал.
'jivo_bot' => [
'webhook_secret' => env('JIVO_BOT_WEBHOOK_SECRET', ''),
'outbound_url' => env('JIVO_BOT_OUTBOUND_URL', ''),
'token' => env('JIVO_BOT_TOKEN', ''),
'tours_enabled' => env('JIVO_BOT_TOURS_ENABLED', false),
],
// YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (решение 8 протокола).
'yandexgpt' => [
'api_key' => env('YANDEX_GPT_API_KEY', ''),
'folder_id' => env('YANDEX_GPT_FOLDER_ID', ''),
'model' => env('YANDEX_GPT_MODEL', 'yandexgpt-lite/latest'),
'endpoint' => env('YANDEX_GPT_ENDPOINT', 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion'),
'timeout_seconds' => 8,
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
@@ -9,8 +9,10 @@ use Illuminate\Support\Facades\Schema;
/**
* Plan 5 Task 3: добавить limits JSONB в tenants.
*
* Используется ProjectService::create() для проверки лимита max_projects.
* Default '{}' (int)($tenant->limits['max_projects'] ?? 10) = 10 из сервиса.
* NB (2026-06-27): ключ max_projects и гейт по числу проектов убраны
* лимита по количеству проектов нет (ограничение только по балансу/лидам).
* Колонка limits оставлена как резерв тарифных ограничений (max_users / api_rps
* пока не используются). Default '{}'.
*/
return new class extends Migration
{
@@ -20,8 +22,8 @@ return new class extends Migration
return;
}
Schema::table('tenants', function (Blueprint $table) {
// limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
// Аналог limits в tariff_plans — per-tenant override лимитов тарифа.
// limits JSONB — резерв per-tenant override тарифных ограничений
// (max_users / api_rps зарезервированы; max_projects убран 2026-06-27).
$table->jsonb('limits')->default('{}')->after('api_key_limit');
});
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Путь А (Managed PG): пересчёт hash-цепочки аудита без session_replication_role
* (superuser-only, недоступен в управляемой базе Яндекса).
*
* audit_block_mutation() теперь пропускает мутацию при метке app.audit_rebuild='on'
* И (superuser для dev/test postgres) ИЛИ (членство в crm_migrator покрывает
* crm_supplier_worker, под которым AuditRebuildChain идёт на проде через pgsql_supplier).
* Проверка членства защищена EXISTS-гардом, чтобы не падать на dev, где роли crm_* нет.
*
* Поведение append-only сохранено: без метки любой UPDATE/DELETE аудита запрещён.
* См. docs/superpowers/findings/2026-06-26-db-migration/etap1-sandbox-results.md (шов C).
*/
return new class extends Migration
{
public function up(): void
{
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION public.audit_block_mutation()
RETURNS trigger LANGUAGE plpgsql AS $function$
BEGIN
IF current_setting('app.audit_rebuild', true) = 'on' THEN
-- dev/test: postgres superuser
IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
RETURN COALESCE(NEW, OLD);
END IF;
-- managed: член crm_migrator (в т.ч. crm_supplier_worker)
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
IF pg_has_role(current_user, 'crm_migrator', 'MEMBER') THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
END IF;
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END;
$function$;
SQL);
}
public function down(): void
{
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION public.audit_block_mutation()
RETURNS trigger LANGUAGE plpgsql AS $function$
BEGIN
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END;
$function$;
SQL);
}
};
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* База знаний ИИ-бота (спека 2026-07-02-jivo-ai-support-bot-design §3).
* Глобальная таблица (НЕ tenant-scoped): только публичные статьи инструкции,
* данных клиентов здесь нет по определению RLS не требуется.
* search_tsv generated column (russian) + GIN: поиск за миллисекунды.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id BIGSERIAL PRIMARY KEY,
source_path VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
tour VARCHAR(100),
topics TEXT NOT NULL DEFAULT '',
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
search_tsv tsvector GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(topics, '') || ' ' || coalesce(content, ''))
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_knowledge_chunks_source_chunk UNIQUE (source_path, chunk_index)
)
SQL);
DB::statement('CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_search ON knowledge_chunks USING GIN (search_tsv)');
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS knowledge_chunks');
}
};
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Журнал диалогов ИИ-бота (спека §5). Глобальная (НЕ tenant-scoped) в v1:
* диалоги Jivo анонимны до этапа личных ответов; ПДн клиентов не пишем.
* direction: in = сообщение клиента, out = ответ бота.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS bot_dialogs (
id BIGSERIAL PRIMARY KEY,
jivo_chat_id VARCHAR(64) NOT NULL,
direction VARCHAR(3) NOT NULL CHECK (direction IN ('in', 'out')),
message TEXT NOT NULL,
matched_chunks JSONB,
latency_ms INTEGER,
escalated BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
SQL);
DB::statement('CREATE INDEX IF NOT EXISTS idx_bot_dialogs_chat ON bot_dialogs (jivo_chat_id, created_at)');
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS bot_dialogs');
}
};
+1
View File
@@ -1,4 +1,5 @@
deptrac:
skip_violations:
App\Http\Resources\ProjectResource:
- App\Services\Project\ProjectRuleMessages
- App\Services\Project\SupplierSnapshotGuard
+7 -5
View File
@@ -1,9 +1,11 @@
imports:
# Принятые текущие нарушения (см. комментарий ruleset ниже). Сейчас один:
# ProjectResource → SupplierSnapshotGuard — read-only расчёт состояния замка
# источника для отображения в UI; перенос в контроллер усложнил бы коллекции
# без выигрыша. Гейт ловит только НОВЫЙ дрейф. Регенерация: deptrac analyse
# --formatter=baseline --output=deptrac.baseline.yaml.
# Принятые текущие нарушения (см. комментарий ruleset ниже). Сейчас два,
# оба ProjectResource → Service, оба read-only UI-вычисления (ADR-005):
# - SupplierSnapshotGuard — расчёт состояния замка источника для UI;
# - ProjectRuleMessages — единый текст правил сбора (Эпик 6, баннеры);
# перенос в контроллер усложнил бы коллекции без выигрыша. Гейт ловит только
# НОВЫЙ дрейф. Регенерация: deptrac analyse --formatter=baseline
# --output=deptrac.baseline.yaml.
- deptrac.baseline.yaml
deptrac:
+245 -77
View File
@@ -6,6 +6,12 @@ parameters:
count: 1
path: app/Console/Commands/PhoneRangesImportCommand.php
-
message: '#^Strict comparison using \=\=\= between int and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Http/Controllers/Api/AdminPaymentGatewayController.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$tariff_name\.$#'
identifier: property.notFound
@@ -114,6 +120,12 @@ parameters:
count: 2
path: app/Mail/NewLeadNotification.php
-
message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Models/PaymentGateway.php
-
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
@@ -210,6 +222,12 @@ parameters:
count: 1
path: routes/console.php
-
message: '#^Trait Tests\\Concerns\\SharesAdminPdo is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: tests/Concerns/SharesAdminPdo.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -270,6 +288,36 @@ parameters:
count: 2
path: tests/Feature/Account/UserSessionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/AdminDashboardFinanceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/AdminDashboardHealthTest.php
-
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
@@ -468,6 +516,24 @@ parameters:
count: 4
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -576,6 +642,12 @@ parameters:
count: 15
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Api/ProjectResourceBalanceBlockedTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -663,7 +735,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 13
count: 15
path: tests/Feature/Auth/AuthFlowIntegrationTest.php
-
@@ -675,7 +747,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 21
count: 22
path: tests/Feature/Auth/AuthLogCoverageTest.php
-
@@ -708,6 +780,18 @@ parameters:
count: 6
path: tests/Feature/Auth/IpLockoutTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Auth/LoginUnconfirmedEmailTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Auth/LoginUnconfirmedEmailTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -738,6 +822,12 @@ parameters:
count: 9
path: tests/Feature/Auth/NotificationPreferencesTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Auth/PasswordResetUrlTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -888,6 +978,18 @@ parameters:
count: 11
path: tests/Feature/Auth/TwoFactorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -918,6 +1020,12 @@ parameters:
count: 6
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/AdminPaymentGatewayTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -951,7 +1059,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
count: 21
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
@@ -996,6 +1104,72 @@ parameters:
count: 1
path: tests/Feature/Billing/LedgerServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/OnlineTopupServiceTest.php
-
message: '#^Parameter \#1 \$driver of class App\\Services\\Billing\\OnlineTopupService constructor expects App\\Services\\Billing\\Gateway\\PaymentGatewayDriver, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 1
path: tests/Feature/Billing/OnlineTopupServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$gw\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Billing/PaymentWebhookTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 14
path: tests/Feature/Billing/PaymentWebhookTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/Billing/PaymentWebhookTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 8
path: tests/Feature/Billing/PaymentWebhookTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/PreflightUsesCurrentTariffVersionTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$repo\.$#'
identifier: property.notFound
@@ -1005,7 +1179,19 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 4
count: 6
path: tests/Feature/Billing/ProjectBlockedSyncGuardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Billing/ProjectBulkLimitPreflightTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Billing/ProjectPreflightTest.php
-
@@ -1080,6 +1266,12 @@ parameters:
count: 8
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/TopupFlagForkTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -1167,13 +1359,13 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 10
count: 11
path: tests/Feature/DashboardSummaryTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
count: 2
path: tests/Feature/DashboardSummaryTest.php
-
@@ -1299,7 +1491,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 38
count: 40
path: tests/Feature/DealIndexTest.php
-
@@ -1311,7 +1503,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 41
count: 45
path: tests/Feature/DealIndexTest.php
-
@@ -1329,7 +1521,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 29
count: 31
path: tests/Feature/DealIndexTest.php
-
@@ -1737,7 +1929,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 17
count: 16
path: tests/Feature/ImpersonationTest.php
-
@@ -1848,18 +2040,6 @@ parameters:
count: 1
path: tests/Feature/Integration/SupplierLeadFlowTest.php
-
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone_operator\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
-
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$region_substituted\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
@@ -1902,6 +2082,12 @@ parameters:
count: 1
path: tests/Feature/LeadRouter/FrozenFilterTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/LeadRouter/LeadFlowChangedDeletedTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
@@ -2217,7 +2403,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
count: 13
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
-
@@ -2244,6 +2430,30 @@ parameters:
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 8
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -2268,6 +2478,12 @@ parameters:
count: 4
path: tests/Feature/Projects/ProjectMutationsAuditTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Public/PublicPricingTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -2622,36 +2838,6 @@ parameters:
count: 1
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/LeadRouter/LeadFlowChangedDeletedTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
@@ -2664,12 +2850,6 @@ parameters:
count: 1
path: tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andReturnNull\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/OnlineDeferWindowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
@@ -2718,6 +2898,12 @@ parameters:
count: 3
path: tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andReturnNull\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/OnlineDeferWindowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -3005,21 +3191,3 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Auth/PasswordResetUrlTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
+16
View File
@@ -0,0 +1,16 @@
---
title: Что такое проект
tour: create-project
topics: создать проект, заявка на лиды, источник, сайт конкурента, лимит заявок, новый проект
---
Проект — это ваша заявка на поток клиентов. Вы указываете источник (например, сайт,
похожий на ваш бизнес) и сколько заявок в день хотите получать — а система начинает
присылать вам заявки с контактами.
Как создать: раздел «Проекты» → кнопка «Создать проект». Понадобится указать название,
источник и дневной лимит заявок. После создания проект начинает работать не сразу —
обычно в течение суток.
Заявки из проекта появляются в разделе «Сделки» — с телефоном, источником и статусом.
Проект можно поставить на паузу или изменить лимит в любой момент.
+12
View File
@@ -0,0 +1,12 @@
---
title: Как пополнить баланс
tour: top-up-balance
topics: пополнить, закинуть деньги, оплата, счёт, платёж, банковская карта, безнал, пополнение баланса
---
Пополнить баланс: раздел «Биллинг» → кнопка «Пополнить». Доступна оплата по счёту
для юридических лиц и ИП: система выставит PDF-счёт, после оплаты деньги зачислятся,
а акт придёт на почту.
Зачисление по счёту происходит после подтверждения оплаты. Баланс и историю всех
операций видно в разделе «Биллинг».
+14
View File
@@ -0,0 +1,14 @@
---
title: Тарифы и списания
tour: tariffs
topics: сколько стоит, цена заявки, цена лида, тариф, списание, деньги, стоимость, оплата за лид
---
Вы платите только за полученные заявки — абонентской платы нет. Деньги списываются
с баланса за каждую доставленную заявку по вашей тарифной ступени.
Тарифная ступень зависит от объёма: чем больше заявок в месяц, тем дешевле каждая.
Актуальные цены — раздел «Биллинг» → «Тарифы».
Если на балансе не хватает денег на очередную заявку, проекты автоматически встают
на паузу — ничего не сгорает, после пополнения работа продолжается.
+127
View File
@@ -0,0 +1,127 @@
import { apiClient } from './client';
/**
* SaaS-admin «Командный центр» — типизированный клиент read-only агрегатов.
*
* Все 3 эндпоинта — GET под группой ['saas-admin','admin-db'] (cross-tenant
* через pgsql_admin). CSRF не нужен (только чтение).
* Backend: AdminDashboardController. Spec:
* docs/superpowers/specs/2026-06-27-admin-command-center-design.md
*/
export type Light = 'green' | 'amber' | 'red';
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;
job_errors_24h: number;
failed_jobs_24h: number;
last_sync_status: string;
last_sync_at: string | null;
};
leads: {
light: Light;
delivered_today: number;
received_today: number;
stuck: number;
unrouted: number;
};
supply: {
light: Light;
demand: number;
formula: number;
ordered: number;
mismatches: number;
total_orders: number;
total_limit: number;
snapshot_date: string | null;
};
}
export interface LeadsDetail {
light: Light;
kpi: {
delivered_today: number;
received_today: number;
stuck: number;
unrouted: number;
};
}
export interface SupplyDetail {
snapshot_date: string | null;
light: Light;
totals: { demand: number; formula: number; ordered: number; mismatches: number };
total_orders: number;
total_limit: number;
groups: Array<{
signal_type: string;
identifier: string;
demand: number;
formula: number;
ordered: number;
in_sync: boolean;
}>;
}
export interface FinanceDetail {
period: string;
kpi: {
topups_rub: string;
charges_rub: string;
net_inflow_rub: string;
negative_balance_count: number;
};
attention: Array<{
id: number;
subdomain: string;
organization_name: string;
balance_rub: string;
state: string;
}>;
top_by_turnover: Array<{
id: number;
organization_name: string;
topped_rub: string;
}>;
}
export interface HealthDetail {
overall_light: Light;
subsystems: Array<{ key: string; light: Light; detail: string }>;
}
export async function getDashboardSummary(period: string): Promise<DashboardSummary> {
const { data } = await apiClient.get<DashboardSummary>('/api/admin/dashboard', { params: { period } });
return data;
}
export async function getDashboardFinance(period: string): Promise<FinanceDetail> {
const { data } = await apiClient.get<FinanceDetail>('/api/admin/dashboard/finance', { params: { period } });
return data;
}
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 -1
View File
@@ -12,7 +12,7 @@ export interface DashboardSummary {
range: string;
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
active_projects: { active: number; limit: number };
active_projects: { active: number };
balance: { amount_rub: string; runway_days: number | null; runway_leads: number };
activity: { points: number[]; labels: string[]; max: number };
funnel: Record<string, number>;
+5
View File
@@ -6,6 +6,7 @@ import '../css/tokens.css';
import '../css/typography.css';
import '../css/motion.css';
import { router } from './router';
import { installMenuRepositionFix } from './utils/menuRepositionFix';
// Точка входа Vue 3 + Vuetify 3 + Vue Router 4 + Pinia (фаза 2, CLAUDE.md §3.3).
// Mount в <div id="app"></div> внутри Blade-шаблона `welcome.blade.php`.
@@ -14,3 +15,7 @@ app.use(createPinia());
app.use(vuetify);
app.use(router);
app.mount('#app');
// Глобальный обход бага позиционирования меню Vuetify (один наблюдатель на всё
// приложение) — подробности в utils/menuRepositionFix.ts.
installMenuRepositionFix();
@@ -9,7 +9,6 @@ import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../../stores/notifications';
import { useCommandPalette } from '../../composables/useCommandPalette';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
defineProps<{
pageTitle: string;
@@ -116,7 +115,6 @@ async function handleLogout(): Promise<void> {
offset="8"
:close-on-content-click="false"
location="bottom end"
@update:model-value="repositionMenuAfterOpen"
>
<template #activator="{ props: bellProps }">
<v-btn
@@ -179,7 +177,7 @@ async function handleLogout(): Promise<void> {
</v-card>
</v-menu>
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
<v-menu offset="8">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
<v-avatar size="28" color="primary" class="mr-2">
@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* GuidedTour обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) меряем с ретраем каждые 300мс до 15с.
* Разметка/стили по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const RETRY_MS = 300;
const RETRY_MAX = 50; // 15 сек
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
let retryTimer: ReturnType<typeof setInterval> | null = null;
const currentStep = computed(() => props.steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
function stopRetry(): void {
if (retryTimer !== null) {
clearInterval(retryTimer);
retryTimer = null;
}
}
function measure(): void {
stopRetry();
targetRect.value = null;
const sel = currentStep.value?.target;
if (!sel) return;
let attempts = 0;
const tryMeasure = (): void => {
const el = document.querySelector(sel);
if (el) {
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
stopRetry();
return;
}
attempts += 1;
if (attempts >= RETRY_MAX) stopRetry();
};
tryMeasure();
if (targetRect.value === null) {
retryTimer = setInterval(tryMeasure, RETRY_MS);
}
}
const highlightStyle = computed(() => {
const r = targetRect.value;
if (!r) return { display: 'none' };
const pad = 6;
return {
top: `${r.top - pad}px`,
left: `${r.left - pad}px`,
width: `${r.width + pad * 2}px`,
height: `${r.height + pad * 2}px`,
};
});
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
measure();
}
function finish(): void {
stopRetry();
emit('finish');
}
function onResize(): void {
if (props.active) measure();
}
watch(
() => props.active,
(on) => {
if (on) {
stepIndex.value = 0;
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
} else {
stopRetry();
window.removeEventListener('resize', onResize);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRetry();
window.removeEventListener('resize', onResize);
});
defineExpose({ stepIndex, targetRect, next, finish });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
<div class="guided-tour__backdrop" />
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
<p class="guided-tour__text">{{ currentStep.text }}</p>
<div class="guided-tour__actions">
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
{{ isLast ? 'Готово' : 'Далее' }}
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.guided-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.guided-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.guided-tour__highlight {
position: absolute;
border: 2px solid var(--liderra-teal, #0f6e56);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.guided-tour__card {
position: absolute;
width: 300px;
max-width: calc(100vw - 24px);
background: #fff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.guided-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.guided-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.guided-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.guided-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -4,7 +4,6 @@ import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
@@ -327,7 +326,6 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
density="comfortable"
hide-details
data-testid="pdd-regions"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -22,7 +22,6 @@
clearable
density="comfortable"
data-testid="region-add-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -48,7 +47,6 @@
clearable
density="comfortable"
data-testid="region-remove-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -78,7 +76,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -0,0 +1,35 @@
/**
* Запуск экскурсии по ссылке из чата бота: /?tour=<имя> (спека ИИ-бота §4).
* Невошедшего роутер сам отправит на /login с redirect=fullPath query
* переживает вход (router/index.ts beforeEach), поэтому отдельной логики
* логина здесь нет. Мусорное имя молча чистим query (не пугаем клиента).
*/
import { ref, type Ref } from 'vue';
import type { Router } from 'vue-router';
import { findTour, type TourScenario } from '../tours/catalog';
interface RouteLike {
query: Record<string, unknown>;
}
export function useTourLauncher(route: Ref<RouteLike>, router: Router) {
const activeTour = ref<TourScenario | null>(null);
async function checkQuery(): Promise<void> {
const name = typeof route.value.query.tour === 'string' ? route.value.query.tour : '';
if (name === '') return;
const tour = findTour(name);
if (tour === null) {
await router.replace({ query: { ...route.value.query, tour: undefined } });
return;
}
activeTour.value = tour;
await router.push({ path: tour.steps[0].route, query: {} });
}
function finishTour(): void {
activeTour.value = null;
}
return { activeTour, checkQuery, finishTour };
}
+21 -2
View File
@@ -9,8 +9,8 @@
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
*/
import { computed, onMounted, ref } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useNotificationsStore } from '../stores/notifications';
import { useTenantStore } from '../stores/tenantStore';
@@ -24,12 +24,17 @@ import JivoWidget from '../components/support/JivoWidget.vue';
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
import ImpersonationSessionBanner from '../components/admin/ImpersonationSessionBanner.vue';
import WelcomeTour from '../components/layout/WelcomeTour.vue';
import GuidedTour from '../components/layout/GuidedTour.vue';
import { useTourLauncher } from '../composables/useTourLauncher';
const auth = useAuthStore();
const notifications = useNotificationsStore();
const tenant = useTenantStore();
const route = useRoute();
const router = useRouter();
const tourLauncher = useTourLauncher(computed(() => route), router);
const drawerOpen = ref(true);
// Тот же навигационный pool что в AppSidebar для crumb-resolution в topbar
@@ -65,7 +70,15 @@ async function loadBalanceStatus(): Promise<void> {
onMounted(() => {
void loadNotifications();
void loadBalanceStatus();
void tourLauncher.checkQuery();
});
watch(
() => route.query.tour,
() => {
void tourLauncher.checkQuery();
},
);
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
</script>
@@ -92,6 +105,12 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<CommandPalette />
<JivoWidget />
<WelcomeTour />
<GuidedTour
v-if="tourLauncher.activeTour.value"
:steps="tourLauncher.activeTour.value.steps"
:active="true"
@finish="tourLauncher.finishTour()"
/>
</v-app>
</template>
+7 -1
View File
@@ -196,7 +196,13 @@ const routes: RouteRecordRaw[] = [
// TODO: дополнительный role-guard на super_admin.
{
path: '/admin',
redirect: '/admin/tenants',
redirect: '/admin/dashboard',
},
{
path: '/admin/dashboard',
name: 'admin-dashboard',
component: () => import('../views/admin/AdminDashboardView.vue'),
meta: { layout: 'admin', title: 'Командный центр', requiresAuth: true, devIndex: 20, devLabel: 'Admin Dashboard' },
},
{
path: '/admin/tenants',
+95
View File
@@ -0,0 +1,95 @@
/**
* Каталог экскурсий «Показать на портале» (спека ИИ-бота §4).
* ИИ шаги НЕ сочиняет только выбирает готовый сценарий по имени
* (frontmatter `tour:` статьи resources/help). Селекторы целей существующие
* data-tour (sidebar: nav-*) и data-testid; target может появиться ПОСЛЕ
* действия клиента (открыл диалог) раннер умеет ждать (см. GuidedTour).
*/
export interface TourStep {
/** Роут, на котором живёт цель шага; раннер переходит туда сам. */
route: string;
/** CSS-селектор цели подсветки. */
target: string;
title: string;
text: string;
}
export interface TourScenario {
name: string;
steps: TourStep[];
}
export const TOURS: TourScenario[] = [
{
name: 'create-project',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Раздел «Проекты»',
text: 'Здесь живут все ваши проекты — заявки на поток клиентов.',
},
{
route: '/projects',
target: '[data-tour="projects-create"]',
title: 'Создать проект',
text: 'Нажмите эту кнопку — откроется форма нового проекта. Понадобятся название, источник и дневной лимит заявок.',
},
],
},
{
name: 'top-up-balance',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Раздел «Биллинг»',
text: 'Баланс, история операций и пополнение — всё здесь.',
},
{
route: '/billing',
target: '[data-tour="billing-topup"]',
title: 'Пополнить баланс',
text: 'Нажмите, чтобы выставить счёт на пополнение. После оплаты деньги зачислятся автоматически.',
},
],
},
{
name: 'tariffs',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Тарифы — в «Биллинге»',
text: 'Вы платите только за полученные заявки. Актуальные цены и ваша тарифная ступень — в этом разделе.',
},
],
},
{
name: 'change-source',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Смена источника — в «Проектах»',
text: 'Откройте нужный проект — в его настройках можно сменить источник без потери заявок.',
},
],
},
{
name: 'notifications',
steps: [
{
route: '/settings',
target: '[data-tour="nav-settings"]',
title: 'Уведомления — в «Настройках»',
text: 'Здесь включаются письма о новых заявках и другие уведомления.',
},
],
},
];
export function findTour(name: string): TourScenario | null {
if (name === '') return null;
return TOURS.find((t) => t.name === name) ?? null;
}
+48 -2
View File
@@ -21,8 +21,10 @@
*
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
*/
export function repositionMenuAfterOpen(open: boolean): void {
if (!open || typeof window === 'undefined') return;
// Ядро: дождаться, пока геометрия последнего открытого меню устаканится, и один
// раз послать resize — Vuetify пересчитает позицию по уже стабильной геометрии.
function scheduleStabilize(): void {
if (typeof window === 'undefined') return;
let prevLeft = Number.NaN;
let stableFrames = 0;
@@ -50,3 +52,47 @@ export function repositionMenuAfterOpen(open: boolean): void {
requestAnimationFrame(tick);
}
let installed = false;
/**
* Глобально включает обход бага позиционирования меню Vuetify: один
* `MutationObserver` ловит появление любого `.v-overlay.v-menu` в DOM и
* запускает стабилизацию позиции. Вешать один раз при запуске приложения
* покрывает все `v-select`/`v-autocomplete`/`v-menu`, текущие и будущие, без
* ручной разметки в шаблонах.
*
* Идемпотентна (повторный вызов noop). SSR-safe. Возвращает teardown
* (отключить наблюдатель нужно тестам и на случай явной остановки).
*/
export function installMenuRepositionFix(): () => void {
const noop = (): void => {};
if (installed) return noop;
if (
typeof window === 'undefined' ||
typeof document === 'undefined' ||
typeof MutationObserver === 'undefined' ||
!document.body
) {
return noop;
}
installed = true;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches('.v-overlay.v-menu') || node.querySelector('.v-overlay.v-menu')) {
scheduleStabilize();
return; // одного запуска на пачку мутаций достаточно
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
installed = false;
};
}
+1 -1
View File
@@ -85,7 +85,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
>
</div>
</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" data-tour="billing-topup" @click="topupOpen = true"
>Пополнить баланс</v-btn
>
</header>
+4 -4
View File
@@ -109,11 +109,11 @@ function applySummary(s: DashboardSummary): void {
{
label: 'Активные проекты',
value: String(s.active_projects.active),
// «/ N» и подпись «лимит тарифа» только если лимит реально задан (>0),
// иначе «3 / 0» выглядит сломанным (UI-аудит).
unit: s.active_projects.limit > 0 ? `/ ${s.active_projects.limit}` : '',
// Лимита по числу проектов нет (ограничение только по балансу/лидам)
// показываем просто количество активных, без «/ N лимит тарифа».
unit: '',
delta: { dir: 'neutral', text: '' },
sub: s.active_projects.limit > 0 ? 'лимит тарифа' : '',
sub: '',
hint: 'Проекты, которые сейчас собирают заявки.',
},
];
+1 -1
View File
@@ -2,7 +2,7 @@
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
</div>
<v-alert
@@ -0,0 +1,660 @@
<script setup lang="ts">
/**
* Админка Командный центр (дашборд). Landing SaaS-админки: 4 плитки-светофора
* с проваливанием в детали (Уровень 2). Все 4 области Финансы, Здоровье, Лиды,
* Заказ у поставщика наполнены живыми данными (Этапы 1 + 2).
*
* Источник дизайна: web/admin-dashboard-mockup.html (Forest-палитра).
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
* Backend: AdminDashboardController (группа ['saas-admin','admin-db']).
*
* Клик по строке «внимание»/«топ» /admin/tenants/{subdomain} (Уровень 3).
*/
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
getDashboardSummary,
getDashboardFinance,
getDashboardHealth,
getDashboardLeads,
getDashboardSupply,
type DashboardSummary,
type FinanceDetail,
type HealthDetail,
type LeadsDetail,
type SupplyDetail,
type Light,
} from '../../api/adminDashboard';
const router = useRouter();
type Area = 'fin' | 'health' | 'leads' | 'supply';
type Period = 'today' | '7d' | '30d' | '60d' | '90d';
const period = ref<Period>('7d');
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);
const PERIODS: Array<{ value: Period; label: string }> = [
{ value: 'today', label: 'Сегодня' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
{ value: '60d', label: '60 дней' },
{ value: '90d', label: '90 дней' },
];
/** Светофор-цвет → Vuetify-цвет. */
function lightColor(light: Light): string {
return light === 'green' ? 'success' : light === 'amber' ? 'warning' : 'error';
}
/** Подпись светофора Финансов на плитке. */
function financeLightLabel(): string {
const n = summary.value?.finance.negative_balance_count ?? 0;
return n > 0 ? `${n} в минусе` : 'в норме';
}
/** Подпись светофора Здоровья на плитке. */
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: 'Очереди / джобы',
scheduler: 'Планировщик',
supplier_sync: 'Синхрон с поставщиком',
csv_drift: 'Сверка CSV (дрейф)',
webhooks: 'Вебхуки',
incidents: 'Инциденты',
};
function subsystemLabel(key: string): string {
return SUBSYSTEM_LABELS[key] ?? key;
}
/** «320000» → «320 000 ₽» (узкие неразрывные пробелы по разрядам). */
function rub(value: string | number | null | undefined): string {
const n = Math.round(Number(value ?? 0));
const sign = n < 0 ? '' : '';
const digits = Math.abs(n)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${sign}${digits}`;
}
async function load() {
loading.value = true;
fetchError.value = false;
try {
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 {
loading.value = false;
}
}
function setPeriod(p: Period) {
period.value = p;
void load();
}
function selectArea(area: Area) {
selected.value = area;
}
function openTenant(subdomain: string) {
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
}
onMounted(load);
defineExpose({ period, selected, summary, finance, health, leads, supply, loading, fetchError, load });
</script>
<template>
<v-container fluid class="admin-dashboard pa-6">
<!-- Шапка: заголовок + период -->
<div class="d-flex align-center justify-space-between mb-1 flex-wrap ga-3">
<h1 class="text-h5 font-weight-bold">Командный центр</h1>
<div class="period-toggle">
<v-btn
v-for="p in PERIODS"
:key="p.value"
:variant="period === p.value ? 'flat' : 'text'"
:color="period === p.value ? 'primary' : undefined"
size="small"
class="text-none"
:data-testid="`period-${p.value}`"
@click="setPeriod(p.value)"
>
{{ p.label }}
</v-btn>
</div>
</div>
<p class="text-body-2 text-medium-emphasis mb-4">
Одна картина всего портала. Кликните плитку провалитесь в детали.
</p>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
Не удалось загрузить данные дашборда. Попробуйте обновить.
</v-alert>
<!-- Плитки L1 -->
<v-row dense>
<!-- ФИНАНСЫ -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'fin' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'fin' }"
class="tile"
data-testid="tile-fin"
@click="selectArea('fin')"
>
<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?.finance.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ financeLightLabel() }}
</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">{{ rub(summary?.finance.topups_rub) }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Списано за лиды</span>
<span class="num font-weight-bold">{{ rub(summary?.finance.charges_rub) }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Активных клиентов</span>
<span class="num font-weight-bold">{{ summary?.finance.active_clients ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Новых за период</span>
<span class="num font-weight-bold text-success">+{{ summary?.finance.new_clients ?? 0 }}</span>
</div>
<div class="tile__more">Открыть финансы </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЗДОРОВЬЕ -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'health' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'health' }"
class="tile"
data-testid="tile-health"
@click="selectArea('health')"
>
<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?.health.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ healthLightLabel() }}
</v-chip>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Ошибок джоб за сутки</span>
<span
class="num font-weight-bold"
:class="{ 'text-error': (summary?.health.job_errors_24h ?? 0) > 0 }"
>{{ summary?.health.job_errors_24h ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Синхрон с поставщиком</span>
<span class="font-weight-bold">{{ summary?.health.last_sync_status ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Открытых инцидентов</span>
<span class="num font-weight-bold">{{ summary?.health.open_incidents ?? '—' }}</span>
</div>
<div class="tile__more">Открыть здоровье </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЛИДЫ (Этап 2) -->
<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.received_today ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Зависших</span>
<span
class="num font-weight-bold"
:class="{ 'text-error': (summary?.leads.stuck ?? 0) > 0 }"
>{{ summary?.leads.stuck ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Нераспределённых</span>
<span class="num font-weight-bold">{{ summary?.leads.unrouted ?? '—' }}</span>
</div>
<div class="tile__more">Открыть лиды </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЗАКАЗ У ПОСТАВЩИКА (Этап 2) -->
<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>
</v-row>
<!-- DRILL: ФИНАНСЫ -->
<v-card v-if="selected === 'fin'" variant="outlined" class="drill mt-5" data-testid="drill-fin">
<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">{{ rub(finance?.kpi.topups_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Списано за лиды</div>
<div class="kpi__val num">{{ rub(finance?.kpi.charges_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Чистый приток</div>
<div class="kpi__val num text-success">{{ rub(finance?.kpi.net_inflow_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Клиентов в минусе</div>
<div class="kpi__val num text-error">{{ finance?.kpi.negative_balance_count ?? 0 }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<h4 class="panel__h4">🔴 Требуют внимания (баланс в минусе)</h4>
<v-table density="compact">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right">Баланс</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in finance?.attention ?? []"
:key="t.id"
class="clk"
@click="openTenant(t.subdomain)"
>
<td>{{ t.organization_name }}</td>
<td class="text-right num text-error">{{ rub(t.balance_rub) }}</td>
</tr>
<tr v-if="(finance?.attention?.length ?? 0) === 0">
<td colspan="2" class="text-medium-emphasis text-center">Никто не в минусе 🎉</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<h4 class="panel__h4">Топ по обороту (пополнения за период)</h4>
<v-table density="compact">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right">Пополнил</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in finance?.top_by_turnover ?? []"
:key="t.id"
class="clk"
@click="openTenant(String(t.id))"
>
<td>{{ t.organization_name }}</td>
<td class="text-right num">{{ rub(t.topped_rub) }}</td>
</tr>
<tr v-if="(finance?.top_by_turnover?.length ?? 0) === 0">
<td colspan="2" class="text-medium-emphasis text-center">Нет пополнений за период</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- DRILL: ЗДОРОВЬЕ -->
<v-card v-else-if="selected === 'health'" variant="outlined" class="drill mt-5" data-testid="drill-health">
<v-card-title class="drill__head"> Здоровье портала детали</v-card-title>
<v-card-text>
<v-row dense>
<v-col v-for="s in health?.subsystems ?? []" :key="s.key" cols="12" sm="6" md="4">
<div class="sub">
<div class="sub__nm">
<v-icon :color="lightColor(s.light)" size="12" icon="mdi-circle" class="mr-2" />
{{ subsystemLabel(s.key) }}
</div>
<div class="sub__meta">{{ s.detail }}</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 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-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">{{ leads?.kpi.received_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-row>
<p class="text-medium-emphasis text-body-2 mt-2">
«Доставлено» сделки, созданные клиентам сегодня. «Получено» лиды, пришедшие от поставщика
сегодня. «Зависшие» лиды без распределения дольше 4 часов (если их много проблема синхронизации).
</p>
</v-card-text>
</v-card>
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
<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-alert variant="tonal" density="compact" class="mb-4" type="info">
Всего у поставщика активных заказов: <b>{{ supply?.total_orders ?? 0 }}</b>
на <b>{{ supply?.total_limit ?? 0 }}</b> лидов/день.
Сверка ниже по снимку маршрутизации на <b>{{ supply?.snapshot_date ?? '—' }}</b>
(снимок делается каждый день в 18:02 МСК; в нём только проекты, активные на тот день).
</v-alert>
<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>
</template>
<style scoped>
.admin-dashboard {
max-width: 1200px;
}
.period-toggle {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.03);
border-radius: 9px;
padding: 3px;
}
.tile {
height: 100%;
cursor: pointer;
transition: transform 0.15s ease;
}
.tile:hover {
transform: translateY(-2px);
}
.tile--sel {
border-color: rgb(var(--v-theme-primary));
}
.tile__ico {
font-size: 18px;
}
.tile__more {
margin-top: 14px;
font-size: 12px;
font-weight: 600;
color: rgb(var(--v-theme-primary));
}
.num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.drill__head {
font-size: 16px;
font-weight: 700;
}
.kpi {
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
padding: 14px;
}
.kpi__lab {
font-size: 12px;
opacity: 0.7;
}
.kpi__val {
font-size: 20px;
font-weight: 800;
margin-top: 4px;
}
.panel__h4 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
margin-bottom: 12px;
}
.sub {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 14px;
height: 100%;
}
.sub__nm {
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
}
.sub__meta {
font-size: 12px;
opacity: 0.7;
margin-top: 6px;
}
.clk:hover {
background: rgba(15, 110, 86, 0.06);
cursor: pointer;
}
</style>
@@ -59,6 +59,55 @@ async function setExportMode(mode: ExportMode): Promise<void> {
}
}
// --- Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot) ---
const sourceEditEnabled = ref(false);
const sourceEditError = ref<string | null>(null);
const sourceEditSaving = ref(false);
const sourceEditConfirmOpen = ref(false);
const pendingSourceEditValue = ref(false);
// VSwitch флипает внутреннее состояние по клику; бамп ключа ре-маунтит тумблер,
// чтобы он вернулся к фактическому sourceEditEnabled после отмены/ошибки.
const sourceEditSwitchKey = ref(0);
async function loadSourceEditFlag(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/source-edit-flag');
sourceEditEnabled.value = data?.enabled === true;
} catch {
sourceEditError.value = 'Не удалось загрузить переключатель.';
}
}
// Тумблер привязан к sourceEditEnabled один-в-один; запрос смены открывает
// подтверждение, фактическое значение меняется только после «Подтвердить».
function onSourceEditToggleRequest(val: boolean | null): void {
pendingSourceEditValue.value = val === true;
sourceEditConfirmOpen.value = true;
}
function cancelSourceEditToggle(): void {
sourceEditConfirmOpen.value = false;
sourceEditSwitchKey.value++; // вернуть тумблер к фактическому состоянию
}
async function confirmSourceEditToggle(): Promise<void> {
sourceEditConfirmOpen.value = false;
sourceEditSaving.value = true;
sourceEditError.value = null;
try {
const { data } = await axios.post('/api/admin/supplier-integration/source-edit-flag', {
enabled: pendingSourceEditValue.value,
});
sourceEditEnabled.value = data?.enabled === true;
} catch {
sourceEditError.value = 'Не удалось сохранить переключатель.';
} finally {
sourceEditSaving.value = false;
sourceEditSwitchKey.value++; // синхронизировать тумблер с фактом (вкл. при ошибке)
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = null;
@@ -196,6 +245,7 @@ onMounted(() => {
void load();
void loadManualQueue();
void loadExportMode();
void loadSourceEditFlag();
void loadSyncRuns();
});
</script>
@@ -233,6 +283,63 @@ onMounted(() => {
</v-card-text>
</v-card>
<v-card class="mb-4" data-testid="source-edit-flag-card">
<v-card-title>Разблокировка смены источника</v-card-title>
<v-card-text>
<v-alert v-if="sourceEditError" type="error" density="compact" class="mb-3">
{{ sourceEditError }}
</v-alert>
<v-switch
:key="sourceEditSwitchKey"
:model-value="sourceEditEnabled"
:loading="sourceEditSaving"
:disabled="sourceEditSaving"
color="primary"
hide-details
inset
data-testid="source-edit-flag-switch"
:label="sourceEditEnabled ? 'Включена' : 'Выключена'"
@update:model-value="onSourceEditToggleRequest"
/>
<p class="text-caption text-medium-emphasis mt-1 mb-0">
ВКЛ клиенты могут менять источник проекта без потери лидов (маршрутизация по слепку).
ВЫКЛ смена источника заблокирована. Откат безопасен в любой момент.
</p>
</v-card-text>
</v-card>
<v-dialog v-model="sourceEditConfirmOpen" max-width="480" data-testid="source-edit-confirm">
<v-card>
<v-card-title>
{{ pendingSourceEditValue ? 'Включить' : 'Выключить' }} разблокировку смены источника?
</v-card-title>
<v-card-text>
<template v-if="pendingSourceEditValue">
Клиенты смогут менять источник проекта без потери лидов (матч по слепку).
Рекомендуется сутки понаблюдать по «Вечерней заливке», что лиды доезжают.
</template>
<template v-else>
Вернётся прежнее поведение: смена источника заблокирована. Откат безопасен.
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" data-testid="source-edit-confirm-cancel" @click="cancelSourceEditToggle">
Отмена
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="sourceEditSaving"
data-testid="source-edit-confirm-apply"
@click="confirmSourceEditToggle"
>
Подтвердить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-card class="mb-4" data-testid="sync-runs-card">
<v-card-title>Вечерняя заливка проектов поставщику</v-card-title>
<v-card-text>
@@ -83,6 +83,26 @@
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
</v-alert>
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
<span>Как увеличить количество сделок</span>
<v-tooltip
text="Ваш лимит распределяется на нескольких поставщиков равномерно. Даже если лимит не выбирается полностью, просто увеличьте лимит — и сделок придёт больше."
location="top"
max-width="280"
>
<template #activator="{ props: tip }">
<v-icon
v-bind="tip"
size="14"
class="src-hint ml-1"
icon="mdi-help-circle-outline"
aria-label="Как увеличить количество сделок"
tabindex="0"
/>
</template>
</v-tooltip>
</div>
<div class="d-flex align-center mb-1 text-body-2 text-medium-emphasis">
<span>Откуда собирать заявки</span>
<v-tooltip
@@ -211,7 +231,6 @@
data-testid="regions-autocomplete"
:error-messages="errors.regions"
@update:model-value="onRegionsChange"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -316,7 +335,6 @@ import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/clie
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
+11 -1
View File
@@ -4,6 +4,7 @@ use App\Jobs\SendNewLeadsDigestJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\FlushDeferredOnlineSyncJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Services\SchedulerHeartbeatTracker;
@@ -43,7 +44,7 @@ Schedule::command('projects:reset-delivered-today')
// Task 4.2: досыл отложенной онлайн-очереди в 00:05 МСК (после сброса счётчиков в 00:00,
// вне окна 18:00→00:00 — отложенные правки уходят поставщику немедленно).
Schedule::job(new \App\Jobs\Supplier\FlushDeferredOnlineSyncJob)
Schedule::job(new FlushDeferredOnlineSyncJob)
->dailyAt('00:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
@@ -185,3 +186,12 @@ Schedule::command('scheduler:check-heartbeats')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
// База знаний ИИ-бота: ночная переиндексация статей resources/help
// (спека 2026-07-02-jivo-ai-support-bot-design §3). Изменил статью — ночью бот знает.
Schedule::command('help:rebuild-knowledge')
->dailyAt('04:30')
->timezone('Europe/Moscow')
->onOneServer()
->onSuccess(fn () => $hb->recordRunResult('help:rebuild-knowledge', true, null, null))
->onFailure(fn () => $hb->recordRunResult('help:rebuild-knowledge', false, 'Command failed', null));
+22 -1
View File
@@ -104,7 +104,18 @@ Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobCon
// app-слой (REMOTE_USER ∈ ADMIN_ALLOWED_USERS, закрывает обходы фронт-контроллера)
// + запрет входа во время impersonation. Реальный Yandex 360 SSO — TODO под Б-1+DO-4.
// admin_user_id для audit — трейт ResolvesAdminUserId (отдельная зона).
Route::middleware('saas-admin')->group(function () {
// admin-db (UseAdminConnection) — ПОСЛЕ saas-admin: на время admin-запроса
// default-подключение = pgsql_admin (роль crm_admin_user, srv_bypass), чтобы
// AdminTenants/AdminBillingController видели все тенанты после переезда на
// Managed PG (Путь А). Контроллеры на pgsql_supplier не затрагиваются.
Route::middleware(['saas-admin', 'admin-db'])->group(function () {
// Командный центр (дашборд) — read-only агрегаты L1 + L2.
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 () {
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
@@ -188,6 +199,10 @@ Route::middleware('saas-admin')->group(function () {
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
Route::get('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getSourceEditFlag');
Route::post('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setSourceEditFlag');
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
@@ -321,6 +336,12 @@ Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookCo
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// ИИ-бот техподдержки: события Jivo Bot API (CLIENT_MESSAGE и служебные).
// Защита — секрет в URL по образцу supplier-webhook; ack мгновенный, работа в джобе
// (спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §5).
Route::post('/api/webhook/jivo/{secret}', 'App\Http\Controllers\Api\JivoBotController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Concerns;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
/**
* Share PDO между pgsql и pgsql_admin connections в тестах.
*
* Зачем: middleware UseAdminConnection (alias admin-db) на группе saas-admin
* переключает default-подключение на pgsql_admin (роль crm_admin_user). В тестах
* DatabaseTransactions оборачивает каждый connection в свою транзакцию: данные,
* засеянные через default Tenant::factory() ($pgsql), не видны с pgsql_admin
* connection до commit'а admin-эндпоинты в тестах видят 0 строк / 404.
* Sharing PDO означает обе connection используют ту же PDO session одну
* транзакцию, и засеянные данные видны admin-контроллеру.
*
* На production обе connection реальные separate PDO; pgsql_admin (srv_bypass)
* видит все тенанты по READ COMMITTED. Этот trait только для test-окружения.
*
* Зеркало [[SharesSupplierPdo]] для pgsql_admin. Применяется глобально к Feature
* suite (см. tests/Pest.php), т.к. admin-db висит на всей группе saas-admin
* любой admin-тест (текущий и будущий) получает cross-connection visibility без
* per-file opt-in. Для не-admin тестов инертен (pgsql_admin просто не запрашивают).
*/
trait SharesAdminPdo
{
protected function setUpSharesAdminPdo(): void
{
if (! config()->has('database.connections.pgsql_admin')) {
return;
}
$defaultConnection = DB::connection('pgsql');
$adminConnection = DB::connection('pgsql_admin');
$adminConnection->setPdo($defaultConnection->getPdo());
$adminConnection->setReadPdo($defaultConnection->getReadPdo());
// Синхронизируем уровень вложенности транзакции: DatabaseTransactions уже
// открыл транзакцию на pgsql (тот же PDO) к моменту setUp. Без синхронизации
// pgsql_admin считает transactions=0 и при ->transaction() зовёт
// PDO->beginTransaction() на уже активной транзакции → PDOException
// "There is already an active transaction" (например AdminTenantsController::
// updateBalance). С синхронизацией вложенный transaction() делает SAVEPOINT.
$level = $defaultConnection->transactionLevel();
if ($level > 0) {
$prop = new \ReflectionProperty(Connection::class, 'transactions');
$prop->setAccessible(true);
$prop->setValue($adminConnection, $level);
}
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
it('applies admin-db middleware to the admin api route group', function () {
$route = collect(Route::getRoutes()->getRoutes())
->first(fn ($r) => $r->uri() === 'api/admin/tenants');
expect($route)->not->toBeNull();
expect($route->gatherMiddleware())->toContain('admin-db');
// saas-admin по-прежнему в пайплайне (гейт не потерян)
expect($route->gatherMiddleware())->toContain('saas-admin');
});
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('balance_transactions')->delete();
DB::table('tenants')->delete();
});
it('GET /api/admin/dashboard/finance returns KPIs + attention + top tables', function () {
$neg = DB::table('tenants')->insertGetId([
'subdomain' => 'neg', 'organization_name' => 'Negative', 'contact_email' => 'n@x.ru',
'status' => 'active', 'is_trial' => false, 'balance_rub' => -100, 'balance_leads' => 0,
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
]);
DB::table('balance_transactions')->insert([
'tenant_id' => $neg, 'type' => 'topup', 'amount_rub' => 5000, 'created_at' => now(),
]);
$res = $this->getJson('/api/admin/dashboard/finance?period=30d');
$res->assertOk();
$res->assertJsonStructure([
'kpi' => ['topups_rub', 'charges_rub', 'net_inflow_rub', 'negative_balance_count'],
'attention', 'top_by_turnover', 'period',
]);
expect($res->json('kpi.negative_balance_count'))->toBe(1);
expect(collect($res->json('attention'))->pluck('organization_name'))->toContain('Negative');
expect(collect($res->json('top_by_turnover'))->pluck('organization_name'))->toContain('Negative');
});
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
it('GET /api/admin/dashboard/health returns 6 subsystems with light', function () {
$res = $this->getJson('/api/admin/dashboard/health');
$res->assertOk();
$res->assertJsonStructure([
'subsystems' => [['key', 'light', 'detail']],
'overall_light',
]);
$keys = collect($res->json('subsystems'))->pluck('key')->all();
expect($keys)->toContain('queues', 'scheduler', 'supplier_sync', 'csv_drift', 'webhooks', 'incidents');
});
@@ -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', 'received_today', 'stuck', 'unrouted'],
]);
expect($res->json('kpi.stuck'))->toBe(1); // 1 зависший (6ч, не обработан)
expect($res->json('kpi.unrouted'))->toBe(1); // 1 в очереди
expect($res->json('light'))->toBe('red'); // есть зависший
});
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
// Чистые счётчики: убираем seed/прошлые данные (FK-порядок: сначала проводки).
DB::table('balance_transactions')->delete();
DB::table('tenants')->delete();
});
it('GET /api/admin/dashboard returns finance + health tiles', function () {
$tenant = DB::table('tenants')->insertGetId([
'subdomain' => 'acme', 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru',
'status' => 'active', 'is_trial' => false, 'balance_rub' => -500, 'balance_leads' => 0,
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
]);
DB::table('balance_transactions')->insert([
['tenant_id' => $tenant, 'type' => 'topup', 'amount_rub' => 10000, 'created_at' => now()],
['tenant_id' => $tenant, 'type' => 'lead_charge', 'amount_rub' => -3000, 'created_at' => now()],
]);
$res = $this->getJson('/api/admin/dashboard?period=30d');
$res->assertOk();
$res->assertJsonStructure([
'finance' => ['topups_rub', 'charges_rub', 'active_clients', 'new_clients', 'negative_balance_count', 'light'],
'health' => ['light', 'open_incidents', 'last_sync_status'],
'period',
]);
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', 'received_today', 'stuck', 'unrouted'],
'supply' => ['light', 'demand', 'formula', 'ordered', 'mismatches', 'total_orders', 'total_limit'],
'period',
]);
});
@@ -0,0 +1,32 @@
<?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'],
'total_orders',
'total_limit',
'groups',
]);
expect($res->json('light'))->toBeIn(['green', 'red']);
});
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot) на экране
// «Интеграция с поставщиком» — чтобы владелец включал/выключал мышкой без правки БД.
// EnsureSaasAdmin — стаб в testing; actingAs нужен для прохода auth+admin middleware.
it('GET source-edit-flag returns false when flag absent', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->delete();
$this->getJson('/api/admin/supplier-integration/source-edit-flag')
->assertOk()
->assertJson(['enabled' => false]);
});
it('GET source-edit-flag returns true when flag set', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
);
$this->getJson('/api/admin/supplier-integration/source-edit-flag')
->assertOk()
->assertJson(['enabled' => true]);
});
it('POST source-edit-flag enables the flag', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->delete();
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => true])
->assertOk()
->assertJson(['enabled' => true]);
expect(DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value'))
->toBe('true');
});
it('POST source-edit-flag disables the flag', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
);
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => false])
->assertOk()
->assertJson(['enabled' => false]);
expect(DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value'))
->toBe('false');
});
it('POST source-edit-flag rejects non-boolean', function (): void {
$this->actingAs(User::factory()->create());
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => 'maybe'])
->assertStatus(422);
});
@@ -322,3 +322,38 @@ it('audit:rebuild-chain handles single-row partition (first row of tenant) ко
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Single-row per-tenant partition должен остаться intact');
});
// ──────────────────────────────────────────────────────────────────────────────
// Managed PG (Путь А): пересчёт аудита БЕЗ session_replication_role (superuser-only).
// audit_block_mutation должен пропускать мутацию по метке app.audit_rebuild='on'
// (+ membership в crm_migrator / superuser), а без метки — запрещать (append-only).
// ──────────────────────────────────────────────────────────────────────────────
it('audit_block_mutation чтит метку app.audit_rebuild (без session_replication_role)', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
DB::table('activity_log')->insert([
'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
'event' => 'flag.test', 'context' => null, 'created_at' => now(),
]);
$id = (int) DB::table('activity_log')->where('tenant_id', $tenant->id)->orderByDesc('id')->value('id');
// БЕЗ метки — UPDATE аудита запрещён (append-only). В savepoint, чтобы внешняя
// транзакция теста пережила ожидаемую ошибку PostgreSQL.
$blocked = false;
try {
DB::transaction(function () use ($id) {
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
});
} catch (Throwable $e) {
$blocked = true;
}
expect($blocked)->toBeTrue('UPDATE аудита без метки должен быть запрещён');
// С меткой app.audit_rebuild='on' — UPDATE проходит, БЕЗ session_replication_role.
DB::transaction(function () use ($id) {
DB::statement("SET LOCAL app.audit_rebuild = 'on'");
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
});
expect(true)->toBeTrue(); // дошли без исключения = метка сработала
});
@@ -9,8 +9,14 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
// SharesSupplierPdo: регистрация (RegistrationService) пишет users/tenants/
// email_verifications через BYPASSRLS-подключение pgsql_supplier. Без шаринга PDO
// эти записи коммитятся мимо DatabaseTransactions и не откатываются — тест
// перестаёт быть идемпотентным (повторный прогон/«грязная» БД → 422 «email уже
// существует»). Шаринг PDO кладёт supplier-записи в ту же откатываемую транзакцию.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Reset the Auth manager's default guard and cached guard instances back to
@@ -10,8 +10,14 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
// SharesSupplierPdo: регистрация (RegistrationService) пишет users/tenants/
// email_verifications через BYPASSRLS-подключение pgsql_supplier. Без шаринга PDO
// эти записи коммитятся мимо DatabaseTransactions и не откатываются — тест
// перестаёт быть идемпотентным (повторный прогон/«грязная» БД → 422 «email уже
// существует»). Шаринг PDO кладёт supplier-записи в ту же откатываемую транзакцию.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('logout writes auth_log event=logout', function () {
$tenant = Tenant::factory()->create();
@@ -18,18 +18,27 @@ it('создаёт saas_transactions(pending) и возвращает confirmati
// legal_entities.legal_entity_id NOT NULL REFERENCES legal_entities(id)
$legalEntity = LegalEntity::create([
'code' => 'test_le_' . uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO',
'code' => 'test_le_'.uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO',
'inn' => '7700000000',
]);
$gw = PaymentGateway::create([
'code' => 'yookassa_' . uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa',
'code' => 'yookassa_'.uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa',
'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true,
'accepts_methods' => ['card', 'sbp'], 'min_amount_rub' => '100.00',
]);
$fakeDriver = Mockery::mock(PaymentGatewayDriver::class);
// Чек 54-ФЗ обязателен на стороне магазина ЮKassa — без него платёж отклоняется
// 400 "Receipt is missing" (инцидент 26.06.2026). Гарантируем, что receipt передаётся.
$fakeDriver->shouldReceive('createPayment')->once()
->withArgs(function ($gw, $amount, $idemp, $returnUrl, $receipt) {
return is_array($receipt)
&& ! empty($receipt['customer']['email'])
&& ($receipt['items'][0]['vat_code'] ?? null) === 1
&& ($receipt['items'][0]['amount']['value'] ?? null) === '500.00'
&& ($receipt['items'][0]['payment_subject'] ?? null) === 'service';
})
->andReturn(new CreatePaymentResult('pay_abc', 'https://yoomoney.ru/checkout/pay_abc'));
$service = new OnlineTopupService($fakeDriver);
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\BotAnswerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.yandexgpt', [
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
'topics' => 'создать проект', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов.',
]);
});
it('стоп-тема (мой баланс) → эскалация без похода в LLM', function () {
Http::fake();
$answer = app(BotAnswerService::class)->answer('какой у меня баланс?');
expect($answer->escalate)->toBeTrue()
->and($answer->text)->toContain('специалисту');
Http::assertNothingSent();
});
it('просьба позвать человека → эскалация', function () {
Http::fake();
expect(app(BotAnswerService::class)->answer('позовите оператора')->escalate)->toBeTrue();
});
it('вопрос не по базе (пустой поиск) → честное «не знаю» + эскалация', function () {
Http::fake();
$answer = app(BotAnswerService::class)->answer('какая погода в москве');
expect($answer->escalate)->toBeTrue();
Http::assertNothingSent();
});
it('обычный вопрос → ответ LLM по контексту, без эскалации', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это заявка на поток клиентов.']]]],
]),
]);
$answer = app(BotAnswerService::class)->answer('что такое проект?');
expect($answer->escalate)->toBeFalse()
->and($answer->text)->toContain('Проект')
->and($answer->matchedChunkIds)->not->toBeEmpty();
});
it('tour-ссылка добавляется только при включённом tours_enabled', function () {
config()->set('services.jivo_bot.tours_enabled', true);
config()->set('app.url', 'https://liderra.ru');
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
]);
$withTours = app(BotAnswerService::class)->answer('что такое проект?');
expect($withTours->text)->toContain('https://liderra.ru/?tour=create-project');
config()->set('services.jivo_bot.tours_enabled', false);
$without = app(BotAnswerService::class)->answer('что такое проект?');
expect($without->text)->not->toContain('?tour=');
});
it('LLM недоступен → эскалация', function () {
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
expect(app(BotAnswerService::class)->answer('что такое проект?')->escalate)->toBeTrue();
});
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('bot_dialogs принимает запись входа и выхода', function () {
DB::table('bot_dialogs')->insert([
'jivo_chat_id' => 'chat-1',
'direction' => 'in',
'message' => 'что такое проект?',
'created_at' => now(),
]);
DB::table('bot_dialogs')->insert([
'jivo_chat_id' => 'chat-1',
'direction' => 'out',
'message' => 'Проект — это…',
'matched_chunks' => json_encode([1, 2]),
'latency_ms' => 2100,
'escalated' => false,
'created_at' => now(),
]);
expect(DB::table('bot_dialogs')->where('jivo_chat_id', 'chat-1')->count())->toBe(2);
});
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('наша часть тракта (без сети) укладывается в 500 мс на вопрос', function () {
config()->set('services.jivo_bot.outbound_url', ''); // исходящие в лог
config()->set('services.yandexgpt', [
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ответ.']]]],
]),
]);
for ($i = 0; $i < 20; $i++) {
KnowledgeChunk::create([
'source_path' => "help/a{$i}.md", 'title' => "Статья {$i}", 'tour' => null,
'topics' => 'проект, баланс, тариф', 'chunk_index' => 0,
'content' => str_repeat("Текст про проект и баланс номер {$i}. ", 30),
]);
}
$latencies = [];
for ($i = 0; $i < 10; $i++) {
(new ProcessJivoMessageJob("chat-{$i}", 'c', 'что такое проект?'))->handle();
$latencies[] = (int) BotDialog::where('jivo_chat_id', "chat-{$i}")
->where('direction', 'out')->value('latency_ms');
}
sort($latencies);
$p95 = $latencies[(int) floor(count($latencies) * 0.95) - 1] ?? end($latencies);
// Бюджет спеки §6: поиск ≤300мс + сборка/журнал ≤200мс. LLM (до 3с) и сеть
// Jivo (до 0.5с) — вне нашего кода, замоканы; живой p95 — на приёмке.
expect($p95)->toBeLessThan(500);
});
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('перечитывает resources/help и заполняет knowledge_chunks с нуля', function () {
// Мусорная строка от прошлой индексации — должна исчезнуть (полная перезаливка).
DB::table('knowledge_chunks')->insert([
'source_path' => 'help/deleted-article.md', 'title' => 'Старая', 'topics' => '',
'chunk_index' => 0, 'content' => 'мусор', 'created_at' => now(), 'updated_at' => now(),
]);
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
expect(DB::table('knowledge_chunks')->where('source_path', 'help/deleted-article.md')->count())->toBe(0)
->and(DB::table('knowledge_chunks')->where('title', 'Что такое проект')->count())->toBeGreaterThan(0)
->and(DB::table('knowledge_chunks')->where('title', 'Тарифы и списания')->count())->toBeGreaterThan(0);
});
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
it('каждый tour из статей resources/help существует в каталоге экскурсий', function () {
$catalog = (string) file_get_contents(resource_path('js/tours/catalog.ts'));
preg_match_all("/name: '([a-z0-9\\-]+)'/", $catalog, $m);
$known = $m[1];
expect($known)->not->toBeEmpty();
$parser = new HelpArticleParser;
foreach (glob(resource_path('help').'/*.md') ?: [] as $file) {
$article = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
if ($article->tour !== null) {
expect($known)->toContain($article->tour);
}
}
});
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
const JIVO_SECRET = 'test-secret-0123456789abcdef0123456789abcdef';
beforeEach(function () {
config()->set('services.jivo_bot.webhook_secret', JIVO_SECRET);
});
function jivoPayload(string $text = 'что такое проект?'): array
{
return [
'event' => 'CLIENT_MESSAGE',
'id' => 'evt-1',
'chat_id' => 'chat-1',
'client_id' => 'client-1',
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => 1780000000],
];
}
it('валидный секрет + CLIENT_MESSAGE → 200 и джоба в очереди bot', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, jivoPayload())->assertOk();
Queue::assertPushedOn('bot', ProcessJivoMessageJob::class, function (ProcessJivoMessageJob $job) {
return $job->chatId === 'chat-1' && $job->text === 'что такое проект?';
});
});
it('неверный секрет → 404 без джобы', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/wrong-secret', jivoPayload())->assertNotFound();
Queue::assertNothingPushed();
});
it('секрет не настроен (пустой конфиг) → 404 даже с пустым секретом в URL', function () {
config()->set('services.jivo_bot.webhook_secret', '');
Queue::fake();
$this->postJson('/api/webhook/jivo/anything', jivoPayload())->assertNotFound();
Queue::assertNothingPushed();
});
it('не-CLIENT_MESSAGE (служебное событие) → 200 без джобы', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, ['event' => 'AGENT_JOINED', 'chat_id' => 'c'])
->assertOk();
Queue::assertNothingPushed();
});
it('CLIENT_MESSAGE без текста → 200 без джобы', function () {
Queue::fake();
$payload = jivoPayload();
$payload['message']['text'] = '';
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, $payload)->assertOk();
Queue::assertNothingPushed();
});
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('knowledge_chunks существует и ищется полнотекстово по-русски', function () {
DB::table('knowledge_chunks')->insert([
'source_path' => 'help/project.md',
'title' => 'Что такое проект',
'tour' => 'create-project',
'topics' => 'заявка на лиды, создать проект, источник',
'chunk_index' => 0,
'content' => 'Проект — это заявка на поток лидов с выбранного источника.',
'created_at' => now(),
'updated_at' => now(),
]);
$found = DB::select(
"SELECT id, title FROM knowledge_chunks
WHERE search_tsv @@ websearch_to_tsquery('russian', ?)",
['что такое проект']
);
expect($found)->toHaveCount(1)
->and($found[0]->title)->toBe('Что такое проект');
});
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
'topics' => 'создать проект, заявка на лиды', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов с выбранного источника.',
]);
KnowledgeChunk::create([
'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance',
'topics' => 'пополнить, закинуть деньги, оплата', 'chunk_index' => 0,
'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.',
]);
});
it('находит релевантный чанк и ранжирует его первым', function () {
$hits = app(KnowledgeSearch::class)->search('а что такое проект?', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toBe('Что такое проект');
});
it('находит по синонимам из topics («закинуть деньги»)', function () {
$hits = app(KnowledgeSearch::class)->search('как закинуть деньги', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toBe('Как пополнить баланс');
});
it('на вопрос не по теме возвращает пусто', function () {
expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty();
});
it('не падает на спецсимволах в вопросе', function () {
expect(app(KnowledgeSearch::class)->search('проект & | ! ( )', 3))->toBeArray();
});
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t');
config()->set('services.yandexgpt', [
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => null,
'topics' => 'создать проект', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов.',
]);
});
it('happy path: ответ уходит в Jivo, журнал пишет in+out с latency', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
'bot.jivosite.com/*' => Http::response(['ok' => true]),
]);
(new ProcessJivoMessageJob('chat-1', 'client-1', 'что такое проект?'))->handle();
Http::assertSent(fn ($r) => str_contains($r->url(), 'bot.jivosite.com') && $r['event'] === 'BOT_MESSAGE');
$out = BotDialog::where('direction', 'out')->firstOrFail();
expect(BotDialog::where('direction', 'in')->count())->toBe(1)
->and($out->latency_ms)->toBeGreaterThanOrEqual(0)
->and($out->escalated)->toBeFalse()
->and($out->matched_chunks)->not->toBeNull();
});
it('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журнал escalated=true', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
(new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle();
Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT');
expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue();
});
it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () {
$job = new ProcessJivoMessageJob('c', 'c', 'q');
expect($job->queue)->toBe('bot')
->and($job->timeout)->toBeLessThanOrEqual(12);
});
+4 -5
View File
@@ -51,7 +51,6 @@ it('401 без авторизации', function () {
it('возвращает структуру summary с range по умолчанию 7d', function () {
$tenant = Tenant::factory()->create([
'limits' => ['max_projects' => 10],
'balance_rub' => '14250.00',
'balance_leads' => 285,
]);
@@ -64,7 +63,7 @@ it('возвращает структуру summary с range по умолчан
'range',
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
'conversion' => ['value', 'delta_pp', 'delta_dir'],
'active_projects' => ['active', 'limit'],
'active_projects' => ['active'],
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
'activity' => ['points', 'labels', 'max'],
'funnel',
@@ -103,8 +102,8 @@ it('conversion = доля статуса won в окне', function () {
->assertJsonPath('conversion.value', 25);
});
it('active_projects считает is_active=true + limit из limits', function () {
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
it('active_projects считает только is_active=true (лимита по числу проектов нет)', function () {
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
@@ -112,7 +111,7 @@ it('active_projects считает is_active=true + limit из limits', function
$this->getJson('/api/dashboard/summary')
->assertOk()
->assertJsonPath('active_projects.active', 2)
->assertJsonPath('active_projects.limit', 10);
->assertJsonMissingPath('active_projects.limit');
});
it('funnel группирует живые сделки по статусу', function () {
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
// Guard против повторения инцидента входа 26.06.2026 (см. db/CHANGELOG_schema.md v8.57).
//
// На Yandex Managed PG (PgBouncer transaction pooling) GUC app.current_tenant_id на
// пуло-соединении бывает пуст ('') или не задан. Прямое приведение
// current_setting('app.current_tenant_id'[, true])::bigint
// падает: '' → 22P02 (invalid bigint), не задан → 42704 (unrecognized parameter).
// Это роняло вход (резолв users до tenant-контекста). Канон — всегда:
// NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
// Любое прямое приведение в db/ снова сломает вход. Тест статический (без БД).
it('в schema.sql нет небезопасного current_setting(app.current_tenant_id)::bigint (только через NULLIF)', function () {
// Канон для пересборки БД — db/schema.sql (psql -f). Он ОБЯЗАН быть чистым.
// Старые миграции — неизменяемая история; их небезопасные политики пересоздаёт
// hardening-миграция 2026_06_26_153000 (итог migrate:fresh безопасен), поэтому
// их здесь не сканируем — иначе ложные срабатывания на superseded-истории.
//
// Прямое приведение current_setting(...)::bigint без обёртки NULLIF.
// Безопасная форма NULLIF(current_setting(...), '')::bigint этому НЕ соответствует:
// там после current_setting(...) идёт ", ''", а не "::bigint".
$unsafe = "/current_setting\\(\\s*'app\\.current_tenant_id'[^)]*\\)\\s*::\\s*bigint/i";
$offenders = [];
$lines = file(base_path('..').'/db/schema.sql', FILE_IGNORE_NEW_LINES) ?: [];
foreach ($lines as $i => $line) {
if (str_starts_with(ltrim($line), '--')) {
continue; // строки-комментарии (документация) — не код политики
}
if (preg_match($unsafe, $line)) {
$offenders[] = 'schema.sql:'.($i + 1).' → '.trim($line);
}
}
expect($offenders)->toBe(
[],
'Небезопасное приведение GUC к bigint (без NULLIF) в schema.sql вернёт инцидент входа на Managed PG/PgBouncer:'
.PHP_EOL.implode(PHP_EOL, $offenders)
);
});
it('5 bootstrap-таблиц в schema.sql сохраняют ветку "NULLIF(...) IS NULL OR ..."', function () {
$schema = file_get_contents(base_path('..').'/db/schema.sql');
expect($schema)->not->toBeFalse();
foreach (['users', 'auth_log', 'email_verifications', 'user_recovery_codes', 'user_sessions'] as $table) {
// В пределах одного CREATE POLICY ... ON <table> ... ; должно быть условие
// NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL.
$pattern = '/POLICY tenant_isolation ON '.preg_quote($table, '/')
."\\b[^;]*?NULLIF\\(current_setting\\('app\\.current_tenant_id', true\\), ''\\)\\s*IS NULL/s";
expect((bool) preg_match($pattern, $schema))->toBeTrue(
"Таблица {$table} должна иметь bootstrap-ветку «NULLIF(...) IS NULL OR ...» "
.'(резолв до tenant-контекста на auth-роутах). Иначе вход/2FA/подтверждение почты сломаются.'
);
}
});
@@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS policies', function () {
it('schema.sql v8.58 has correct metrics — 76 base tables, 130 indexes, 44 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик.
@@ -76,8 +76,9 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
// project_routing_snapshots, tenant_requisites, support_requests и др.).
// v8.54 (Эпик 4 online-defer): +1 таблица supplier_deferred_sync (SaaS-level, PK неявный, +0 явных индексов).
// v8.55 (Эпик 5 отчёт заливки): +1 таблица supplier_sync_runs + 1 индекс idx_supplier_sync_runs_created.
// Статический парс db/schema.sql после v8.54/v8.55: 74 base tables, 128 индексов, 44 RLS-политики.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф (заявляет 79 таблиц/124 индекса) —
// v8.58 (ИИ-бот Jivo): +2 таблицы knowledge_chunks (база знаний, GIN search_tsv) и bot_dialogs
// (журнал диалогов) + 2 индекса. Статический парс: 76 base tables, 130 индексов, 44 RLS-политики.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф —
// это отдельный canon-sync, не предмет этого теста; тест сверяет фактический парс ФАЙЛА.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
@@ -88,10 +89,10 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(74);
expect($baseTables)->toBe(76);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(128); // v8.55 static parse
expect($createIndexes)->toBe(130); // v8.58 static parse
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(44); // v8.52 static parse
@@ -122,18 +122,24 @@ it('rejects sms project without sms_senders', function () {
$response->assertJsonValidationErrors(['sms_senders']);
});
it('rejects when tenant exceeds max_projects limit', function () {
$tenant = Tenant::factory()->withRequisites()->create(['limits' => ['max_projects' => 1]]);
it('does not cap the number of projects — limit is only by balance, not project count', function () {
// Правило продукта: ограничение только по балансу/заказанным лидам, НЕ по числу проектов.
// Даже явно заданный max_projects=1 не должен блокировать создание второго проекта.
// Большой баланс — чтобы изолировать тест от балансового префлайта (он отдельный, 409).
$tenant = Tenant::factory()->withRequisites()->create([
'limits' => ['max_projects' => 1],
'balance_rub' => '10000000.00',
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 1]);
$response = $this->actingAs($user)->postJson('/api/projects', [
'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru',
'daily_limit_target' => 10, 'regions' => [],
'daily_limit_target' => 1, 'regions' => [],
'delivery_days_mask' => 127,
]);
$response->assertStatus(403);
$response->assertCreated();
});
it('forces tenant_id from auth user (not from payload)', function () {
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminDashboardView from '../../resources/js/views/admin/AdminDashboardView.vue';
// Мокаем клиент дашборда: 3 GET-эндпоинта возвращают фикстуры.
vi.mock('../../resources/js/api/adminDashboard', () => ({
getDashboardSummary: vi.fn().mockResolvedValue({
period: '7d',
finance: {
topups_rub: '320000',
charges_rub: '180000',
active_clients: 5,
new_clients: 2,
negative_balance_count: 1,
light: 'red',
},
health: {
light: 'green',
open_incidents: 0,
job_errors_24h: 0,
failed_jobs_24h: 0,
last_sync_status: 'success',
last_sync_at: null,
},
leads: { light: 'green', delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 },
supply: { light: 'red', demand: 250, formula: 160, ordered: 175, mismatches: 1, total_orders: 405, total_limit: 5031, snapshot_date: '2026-06-28' },
}),
getDashboardFinance: vi.fn().mockResolvedValue({
period: '7d',
kpi: { topups_rub: '320000', charges_rub: '180000', net_inflow_rub: '140000', negative_balance_count: 1 },
attention: [{ id: 9, subdomain: 'romashka', organization_name: 'ООО Ромашка', balance_rub: '-4200', state: 'negative' }],
top_by_turnover: [{ id: 2, organization_name: 'lkomega', topped_rub: '200000' }],
}),
getDashboardHealth: vi.fn().mockResolvedValue({
overall_light: 'green',
subsystems: [
{ key: 'queues', light: 'green', detail: '0 упавших за сутки' },
{ key: 'incidents', light: 'green', detail: '0 открытых' },
],
}),
getDashboardLeads: vi.fn().mockResolvedValue({
light: 'green',
kpi: { delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 },
}),
getDashboardSupply: vi.fn().mockResolvedValue({
snapshot_date: '2026-06-28',
light: 'red',
totals: { demand: 250, formula: 160, ordered: 175, mismatches: 1 },
total_orders: 405,
total_limit: 5031,
groups: [{ signal_type: 'site', identifier: 'okna.ru', demand: 150, formula: 100, ordered: 100, in_sync: true }],
}),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe('AdminDashboardView.vue', () => {
const factory = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/dashboard', name: 'admin-dashboard', component: AdminDashboardView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/dashboard');
await router.isReady();
const wrapper = mount(AdminDashboardView, {
global: { plugins: [createVuetify(), router] },
});
await flushPromises();
await wrapper.vm.$nextTick();
return { wrapper, router };
};
it('монтируется и содержит заголовок «Командный центр»', async () => {
const { wrapper } = await factory();
expect(wrapper.find('h1').text()).toBe('Командный центр');
});
it('рендерит 4 плитки: Финансы / Здоровье / Лиды / Заказ у поставщика', async () => {
const { wrapper } = await factory();
const text = wrapper.text();
expect(text).toContain('Финансы');
expect(text).toContain('Здоровье портала');
expect(text).toContain('Лиды');
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();
expect(text).toMatch(/320\s+000\s*₽/); // пополнения
expect(text).toContain('1 в минусе'); // светофор Финансов (red)
expect(text).toContain('success'); // статус синхрона
});
it('по умолчанию открыт drill Финансов с KPI «Чистый приток»', async () => {
const { wrapper } = await factory();
expect(wrapper.find('[data-testid="drill-fin"]').exists()).toBe(true);
expect(wrapper.text()).toMatch(/140\s+000\s*₽/); // net_inflow
expect(wrapper.text()).toContain('ООО Ромашка'); // строка «внимание»
});
it('клик по плитке Здоровье переключает drill на подсистемы', async () => {
const { wrapper } = await factory();
await wrapper.find('[data-testid="tile-health"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="drill-health"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="drill-fin"]').exists()).toBe(false);
expect(wrapper.text()).toContain('Очереди / джобы');
});
it('клик по строке «внимание» уводит на карточку тенанта (Уровень 3)', async () => {
const { wrapper, router } = await factory();
const push = vi.spyOn(router, 'push');
await wrapper.find('[data-testid="drill-fin"] tbody tr.clk').trigger('click');
await wrapper.vm.$nextTick();
expect(push).toHaveBeenCalledWith({ name: 'admin-tenant-detail', params: { code: 'romashka' } });
});
it('смена периода перезагружает данные (вызов summary дважды)', async () => {
const { wrapper } = await factory();
const api = await import('../../resources/js/api/adminDashboard');
expect(api.getDashboardSummary).toHaveBeenCalledTimes(1);
await wrapper.find('[data-testid="period-30d"]').trigger('click');
await flushPromises();
expect(api.getDashboardSummary).toHaveBeenCalledTimes(2);
expect(api.getDashboardSummary).toHaveBeenLastCalledWith('30d');
});
it('API reject → fetch-error-alert виден', async () => {
const api = await import('../../resources/js/api/adminDashboard');
vi.mocked(api.getDashboardSummary).mockRejectedValueOnce(new Error('Network'));
const { wrapper } = await factory();
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import axios from 'axios';
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
vi.mock('axios');
const vuetify = createVuetify();
describe('AdminSupplierIntegrationView — тумблер разблокировки смены источника', () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.endsWith('/source-edit-flag')) {
return Promise.resolve({ data: { enabled: true } });
}
if (url.endsWith('/export-mode')) {
return Promise.resolve({ data: { mode: 'batch' } });
}
if (url.endsWith('/manual-queue')) {
return Promise.resolve({ data: { queue: [] } });
}
return Promise.resolve({ data: { health: null, history: [] } });
});
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { enabled: false } });
});
it('GETs the flag on mount and renders the toggle card with current label', async () => {
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/source-edit-flag');
const card = wrapper.find('[data-testid="source-edit-flag-card"]');
expect(card.exists()).toBe(true);
expect(wrapper.text()).toContain('Разблокировка смены источника');
// флаг enabled=true с бэка → подпись «Включена»
expect(wrapper.text()).toContain('Включена');
});
});
+3 -3
View File
@@ -28,7 +28,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
range: '7d',
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
active_projects: { active: 8, limit: 10 },
active_projects: { active: 8 },
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
funnel: { new: 18, won: 45 },
@@ -112,7 +112,7 @@ describe('DashboardView — косяк 07: онбординг новичка', (
it('показывает онбординг новичку без проектов и лидов', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
makeSummary({
active_projects: { active: 0, limit: 0 },
active_projects: { active: 0 },
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
}),
);
@@ -126,7 +126,7 @@ describe('DashboardView — косяк 07: онбординг новичка', (
it('скрывает онбординг после «скрыть» и помнит это в localStorage', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
makeSummary({
active_projects: { active: 0, limit: 0 },
active_projects: { active: 0 },
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
}),
);
+2 -1
View File
@@ -14,6 +14,7 @@ vi.mock('../../resources/js/api/client', () => ({
import { apiClient } from '../../resources/js/api/client';
import EditProjectDialog from '../../resources/js/views/projects/EditProjectDialog.vue';
import type { Project } from '../../resources/js/stores/projectsStore';
const sampleProject = {
id: 1,
@@ -31,7 +32,7 @@ const sampleProject = {
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
// для wrapper.text() / find(). Паттерн из NewProjectDialog.spec.ts.
const factory = (props: { modelValue: boolean; project: typeof sampleProject }) =>
const factory = (props: { modelValue: boolean; project: Project }) =>
mount(EditProjectDialog, {
props,
global: {
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
import type { TourStep } from '../../resources/js/tours/catalog';
const steps: TourStep[] = [
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
function mountTour() {
return mount(GuidedTour, {
props: { steps, active: true },
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
describe('GuidedTour', () => {
it('показывает первый шаг и счётчик', () => {
const w = mountTour();
expect(w.text()).toContain('Шаг 1');
expect(w.text()).toContain('1 из 2');
});
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.text()).toContain('Шаг 2');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('Пропустить завершает тур сразу', async () => {
const w = mountTour();
await w.find('[data-testid="tour-skip"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
const w = mountTour();
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
});
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
vi.useFakeTimers();
const w = mountTour();
const el = document.createElement('div');
el.setAttribute('data-tour', 'a');
document.body.appendChild(el);
await vi.advanceTimersByTimeAsync(1000);
expect((w.vm as any).targetRect).not.toBeNull();
el.remove();
vi.useRealTimers();
});
});
@@ -67,6 +67,18 @@ describe('NewProjectDialog', () => {
expect(text).toContain('СМС');
});
it('shows hint «Как увеличить количество сделок» with explanatory tooltip', async () => {
const wrapper = factory();
await flushPromises();
expect(wrapper.text()).toContain('Как увеличить количество сделок');
const tips = wrapper.findAllComponents({ name: 'VTooltip' });
const boost = tips.find((t) =>
String(t.props('text')).includes('распределяется на нескольких поставщиков'),
);
expect(boost).toBeTruthy();
expect(String(boost?.props('text'))).toContain('увеличьте лимит');
});
it('switching to SMS tab shows sms_senders field', async () => {
const wrapper = factory();
await flushPromises();
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog';
describe('каталог экскурсий', () => {
it('содержит 5 стартовых сценариев с уникальными именами', () => {
const names = TOURS.map((t: TourScenario) => t.name);
expect(names).toEqual([...new Set(names)]);
for (const required of ['create-project', 'top-up-balance', 'tariffs', 'change-source', 'notifications']) {
expect(names).toContain(required);
}
});
it('каждый шаг имеет route, target, title и text', () => {
for (const tour of TOURS) {
expect(tour.steps.length).toBeGreaterThan(0);
for (const s of tour.steps) {
expect(s.route.startsWith('/')).toBe(true);
expect(s.target).toBeTruthy();
expect(s.title).toBeTruthy();
expect(s.text).toBeTruthy();
}
}
});
it('findTour находит по имени и отдаёт null на мусор', () => {
expect(findTour('create-project')?.name).toBe('create-project');
expect(findTour('no-such-tour')).toBeNull();
expect(findTour('')).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useTourLauncher } from '../../resources/js/composables/useTourLauncher';
function makeRouterMocks(query: Record<string, string>) {
const route = ref({ query, fullPath: '/x' });
const router = { push: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined) };
return { route, router };
}
describe('useTourLauncher', () => {
it('валидный ?tour= → активирует сценарий и ведёт на роут первого шага', async () => {
const { route, router } = makeRouterMocks({ tour: 'create-project' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value?.name).toBe('create-project');
expect(router.push).toHaveBeenCalledWith({ path: '/projects', query: {} });
});
it('мусорный ?tour= → игнор без падения, query чистится', async () => {
const { route, router } = makeRouterMocks({ tour: 'no-such' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.replace).toHaveBeenCalled();
});
it('без ?tour= — ничего не делает', async () => {
const { route, router } = makeRouterMocks({});
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.push).not.toHaveBeenCalled();
});
it('finishTour гасит активный тур', async () => {
const { route, router } = makeRouterMocks({ tour: 'tariffs' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
l.finishTour();
expect(l.activeTour.value).toBeNull();
});
});
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMenuRepositionFix } from '../../resources/js/utils/menuRepositionFix';
// Ручной requestAnimationFrame: кадры прогоняем детерминированно.
let rafQueue: FrameRequestCallback[] = [];
function flushFrames(n: number): void {
for (let i = 0; i < n; i++) {
const batch = rafQueue;
rafQueue = [];
batch.forEach((cb) => cb(0));
}
}
// Дать MutationObserver (микротаска jsdom) сработать.
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
function makeMenu(): HTMLElement {
const menu = document.createElement('div');
menu.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
menu.appendChild(content);
return menu;
}
let teardown: (() => void) | undefined;
let rectWidth = 200;
let rectLeft = 100;
let origRect: typeof HTMLElement.prototype.getBoundingClientRect;
let resizeSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
rafQueue = [];
rectWidth = 200;
rectLeft = 100;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafQueue.push(cb);
return rafQueue.length;
});
origRect = HTMLElement.prototype.getBoundingClientRect;
HTMLElement.prototype.getBoundingClientRect = function (): DOMRect {
return {
width: rectWidth,
height: 10,
left: rectLeft,
top: 0,
right: rectLeft + rectWidth,
bottom: 10,
x: rectLeft,
y: 0,
toJSON: () => ({}),
} as DOMRect;
};
resizeSpy = vi.fn();
window.addEventListener('resize', resizeSpy as unknown as EventListener);
});
afterEach(() => {
teardown?.();
teardown = undefined;
HTMLElement.prototype.getBoundingClientRect = origRect;
window.removeEventListener('resize', resizeSpy as unknown as EventListener);
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
describe('installMenuRepositionFix', () => {
it('при появлении меню стабилизирует позицию и шлёт один resize', async () => {
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('на посторонний узел не реагирует', async () => {
teardown = installMenuRepositionFix();
const other = document.createElement('div');
other.className = 'some-card';
document.body.appendChild(other);
await tick();
flushFrames(6);
expect(resizeSpy).not.toHaveBeenCalled();
});
it('идемпотентна: двойная установка не даёт двойной resize', async () => {
teardown = installMenuRepositionFix();
installMenuRepositionFix(); // второй вызов — noop
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('предохранитель: если геометрия не устаканилась — не виснет и не шлёт resize', async () => {
rectWidth = 0; // контент «нулевой» → условие стабилизации не выполняется
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(120); // больше предохранителя (90 кадров)
expect(resizeSpy).not.toHaveBeenCalled();
expect(rafQueue.length).toBe(0); // цикл остановился, не зациклился
});
});
@@ -1,66 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { repositionMenuAfterOpen } from '../../resources/js/utils/menuRepositionFix';
/**
* Unit-тесты воркэраунда Vuetify location-strategy (см. menuRepositionFix.ts).
* Реальный баг гонка позиционирования в браузере под prefers-reduced-motion
* в jsdom не воспроизводится (нет layout); он покрыт Playwright-пробой. Здесь
* проверяется контракт утилиты: при стабилизации overlay-меню шлётся один resize.
*/
function makeStableMenu(left: number): HTMLElement {
const overlay = document.createElement('div');
overlay.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
content.getBoundingClientRect = () =>
({
width: 400,
height: 300,
left,
top: 50,
right: left + 400,
bottom: 350,
x: left,
y: 50,
toJSON() {},
}) as DOMRect;
overlay.appendChild(content);
document.body.appendChild(overlay);
return overlay;
}
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe('repositionMenuAfterOpen', () => {
afterEach(() => {
document.querySelectorAll('.v-overlay').forEach((el) => el.remove());
});
it('does nothing when menu is closing (open=false)', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(false);
await wait(200);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
it('dispatches a single resize once the overlay content is geometrically stable', async () => {
makeStableMenu(120);
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(true);
await wait(400);
window.removeEventListener('resize', spy);
expect(spy).toHaveBeenCalled();
});
it('does not dispatch resize or throw when no overlay is present', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
expect(() => repositionMenuAfterOpen(true)).not.toThrow();
await wait(300);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -109,7 +109,7 @@ describe('router/index.ts', () => {
expect(router.currentRoute.value.meta.layout).toBe('error');
});
it('/admin redirects to /admin/tenants', async () => {
it('/admin redirects to /admin/dashboard', async () => {
const auth = useAuthStore();
auth.user = {
id: 1,
@@ -122,8 +122,8 @@ describe('router/index.ts', () => {
} as unknown as ReturnType<typeof useAuthStore>['user'];
await router.push('/admin');
await router.isReady();
expect(router.currentRoute.value.path).toBe('/admin/tenants');
expect(router.currentRoute.value.name).toBe('admin-tenants');
expect(router.currentRoute.value.path).toBe('/admin/dashboard');
expect(router.currentRoute.value.name).toBe('admin-dashboard');
});
it('/reset/:token resolves with token param exposed', async () => {
+5
View File
@@ -6,6 +6,7 @@ use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesAdminPdo;
use Tests\TestCase;
/*
@@ -23,6 +24,10 @@ pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
// admin-db middleware swaps default→pgsql_admin; share PDO для cross-connection
// visibility в admin-тестах (любой /api/admin/* эндпоинт). Глобально по Feature.
uses(SharesAdminPdo::class)->in('Feature');
pest()->extend(TestCase::class)->in('Browser');
/*
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
use Tests\TestCase;
uses(TestCase::class);
it('парсит frontmatter и режет тело на чанки по абзацам', function () {
$md = <<<'MD'
---
title: Что такое проект
tour: create-project
topics: создать проект, заявка на лиды
---
Первый абзац про проект.
Второй абзац про создание.
MD;
$article = (new HelpArticleParser)->parse('help/x.md', $md);
expect($article->title)->toBe('Что такое проект')
->and($article->tour)->toBe('create-project')
->and($article->topics)->toBe('создать проект, заявка на лиды')
->and($article->chunks)->toHaveCount(1)
->and($article->chunks[0])->toContain('Первый абзац');
});
it('без frontmatter кидает понятную ошибку', function () {
expect(fn () => (new HelpArticleParser)->parse('help/bad.md', 'просто текст'))
->toThrow(InvalidArgumentException::class);
});
it('длинное тело режет на несколько чанков ~1200 символов по границам абзацев', function () {
$body = implode("\n\n", array_fill(0, 10, str_repeat('а', 300)));
$md = "---\ntitle: Т\ntopics: т\n---\n\n".$body;
$article = (new HelpArticleParser)->parse('help/long.md', $md);
expect(count($article->chunks))->toBeGreaterThan(1);
foreach ($article->chunks as $chunk) {
expect(mb_strlen($chunk))->toBeLessThanOrEqual(1500);
}
});
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Services\Bot\JivoBotClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/prov-1/tok-1');
});
it('BOT_MESSAGE уходит с chat_id/client_id и текстом', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'Проект — это…');
Http::assertSent(function ($request) {
return $request->url() === 'https://bot.jivosite.com/webhooks/prov-1/tok-1'
&& $request['event'] === 'BOT_MESSAGE'
&& $request['chat_id'] === 'chat-1'
&& $request['client_id'] === 'client-1'
&& $request['message']['type'] === 'TEXT'
&& $request['message']['text'] === 'Проект — это…';
});
});
it('INVITE_AGENT уходит без message', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
app(JivoBotClient::class)->inviteAgent('chat-1', 'client-1');
Http::assertSent(fn ($r) => $r['event'] === 'INVITE_AGENT' && $r['chat_id'] === 'chat-1');
});
it('пустой outbound_url (dev/CI) → ничего не шлёт и не падает', function () {
config()->set('services.jivo_bot.outbound_url', '');
Http::fake();
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'x');
Http::assertNothingSent();
});
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Services\Bot\YandexGptClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('services.yandexgpt', [
'api_key' => 'test-key', 'folder_id' => 'b1gtest', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
});
it('шлёт правильный запрос и возвращает текст ответа', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
]);
$text = app(YandexGptClient::class)->complete('системный наказ', 'что такое проект?');
expect($text)->toBe('Проект — это…');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'Api-Key test-key')
&& $request['modelUri'] === 'gpt://b1gtest/yandexgpt-lite/latest'
&& $request['messages'][0]['role'] === 'system'
&& $request['messages'][1]['role'] === 'user'
&& $request['completionOptions']['temperature'] === 0.2;
});
});
it('пустой api_key → null (бот не настроен, не исключение)', function () {
config()->set('services.yandexgpt.api_key', '');
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
});
it('ошибка API → null (эскалация решается выше)', function () {
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
});

Some files were not shown because too many files have changed in this diff Show More