# Design-gate: матч источника по слепку + отложенное удаление (денежное ядро)

**Дата:** 2026-06-25
**Статус:** ✅ СОГЛАСОВАНО владельцем (Дмитрий) 25.06.2026 — Путь A, флаг отката, транзакция, полная гарантия слепка для новых/возобновлённых проектов. Код ещё НЕ тронут (ждёт «делаем»). Это Task 2.0 плана [2026-06-25-source-edit-unblock-snapshot-routing.md](../plans/2026-06-25-source-edit-unblock-snapshot-routing.md).
**Основание:** карта швов [findings §9b/§9c](../findings/2026-06-25-source-edit-block-snapshot-analysis.md) (9 субагентов, построчный аудит).
**Что утвердить:** (1) выбор Пути A/B, (2) ответы по 12 швам, (3) 3 решения по спорным. После «согласен» — можно кодить Эпик 2.

---

## 1. Проблема (одной фразой)

Сейчас поставщиковые (B1/B2/B3) проекты матчатся к лиду через **живую связь** `project_supplier_links`, которая рвётся при смене источника → хвостовые лиды теряются. Нужно матчить по **слепку** `project_routing_snapshots`, который переживает смену/удаление.

---

## 2. Развилка — РЕКОМЕНДУЮ Путь A

