# R3 — Прогон: движок (распределение · деньги · сделки · изоляция)

**Owner:** исполнитель | **Зависит от:** R0, R1 (тест-клиенты), R2 (инъекционный стенд). База: `prod-logic-map.md` §6, §8, §9, §19.
**Формат карточек — уровень C** (полный образец см. R0 §6, карточка M1). Каждая карточка: §ref · Что · Предусловие · Действие · Ожидаем · Сверка · Если не так · Покрывает.
**UI-часть** (уведомления-дайджест/отчёты/импорт/онбординг) — отдельный ранбук R3b (после деплоя G1/G2/G3). Здесь — **движок**, он стабилен.

> Числа денег НЕ зашиты: цену бери из живой `pricing_tiers` (`SELECT threshold, price_per_lead_kopecks FROM pricing_tiers WHERE is_active ORDER BY threshold`). На момент написания T1=50000коп(500₽)…T7=25000коп(250₽); планируется смена на ≤65₽ — **всегда сверяй с БД**.

---

## Часть A — Распределение лида в сделки (§19)

### D-CAP — Один лид раздаётся максимум 3 клиентам
**§ref:** §19 (CAP=3), `LeadDistributor`. **Что:** при ≥4 клиентах на источнике лид уходит ровно ≤3 разным.
**Предусловие:** кластер c1–c7 (7 клиентов, один источник `test-okna.ru`, все регион 82, лимит по 10, баланс достаточный); слепок активной даты построен (R2-I2).
**Действие:** влей 1 лид (R2-I4) на `B2_test-okna.ru`, `vid` уникальный.
**Ожидаем:** ровно ≤3 строки `deals`/`supplier_lead_deliveries` для этого лида, у 3 РАЗНЫХ тенантов; `deals_created_count`≤3.
**Сверка:** `SELECT count(*), count(distinct tenant_id) FROM supplier_lead_deliveries WHERE supplier_lead_id=<L>` → `≤3 | =count` (по одному на тенанта).
**Если не так:** >3 получателей / два на одного тенанта → **СТОП**, критдефект раздачи.
**Покрывает:** CAP=3, по одному на тенанта.

### D-ROUND — «Второй круг»: за серию лидов обслужены все шерящие
**§ref:** §18.2 (взвешенный жребий). **Что:** при 7 шерящих и серии лидов лид крутится, все получают (распределение ∝ остатку лимита, в проде случайное).
**Предусловие:** как D-CAP.
**Действие:** влей ~15 лидов подряд (R2-I7) на тот же источник.
**Ожидаем:** каждый лид ≤3 получателя; **никто не превысил свой дневной лимит**; за серию **все 7** хоть раз получили лид.
**Сверка:** `SELECT tenant_id, count(*) FROM supplier_lead_deliveries sld JOIN supplier_leads sl ON sl.id=sld.supplier_lead_id WHERE sl.vid IN (<серия>) GROUP BY tenant_id` → 7 строк, каждая ≤ лимита.
**Если не так:** кто-то >лимита / часть клиентов 0 за всю серию (при равных лимитах) → разобрать (лимит/баланс/слепок); НЕ сверяй точный состав отдельного лида (жребий случайный).
**Покрывает:** жребий, лимит-инвариант.

### D-CASCADE1/2/3 — Каскад региона
**§ref:** §19.3, `LeadRouter` фазы. **Что:** фаза 1 точный регион → фаза 2 «вся РФ» добор → фаза 3 подмена.
**Предусловие:** кластер c8–c12 на `test-dveri.ru`: c8 регион=[82] Москва, c9=[83] СПб, c10=[] вся РФ, c11=[16] Татарстан; (для фазы 3 — отдельный прогон без «вся РФ»).
**Действие/Ожидаем:**
- **C1 (точный):** лид с телефоном Москвы (subject 82) → среди получателей c8 (точный, routing_step=1). Если точных ≥3 — «вся РФ» c10 НЕ добирается.
- **C2 (добор):** лид региона, где точных < 3 → добор из c10 (вся РФ, step=2), исключая уже выбранных.
- **C3 (подмена):** лид региона, который НИКТО не покрывает и нет «вся РФ» (источник без c10) → fallback (step=3), `deals.region_substituted=true`, **`deals.city` = РЕАЛЬНЫЙ регион** телефона (не подменённый).
**Сверка:** `SELECT subject_code, city, region_substituted FROM deals WHERE id=<D>`; журнал `lead_region_resolution_log` (routing_step, substituted_subject_code).
**Если не так:** «вся РФ» получает лид при достаточных точных / город = подменённый код / step неверный → разобрать против §19.3/§19.4.
**Покрывает:** 3 фазы каскада, город при подмене.

