# Разбор: блокировка смены источника, слепок раздачи, онлайн/офлайн, общие источники

**Дата:** 2026-06-25
**Статус:** разбор по коду + идея владельца. НИЧЕГО не реализовано. Код НЕ тронут.
**Зачем:** зафиксировать выводы, чтобы потом не перепроверять заново.

---

## 0. С чего начали — претензия владельца

На экране правки проекта (проект Caranga, sms) стоит блокировка:
> «🔒 Изменить источник можно будет 27 июня после 21:00. Лидерра ещё получает лиды по старому источнику.»

Владелец: **«клиент ахереет, жди 2 дня — это глупость»**. Разобрались, от чего эта защита и можно ли сделать по-человечески.

---

## 1. От чего защищает блокировка (почему её вообще поставили)

При смене источника код **рвёт связь со старым источником и удаляет его** у поставщика
([ProjectService::detachOldSourceSupplierProjects](../../../app/app/Services/Project/ProjectService.php#L141-L182), вызов на [строке 89-94](../../../app/app/Services/Project/ProjectService.php#L89-L94)).

А поставщик по старому источнику **уже заказал лиды вперёд** и шлёт их ещё ~сутки («хвост»).
После разрыва связи эти лиды **не находят проект → 0 сделок → потеря**. На проде это уже было — паттерн «726 zero-deal».

**Потеря лида = убыток, недопустимо.** Поэтому гард форсирует «пауза → grace → смена», чтобы к моменту смены в полёте не было старых лидов.

Код гарда: [SupplierSnapshotGuard.php](../../../app/app/Services/Project/SupplierSnapshotGuard.php).
Дата разблокировки = ближайший 21:00 МСК после паузы + 24 ч ([computeGraceUntil, строки 30-40](../../../app/app/Services/Project/SupplierSnapshotGuard.php#L30-L40)).

---

## 2. ГЛАВНОЕ ОТКРЫТИЕ: половина решения уже в коде

Идея владельца — «делать слепок в 18:00 и раздавать завтрашние лиды по слепку, а не по текущему состоянию» — **уже реализована**:

- **Слепок делается каждый день в 18:02 МСК** — [SnapshotProjectRoutingJob.php:17](../../../app/app/Jobs/SnapshotProjectRoutingJob.php#L17).
  Фиксирует все 7 полей на завтра: лимит, дни, регионы, источник (signal_type, signal_identifier, sms_senders, sms_keyword) — [INSERT, строки 45-68](../../../app/app/Jobs/SnapshotProjectRoutingJob.php#L45-L68).
- **Раздача идёт по слепку, а не по живому проекту** — [LeadRouter.php:136-158](../../../app/app/Services/LeadRouter.php#L136-L158).
  Лимит = `snap.daily_limit`, регион = `snap.regions`.
- **Таблица слепка специально переживает удаление проекта** — [миграция, комментарий строки 41-42](../../../app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php#L41-L42):
  «НЕ ссылаемся на projects(id) — проект может быть удалён, а snapshot должен пережить (хвост слепка ещё летит)».

Вывод: архитектура слепка = ровно идея владельца, уже в проде.

---

## 3. По каждому из 7 полей слепка — где опасно, где нет

| Поле | Рвётся связь при смене? | Лиды теряются? | Блокировка «жди 2 дня»? |
|---|---|---|---|
| Источник (ключевое слово / отправители / сигнал) | **Да** | **Да, если не страховать** | **Да, есть** |
| Количество (лимит лидов/день) | Нет | Нет | Нет |
| Регион | Нет | Нет | Нет |
| Дни поставки | Нет | Нет | Нет |

Список slepok-полей: [SupplierSnapshotGuard.php:71-79](../../../app/app/Services/Project/SupplierSnapshotGuard.php#L71-L79).

**Количество / регион / дни** — связь НЕ рвётся, при смене просто пере-заказ + «вступит в силу со следующего дня»
([ProjectService.php:110-127](../../../app/app/Services/Project/ProjectService.php#L110-L127)). Блокировки нет, менять можно свободно.

**Мягкий рассинхрон (не потеря):** если поставщик утром заказал по широкому региону, а клиент днём сузил — часть уже летящих лидов может не подойти под новый регион. Это «заказали шире, чем нужно», не пропажа денег. Учесть в плане отдельно.

---

## 4. «А если клиент 5 раз за день поменяет?»

Опасен **не факт смены, а попал ли источник в заказ поставщика** (поставщик заказывает раз в сутки; слепок к 18:00, в силу с 21:00 — [appliesFrom, строки 108-115](../../../app/app/Services/Project/SupplierSnapshotGuard.php#L108-L115)).

- 5 смен **количества/региона/дней** за день → в заказ уходит **последнее значение**, остальные «передумал». Потери нет, хвостов нет.
- 5 смен **источника** до часа заказа → ни один промежуточный не успел заказать, хвоста нет, выигрывает последний.
- Хвост (и риск) есть **только у источника, который реально собирал лиды**. «Доживающих» хвостов у проекта максимум ОДИН — не накапливаются от 5 правок.

---

## 5. Где РЕАЛЬНАЯ дыра — одна, узкая

Слепок хранит источник, **но** в одном месте раздача для поставщиковых проектов сверяется не со слепком, а с **живой связью** `project_supplier_links`
([LeadRouter.php:114-115](../../../app/app/Services/LeadRouter.php#L114-L115)) — а эта связь при смене источника рвётся и удаляется.
→ завтрашний уже заказанный лид «не узнаёт» свой проект → теряется.

Для DIRECT-площадок матч уже идёт по слепку ([LeadRouter.php:108-112](../../../app/app/Services/LeadRouter.php#L108-L112)) — там проблемы нет. Дыра — у поставщиковых (crm.bp) проектов.

---

## 6. Онлайн / офлайн режим экспорта поставщику

Глобальный переключатель `supplier_export_mode` — [SupplierExportMode.php](../../../app/app/Services/Supplier/SupplierExportMode.php):

- **online** — правка летит поставщику СРАЗУ при create/edit ([SyncSupplierProjectJob::handleOnline](../../../app/app/Jobs/SyncSupplierProjectJob.php#L101-L380)).
- **batch (офлайн/ночной)** — каркас сразу, полные параметры дольёт ночной `SyncSupplierProjectsJob` (~18:00).

**Не путать две «18:00»:**
1. Слепок раздачи 18:02 (внутренний, по чему Лидерра раздаёт) — §2.
2. Отправка параметров поставщику наружу (онлайн = сразу / офлайн = ночью) — это онлайн/офлайн.

Для **количества/региона/дней** оба режима ок. Для **источника** онлайн ХУЖЕ: смену источника шлёт поставщику немедленно и сразу удаляет старый источник. → чинить источник надо **независимо от режима**.

### 6.1. Рассинхрон двух слепков — отдельная дыра онлайна (важно)

Есть ДВА слепка в РАЗНОЕ время:
- **наш** слепок раздачи — **18:02** МСК (по нему Лидерра раздаёт завтрашние лиды);
- **поставщика** — **21:00** МСК (по нему он заказывает завтрашние лиды).

Опасное окно — **18:02 → 21:00**. Если клиент правит проект в этом окне (напр. в 19:00) в **онлайн**-режиме:

1. В 18:02 наш слепок уже зафиксировал СТАРОЕ → завтра раздаём по старому.
2. В 19:00 онлайн СРАЗУ шлёт правку поставщику и подменяет у него проект.
3. В 21:00 поставщик делает слепок — берёт уже НОВОЕ.
4. → Поставщик завтра шлёт лиды по НОВЫМ параметрам, а мы раздаём по СТАРОМУ слепку. Не совпадают → лиды не лягут → потеря.

Достаточно поменять даже регион или лимит (не обязательно источник) — онлайн в этом окне сам создаёт рассинхрон.

**Зачем нужно окно 18:00→21:00 (контекст владельца):** 3 часа взяты НАМЕРЕННО — запас, чтобы при падении канала успеть подделать/залить заказ в полуручном режиме (failover ярус 2 форма → ярус 3 ручная очередь). Окно трогать нельзя.

**Кандидат решения (идея владельца, НЕ сделано):**
- **Глушить онлайн-отправку поставщику с 18:00 до 00:00 МСК; в 00:00 включать и досылать накопленную очередь.** В опасном окне правки клиента не улетают поставщику → слепок поставщика 21:00 не подменяется → наш слепок 18:02 и заказ поставщика совпадают. Заливка в 00:00 (после 21:00) попадает в следующий цикл → клиенту честно «изменения с послезавтра». Бонус: 00:00 совпадает со сбросом дневных счётчиков, уже стоящим в расписании.
- Это фактически делает онлайн в опасном окне = офлайн-поведению. Малая правка, не переделка ядра.
- Альтернатива — сдвинуть наш слепок к 21:00 (синхронно с поставщиком). Вероятно хуже: съедает запас на ручную доливку. Решить в плане.
- NB: онлайн-sync уже частично знает про 21:00 поставщика ([targetWeekdayForNow, строки 558-565](../../../app/app/Jobs/SyncSupplierProjectJob.php#L558-L565)), но это про выбор дня недели, рассинхрон слепков это НЕ закрывает.

### 6.2. Проверка офлайн (batch) канала — ВЕРДИКТ: работает правильно

Офлайн — основной режим (онлайн нужен только на обкатку). Проверен по коду + расписание [routes/console.php](../../../app/routes/console.php).

**Вечерняя цепочка (МСК), всё успевает до слепка поставщика 21:00:**

| Время | Что | Зачем |
|---|---|---|
| 17:45 | RefreshSupplierSessionJob | свежий логин портала перед заливкой |
| 18:00 | billing:preflight-sweep | заморозка «бедных» клиентов до заказа |
| 18:02 | SnapshotProjectRoutingJob | слепок раздачи на завтра |
| 18:05 | SyncSupplierProjectsJob | батч-заливка поставщику ПО СЛЕПКУ |
| 18:05–20:55 | запас ~3 ч на failover | ярус 2 форма / ярус 3 ручная очередь |
| 21:00 | слепок поставщика (внешний) | заказ по залитому |

**Что подтверждено правильным:**
- Батч заливает ПО СЛЕПКУ, не по live — `collectEligibleProjects` join `project_routing_snapshots` за завтра ([SyncSupplierProjectsJob.php:208-237](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L208-L237)). Race 18:02→18:05 закрыт (slepok-инвариант).
- Failover 3 яруса ([FailoverProjectChannel.php](../../../app/app/Services/Supplier/Channel/FailoverProjectChannel.php)): AJAX → браузер-форма → ручная очередь + алерт-письмо.
- Анти-overspend: лимит группы делится по B1/B2/B3 ([distributeForPlatform, строка 288](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L288)).
- Подстраховки: external-deletion recovery, partial-set recovery, идемпотентность, mass-fail threshold 50 → алерт+стоп, TIME_BUDGET_CUTOFF 20:55, резервный CSV-канал сверки каждые 30 мин, supplier:retry-failed hourly.
- **Офлайн рассинхрона НЕ имеет:** батч + раздача + заказ поставщика на одном слепке 18:02. Рассинхрон (§6.1) — болезнь только онлайна.

**Косметика (не баг):** docblock `SyncSupplierProjectsJob` (шапка класса) говорит «18:00», реально джоб в 18:05 (расписание верное, врёт комментарий). Поправить при случае.

---

## 7. Самый важный нюанс — общие источники на ГРУППУ клиентов

«Один проект ↔ один источник» — упрощение. На самом деле:

> **Один источник у поставщика может кормить НЕСКОЛЬКО клиентов Лидерры сразу** (общий sms-отправитель / домен).

Видно в коде: онлайн-правка ОДНОГО проекта пересчитывает **всю группу**, делящую источник — регионы union, лимит = общая сумма заказа, дни union
([SyncSupplierProjectJob.php:115-164](../../../app/app/Jobs/SyncSupplierProjectJob.php#L115-L164)).
Удаление источника осторожное: убивается у поставщика **только если других потребителей не осталось**, иначе — пересчёт общего заказа
([ProjectService.php:138-139](../../../app/app/Services/Project/ProjectService.php#L138-L139)).

→ «Дать старому источнику дожить хвост» нельзя считать на одном проекте — только на уровне источника-для-группы: источник живёт, пока нужен хоть кому-то ИЛИ пока летит его хвост.

---

## 8. Идея владельца (целевой UX) — как должно быть

Вместо жёсткой блокировки «жди 2 дня»:

- **Количество / регион / дни** меняются свободно. Если после порога слепка — объявление:
  > «Мы уже ведём сбор на завтра. Ваши изменения вступят в силу с послезавтра.»
- **Источник / удаление проекта** — не блокировать, а подтверждение:
  > «Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. Послезавтра — уже нет. Подтвердите смену/удаление.»

**Жёсткая связка:** баннер «лиды придут в любом случае» можно показывать ТОЛЬКО после доделки §9 — иначе это враньё, лиды потеряются. Сначала техника, потом обещание.

---

## 9. Что доделать (НЕ сделано, на согласование)

Чтобы убрать блокировку источника и сделать обещание честным:

1. Раздача матчит источник **по слепку**, а не по живой связи `project_supplier_links` (как уже делает для DIRECT) — узел [LeadRouter.php:114-115](../../../app/app/Services/LeadRouter.php#L114-L115).
2. Старый источник **не удалять сразу**, пока летит его хвост И пока он нужен другим клиентам группы — узлы [detachOldSourceSupplierProjects](../../../app/app/Services/Project/ProjectService.php#L141-L182), `DeleteSupplierProjectJob`.
3. **Заморозка онлайн-отправки 18:00→00:00 + досыл очередью в 00:00** (§6.1): правки клиента в это окно не улетают поставщику сразу (защищают слепок поставщика 21:00 от подмены), а заливаются в 00:00 в следующий цикл. Офлайн-канал это уже умеет — переиспользовать его механику, а не городить новую. Окно 18:00→21:00 (запас на ручную доливку) НЕ трогать.
4. Тесты: «лид по старому источнику доезжает на следующий день после смены/удаления», «сосед по общему источнику не пострадал», «правка в окне 18:02–21:00 в онлайне не рассинхронит раздачу с заказом поставщика».
5. UX: убрать жёсткий замок, поставить баннеры-объявления и подтверждение (§8).

**Размер:** средняя правка в денежном ядре (раздача + удаление источника + слепок), НЕ «фундаментальная переделка», как пугала старая спека [2026-06-22-project-source-edit-lock-ux-design.md](../specs/2026-06-22-project-source-edit-lock-ux-design.md). Но с обязательной аккуратностью к общим источникам.

---

## 9a. ✅ ЗАКРЫТО (26.06.2026): отчёт о завершении вечерней заливки (для сверки глазами)

**РЕШЕНИЕ ВЛАДЕЛЬЦА (26.06.2026): формат — ЭКРАН в админке (НЕ письмо).** Реализовано в Эпике 5:
таблица `supplier_sync_runs` (одна строка на прогон `SyncSupplierProjectsJob`: групп обновлено / синхронизировано / в ручную / отложено / упало + статус ok/partial/failed/aborted, пишется в `finally`),
эндпоинт `GET /api/admin/supplier-integration/sync-runs`, карточка «Вечерняя заливка» в `AdminSupplierIntegrationView`. Владелец сверяет глазами до 21:00. Коммит Эпика 5 `1d18933d`.

---

### (исходная постановка — для истории)

**Запрос владельца (25.06.2026):** после того как система закончила обновлять проекты у поставщика (вечерний цикл 18:05–20:55, перед слепком поставщика 21:00) — прислать **сводный отчёт «готово, вот итог»**, чтобы человек сверил глазами и убедился, что всё ровно. **От этого зависят деньги.** Обсуждалось ранее — проверено: **до реализации НЕ дошло.**

**Что есть сейчас (по коду):**
- Алерты только на СБОИ: `manual_required` / `failover_to_form` / `sticky_auth` / `mass_transient` / `csv_reconcile_failed`.
- Технический heartbeat (`recordRunResult` onSuccess/onFailure) — телеметрия, не для глаз.
- Построчный `SupplierSyncLog` в БД (create/update/http_status/error) — есть данные, но нет человекочитаемой сводки.

**Чего НЕТ:** письма/экрана «вечерняя заливка завершена в HH:MM — групп обновлено N, создано M, в ручной очереди K, расхождений 0/список». При «нормальном» прогоне владелец ничего не получает и сверить не может.

**Кандидат:** по завершении `SyncSupplierProjectsJob` собрать сводку (из `SupplierSyncLog` за прогон + `SupplierManualSyncQueue` pending) → письмо/экран в админке до 21:00, чтобы успеть вмешаться в окно ручной доливки. Вынести в реестр [Открытые_вопросы_v8_3.md](../../Открытые_вопросы_v8_3.md) как `OPEN-*` / `DO-*`.

---

## 9b. КАРТА ШВОВ — ювелирный аудит (5 субагентов, построчно, 25.06.2026)

Метод: 5 параллельных `function-analyzer` субагентов прочитали построчно весь горячий путь (поток лида, матч, смена/удаление источника, слепок/синхронизация, CSV+биллинг). Ниже — швы по приоритету. **Каждый — место, где переделка может потерять деньги/лиды.**

### ✅ Хорошие новости (что НЕ ломается — можно не бояться)

- **`pgsql` и `pgsql_supplier` — одна физическая БД** (config/database.php: тот же host/database, разные роли). Синхронный разрыв pivot и асинхронное удаление видят одну таблицу — рассинхрона connection'ов нет.
- **Себестоимость (`supplier_lead_costs`) ссылается на `suppliers`, НЕ на `supplier_projects`** (schema). Удаление supplier_project НЕ создаёт orphan-FK в учёте себестоимости.
- **Тариф-цена завязана на `tenants.delivered_in_month`, не на проект/слепок** (LedgerService:55). Переделка матча не двигает цену лида.
- **`CleanupInactiveSupplierProjectsJob` физически удаляет только через TTL 180 дней** — доживающий источник он преждевременно НЕ снесёт (метит inactive сразу, но удаляет через 180д ≫ хвост 24ч).
- **Каскад регионов и взвешенный жребий не зависят от pivot** (LeadRouter) — читают `snap.regions`/лимит. Переезд источника на слепок их не трогает.
- **`assertCanMutateSource`: change_source и delete отличаются ТОЛЬКО глаголом текста** — условие 422 идентично. Снять блок смены источника, оставив защиту delete, безопасно.
- **`delivered_count` инкрементится по `pgsql_supplier`** — снимок переживает удаление проекта (нет FK на projects).

### 🔴 КРИТИЧНО — ДЕНЬГИ: двойное списание через промах CSV-merge

- **Места:** [RouteSupplierLeadJob.php:352-402](../../../app/app/Jobs/RouteSupplierLeadJob.php#L352-L402) (merge), [:406-418](../../../app/app/Jobs/RouteSupplierLeadJob.php#L406-L418) (delivery-lock), [LedgerService.php:47-128](../../../app/app/Services/Billing/LedgerService.php#L47-L128) (charge).
- **Механизм:** webhook-лид и CSV-восстановленный лид — это **ДВЕ разные строки `supplier_leads`** (разные `supplier_lead_id`). Замок `supplier_lead_deliveries` PK = `(supplier_lead_id, tenant_id)` ловит дубль ТОЛЬКО в пределах одного lead_id, НЕ между webhook и CSV одного физического лида. Единственное, что схлопывает их без второго списания — **merge-ветка** по ключу `(tenant_id, phone, project_id, source_crm_id IS NULL, received_at ≥ now-24ч)`.
- **Где сломаем:** если переделка матча изменит, к какому **`project_id`** сматчится лид (например, слепок за разные даты у CSV и webhook дадут разный набор проектов), merge промахнётся → создастся второй deal → **второе списание за один физический лид**. `lead_charges` собственного unique-констрейнта НЕ имеет — идемпотентность держится только на delivery-lock + merge.
- **Что обязан гарантировать план:** webhook и CSV-recovery одного лида ВСЕГДА матчатся к одному `project_id` в окне 24ч. Тест-обязателен.

### 🔴 КРИТИЧНО — ПОТЕРЯ ЛИДОВ: resolveOrStub пересоздаёт источник с новым id

- **Места:** [SupplierProjectResolver.php:48](../../../app/app/Services/SupplierProjects/SupplierProjectResolver.php#L48) (firstOrCreate), [LeadRouter.php:114-116](../../../app/app/Services/LeadRouter.php#L114-L116) (pivot-матч по `sp.id`).
- **Механизм:** входящий лид резолвится в supplier_project по `(platform, unique_key)`. Если старый источник **удалён** — `firstOrCreate` создаёт **новый stub с новым id**. Матч по живому pivot (`psl.supplier_project_id = новый_id`) → pivot ссылается на старый id → 0 проектов → **хвостовой лид теряется молча**.
- **Вывод:** это прямое доказательство, что матч обязан идти по **signal-полям слепка** (не по `sp.id`), И/ИЛИ старый источник нельзя удалять, пока летит хвост. Оба нужны.

### 🔴 КРИТИЧНО — ПОТЕРЯ ЛИДОВ: отсутствие слепка = молчаливый skip

- **Места:** [RouteSupplierLeadJob.php:317-326](../../../app/app/Jobs/RouteSupplierLeadJob.php#L317-L326) (`no_snapshot_skipped` → return false, НЕ ошибка).
- **Механизм:** при списании лид повторно ищет слепок за активную дату; нет слепка → лид молча не доставлен (не считается failure, не ретраится). Граница 21:00 считается дважды (в матче и в транзакции) — если джоб пересёк 21:00 между ними, ищется слепок другой даты → срыв доставки.
- **Где сломаем:** новый проект без слепка, или проект после смены источника без свежего слепка → массовый молчаливый канал потери. План обязан гарантировать слепок для каждого активного проекта на момент И матча, И транзакции.

### 🔴 КРИТИЧНО — РАССИНХРОН: даже ОФЛАЙН-батч строит источник по LIVE, не по слепку

- **Места:** [SyncSupplierProjectsJob.php:219](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L219) (select projects.* — источник live), [:231-233](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L231-L233) (override ТОЛЬКО лимит/дни/регионы).
- **ПОПРАВКА к §6.2:** я раньше сказал «офлайн рассинхрона не имеет». Уточнение по коду: офлайн-батч (`collectEligibleProjects`) переопределяет из слепка **только лимит/дни/регионы**, а **источник (identifier) берёт LIVE из projects**. Значит при смене источника в окне 18:02→18:05 батч закажет поставщику по НОВОМУ источнику, а слепок раздачи хранит СТАРЫЙ. Окно узкое (3 мин) и сейчас закрыто блокировкой смены источника — но **когда блокировку снимем, окно станет реальным.**
- **Вывод:** матч (LeadRouter) И заказ (collectEligibleProjects) должны брать источник из ОДНОГО источника истины — слепка. План обязан переопределять и поле источника из слепка, либо строить identifier из снимка.

### 🟠 ВАЖНО — sms agnostic-ключ: матч по `sms_senders[0]` + keyword, не «любой элемент»

- **Места:** [SupplierProjectGrouping.php:31-43](../../../app/app/Services/Supplier/SupplierProjectGrouping.php#L31-L43) (формула), снимок хранит `sms_senders` JSONB + `sms_keyword` TEXT.
- **Механизм:** agnostic-ключ sms = `sms_senders[0]` (ТОЛЬКО первый элемент массива) + `+keyword` если keyword непустой. DIRECT-образец матчит только `signal_identifier`, который **для sms = NULL** — значит «скопировать DIRECT-ветку» для sms НЕ сработает.
- **Коллизия:** два проекта на одном sender — A с keyword, B без → разные ключи, разные supplier_projects. SQL-матч по слепку должен сравнивать `snap.sms_senders ->> 0` (первый!) + `snap.sms_keyword`, иначе `@>` (любой элемент) расширит матч и захватит чужие проекты.
- **Открытый вопрос:** приходит ли keyword в payload лида склеенным с sender или отдельно — нужен реальный `raw_payload['project']` sms-лида.

### 🟠 ВАЖНО — root-domain auto-link живёт только в pivot, не в матче

- **Места:** auto-link в [SyncSupplierProjectsJob.php:504-527](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L504-L527) и онлайн-синке; в LeadRouter root-domain логики НЕТ.
- **Механизм:** лид с корневым доменом `carmoney.ru` доходит до подписчиков на субдомены `*.carmoney.ru` — но это обеспечивает доп. pivot-строка, которую ставит sync. При переходе на матч по `signal_identifier` строгим равенством субдомен-проекты **перестанут** получать корневые лиды.
- **Вывод:** site-матч по слепку должен воспроизвести root-domain раскрытие (суффиксное сравнение), однонаправленно (корень-лид → субдомен-проекты), не расширяя на обратное.

### 🟠 ВАЖНО — удаление источника по единственному критерию pivot-count

- **Места:** [DeleteSupplierProjectJob.php:51-73](../../../app/app/Jobs/Supplier/DeleteSupplierProjectJob.php) — удаляет sp если `count(pivot)=0`, БЕЗ проверки необработанных `supplier_leads` или снимка на завтра. [ProjectService.php:164-167](../../../app/app/Services/Project/ProjectService.php#L164-L167) — pivot рвётся СИНХРОННО до асинхронного удаления.
- **Механизм:** при смене источника единственным потребителем — удаление немедленное; `supplier_leads.supplier_project_id` обнулится (ON DELETE SET NULL) → пришедший позже хвост теряет привязку.
- **Что добавить в условие «можно удалять»:** (а) pivot-count=0 И (б) нет снимка с этим источником за сегодня/завтра И (в) нет необработанных `supplier_leads` на этот sp.

### 🟡 УЧЕСТЬ — прочее

- **Терминальная классификация ошибок** ([RouteSupplierLeadJob.php:126-130](../../../app/app/Jobs/RouteSupplierLeadJob.php#L126-L130)): если новый матч начнёт бросать ошибку без подстрок `'does not support'`/`'platform mismatch'`/`'no matching supplier_project'` → она станет НЕ-терминальной → retry-шторм (инциденты 21-22.05, 25k записей). Список терминальных подстрок синхронизировать с новым кодом.
- **Два connection'а для счётчиков** ([RouteSupplierLeadJob.php:464-473](../../../app/app/Jobs/RouteSupplierLeadJob.php#L464-L473)): live `delivered_today`++ (pgsql) и snapshot `delivered_count`++ (pgsql_supplier) НЕ атомарны одним COMMIT — возможен дрейф. План должен решить, какой счётчик авторитетен.
- **`delivered_count` НЕ инкрементится при merge** → business-drift расчёт ([CsvReconcileJob.php:324](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L324)) может занижать. Учесть при изменении merge.
- **`applies_from` считается ПОСЛЕ detach** ([ProjectService.php:92](../../../app/app/Services/Project/ProjectService.php#L92) до :119) → для смены источника метка «вступит в силу с…» будет null (pivot уже снесён). UI это учесть.
- **dual-balance ликвидирован** — списание всегда в рублях (LedgerService); prepaid `balance_leads` не используется. Не ссылаться на prepaid.
- **`effective_daily_limit_today = MIN(daily_limit_target, FLOOR(balance/lead_cost))`** — лимит в слепке уже автокорректирован по балансу.

### Открытые вопросы (требуют отдельного чтения перед кодингом)

1. Формат `raw_payload['project']` для sms-лида — keyword склеен с sender или нет? (нужен реальный payload / спека поставщика).
2. Точный уникальный constraint `supplier_lead_deliveries` — `(supplier_lead_id, tenant_id)` (агенты сошлись на этом по schema, подтвердить).
3. Нормализация телефона в CSV vs webhook-ingest (дедуп делает только `trim`) — где приводится формат.
4. Как `SyncSupplierProjectsJob` переносит pivot при пересоздании stub с новым id.
5. Бывают ли DIRECT+sms проекты на практике (DIRECT-ветка их не ловит — signal_identifier=NULL).

---

## 9c. ОТВЕТЫ на открытые вопросы (второй раунд субагентов, закрыто)

### ✅ Q1 — SMS-формат: поставщик шлёт `sender+keyword` СКЛЕЕННЫМ

Подтверждено цепочкой: webhook хранит `project` дословно → `parseProjectField` для sms берёт **весь хвост как один opaque-identifier, без разбивки** ([RouteSupplierLeadJob.php:244-245](../../../app/app/Jobs/RouteSupplierLeadJob.php#L244-L245)) → этот identifier должен совпасть с project-side ключом `sender+keyword` (или `sender`) из [buildUniqueKeyAgnostic:37-42](../../../app/app/Services/Supplier/SupplierProjectGrouping.php#L37-L42). Обратное подтверждение: `SupplierImportMapper::parseSmsContent` сплитит контент поставщика по **первому `+`**; спека supplier-integration §61 прямо: `sender+keyword`.
**Вывод для матча по слепку:** sms-проект слепка матчится, если лид-строка = `snap.sms_senders[0] + '+' + snap.sms_keyword` (keyword есть) ИЛИ `snap.sms_senders[0]` (нет). Сравнивать с **первым** элементом массива, не «любым». Регистр sms — БЕЗ `LOWER` (в отличие от DIRECT site/call). Остаётся проверить на живом payload, но конвенция доказана кодом+спекой.

### ✅ Q2 — Замок доставки = `(supplier_lead_id, tenant_id)`, в `lead_charges` UNIQUE НЕТ

Подтверждено по schema: `supplier_lead_deliveries` PK = **`(supplier_lead_id, tenant_id)`** (НЕ project, НЕ deal). `lead_charges` — никакого UNIQUE по deal/lead, только суррогатный `id`; индекс `(deal_id, deal_received_at)` НЕ уникальный.
**Следствие (усиливает шов двойного списания):** идемпотентность списания держится ИСКЛЮЧИТЕЛЬНО на (а) замке доставки по lead+tenant и (б) merge-ветке. Webhook и CSV одного физического лида — **разные `supplier_lead_id`**, замок их НЕ различает как дубль → только merge по `(tenant, phone, project_id, received_at 24ч)` спасает от второго списания. Сохранить merge-эквивалентность по `project_id` — критично.

### ✅ Q3 — Телефон в CSV НЕ нормализуется (латентный шов, вне нашей переделки)

Webhook жёстко требует `7\d{10}` регексом валидации. CSV-канал телефон **не нормализует вообще** (только `trim` в дедуп-ключе), хотя `PhoneNormalizer` в коде есть — но в обоих ingest-путях НЕ вызывается. Совпадение форматов webhook↔CSV **предполагается**, не обеспечено кодом. Если отчёт поставщика однажды пришлёт `8…`/`+7…` — дедуп промахнётся → лид пере-вставится. Это существующий латентный риск, **не вносится нашей переделкой**, но фиксируем.

### ✅ Q4 — Pivot восстанавливается ночным батчем ADDITIVELY; stale-строку никто не чистит

Связь project↔новый источник восстанавливает ночной `SyncSupplierProjectsJob` ([:495-501](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L495-L501)): группирует по новому agnostic-ключу → находит/создаёт supplier_project нового ключа → `insertOrIgnore` новый pivot. **Только добавление, без DELETE.** Старая строка `(project_id, old_sp_id)` в `syncGroup` НЕ удаляется; механизма очистки stale-pivot в проверенном объёме НЕ найдено (`SupplierRekeyOrphansCommand` — только одноразовый sms sender→sender+keyword, не про смену источника).
**Следствие:** при отложенном удалении (Task 2.4) надо **целенаправленно чистить старый pivot**, иначе проект остаётся связан и со старым, и с новым источником. Восстановление к новому требует пересборки слепка за завтра с новым источником.

### ✅ Q5 — DIRECT+sms возможны, но СЕЙЧАС матчатся НИ С ЧЕМ (существующий тихий провал)

Только `B1+sms` запрещён; `DIRECT+sms` создаётся свободно ([resolveOrStub:42-46](../../../app/app/Services/SupplierProjects/SupplierProjectResolver.php#L42-L46)). НО DIRECT-ветка матча сравнивает `LOWER(snap.signal_identifier)`, а для sms `signal_identifier = NULL` → `LOWER(NULL)=LOWER(sender)` даёт NULL (не TRUE) → **0 кандидатов на всех фазах**. То есть DIRECT+sms-лиды создают supplier_project, но **никогда не доставляются** — это уже сейчас тихая потеря.
**Вывод для переделки:** sms ВЕЗДЕ матчить по `sms_senders`, никогда по `signal_identifier`. Заодно эта переделка чинит существующий провал DIRECT+sms.

### 🆕 Дополнительные находки (важные)

- **НЕТ транзакции вокруг `update → detach → DeleteSupplierProjectJob::dispatch`** ([ProjectService.php](../../../app/app/Services/Project/ProjectService.php) — `DB::transaction` отсутствует). pivot DELETE, project update, dispatch — отдельные автокоммиты. При сбое в середине возможно частичное состояние. Очередь на проде = redis (async). План должен решить, нужна ли транзакция вокруг смены источника.
- **`computeOrder = max(max(лимиты), ceil(Σ/3))`** — НЕ сумма лимитов группы! `3` = cap шеринга (лид ≤3 раз). `[10,10,10]`→order 10, не 30. `distributeForPlatform` — largest-remainder, Σ долей = order. (Закрывает вопрос про формулу заказа.)
- **Защита от retry-шторма трёхслойная:** `retry_count≤3` + cooldown 1ч + окно 24ч (в команде) + terminal-detect по подстрокам в джобе. Распределена — ни один слой не полон сам по себе.
- **Нотификации:** `notifyNewLead` внутри транзакции делает только in-app INSERT (откатываемо), без синхронного email. НО `notifyZeroBalancePaused` — синхронный `Mail::send` ВНЕ транзакции и **без try/catch** → Mail-исключение может уйти наружу. Не наш scope, но рядом.
- **`LeadRegionResolver` fail-safe только для DaData/сети** (`catch DaDataException`); `rossvyazLookup`/`cache`/`budgetGuard` без try/catch — их не-DaData исключение уронит обработку лида в retry. Не наш scope.
- **Две модели связи сосуществуют:** legacy `projects.supplier_b{1,2,3}_project_id` (ON DELETE SET NULL) + новый pivot `project_supplier_links` (ON DELETE CASCADE). Какая канон в runtime — неясно; detach чистит обе.
- **RLS-асимметрия:** `lead_charges` имеет `WITH CHECK`, а `supplier_lead_deliveries` и `project_routing_snapshots` — только `USING`. Снимок читает GUC мягко (`,true`), deliveries — жёстко.

### Остаточные «неясно» (некритичные, на этап кода)

1. Живой пример `raw_payload['project']` sms-лида — финально подтвердить склейку (конвенция доказана, но не на проде).
2. Multi-sender sms-проекты: ключ берёт только `sms_senders[0]` — как представлены 2+ отправителя.
3. Формат телефона в реальном отчёте поставщика (вся CSV-дедупликация на этом держится).
4. Поведение вложенной транзакции `notifyInApp` поверх tenant-транзакции на PgBouncer transaction-pooling.

---

## 10. Файлы-якоря

- Гард блокировки: `app/app/Services/Project/SupplierSnapshotGuard.php`
- Смена источника / detach: `app/app/Services/Project/ProjectService.php:60-182`
- Слепок раздачи (18:02): `app/app/Jobs/SnapshotProjectRoutingJob.php`
- Раздача по слепку: `app/app/Services/LeadRouter.php:104-158`
- Таблица слепка (переживает удаление): `app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php`
- Онлайн/офлайн: `app/app/Services/Supplier/SupplierExportMode.php`, `app/app/Jobs/SyncSupplierProjectJob.php`
- Группы / общий источник: `app/app/Jobs/SyncSupplierProjectJob.php:115-164`
- Старая спека (блокировка как UX): `docs/superpowers/specs/2026-06-22-project-source-edit-lock-ux-design.md`
</content>
</invoke>
