Полный комплект под фичу «матч источника по слепку, без потери лидов»: - findings: ультра-гранулярный аудит швов денежного ядра (9 субагентов, §9a-9c) - plans: реализация в 6 эпиков (TDD, флаг отката, порядок выката) - runbooks: план проверки (код + глаза + деньги, GO/NO-GO) - specs: design-gate Путь A — согласован владельцем 25.06.2026 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
Design-gate: матч источника по слепку + отложенное удаление (денежное ядро)
Дата: 2026-06-25 Статус: ✅ СОГЛАСОВАНО владельцем (Дмитрий) 25.06.2026 — Путь A, флаг отката, транзакция, полная гарантия слепка для новых/возобновлённых проектов. Код ещё НЕ тронут (ждёт «делаем»). Это Task 2.0 плана 2026-06-25-source-edit-unblock-snapshot-routing.md. Основание: карта швов findings §9b/§9c (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).
- ✅ Слепок уже хранит
signal_type/signal_identifier/sms_senders/sms_keyword— данные есть. - ✅ Слепок специально переживает удаление проекта (нет FK на projects).
- ✅ Матч перестаёт зависеть от
supplier_project.id→ пересоздание stub (§9b SEAM-B) больше не рвёт доставку. - ✅ Сближает матч и списание — оба на слепке (списание уже на слепке, RouteSupplierLeadJob.php:327).
- ⚠️ Требует воспроизвести в 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). Лид несёт $sp->unique_key = agnostic-ключ (для sms это уже sender+keyword или sender — подтверждено §9c-Q1).
site / call: прямое сравнение (как DIRECT):
snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?) -- ? = signal_type, unique_key
sms: реконструировать agnostic-ключ из компонентов слепка и сравнить с лид-ключом:
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 (первый отправитель + keyword). Это чинит и существующий тихий провал DIRECT+sms (§9c-Q5).
root-domain (site): лид по корню carmoney.ru должен доходить до подписчиков на субдомены (однонаправленно, §9b SEAM-«root»):
... 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); delivered_today (live) — остаток-счётчик; delivered_count (snapshot) — для drift. Переделка их не меняет. |
| 10 | Stale pivot | detach уже рвёт старый pivot СИНХРОННО при смене источника (ProjectService.php:164-167) — по Пути 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. Резюме инвариантов, которые переделка ОБЯЗАНА сохранить
- Один физический лид → один набор проектов-получателей (webhook и CSV сходятся по project_id) → нет двойного списания.
- Хвостовой лид по старому источнику доезжает на следующий день после смены (матч по слепку).
- Сосед по общему источнику не страдает при смене у одного клиента.
- Лимит/регион из слепка; is_active/баланс из live (гибрид не ломаем).
- sms матчится по
sms_senders[0]+keyword, не поsignal_identifier.
8. После утверждения — порядок работ
- Этот файл → «согласовано».
- Эпик 0 (страховочные тесты) → Эпик 2 (ядро, под флагом 5.1) → наблюдение на стенде по плану проверки.
- Эпики 4/5/6 (независимы) → Эпик 3 (снятие замка) последним.
Открытые под-вопросы для этапа кода (не блокируют утверждение): точка backfill слепка (шов 3); живой пример sms-payload (§9c-Q1); multi-sender sms (§9c остаток).