### D-LIMIT — Лимит под локом не превышается (гонка)
**§ref:** §19.4 (recheck delivered_today под локом). **Что:** даже при потоке лид не превысит дневной лимит проекта.
**Предусловие:** c2 лимит=5 на общем источнике.
**Действие:** влей > 5 лидов так, чтобы c2 мог попасть в получатели.
**Ожидаем:** `projects.delivered_today` c2 ≤ 5; лиды сверх лимита уходят другим (перелив) или не доставляются c2.
**Сверка:** `SELECT delivered_today FROM projects WHERE id=<c2_proj>` ≤ 5; в логах `supplier_lead.project_at_limit_skipped`.
**Если не так:** delivered_today > лимита → **СТОП**, критдефект.
**Покрывает:** R-04/R-06 лимит из слепка под локом.

### D-PAUSE — Пауза «на лету» (R-09)
**§ref:** §19.4 (recheck is_active под локом). **Что:** проект, паузнутый между слепком и доставкой, лид НЕ получает.
**Предусловие:** проект c3 в слепке (активен), затем поставь его на паузу (через портал/SQL).
**Действие:** влей лид на источник c3 сразу после паузы.
**Ожидаем:** c3 НЕ в получателях; в логе `supplier_lead.project_paused_under_lock`.
**Сверка:** нет `supplier_lead_deliveries` для (lead, c3-tenant).
**Если не так:** лид ушёл паузнутому → критдефект.
**Покрывает:** R-09.

### D-IDEMP — Один лид (vid) обрабатывается один раз
**§ref:** §19.1. **Действие:** влей лид с `vid=X`; затем влей повторно тот же `vid=X`.
**Ожидаем:** 1-й → 202 accepted; 2-й → 200 already_processed; **число сделок/списаний НЕ выросло** на повторе.
**Сверка:** `SELECT deals_created_count, processed_at FROM supplier_leads WHERE vid=X` (одна строка); `count` сделок по лиду не изменился.
**Если не так:** повтор создал лишние сделки/списания → **СТОП**, критдефект (был инцидент 25k).
**Покрывает:** идемпотентность по vid.

### D-DELIV — Одна поставка одному клиенту = один раз
**§ref:** §19.4 (`supplier_lead_deliveries insertOrIgnore`). **Что:** гонка/повтор не создаёт две сделки одному тенанту по одному лиду.
**Сверка:** UNIQUE (supplier_lead_id, tenant_id) в `supplier_lead_deliveries`; на лид у тенанта ≤1 сделка.
**Если не так:** дубль на (lead,tenant) → критдефект.
**Покрывает:** одна поставка клиенту = раз.

### D-MERGE — Слияние с CSV-восстановлением (без второго списания)
**§ref:** §19.4 (merge). **Что:** если сверка (CsvReconcile) уже создала сделку без vid, а потом догнал webhook с vid → слияние, НЕ вторая сделка, без второго charge.
**Предусловие:** требует прогона `CsvReconcileJob` (см. R3b/R5) — пометить порядок. **Действие:** создать csv-recovered сделку (vid=null), затем webhook с тем же phone+project+tenant и vid.
**Ожидаем:** одна сделка; `source_crm_id` проставлен; **lead_charges НЕ удвоился**; регион улучшен если webhook-резолв dadata/rossvyaz.
**Сверка:** одна `deals` строка с этим phone+project за сутки; `count(lead_charges)`=1.
**Если не так:** две сделки / два списания → критдефект денег.
**Покрывает:** merge без второго списания.

### D-DIRECT / D-PARSE — DIRECT и парсинг поля project
**§ref:** §19.2. **Действие:** влей лиды с `project`: `B1_…`/`B2_…`/`B3_…` (площадки), `test-okna.ru` (DIRECT site), `79990001122` (DIRECT call), `имя-смс` (DIRECT sms), и «грязный» `заявка test-okna.ru/` (домен в тексте → site).
**Ожидаем:** площадка/тип определены верно; DIRECT матчится по signal_type+identifier без pivot.
**Сверка:** `SELECT platform FROM supplier_leads WHERE vid=…`; сделки созданы у DIRECT-подписчиков.
**Если не так:** неверный тип/площадка → разобрать парсер §19.2.
**Покрывает:** парсинг project, DIRECT.

---

## Часть B — Деньги (§6, §19.4)

### M1 — Списание за один лид (тройная запись + копейки)
**Полная карточка — в R0 §6 (образец).** Кратко: 1 лид → баланс −T1₽; `lead_charges`(price=T1, source=rub, tier_no=1) + `balance_transactions`(−T1) + `supplier_lead_costs` — все три; `(старый−новый)*100==price_kopecks`. **Если не так → СТОП, критдефект.** Покрывает §6, §19.4-8.