**Путь A (рекомендуется): матч поставщиковых проектов по signal-полям слепка** — так же, как уже работает DIRECT ([LeadRouter.php:108-112](../../../app/app/Services/LeadRouter.php#L108-L112)).
- ✅ Слепок уже хранит `signal_type/signal_identifier/sms_senders/sms_keyword` — данные есть.
- ✅ Слепок специально переживает удаление проекта (нет FK на projects).
- ✅ Матч перестаёт зависеть от `supplier_project.id` → пересоздание stub (§9b SEAM-B) больше не рвёт доставку.
- ✅ Сближает матч и списание — оба на слепке (списание уже на слепке, [RouteSupplierLeadJob.php:327](../../../app/app/Jobs/RouteSupplierLeadJob.php#L327)).
- ⚠️ Требует воспроизвести в SQL agnostic-логику sms (sender+keyword) и root-domain — детали в §3.

**Путь B (отклонён): слепок хранит `supplier_project_id`** + матч по нему + sp «живёт мёртвым».
- ✖️ Миграция таблицы снимков (партиционированной).
- ✖️ Больше состояний у supplier_project; не решает пересоздание stub.
- ✖️ Дальше от текущего DIRECT-образца.

**РЕШЕНИЕ К УТВЕРЖДЕНИЮ:** Путь A. ☐ согласен ☐ обсудить.

---

## 3. Как именно матчить по слепку (SQL, заменяет pivot-EXISTS)

Заменяем `$sourceWhere` для не-DIRECT ([LeadRouter.php:113-117](../../../app/app/Services/LeadRouter.php#L113-L117)). Лид несёт `$sp->unique_key` = agnostic-ключ (для sms это уже `sender+keyword` или `sender` — подтверждено §9c-Q1).

**site / call:** прямое сравнение (как DIRECT):
```sql
snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)   -- ? = signal_type, unique_key
```

**sms:** реконструировать agnostic-ключ из компонентов слепка и сравнить с лид-ключом:
```sql
snap.signal_type = 'sms'
AND (
  (snap.sms_senders ->> 0)
  || CASE WHEN COALESCE(snap.sms_keyword,'') <> '' THEN '+' || snap.sms_keyword ELSE '' END
) = ?                                                                -- ? = lead unique_key (= sender[+keyword])
```
То есть собираем `sender[+keyword]` из снимка ровно по формуле [buildUniqueKeyAgnostic](../../../app/app/Services/Supplier/SupplierProjectGrouping.php#L37-L42) (первый отправитель + keyword). Это чинит и существующий тихий провал DIRECT+sms (§9c-Q5).

**root-domain (site):** лид по корню `carmoney.ru` должен доходить до подписчиков на субдомены (однонаправленно, §9b SEAM-«root»):
```sql
... AND ( snap.signal_identifier = LOWER(?) OR snap.signal_identifier LIKE '%.' || LOWER(?) )
```
где `?` — корневой домен лида (через `SupplierIdentifier::extractRootDomain`). Обратное направление (лид-субдомен → корневой проект) НЕ добавляем — сохраняем текущую семантику.

**Не меняем:** регион-каскад, weightedPick, фильтр лимита/баланса — они уже на слепке/live и ортогональны источнику (§9b «хорошие новости»).

---

## 4. Ответы по 12 швам (к утверждению)

| # | Шов | Решение |
|---|---|---|
| 1 | Двойное списание (CSV-merge) | **Не трогаем merge-ключ.** Матч по слепку детерминирован: один физический лид → один и тот же набор проектов независимо от webhook/CSV. Страховочный тест `NoDoubleChargeOnSourceChangeTest` (Task 2.7) гонять до И после — если позеленел до и покраснел после, матч развёл project_id → стоп. |
| 2 | resolveOrStub новый id | Решено Путём A: матч по signal-полям, не по `sp.id`. Пересоздание stub доставку не рвёт. |
| 3 | no_snapshot молчаливый skip | **Гарантировать слепок.** При создании/возобновлении проекта в окне (до 18:02 на сегодня) — снять слепок на сегодня через существующий `SnapshotBackfillCommand`/`SnapshotProjectRoutingJob`-логику. Плюс тест `NoSnapshotNoSilentLossTest`. (Открытый под-вопрос: точная точка backfill — решить в Task реализации.) |
| 4 | Источник в офлайн-батче (LIVE) | Task 2.6: `collectEligibleProjects` переопределяет ИЗ слепка не только лимит/дни/регионы, но и `signal_identifier/sms_senders/sms_keyword`. |
| 5 | sms agnostic-ключ | §3 sms-SQL: реконструкция `sender[+keyword]` из снимка, сравнение с лид-ключом. По `->> 0` (первый отправитель). |
| 6 | root-domain | §3 root-SQL: суффиксное сравнение, однонаправленно. |
| 7 | Удаление источника преждевременное | Task 2.4. ВНЕШНЕЕ удаление rt-проекта у поставщика откладывать пока: нет снимка-хвоста (`snapshot_date >= today` с этим источником) И нет необработанных `supplier_leads` (processed_at IS NULL) на этот sp И pivot-count=0. Внутренний матч по Пути A от этого не зависит. |
| 8 | Терминальные подстроки ошибок | **Безопасно.** Новый матч НЕ бросает новых ошибок — он, как DIRECT, просто возвращает 0 кандидатов (тихий не-доставщик), а не исключение. Список терминальных подстрок не трогаем. |
| 9 | Авторитетный счётчик | **Оставляем как есть:** лимит — из `snapshot.daily_limit` (уже так, [:327](../../../app/app/Jobs/RouteSupplierLeadJob.php#L327)); `delivered_today` (live) — остаток-счётчик; `delivered_count` (snapshot) — для drift. Переделка их не меняет. |
| 10 | Stale pivot | `detach` уже рвёт старый pivot СИНХРОННО при смене источника ([ProjectService.php:164-167](../../../app/app/Services/Project/ProjectService.php#L164-L167)) — по Пути A это БЕЗОПАСНО (матч не зависит от pivot). Ночной батч добавляет новый. Спец-чистка не нужна сверх существующего detach. |
| 11 | sms везде по `sms_senders` | §3 sms-SQL. Заодно чинит DIRECT+sms. |
| 12 | Транзакция вокруг смены источника | **Обернуть** `update` + `detach` в `DB::transaction`; `dispatch` джобов — ПОСЛЕ commit (`DB::afterCommit` / вне транзакции). Убирает частичное состояние при сбое. Малая правка в ProjectService. |

---

## 5. Три решения по спорным (к утверждению)

**5.1. Флаг отката.** Ввести `system_settings.routing_match_by_snapshot` (bool). `true` → новый матч по слепку; `false` → старый pivot-EXISTS. Оба пути в `queryCandidates` за `if`. Откат раздачи — одним переключателем без релиза. ☐ согласен.

**5.2. Транзакция.** Обернуть смену источника в транзакцию (шов 12), dispatch после commit. ☐ согласен.

**5.3. Гарантия слепка для новых/возобновлённых проектов (шов 3).** Backfill слепка на сегодня при создании/возобновлении в окне до 18:02. ☐ согласен ☐ только диагностика без backfill.

---

## 6. Что НЕ трогаем (подтверждено аудитом)

- merge-логику CSV↔webhook (шов 1), `lead_charges`/`supplier_lead_costs` привязку (устойчивы, §9b SEAM-F).
- tier-цену (на tenant, не на проекте), `delivered_in_month`.
- регион-каскад, weightedPick, баланс-фильтр.
- `CleanupInactiveSupplierProjectsJob` (TTL 180д не удалит хвост преждевременно).
- DIRECT site/call матч (уже корректен).
- Нормализацию телефона в CSV (латентный шов вне scope, §9c-Q3 — отдельный follow-up).

---

## 7. Резюме инвариантов, которые переделка ОБЯЗАНА сохранить

1. Один физический лид → один набор проектов-получателей (webhook и CSV сходятся по project_id) → нет двойного списания.
2. Хвостовой лид по старому источнику доезжает на следующий день после смены (матч по слепку).
3. Сосед по общему источнику не страдает при смене у одного клиента.
4. Лимит/регион из слепка; is_active/баланс из live (гибрид не ломаем).
5. sms матчится по `sms_senders[0]`+keyword, не по `signal_identifier`.

---

## 8. После утверждения — порядок работ

1. Этот файл → «согласовано».
2. Эпик 0 (страховочные тесты) → Эпик 2 (ядро, под флагом 5.1) → наблюдение на стенде по [плану проверки](../runbooks/2026-06-25-source-edit-test-acceptance-plan.md).
3. Эпики 4/5/6 (независимы) → Эпик 3 (снятие замка) последним.

**Открытые под-вопросы для этапа кода** (не блокируют утверждение): точка backfill слепка (шов 3); живой пример sms-payload (§9c-Q1); multi-sender sms (§9c остаток).
