9.3 KiB
Спека: UX блокировки смены источника проекта + баннер нового проекта
Дата: 2026-06-22 Статус: дизайн согласован владельцем. РЕАЛИЗАЦИЯ ОТЛОЖЕНА — делает другая сессия по команде владельца. НЕ реализовывать без явного «делай». Тип: UX-правка (фронт + одно read-only поле бэкенда). Revenue-ядро НЕ трогаем.
1. Проблема (откуда выросло)
Клиент (омега, проект 135 «Caranga», sms) внёс ключевое слово в активный проект — оно молча не сохранилось, у поставщика ничего не создалось. Диагноз:
- Правка источника (
sms_keyword/sms_senders/signal_identifier) у активного supplier-связанного проекта блокируетсяSupplierSnapshotGuard::assertCanMutateSource→ возвращает 422{errors:{project:[…]}}. - Форма (
ProjectDetailsDrawer.vue) ловит 422 и показывает баннерerrors.project(строки 148-149), но баннер вверху, а поле источника и кнопка «Сохранить» — ниже, без тоста и без прокрутки к ошибке → клиент не видит → ощущение «молча не сохранилось».
2. Главное решение: ГАРД ОСТАВЛЯЕМ (не смягчать, не сносить)
Рассматривали «смягчение» (разрешить смену источника на лету + баннер-предупреждение). Отклонено — это фундаментальная переделка revenue-ядра. Доказательство из кода:
- Раздача ищет проект для входящего лида только через pivot —
LeadRouter::matchEligibleProjects(app/app/Services/LeadRouter.php:114):EXISTS (project_supplier_links psl WHERE psl.project_id=snap.project_id AND psl.supplier_project_id=?). - Смена источника рвёт этот pivot и удаляет старый supplier_project —
ProjectService::detachOldSourceSupplierProjects(app/app/Services/Project/ProjectService.php:159-176) →DeleteSupplierProjectJob::dispatch. - Поставщик по СТАРОМУ источнику уже заказал и шлёт лиды (~сутки + хвост). После разрыва связи эти лиды не находят проект → 0 сделок → потеря (ровно паттерн «726 zero-deal», что видели на проде).
Вывод: гард — несущая стена. Он специально форсирует «пауза → grace → смена», чтобы к моменту смены источника в полёте не было старых лидов. Снять его = строить переходную раздачу на два источника одновременно (узлы: detachOldSourceSupplierProjects, DeleteSupplierProjectJob+чистка, LeadRouter::matchEligibleProjects, RouteSupplierLeadJob+project_routing_snapshots, биллинг). Не делаем.
3. Что делаем (вариант 3 — только UX, безопасно)
3.1 Бэкенд — ProjectResource отдаёт 2 read-only поля
Источник расчёта — SupplierSnapshotGuard (без изменения его логики):
source_locked: bool=SupplierSnapshotGuard::isProtected($project).source_unlock_at: string|null(ISO8601, МСК):- проект на паузе в grace →
computeGraceUntil($project->paused_at)(твёрдая дата); - проект активен (паузы ещё нет) → прогноз
computeGraceUntil(now())— «если поставить паузу сейчас»; - не заблокирован →
null.
- проект на паузе в grace →
- Фронту отдаём флаг
source_unlock_projected: bool(true для активного — чтобы показать «если поставите паузу сейчас»).
3.2 Фронт — ProjectDetailsDrawer.vue
Когда source_locked = true:
- поля источника (
signal_identifier/sms_senders/sms_keyword) — disabled, серые, с замком 🔒; - под ними — подсказка (тексты §4);
- остальные поля (название, лимит, регионы, дни) — редактируются как обычно.
Красный баннер errors.project НЕ убираем — он остаётся для других ошибок (например, «нельзя удалить — есть сделки»). Меняем поведение только для источника: было «дать нажать и упасть» → стало «закрыть поле и подсказать заранее».
4. Тексты (согласованы, без жаргона; «Лидерра», не «поставщик»)
Дата подставляется системой; формат «D MMMM», для разблокировки — «после 21:00».
Проект РАБОТАЕТ (активен) — поле серое, замок, дата как прогноз:
🔒 Чтобы изменить источник, поставьте проект на паузу. Лидерра уже собирает по нему лиды. Поставите паузу сейчас — изменить сможете {дата} после 21:00.
Проект НА ПАУЗЕ (идёт grace) — поле серое, замок, дата твёрдая:
🔒 Изменить источник можно будет {дата} после 21:00. Лидерра ещё получает лиды по старому источнику.
Пауза, срок прошёл — поле обычное, белое, без подсказки.
Поменял источник и сохранил — тост:
✅ Готово. Лиды по новому источнику пойдут со следующего дня.
Баннер при создании НОВОГО проекта (вверху формы / после сохранения):
📣 Проект создан — Лидерра уже ставит его в сбор. Первые лиды по нему пойдут с {дата}.
(тёплый альт, на выбор владельца: «🚀 Готово! Проект создан. Лидерра начнёт собирать по нему лиды — первые ждите с {дата}.»)
5. Логика дат (та же «до/после порога»)
- Разблокировка источника (
computeGraceUntil): ближайшие 21:00 МСК после паузы (если пауза ≥21:00 → следующий день) + 24 ч.- пауза до 21:00 → разблокировка завтра после 21:00;
- пауза после 21:00 → разблокировка послезавтра после 21:00.
- Первые лиды нового проекта / вступление правок (порог 18:00 МСК — внутренний запас до слепка поставщика 21:00; уже используется в
SupplierSnapshotGuard::appliesFrom):- создал/правил до 18:00 → лиды с завтра;
- после 18:00 → лиды с послезавтра.
6. Вне scope (НЕ делать)
- НЕ убирать/смягчать
SupplierSnapshotGuard. - НЕ трогать раздачу (
LeadRouter/RouteSupplierLeadJob),detachOldSourceSupplierProjects,DeleteSupplierProjectJob,project_routing_snapshots, биллинг.
7. Тесты (при реализации)
- Бэкенд:
ProjectResourceотдаётsource_locked/source_unlock_at/source_unlock_projectedкорректно в 3 состояниях (активен / пауза-в-grace / пауза-после-grace / нет supplier-связей). - Фронт: при
source_lockedполя источника disabled + видна подсказка; другие поля редактируемы; красный баннерerrors.projectостаётся живым для прочих ошибок; новый проект показывает баннер-объявление.
8. Файлы-якоря
app/app/Services/Project/SupplierSnapshotGuard.php(источникisProtected/computeGraceUntil— НЕ менять логику).app/app/Http/Resources/ProjectResource.php(+ 3 поля).app/resources/js/components/projects/ProjectDetailsDrawer.vue(disable поля + подсказка).app/resources/js/views/projects/NewProjectDialog.vue(баннер нового проекта).- Контекст «почему гард»:
app/app/Services/LeadRouter.php:114,app/app/Services/Project/ProjectService.php:159-176,app/app/Jobs/RouteSupplierLeadJob.php.