### M-TIER — Переход тарифной ступени
**§ref:** §6 (цена по delivered_in_month+1). **Что:** при пересечении порога ступени цена падает на следующем по счёту лиде.
**Предусловие:** прочитай пороги из `pricing_tiers`. Клиент с `delivered_in_month` у границы (напр. на 1 меньше порога T2).
**Действие:** влей лиды через границу.
**Ожидаем:** лид ДО порога — по цене T1; лид НА пороге+1 — по цене T2 (и т.д.), ровно на нужном по счёту.
**Сверка:** `SELECT tier_no, price_kopecks FROM lead_charges WHERE tenant_id=<c> ORDER BY charged_at` — цена меняется на правильном номере.
**Если не так:** цена сменилась раньше/позже → критдефект.
**Покрывает:** ступень по месячному счёту.

### M-INSUFF — Нехватка баланса → полный откат + автопауза + письмо
**§ref:** §6, §19.4-8, §19.5. **Что:** баланс < цены → транзакция откатывается ПОЛНОСТЬЮ, проект на автопаузу, письмо (1/час).
**Предусловие:** c с балансом < цены лида (но >0).
**Действие:** влей лид этому клиенту.
**Ожидаем:** **НЕТ** сделки, **НЕТ** charge/transaction для него (полный откат); `projects.is_active=false`; письмо `ZeroBalancePausedMail` (на живой ящик); повтор в течение часа — без второго письма.
**Сверка:** нет `deals`/`lead_charges` по этому лиду+тенанту; `is_active=false`; письмо пришло.
**Если не так:** частичная запись (charge без сделки или наоборот) / нет автопаузы → **СТОП**, критдефект.
**Покрывает:** атомарный откат, автопауза, throttle письма.

### M-BAL0 — Граница баланса (0 < баланс < цена)
**§ref:** §19.4 (кандидат balance_rub>0). **Что:** клиент с балансом >0 но < цены попадает в кандидаты, но charge падает → откат+автопауза (см. M-INSUFF). Клиент с балансом=0 — не кандидат (не выбирается вовсе).
**Сверка:** balance строго >0 для попадания в кандидаты; при <цены — откат.
**Покрывает:** граница баланса.

### M-DOUBLEPROJ — Один клиент с 2 проектами на источнике → списание ОДИН раз
**§ref:** §19.4 (DISTINCT ON tenant_id). **Предусловие:** один тест-клиент с ДВУМЯ проектами на одном источнике.
**Действие:** влей лид на источник.
**Ожидаем:** клиенту — **одна** сделка и **одно** списание (не два).
**Сверка:** по лиду у этого тенанта ≤1 `deals` и ≤1 `lead_charges`.
**Если не так:** двойное списание клиенту → **СТОП**, критдефект.
**Покрывает:** по одному проекту на тенанта.

### M-LEDGER — Лента денег = БД
**§ref:** §8 (`TenantChargesController`). **Действие:** в кабинете открой ленту денег клиента после серии лидов; выгрузи CSV.
**Ожидаем:** каждая строка (когда/сделка/ступень/источник/цена/остаток-после) совпадает с `lead_charges`/`balance_transactions`; `остаток после` падает монотонно; число строк = числу списаний; CSV = экрану (`;`+BOM).
**Сверка:** count строк ленты == `SELECT count(*) FROM lead_charges WHERE tenant_id=<c>`.
**Если не так:** расхождение лента↔БД → критдефект.
**Покрывает:** лента денег, выгрузка.

### M-CALC — Калькуляторы «хватит на лиды/дни» + согласованность дашборд↔кошелёк
**§ref:** §6 (`BalanceToLeadsConverter`/`RunwayCalculator`/`BalancePreflightService`). **Действие:** сверь число на экране с ручным расчётом по живым `pricing_tiers`.
**Ожидаем:** «хватит на лиды» = ручной расчёт по ступеням; дашборд и кошелёк показывают одинаково (F3 починен); «средняя» реальная (F5); карточка сделки цена ≠ 0 (F2).
**Если не так:** F2/F3/F5 не исправлены → находка.
**Покрывает:** калькуляторы, F2/F3/F5.

### M-AUDIT — Денежные таблицы в hash-chain
**§ref:** §12. **Проверяется в R5:** после прогона `audit:verify-chains` зелёный; UPDATE/DELETE записи ledger заблокированы (`audit_block_mutation`).
**Покрывает:** целостность денег.

---

## Часть C — Сделки (§8)

### T-LIST — Лента: фильтры, пагинация, партиции
**§ref:** §8. **Ожидаем:** все поставленные лиды видны как сделки (число=БД); фильтры статус/проект/менеджер/поиск/даты работают; пагинация (keyset+offset) не теряет/не дублирует на границах; сделки прошлых месяцев (партиции) открываются; дата сделки = времени поставки (payload.time).
**Сверка:** count в ленте == `SELECT count(*) FROM deals WHERE tenant_id=<c> AND deleted_at IS NULL`.
**Покрывает:** лента/фильтры/пагинация/партиции.

### T-CARD — Карточка
**Ожидаем:** телефон/источник/**город(реальный)**/статус/**цена(cost_kopecks≠0)**; события (до 50) в хронологии; источники rub/csv_recovery — цена корректна.
**Покрывает:** карточка, события, F2.

### T-STATUS — Воронка статусов + won не списывает
**§ref:** §8 (`StatusRuToSlugMapper`). **Ожидаем:** переходы Новая→Просмотрено→В работе→Сделка/Не реализовано; запрещённые/обратные переходы ведут себя предсказуемо; **смена на «Сделка»(won) НЕ меняет баланс** (деньги — при поставке).
**Сверка:** баланс до и после смены статуса — равен.
**Если не так:** won списал повторно → критдефект.
**Покрывает:** воронка, won-семантика.

### T-MANUAL — Ручная сделка не трогает деньги
**Действие:** создай сделку вручную. **Ожидаем:** в ленте; **баланс не изменился**; в ленте денег записи нет; визуально отличима (нет цены/источник ручной).
**Сверка:** баланс до==после; нет `lead_charges` для ручной сделки.
**Покрывает:** ручная сделка без списания.

### T-BULK — Массовые + мягкое удаление + восстановление (деньги не откатываются)
**§ref:** §8. **Ожидаем:** bulk статус/удаление/восстановление (≤1000, аудит); удалённые скрыты из ленты/экспорта/счётчиков; **списания и события удалённой сделки ОСТАЮТСЯ (баланс не возвращается)**; restore возвращает сделку с историей.
**Сверка:** после удаления — `lead_charges` сделки на месте; баланс не вырос; restore вернул в ленту.
**Если не так:** удаление сделки вернуло деньги → критдефект.
**Покрывает:** bulk, soft-delete не трогает деньги.

### T-EXPORT — Экспорт = БД + безопасность
**Ожидаем:** CSV/XLSX колонки Телефон/Источник/Город/Статус/Комментарий/Поставлен; `;`+BOM; содержимое=ленте и БД; с учётом фильтров; **CSV-инъекция** (`=`/`+`/`-`/`@` в начале значения) экранирована; XSS чужого ввода экранирован; большой объём стримингом не падает.
**Покрывает:** экспорт, CSV-инъекция/XSS.

### T-DELPROJ — Сделки переживают удаление проекта
**Действие:** удали тест-проект, у которого есть сделки. **Ожидаем:** сделки НЕ пропали (история сохранена).
**Сверка:** `SELECT count(*) FROM deals WHERE project_id=<deleted>` не обнулился.
**Покрывает:** сохранность сделок.

---

## Часть D — Изоляция (§9)

### ISO-SECTIONS — Каждый раздел кабинета только своё
**§ref:** §9. **Предусловие:** два тенанта c1 и c8 с данными. **Действие:** под c1 пройди ВСЕ разделы (сделки, проекты, лента денег, отчёты, уведомления, баланс).
**Ожидаем:** видны только данные c1; нигде нет данных c8.
**Сверка:** счётчики в кабинете c1 == `SELECT count … WHERE tenant_id=<c1>`.
**Если не так:** видны чужие данные → **СТОП**, критдефект изоляции.
**Покрывает:** RLS по всем разделам.

### ISO-SHARE — Шеринг не течёт в кабинет
**§ref:** §9, §19. **Что:** один лид раздан c1,c2,c3 → каждый видит ТОЛЬКО свою копию сделки, со своей ценой, и НЕ видит, что лид ушёл другим.
**Действие:** влей шеринг-лид; под c1 и под c2 открой свою сделку.
**Ожидаем:** c1 видит только свою сделку/цену; нет признаков, что тот же лид у c2/c3; cross-tenant раздача (движок) в кабинет не протекает.
**Если не так:** клиент видит со-получателей/чужую цену → **СТОП**, критдефект.
**Покрывает:** изоляция при шеринге.

### ISO-DIRECT — Прямой доступ по чужому ID/URL
**Действие:** под c1 открой URL сделки/проекта/отчёта/уведомления, принадлежащих c8 (по их ID).
**Ожидаем:** 404/403 (RLS + явный where не отдают чужое).
**Если не так:** отдало чужой объект → **СТОП**, критдефект.
**Покрывает:** прямой доступ.

### ISO-NEG — Негативный RLS-тест на уровне БД
**Действие:** `sudo postgres psql` с `SET app.current_tenant_id='<c1>'` → `SELECT count(*) FROM deals WHERE tenant_id=<c8>`.
**Ожидаем:** 0 (RLS режет чужой tenant даже при прямом запросе).
**Покрывает:** RLS-политика.

### ISO-GLOBAL — Глобальные таблицы не видны в кабинете
**Ожидаем:** `supplier_leads`/`supplier_lead_deliveries`/`failed_webhook_jobs` (BYPASSRLS, cross-tenant) НЕ отображаются клиенту нигде в кабинете.
**Покрывает:** глобальные таблицы.

---

## Часть E — All-pairs прогон раздачи (систематическое покрытие комбинаций)

База: §18, §19, §21. **Популяция: 7 клиентов · 16 проектов · 12 rt** (см. R1). 27 прогонов покрывают **все пары** значений S×R×B×Lim×D (доказано генератором: 111 пар, все). N (шеринг) вынесен в кластер шеринга (D-CAP/D-ROUND). Прогоны идут на 16 проектах через переконфигурацию.

**Легенда:** S сигнал · R регион лида (Msk82=Москва / Mobl56=Москобл-не-маппится / multi / vsiaRF=вся РФ / nocover=непокрытый→подмена) · B баланс (ok/edge=граница/zero=ноль-заморозка) · Lim (n=10/s=2) · D (IN/OUT). **Слепок** — нужен `snapshot:rebuild`.

| # | S | R | B | Lim | D | Слепок | Δ |
|---|---|---|---|---|---|---|---|
|1|site|Msk82|ok|s|IN|Y|старт Lim=s|
|2|site|Mobl56|ok|n|IN|Y|R,Lim|
|3|site|multi|ok|n|IN|Y|R|
|4|site|vsiaRF|edge|n|IN|Y|R+баланс|
|5|site|nocover|zero|n|OUT|Y|R,D+баланс|
|6|call|nocover|ok|n|IN|Y|нов.источник+баланс|
|7|call|Mobl56|ok|n|IN|Y|R|
|8|call|multi|ok|n|IN|Y|R|
|9|call|vsiaRF|zero|s|IN|Y|R,Lim+баланс|
|10|call|Msk82|edge|n|OUT|Y|R,Lim,D+баланс|
|11|sms+kw|Msk82|ok|n|IN|Y|нов.источник+баланс|
|12|sms+kw|nocover|ok|n|IN|Y|R|
|13|sms+kw|vsiaRF|ok|n|IN|Y|R|
|14|sms+kw|multi|edge|n|IN|Y|R+баланс|
|15|sms+kw|multi|ok|s|OUT|Y|Lim,D+баланс|
|16|sms+kw|Mobl56|zero|n|IN|Y|R,Lim,D+баланс|
|17|sms|Mobl56|ok|n|IN|Y|нов.источник+баланс|
|18|sms|nocover|ok|n|IN|Y|R|
|19|sms|multi|ok|n|IN|Y|R|
|20|sms|vsiaRF|ok|n|IN|Y|R|
|21|sms|Msk82|zero|n|IN|Y|R+баланс|
|22|sms|Mobl56|edge|s|OUT|Y|R,Lim,D+баланс|
|23|DIRECT|Msk82|ok|n|IN|Y|нов.источник+баланс|
|24|DIRECT|Mobl56|ok|n|IN|Y|R|
|25|DIRECT|multi|zero|n|IN|Y|R+баланс|
|26|DIRECT|vsiaRF|ok|n|OUT|Y|R,D+баланс|
|27|DIRECT|nocover|edge|s|IN|Y|R,Lim,D+баланс|

**Как исполнять каждый прогон:** (1) переконфиг по Δ (регион лида = телефон; баланс = `UPDATE tenants`; лимит/дни = на проекте) → (2) `snapshot:rebuild --date=<активная>` если менялись регион/лимит/дни → (3) инъекция 1 лида (R2) → (4) сверка исхода.
**Тали:** ~27 пере-слепков + ~13 правок баланса.

**⚠️ Ожидаемый исход блокировки (B, §21) — считать через share-aware `required`, НЕ сырой лимит:**
- доставлен? — да, если клиент кандидат (баланс>0, не frozen, под лимитом, день активен, регион матчит/каскад);
- B=zero/frozen → клиент **исключён** (не получает); проверяем исключение;
- заморозка/preflight-блок → `required = Σ ⌈groupOrder × (лимит/Σгруппы)⌉` vs `capacity(баланс→лиды по ступеням)`. Шерящий нуждается в доле, не в полном лимите.
- D=OUT → проект не в слепке → не доставляется.

**Групповые тесты (~6, вне all-pairs):** заказ Σ/3-vs-max (группа с одним крупным лимитом → max побеждает); объединение регионов группы; «вся РФ в группе перебивает гео»; объединение дней группы; авто-привязка субдомена к корню (P8 C1: лид по корню → C1 списан **раз**, DISTINCT по тенанту).

## Часть F — Расширенный охват (Раздел B: реальные код-пути без карточки)

> Добавлено по ревью 21.06 ([acceptance-coverage-expansion-NEW.md](../specs/2026-06-21-acceptance-coverage-expansion-NEW.md) Раздел B). Каждая карточка — по факту кода (file:line). Гонять после базовых A–E.

### D-RECONCILE — Сейф-нет CsvReconcile: recovery + drift-алерты
**§ref:** §5 (CsvReconcileJob). **Что:** ночная сверка CSV-отчёта поставщика vs webhook-лиды восстанавливает пропущенные и алертит на дрейф — крупнейшая денежно-критичная слепая зона.
**Действие:** подготовь тест-CSV поставщика за активную дату с: (а) строкой, которой нет в webhook-лидах (пропуск); (б) пакетом, где >5% строк отсутствуют; (в) для (дата,тенанта) `delivered_count` < 80% от `expected_volume`. Прогони `CsvReconcileJob` ([CsvReconcileJob.php:148-159,175-202,264-323](../../../app/app/Jobs/CsvReconcileJob.php#L148)).
**Ожидаем:** (а) новый `supplier_leads` `vid=NULL, source='csv_recovery', recovered_from_csv_at` + downstream сделка; (б) `supplier_csv_reconcile_log.status='drift_alert'`, `drift_ratio>0.05`, письмо `CsvDriftAlertMail`; (в) `TenantBusinessDriftAlertMail` + лог `csv_reconcile.business_drift_alert`. Нечитаемый `project` в дрейф НЕ попадает (только `parseableTotal`).
**Сверка:** `SELECT status, drift_ratio FROM supplier_csv_reconcile_log ORDER BY id DESC LIMIT 3`.
**Если не так:** recovery не создал лид / дрейф не сосчитан / нет письма → разобрать.
**Покрывает:** §5 сейф-нет, recovery, оба дрейфа.

### D-TERMINAL — Терминальные/частичные пути оркестратора (анти-шторм)
**§ref:** §19.1, §19.6. **Что:** защита от retry-шторма (инцидент 25k) и корректные failed-пути.
**Действие/Ожидаем (по кейсу):**
- **lead==null:** dispatch `RouteSupplierLeadJob` на удалённый supplier_lead_id → чистый return, **нет** `failed_webhook_jobs`, лог `not_found_terminal` ([:102-108](../../../app/app/Jobs/RouteSupplierLeadJob.php#L102)).
- **fast-fail terminal:** лид с `error` содержащим «platform mismatch»/«no matching supplier_project», `processed_at=null` → `processed_at` проставлен + суффикс, нет сделок, нет failed-row ([:126-146](../../../app/app/Jobs/RouteSupplierLeadJob.php#L126)).
- **частичный успех:** один получатель падает (не баланс), ≥1 успешен → `deals_created_count`=успехам, `processed_at` стоит, без RuntimeException ([:172-202](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172)).
- **все падают:** RuntimeException → после tries=3 строка `failed_webhook_jobs` (tenant_id=null) + `supplier_leads.error` ([:646-655](../../../app/app/Jobs/RouteSupplierLeadJob.php#L646)).
**Если не так:** удалённый лид породил failed-шторм / частичный сбой уронил весь лид → критдефект устойчивости.
**Покрывает:** §19.1/§19.6 терминальные пути.

### D-DADATA — Каскад резолва региона: edges
**§ref:** §5 (LeadRegionResolver). **Действие/Ожидаем:**
- бюджет DaData исчерпан (`canSpend()`=false) → резолв уходит на Россвязь/tag, без траты DaData ([:79](../../../app/app/Services/Lead/LeadRegionResolver.php#L79));
- DaData qc=2/7 → сразу tag/unknown, Россвязь пропущена ([:101-104](../../../app/app/Services/Lead/LeadRegionResolver.php#L101));
- ре-резолв лида с уже заполненным `resolved_subject_code` → без повторного вызова DaData ([:47-50](../../../app/app/Services/Lead/LeadRegionResolver.php#L47)).
**Сверка:** `SELECT region_source FROM supplier_leads WHERE vid=…`; `lead_region_resolution_log.dadata_*`.
**Покрывает:** §5 каскад-edges, идемпотентность резолва, защита бюджета.

### D-CASCADE3+ — Подмена: внутренний subject_code = подставленный (дополнение к D-CASCADE3)
**Что:** при фазе 3 `deals.city` = реальный регион (уже в D-CASCADE3), а **внутренний `deals.subject_code` = подставленный** (1-й код из `snapshot.regions`) — суть подмены.
**Сверка:** `SELECT subject_code, city, region_substituted FROM deals WHERE id=<D>` → `subject_code`=подставленный, `city`=реальное имя; `lead_region_resolution_log.substituted_subject_code` заполнен, `actual_subject_code`=реальный.
**Покрывает:** §19.4 split subject_code/city при подмене.

### M-FREEZE-SWEEP — Вечерняя заморозка @18:00 + разморозка + письма reminder/final
**§ref:** §20, §21 (BalancePreflightSweepJob/BalanceFrozenReminderJob). **Что:** отдельный от мгновенной паузы (M-INSUFF) механизм — смотрит на ЗАВТРА (share-aware), сохраняет ручные паузы, шлёт повторные письма.
**Действие/Ожидаем:**
- баланс покрывает сегодня, но не завтрашний `requiredLeadsForTomorrow` → прогон `BalancePreflightSweepJob` ([:62](../../../app/app/Jobs/BalancePreflightSweepJob.php#L62)) → `frozen_by_balance_at` + непаузнутые проекты `paused_at` + `BalanceFrozenMail` + лог `frozen`;
- **разморозка сохраняет ручные паузы:** один проект паузнут вручную ДО заморозки (`paused_at<frozenAt`), другие — заморозкой; пополни баланс → sweep → снимаются ТОЛЬКО `paused_at>=frozenAt`, ручная пауза цела ([:95-108](../../../app/app/Jobs/BalancePreflightSweepJob.php#L95));
- идемпотентность: второй прогон в том же состоянии — без дублей письма/лога;
- **reminder/final:** бэкдейт `frozen_by_balance_at` на 30ч → `BalanceFrozenReminderJob` → `reminder_sent`+письмо; повтор окна → тихо; 80ч → `final_sent`+`BalanceFrozenFinalMail` ([BalanceFrozenReminderJob.php:39-45,97-116](../../../app/app/Jobs/BalanceFrozenReminderJob.php#L39)).
**Сверка:** `balance_freeze_log` (событие/маркеры); `frozen_by_balance_at`; `projects.paused_at`.
**Если не так:** заморозка по сегодня (не завтра) / разморозка снесла ручную паузу / дубль письма → разобрать против §20.
**Покрывает:** §20/§21 вечерний sweep, ручные паузы, повторные письма.

### M-TIER7 — Верхняя ступень T7 (безлимитная)
**§ref:** §6. **Действие:** клиент с `delivered_in_month`=6000 → влей лид. **Ожидаем:** `tier_no`=7, цена = нижняя (на 19.06 250₽); следующий лид (6001+) — остаётся T7, без ошибки выхода за диапазон.
**Сверка:** `SELECT tier_no, price_per_lead_kopecks FROM lead_charges WHERE tenant_id=<c> ORDER BY charged_at DESC LIMIT 2`.
**Покрывает:** §6 «всё выше последнего порога».

### T-MANUAL+ — Ручная сделка: себестоимость + FK менеджера + проект
**§ref:** §8 (DealController store). **Дополнение к T-MANUAL:** баланс не трогается, НО:
- на проекте с активным поставщиком создаётся **`supplier_lead_costs`** (себестоимость, не баланс) ([DealController.php:528-539](../../../app/app/Http/Controllers/Api/DealController.php#L528)) — «нет списания ≠ нет cost-записи»;
- `manager_id` чужого/неактивного тенанта → 422, сделка не создана ([:484-497](../../../app/app/Http/Controllers/Api/DealController.php#L484));
- новый `project_name` → `firstOrCreate` тип `manual`, повтор имени — без дубля ([:505-508](../../../app/app/Http/Controllers/Api/DealController.php#L505)).
**Сверка:** баланс до==после; есть `supplier_lead_costs` для ручной сделки на supplier-проекте; чужой manager → 422.
**Покрывает:** §8 побочные эффекты ручной сделки.

### T-CARD-NULL — Цена карточки null при не-rub источнике
**§ref:** §8. **Что:** `cost_kopecks` карточки = снимок только при `charge_source='rub'`; иначе **null** ([DealController.php:311](../../../app/app/Http/Controllers/Api/DealController.php#L311)).
**Действие:** открой карточку сделки с `charge_source` `prepaid`/`csv_recovery`. **Ожидаем:** `cost_kopecks`=null (не цена). (Поправка к T-CARD: «csv_recovery — цена корректна» неверно — там null.)
**Покрывает:** §8 null-ветка цены.

### T-INDEX — Лента: спец-параметры
**§ref:** §8. **Действие/Ожидаем:**
- `?count_only=1` → только `{total}` ([DealController.php:72,109](../../../app/app/Http/Controllers/Api/DealController.php#L72));
- `?only_deleted=1` (Корзина) → только soft-deleted строки ([:73,139](../../../app/app/Http/Controllers/Api/DealController.php#L139));
- `?cursor=<мусор>` → 422 «невалидный курсор»; валидный курсор идёт по страницам без потерь/дублей на границе ([:78-92,143-164](../../../app/app/Http/Controllers/Api/DealController.php#L78)).
**Покрывает:** §8 count_only/Корзина/keyset-курсор.

### T-BULK-ISO — Массовые: изоляция записи + граница ≤1000 + аудит
**§ref:** §8 (DealBulkActionController). **Действие/Ожидаем:**
- bulk transition по `[свой_id, чужой_id]` → тронут только свой; чужая сделка в БД без изменений ([:73-76](../../../app/app/Http/Controllers/Api/DealBulkActionController.php#L73));
- 1001 id → 422; 1000 → принято (`:52` transition, `:139` destroy, `:203` restore);
- no-op фильтр: transition к уже-текущему статусу → `updated` его не считает (`:78-81`);
- после bulk destroy → N строк `activity_log` `event=deal.deleted`, `context.source='bulk'` (`:99-114`).
**Если не так:** bulk тронул чужую сделку → **СТОП**, критдефект изоляции.
**Покрывает:** §8/§9 write-изоляция bulk, лимит, аудит.

### T-DELPROJ+ — Удаление проекта: причины отказа
**§ref:** §8 (ProjectService). **Дополнение к T-DELPROJ:** удаление со сделками → 422 (сделки целы — уже в T-DELPROJ). Дополнительно:
- проект с активным supplier-снапшотом И сделками → 422 со **snapshot-сообщением** (раньше has-deals, `:184` vs `:186-191`);
- bulk-delete `[пустой, со-сделками]` → `updated=1`, `skipped=[{reason:has_deals}]` ([:319-347](../../../app/app/Services/Project/ProjectService.php#L319)).
**Покрывает:** §8 причины отказа удаления.

## Матрица покрытия R3-движок
| Инвариант | Карточка |
|---|---|
| §19 CAP=3, по одному на тенанта | D-CAP |
| §18.2 жребий/второй круг, лимит-инвариант | D-ROUND, D-LIMIT |
| §19.3 каскад 3 фазы + город при подмене | D-CASCADE1/2/3 |
| §19.4 пауза под локом (R-09) | D-PAUSE |
| §19.1 идемпотентность vid | D-IDEMP |
| §19.4 одна поставка клиенту = раз | D-DELIV |
| §19.4 merge с CSV без второго списания | D-MERGE |
| §19.2 DIRECT/парсинг | D-DIRECT, D-PARSE |
| §6/§19.4-8 списание+тройная запись+копейки | M1 |
| §6 ступень по месяцу | M-TIER |
| §19.4-8/§19.5 откат+автопауза+throttle | M-INSUFF |
| §19.4 граница баланса | M-BAL0 |
| §19.4 DISTINCT по тенанту | M-DOUBLEPROJ |
| §8 лента денег=БД | M-LEDGER |
| §6 калькуляторы, F2/F3/F5 | M-CALC |
| §12 аудит денег | M-AUDIT (в R5) |
| §8 лента/карточка/статусы/ручная/массовые/экспорт | T-LIST..T-EXPORT |
| §8 сделки переживают удаление проекта | T-DELPROJ |
| §9 изоляция (разделы/шеринг/прямой/негатив/глобал) | ISO-* |
| §5 CsvReconcile recovery + drift (Раздел B) | D-RECONCILE |
| §19.1/§19.6 терминальные/частичные пути | D-TERMINAL |
| §5 каскад резолва edges (DaData/бюджет/идемпотентность) | D-DADATA |
| §19.4 split subject_code/city при подмене | D-CASCADE3+ |
| §20/§21 вечерняя заморозка + разморозка + reminder/final | M-FREEZE-SWEEP |
| §6 верхняя ступень T7 | M-TIER7 |
| §8 ручная: cost-запись/FK менеджера/проект | T-MANUAL+ |
| §8 null-цена карточки (не-rub) | T-CARD-NULL |
| §8 count_only/Корзина/курсор-422 | T-INDEX |
| §8/§9 bulk write-изоляция/≤1000/аудит | T-BULK-ISO |
| §8 причины отказа удаления проекта | T-DELPROJ+ |

**Чек-лист «не пропущено»:** ✅ распределение A · ✅ деньги B · ✅ сделки C · ✅ изоляция D · ✅ расширенный охват F (Раздел B) · ✅ все инварианты §19/§6/§8/§9/§20/§21 имеют карточку. **G-зависимое (дайджест/отчёты/импорт/онбординг/убранные напоминания) → R3b после деплоя. Дальше → R4 (слой D).**
