# Защита слепка поставщика в маршрутизации лидов — дизайн

**Автор:** Claude Opus 4.7 (сессия `135a4adf`, аудит «вчерашний слепок vs live state» 26.05.2026)
**Статус:** Draft v0.4 — уточнение оценки Этапа 1 после свежего git diff (26.05.2026 поздний вечер).
**Дата:** 2026-05-26

**Changelog v0.3 → v0.4:**

- §4.1.1 / §11: оценка Этапа 1 пересмотрена `4ч` → **`0.5–1 рабочий день`**. Свежий diff показал, что `feat/billing-v2-spec-c` отстаёт от `origin/main` на **50+ коммитов** (а не 16 как предполагалось в v0.1), включая Snapshot Guard 10-коммитную серию (`8b6b4101..0902de96`) и Phase 2 FK-fix `0da72778` («не обновлять deals.received_at»). Merge spec-c в main потребует аккуратного резолва конфликтов в `LeadRouter.php` / `RouteSupplierLeadJob.php` / `Project.php` / `Tenant.php` + сохранения Phase 2 FK fix.
- §11: добавлена строка «гипотезы R-11/R-16/R-17/R-19 не верифицированы на проде из-за отсутствия SSH-доступа с dev-машины в этой сессии; они остаются как unfalsified assumptions, конкретные пороги уточняются на этапе реализации через прод-SQL запросы».

**Changelog v0.2 → v0.3:**

- §4.2.5/UX: `appliesFrom` возвращает не `00:00 МСК`, а `21:00 МСК` дня старта slepok'а (тот момент, с которого slepok реально работает по канону §0).
- §4.2.4: `delivered_count` инкрементится в **активный** snapshot (учитывает 21:00 МСК границу), а не в `Carbon::today()` — иначе в окне 21:00–24:00 МСК счётчик уезжает не в тот snapshot.
- §4.2 + §6: явно прописано, что `SyncSupplierProjectsJob` (ночной push в 18:05 МСК) **читает из snapshot**, а не из live `projects` — иначе race 18:02 → 18:05 ломает идемпотентность.
- §4.2.2: добавлен fail-loud механизм при отсутствии snapshot за активную дату — алёрт + heartbeat-флаг (роутер не молчит, мы знаем сразу).
- §4.2.6/§4.2.7/§6/§8.1: синхронизированы все упоминания времени cron'а на **18:02 МСК** (было местами 21:00 как наследие v0.1).

**Changelog v0.1 → v0.2 (для истории):**

- Cut-off Лидерры исправлен 21:00 → **18:00 МСК** (3-часовое окно 18:00–21:00 на push поставщику до его фиксации).
- Бизнес-инвариант переписан в каноническую формулировку заказчика (slepok №NЛ / №NП).
- `SnapshotProjectRoutingJob` cron сдвинут 21:00 → **18:00 МСК** (после `BalancePreflightSweepJob`).
- Активный snapshot в роутере: до 21:00 МСК = сделанный вчера; после 21:00 МСК = сделанный сегодня.
- `SupplierSnapshotGuard::appliesFrom`: граница 21:00 → **18:00 МСК**.
- OPEN-2 закрыт: resume = со следующего slepok'а; emergency-кнопка НЕ нужна.
- OPEN-5 закрыт: `effective_daily_limit_today` сохраняется, в snapshot подхватывается, обнуляется в 00:00 МСК (вариант A).
- OPEN-1 закрыт: после 21:00 МСК поставщик строго работает только по завтрашнему слепку.
**Связано:**
- [[project-billing-v2]] memory — режим online/batch policy.
- [docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md] §3.10 (deferred item «отдать разморозившемуся клиенту лиды»).
- [docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md] — текущий SupplierSnapshotGuard, защищает только delete/change_source.

---

## §0. TL;DR

В Лидерре нет собственного фиксированного «снимка заказа на сегодня». `LeadRouter` распределяет входящие лиды по живому состоянию настроек проекта (`is_active`, `daily_limit_target`, `delivery_days_mask`, `regions`). Поставщик у себя фиксирует **свой** слепок в 21:00 МСК на следующий день, и до 21:00 МСК следующего дня лиды летят по этому слепку. Любое изменение клиентом (пауза, лимит, регионы, дни) применяется к роутеру **мгновенно** — но слепок поставщика об этом уже не знает. Возникает разрыв «что клиент заказал у Лидерры вчера» vs «что роутер сегодня делает».

**Канонический бизнес-инвариант (формулировка заказчика, 26.05.2026):**

> Наш клиент Лидерры подаёт нам до 18:00 МСК. Лидерра подаёт в 21:00 МСК поставщику; разница во времени вызвана тем, чтобы нам успеть перенести все правки поставщику.
>
> 26.05.26 в 18:00 МСК делается слепок **№1Л** клиентов Лидерры. 26.05.26 в 21:00 МСК делается слепок **№1П** поставщиком проектов Лидерры. Все лиды, которые поступят от поставщика в период с **26.05.26 21:01** до **27.05.26 20:59** будут приходить по слепку №1П (его внутренний эквивалент у нас — слепок №1Л).
>
> 27.05.26 в 18:00 и в 21:00 появятся ещё 2 слепка — **№2Л** и **№2П**. Если клиент 26.05.26 в 23:00 нажмёт «возобновить проект», то он получит свои сделки и мы свои лиды только в рамках слепков №2Л и №2П — в период с 27.05.26 21:00 МСК по 28.05.26 20:59 МСК.

Из этого инварианта вытекает временна́я схема:

```
N.18:00 МСК — cut-off клиента (последний момент правок на день N+1)
N.18:00 МСК — Лидерра создаёт slepok №NЛ (на день N+1)
N.18:00 – N.21:00 МСК — Лидерра пушит поставщику (3-часовое окно доставки)
N.21:00 МСК — поставщик фиксирует slepok №NП (на день N+1)
N.21:01 МСК → (N+1).20:59 МСК — лиды летят по slepok №NЛ / №NП
```

Спек закрывает 19 рисков (R-01..R-19), выявленных в аудите, через 5 этапов: **(1)** стабилизация прод-state, **(2)** собственная snapshot-таблица + переключение роутера на неё, **(3)** фильтр frozen/blocked в роутере, **(4)** корректные расчёты (legacy FK / online vs batch ключи / preflight с шерингом / Carbon::tomorrow boundary), **(5)** переключение боевого режима `online → batch`.

---

## §1. Контекст и проблема

### §1.1 Контракты

**Контракт #1 — внешний (Лидерра ↔ поставщик crm.bp-gr.ru):**

- Лидерра агрегирует заявки всех своих клиентов по `(signal_type, identifier)` и шлёт поставщику **одну строку** «нужно N лидов с +7XXX, регионы X, дни Y».
- Поставщик не знает про tenant_id Лидерры — для него заказчик един.
- Слепок поставщика на следующий день фиксируется в 21:00 МСК.
- Расчёт обязательства Лидерры перед поставщиком — агрегатный per-supplier_project, не per-client.

**Контракт #2..N — внутренние (Лидерра ↔ каждый клиент-тенант):**

- Каждый клиент договорился с Лидеррой на свой объём, лимит, источники, регионы, дни.
- Если несколько клиентов на одном источнике — шеринг до 3-х копий ([LeadDistributor::CAP=3](app/app/Services/LeadDistributor.php)).
- Заказ Лидерры поставщику для одной группы: `order = max(наибольший_лимит, ceil(Σ_лимитов / 3))` ([SupplierQuotaAllocator::computeOrder](app/app/Services/Supplier/SupplierQuotaAllocator.php)).
- Расчёт обязательства клиента перед Лидеррой — per-client, по тарифной ступени.

### §1.2 Корень проблемы

В коде:

| Слой | Что фиксируется | Когда | Что читает роутер |
|---|---|---|---|
| 18:05 МСК sync | push поставщику | один раз в сутки | — |
| 21:00 МСК слепок поставщика | внешний снимок (его БД) | один раз в сутки | — |
| 00:00 МСК reset | `delivered_today=0` | один раз в сутки | — |
| **— нет собственного snapshot —** | — | — | live `projects.*` |
| Изменения через UI | мгновенно в `projects` | в любой момент | мгновенно влияет |

`LeadRouter` ([app/app/Services/LeadRouter.php](app/app/Services/LeadRouter.php)) делает SQL по live полям таблицы `projects`. Любая правка через `ProjectService::update` / `bulkPauseResume` / `bulkUpdateLimit` / `bulkUpdateRegions` / `bulkUpdateDays` применяется в тот же момент.

### §1.3 19 рисков из аудита (краткая ссылка)

🔴 **P0 (5):**

- R-01 — pause после 21:00 МСК: A заплатил Лидерре, лиды не получил.
- R-02 — лид через шеринг к B/C при выпавшем A: внутренний разрыв расходов.
- R-03 — frozen клиент получает лиды по уже зафиксированному слепку: списания идут.
- R-04 — auto-pause каскад: первый InsufficientBalance → `is_active=false`, хвост слепка исчезает.
- R-05 — CSV reconcile считает webhook-loss, а не «доставлено-ли-тем-кому-заказано».

🟡 **P1 (11):**

- R-06 — уменьшение лимита после слепка.
- R-07 — изменение `delivery_days_mask` на ходу.
- R-08 — изменение `regions` на ходу.
- R-09 — race webhook ↔ pause: `lockForUpdate` не recheck'ает `is_active`.
- R-10 — editable `signal_identifier` без pivot.
- R-11 — `effective_daily_limit_today` admin-override без TTL.
- R-12 — drop column `balance_leads` (Spec B Phase B) сломает SQL роутера.
- R-16 — `CleanupInactiveSupplierProjectsJob` использует legacy FK, а не pivot.
- R-17 — `buildUniqueKey` vs `buildUniqueKeyAgnostic` расходятся для SMS+keyword.
- R-18 — `Carbon::tomorrow` boundary в полночь.
- R-19 — preflight считает Σ лимитов, реальная нагрузка ≤ Σ/3 при шеринге.

🟠 **P2 (3):**

- R-13 — `paused_at` не выставляется при freeze (Snapshot Guard слепой).
- R-14 — RuntimeException на non-B префиксе если прод на spec-c версии RouteSupplierLeadJob.
- R-15 — создание проекта после 18:05 МСК не успевает к завтрашнему слепку.

---

## §2. Цель / Не-цель

### §2.1 Цель

1. Зафиксировать в коде Лидерры собственный snapshot настроек проектов на каждый рабочий день, синхронно со слепком поставщика.
2. Переключить `LeadRouter` на чтение из snapshot для **всех eligibility-полей** кроме баланса. Баланс остаётся live (это нужно для auto-pause).
3. Дать клиенту явную UX-семантику: «изменения настроек применяются с послезавтра».
4. Закрыть точечные дефекты R-12/R-14/R-16/R-17/R-18/R-19 параллельно.
5. Перевести продакшен с `online` на `batch` режим после стабилизации.

### §2.2 Не-цель

1. Не меняем модель шеринга (CAP=3 в `LeadDistributor`) — она корректна.
2. Не меняем форму заказа Лидерра ↔ поставщик (`computeOrder = max(max, ceil(Σ/3))`) — она корректна и одобрена брейнстормом.
3. Не меняем биллинг-инварианты `LedgerService::chargeForDelivery` — bcmath, tier-резолюция, atomicity транзакции остаются как есть.
4. Не строим механизм «доставить позже после разморозки» из Spec C §3.10 (deferred) — это отдельный спек после месяца статистики.
5. Не меняем биллинг с поставщиком (`supplier_lead_costs` агрегат) — это смежная задача, отдельный аудит при необходимости.

### §2.3 Бизнес-инвариант после реализации

См. каноническую формулировку в §0 (slepok №NЛ / №NП).

**Производное правило для UI/UX:**

> Правка клиента **до 18:00 МСК дня N** — попадает в slepok №NЛ → действует с **N.21:00 МСК до (N+1).20:59 МСК**.
>
> Правка клиента **после 18:00 МСК дня N** — попадает в slepok №(N+1)Л → действует с **(N+1).21:00 МСК до (N+2).20:59 МСК**.

**Исключения** (правки применяются вне семантики slepok'а):

- Удаление баланса / отсутствие баланса → `auto-pause` работает мгновенно (защита Лидерры от перерасхода).
- `delete` / `change_source` — защищено существующим `SupplierSnapshotGuard` (grace до 21:00 МСК + 24h хвост).
- Frozen tenant ↔ unfrozen tenant — переход подчиняется тому же правилу: эффект со следующего slepok'а; если freeze случился **до 18:00 МСК** дня N, проект не попадает в slepok №NЛ, и сегодняшние лиды по slepok №(N-1)П всё ещё могут лететь до 20:59 МСК (закрывается дополнительно фильтром `frozen_by_balance_at IS NULL` в `LeadRouter`).

**Отдельно подтверждено заказчиком (ответ OPEN-2, 26.05.2026):** «emergency-кнопка» для немедленного включения проекта **не нужна** — она бы шла против бизнес-инварианта и снова создала бы разрыв между нашим snapshot'ом и слепком поставщика. Resume в любое время = эффект со следующего slepok'а.

---

## §3. Текущее состояние (что есть)

См. аудит сессии `135a4adf` 26.05.2026. Краткое резюме здесь:

- [SupplierSnapshotGuard.php](app/app/Services/Project/SupplierSnapshotGuard.php) — закрывает только delete/change_source, через `paused_at` grace.
- [SyncSupplierProjectsJob.php](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) — 18:05 МСК push поставщику (после 18:00 МСК `BalancePreflightSweepJob` фильтрует frozen).
- [LeadRouter.php](app/app/Services/LeadRouter.php) — SELECT по live `projects.*`, фильтр `is_active`, `delivery_days_mask`, `delivered_today`, `daily_limit_target`, `tenant.balance_rub > 0 OR tenant.balance_leads > 0`. Не фильтрует `frozen_by_balance_at`, не фильтрует `preflight_blocked_at`.
- [RouteSupplierLeadJob.php](app/app/Jobs/RouteSupplierLeadJob.php) — `lockForUpdate(Project)`, recheck `delivered_today >= limit`. Не recheck'ает `is_active`. На `InsufficientBalanceException` → мгновенный `UPDATE projects SET is_active=false`.
- [CleanupInactiveSupplierProjectsJob.php](app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php) — 02:00 МСК через legacy FK `supplier_b{1,2,3}_project_id`. **Дефект R-16.**
- `SupplierExportMode` = `online` на проде (debug-режим, заказчик ограничил жизненный цикл первыми 10-15 клиентами).
- На проде = main `0902de96` + scp-патчи из `feat/billing-v2-spec-c` (HEAD `f0269534`).

---

## §4. Решение по этапам

### §4.1 Этап 1 — стабилизация прод-state (≈ 0.5–1 рабочий день, 0 архитектурных правок)

Цель: убрать рассинхрон git ↔ прод и закрыть точечные дефекты, которые не требуют новой архитектуры.

#### §4.1.1 Merge `feat/billing-v2-spec-c` в `main`

Сейчас на проде = main + scp-патчи из spec-c. Следующий `redeploy.sh` через `git pull` затрёт ручные scp, если ветка не влита. Действие:

1. Rebase `feat/billing-v2-spec-c` на текущий `origin/main` (свежий замер 26.05 поздний вечер: **14 коммитов forward** на ветке + **50+ коммитов backward** на main).
2. Резолв конфликтов — главные в:
   - `LeadRouter.php` (DIRECT path добавился в main).
   - `RouteSupplierLeadJob.php` (Phase 3 parsing + Phase 2 CSV merge + `0da72778` FK fix — **сохранить!**).
   - `Project.php` (`paused_at` в main, отсутствует в spec-c).
   - `Tenant.php` (`frozen_by_balance_at` в spec-c).
   - `ProjectService.php` (Snapshot Guard в main, `c84272c5c` врезка в delete/update).
3. **Особое внимание Phase 2 FK fix `0da72778`** — он защищает от `lead_charges_deal_id_deal_received_at_fkey` violation при merge CSV-recovered deal'а с webhook. Не теряем этот фикс при rebase.
4. Полный `composer test --parallel` + `npm run test:vue` после merge. Регресс должен оставаться зелёным **до** правок R-16/R-14 — иначе откатываемся.
5. PR в main, merge через FF после успешного регресса.
6. Документация: ПИЛОТ.md обновить — пометить «прод теперь = main HEAD после merge». [project_billing_v2.md](memory) пометить «Spec C влит в main, scp-патчи больше не нужны».
7. **Не делать redeploy** до окончания этапа 1 — следующий деплой = после правок R-16 / R-14, чтобы избежать множественных деплоев подряд.

#### §4.1.2 R-12 — подготовка к DROP COLUMN `balance_leads`

Файл: [LeadRouter.php:65-66, 95-97](app/app/Services/LeadRouter.php).

Текущий SQL:

```sql
AND EXISTS (
    SELECT 1 FROM tenants
    WHERE tenants.id = projects.tenant_id
      AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
)
```

Изменить на:

```sql
AND EXISTS (
    SELECT 1 FROM tenants
    WHERE tenants.id = projects.tenant_id
      AND tenants.balance_rub > 0
)
```

Должно быть выкачено **до** Spec B Phase B (`DROP COLUMN balance_leads`).

Тест: добавить feature-test в [tests/Feature/LeadRouterTest.php](app/tests/Feature) — сценарий «тенант с balance_leads=999 balance_rub=0 → проект НЕ eligible».

#### §4.1.3 R-14 — проверить и зафиксировать версию `RouteSupplierLeadJob` на проде

Действие:

1. SSH на прод, `cat /var/www/liderra/app/app/Jobs/RouteSupplierLeadJob.php | grep -A2 "parseProjectField"`.
2. Если версия с `throw RuntimeException` (spec-c) — заменить на main-версию через `redeploy.sh` после §4.1.1 merge.
3. Smoke: отправить тестовый webhook с `project='lkomega.ru'` (без B-префикса) — должен получить platform='DIRECT', НЕ упасть с retry-штормом.

#### §4.1.4 R-16 — Cleanup на pivot вместо legacy FK

Файл: [CleanupInactiveSupplierProjectsJob.php Phase A+B subquery](app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php).

Заменить:

```sql
SELECT DISTINCT id FROM (
    SELECT supplier_b1_project_id AS id FROM projects WHERE is_active=true AND supplier_b1_project_id IS NOT NULL
    UNION SELECT supplier_b2_project_id FROM projects WHERE is_active=true AND supplier_b2_project_id IS NOT NULL
    UNION SELECT supplier_b3_project_id FROM projects WHERE is_active=true AND supplier_b3_project_id IS NOT NULL
) AS active_supplier_ids
```

На:

```sql
SELECT DISTINCT psl.supplier_project_id AS id
FROM project_supplier_links psl
INNER JOIN projects p ON p.id = psl.project_id
WHERE p.is_active = true
```

Тесты:

- existing-pivot, нет legacy FK → supplier_project не помечается inactive.
- legacy FK заполнен (старый проект), пустой pivot → также не помечается (потому что legacy FK на проде уже почти везде null после Plan 3 migration; нужно проверить вручную через Boost MCP).

#### §4.1.5 Acceptance Этапа 1

- ✅ main = прод (никакого git-drift).
- ✅ `LeadRouter` готов к DROP `balance_leads`.
- ✅ DIRECT path работает (R-14 закрыт).
- ✅ Cleanup на pivot.
- ✅ Полный регресс зелёный.

### §4.2 Этап 2 — собственный slepok (≈ 3-5 дней, главная архитектурная работа)

#### §4.2.1 Новая таблица `project_routing_snapshots`

Хранит фиксированное состояние каждого `is_active` проекта на каждый рабочий день.

```sql
CREATE TABLE project_routing_snapshots (
    id              BIGSERIAL,
    snapshot_date   DATE        NOT NULL,
    project_id      BIGINT      NOT NULL,
    tenant_id       BIGINT      NOT NULL,
    daily_limit     INT         NOT NULL CHECK (daily_limit >= 0),
    delivery_days_mask INT      NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
    regions         INT[]       NOT NULL DEFAULT '{}',
    signal_type     TEXT        NOT NULL,
    signal_identifier TEXT,
    sms_senders     JSONB,
    sms_keyword     TEXT,
    expected_volume INT         NOT NULL CHECK (expected_volume >= 0),
    -- бизнес-наблюдение (заполняется в течение дня)
    delivered_count INT         NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
    created_at      TIMESTAMP   NOT NULL DEFAULT NOW(),
    PRIMARY KEY (snapshot_date, project_id),
    -- ↑ композитный PK обеспечивает идемпотентность + индекс роутера на snapshot_date
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
    -- NB: НЕ ссылаемся на projects(id) — проект может быть удалён,
    --     а snapshot должен пережить (хвост слепка ещё летит).
);
CREATE INDEX project_routing_snapshots_tenant_date_idx
    ON project_routing_snapshots (tenant_id, snapshot_date);
CREATE INDEX project_routing_snapshots_signal_idx
    ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
```

**RLS:**

```sql
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_routing_snapshots_tenant_isolation
    ON project_routing_snapshots
    USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE project_routing_snapshots_id_seq TO crm_supplier_worker;
```

**Retention:** snapshots старше 90 дней удаляются партиционным cleanup-ом (если таблица большая) или WHERE-DELETE раз в неделю. На этапе 2 — простая partitioning по `snapshot_date` (PARTITION BY RANGE).

#### §4.2.2 Cron `SnapshotProjectRoutingJob` @ 18:00 МСК

Создаёт slepok №NЛ для дня N+1 — **синхронно с cut-off'ом клиента** (18:00 МСК), за 3 часа до фиксации slepok'а №NП поставщиком (21:00 МСК). 3-часовое окно — на push поставщику через `SyncSupplierProjectsJob`.

**Порядок cron'ов в 18:00 МСК (важно — зависимость):**

1. `billing:preflight-sweep` — обновляет `tenants.frozen_by_balance_at`.
2. `SnapshotProjectRoutingJob` — создаёт snapshot, читая актуальный frozen-статус.
3. `SyncSupplierProjectsJob` (в 18:05 МСК) — пушит поставщику ровно то, что в snapshot'е.

```php
namespace App\Jobs;

final class SnapshotProjectRoutingJob implements ShouldQueue {
    use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;

    public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS

    public function handle(): void {
        // Создаём slepok №NЛ на завтрашний день N+1. Cron запускается в N.18:00 МСК.
        $snapshotDate = Carbon::tomorrow('Europe/Moscow')->toDateString();
        $weekdayBit = 1 << (Carbon::tomorrow('Europe/Moscow')->isoWeekday() - 1);

        DB::connection(self::DB_CONNECTION)->transaction(function () use ($snapshotDate, $weekdayBit) {
            // Идемпотентность: если уже есть snapshot за эту дату — пропускаем.
            $exists = DB::connection(self::DB_CONNECTION)
                ->table('project_routing_snapshots')
                ->where('snapshot_date', $snapshotDate)
                ->exists();
            if ($exists) {
                Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
                return;
            }

            // Eligible: is_active=true + рабочий день в маске + tenant не frozen + проект не blocked.
            // ВНИМАНИЕ: для расчёта expected_volume используем формулу LeadDistributor:
            //   на каждом источнике (signal_type, identifier) сумма expected_volume у всех
            //   шерящих клиентов = order группы (max(max_limit, ceil(Σ/3))) × 3 (CAP)
            //   распределяется по числу шерящих, пропорционально daily_limit.
            // Это даст preflight'у корректный «реальный требуемый capacity per tenant»
            // (закрывает R-19).

            $eligibleSql = <<<SQL
                INSERT INTO project_routing_snapshots (
                    snapshot_date, project_id, tenant_id,
                    daily_limit, delivery_days_mask, regions,
                    signal_type, signal_identifier, sms_senders, sms_keyword,
                    expected_volume
                )
                SELECT
                    ?::date AS snapshot_date,
                    p.id, p.tenant_id,
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
                    p.delivery_days_mask,
                    p.regions,
                    p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
                    -- expected_volume: распределяем order группы пропорционально лимитам
                    -- (см. §4.4.4 R-19 — реальная formula тут)
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target) AS expected_volume
                    -- TODO Этап 4: заменить на share-aware формулу
                FROM projects p
                INNER JOIN tenants t ON t.id = p.tenant_id
                WHERE p.is_active = true
                  AND (p.delivery_days_mask & ?::int) <> 0
                  AND p.preflight_blocked_at IS NULL
                  AND t.frozen_by_balance_at IS NULL
                  AND t.deleted_at IS NULL
            SQL;

            $count = DB::connection(self::DB_CONNECTION)
                ->insert($eligibleSql, [$snapshotDate, $weekdayBit]);

            Log::info('snapshot.created', [
                'date' => $snapshotDate,
                'rows' => $count,
            ]);
        });
    }
}
```

**Регистрация в `routes/console.php`:**

```php
// §4.2.2: snapshot настроек проектов на завтра (cut-off клиента Лидерры).
// Запускается в 18:02 МСК — ПОСЛЕ billing:preflight-sweep (18:00) чтобы
// успеть подхватить актуальный tenants.frozen_by_balance_at, и ДО
// SyncSupplierProjectsJob (18:05) чтобы тот пушил поставщику ровно snapshot.
Schedule::job(new \App\Jobs\SnapshotProjectRoutingJob)
    ->dailyAt('18:02')
    ->timezone('Europe/Moscow')
    ->onSuccess(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', true, null, null))
    ->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
```

**NB:** Зависимости по timing:

- `billing:preflight-sweep` (18:00 МСК) — выставляет `frozen_by_balance_at`.
- `SnapshotProjectRoutingJob` (18:02 МСК) — читает frozen + создаёт snapshot.
- `SyncSupplierProjectsJob` (18:05 МСК) — пушит поставщику.

Между ними 2-5 минут запаса. Если первые два не успеют — третий запустится с устаревшим состоянием. На практике sweep отрабатывает за секунды (≤200 тенантов), snapshot тоже секунды (≤2000 активных проектов). Запас достаточный.

**Fail-loud механизм при отсутствии snapshot.** Если `SnapshotProjectRoutingJob` упал и snapshot за активную дату отсутствует — `LeadRouter::matchEligibleProjects` вернёт 0 проектов на КАЖДЫЙ входящий лид. Это **катастрофа молча**: весь трафик в простой. Защитные слои:

1. **Heartbeat alarm.** `SchedulerHeartbeatTracker::recordRunResult('SnapshotProjectRoutingJob', false, ...)` уже подключён (см. `routes/console.php`). Если cron упал → запись `false`. Cron `scheduler:check-heartbeats` (каждый час) поднимет инцидент через `IncidentsLog`.
2. **Routing guard в `LeadRouter`** — при возврате `Collection`-empty логировать `Log::error('lead_router.no_snapshot_for_date', ['date' => $activeDate, 'supplier_project_id' => $sp->id])`. По существующему cron `incidents:watch-failures` (каждые 10 мин, порог 200) — если за 10 мин 200+ таких записей, поднимается отдельный инцидент high severity.
3. **Ручной recovery.** Артизан-команда `php artisan snapshot:rebuild --date=YYYY-MM-DD` — пересоздаёт snapshot из live `projects` по требованию (для одноразового восстановления). NB: rebuild не воспроизводит «правильное» состояние момента 18:02 МСК — он берёт live на момент запуска; это known-tradeoff между «промолчать» и «маршрутизировать по приближению».

**Не делаем fallback на live `projects` в самом роутере** — это нарушит бизнес-инвариант (правки сегодня влияют на сегодня). Лучше явный простой + алёрт + ручной rebuild, чем тихая регрессия к старому поведению.

#### §4.2.3 Изменения `LeadRouter::matchEligibleProjects`

Файл: [LeadRouter.php](app/app/Services/LeadRouter.php).

**До:**

```sql
SELECT DISTINCT ON (projects.tenant_id) projects.*
FROM projects
WHERE EXISTS (SELECT 1 FROM project_supplier_links psl ...)
  AND projects.is_active = true
  AND (projects.delivery_days_mask & ?) <> 0
  AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
  AND EXISTS (SELECT 1 FROM tenants WHERE tenants.id = projects.tenant_id AND tenants.balance_rub > 0)
ORDER BY ...
```

**После:**

Выбор активного `snapshot_date` зависит от времени в МСК (по канону §0):

- Если сейчас **< 21:00 МСК** — активен slepok, сделанный **вчера в 18:00 МСК** (с датой=сегодня).
- Если сейчас **≥ 21:00 МСК** — активен slepok, сделанный **сегодня в 18:00 МСК** (с датой=завтра).

```sql
WITH active_slepok_date AS (
    SELECT
        CASE
            WHEN EXTRACT(HOUR FROM (NOW() AT TIME ZONE 'Europe/Moscow')) >= 21
                THEN ((NOW() AT TIME ZONE 'Europe/Moscow')::date + INTERVAL '1 day')::date
            ELSE (NOW() AT TIME ZONE 'Europe/Moscow')::date
        END AS d
)
SELECT DISTINCT ON (snap.tenant_id)
    projects.*,
    snap.daily_limit AS effective_daily_limit_snapshot
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = (SELECT d FROM active_slepok_date)
  AND EXISTS (
      SELECT 1 FROM project_supplier_links psl
      WHERE psl.project_id = snap.project_id
        AND psl.supplier_project_id = ?
  )
  AND projects.delivered_today < snap.daily_limit
  -- баланс ЖИВОЙ (защита auto-pause, не slepok):
  AND EXISTS (
      SELECT 1 FROM tenants
      WHERE tenants.id = snap.tenant_id
        AND tenants.balance_rub > 0
        AND tenants.frozen_by_balance_at IS NULL  -- ← Этап 3 R-03
  )
ORDER BY snap.tenant_id,
         (snap.daily_limit - projects.delivered_today) DESC,
         projects.created_at,
         projects.id
```

Ключевые изменения:

1. JOIN с `project_routing_snapshots` по **активному slepok_date** (CTE учитывает 21:00 МСК границу) — лимит, дни, регионы берутся **из снимка**.
2. `is_active=true` фильтр **снят** — snapshot уже содержит только активные на момент 18:00 МСК.
3. `delivery_days_mask & todayBit` фильтр **снят** — snapshot уже содержит только проекты с маской на день N+1.
4. `regions` — теперь читается из snapshot для соответствия региона (TODO: уточнить логику matching в Этап 2 task 4).
5. `delivered_today` — остаётся live (это инкремент в реальном времени, не slepok-поле). NB: cron `projects:reset-delivered-today` в 00:00 МСК сейчас обнуляет `delivered_today` — это совместимо со схемой slepok'а: окно 21:00–24:00 МСК работает по новому snapshot (на завтра), но `delivered_today` ещё накоплен от сегодня; после 00:00 МСК счётчик обнулён, и продолжает считать по тому же snapshot. **TODO Этап 2 task 5:** перенести счётчик `delivered_count` в сам snapshot (см. поле `delivered_count` в §4.2.1) и убрать live `projects.delivered_today`.
6. Баланс остаётся live + добавлен `frozen_by_balance_at IS NULL` (Этап 3).

#### §4.2.4 Изменения `RouteSupplierLeadJob::createDealCopyForProject`

Файл: [RouteSupplierLeadJob.php:212-386](app/app/Jobs/RouteSupplierLeadJob.php).

Изменения:

1. **`lockForUpdate` recheck** теперь смотрит на snapshot:

   ```php
   $snapshot = DB::connection('pgsql_supplier')
       ->table('project_routing_snapshots')
       ->where('snapshot_date', Carbon::today('Europe/Moscow')->toDateString())
       ->where('project_id', $project->id)
       ->lockForUpdate()
       ->first();
   if ($snapshot === null) {
       Log::info('supplier_lead.no_snapshot_skipped', ['project_id' => $project->id]);
       return false;
   }
   $effectiveLimit = (int) $snapshot->daily_limit;
   if ($lockedProject->delivered_today >= $effectiveLimit) {
       // ... existing limit-exceeded log + return false
   }
   ```

2. **R-09 fix:** добавить recheck `is_active` под lockForUpdate(Project):

   ```php
   if (!$lockedProject->is_active) {
       Log::info('supplier_lead.project_paused_under_lock', [...]);
       return false;
   }
   ```

   (NB: это уже не «slepok»-поле — это live, мы его всё ещё recheck'аем потому что paused между matchEligible и lock — реальный race, защищаем от двойной обработки.)

3. **Инкремент `delivered_count` в snapshot:**

   ```php
   // Используем АКТИВНЫЙ snapshot_date (та же логика, что в LeadRouter CTE active_slepok_date).
   // Иначе в окне 21:00-24:00 МСК счётчик уезжает в today, а матчинг идёт по today+1 —
   // разойдутся за вечер.
   $msk = Carbon::now('Europe/Moscow');
   $activeSnapshotDate = $msk->hour >= 21
       ? $msk->copy()->addDay()->toDateString()
       : $msk->toDateString();

   DB::connection('pgsql_supplier')
       ->table('project_routing_snapshots')
       ->where('snapshot_date', $activeSnapshotDate)
       ->where('project_id', $project->id)
       ->increment('delivered_count');
   ```

   NB: `increment()` в Laravel генерирует `UPDATE ... SET col = col + 1 WHERE ...` — атомарная PG-операция, lockForUpdate не нужен.

#### §4.2.4b Изменения `SyncSupplierProjectsJob` (ночной push поставщику)

Файл: [SyncSupplierProjectsJob.php:83-91](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php).

**Проблема (без правки):** `collectEligibleProjects()` читает live `Project::on(...)->where('is_active', true)...`. Cron запускается в 18:05 МСК — через ~3 минуты после `SnapshotProjectRoutingJob` (18:02). В окне 18:02→18:05 клиент может успеть нажать «пауза» через UI; live state будет отличаться от только что зафиксированного snapshot'а. Поставщик получит push **не равный** нашему slepok'у на следующий день — разрыв.

**Решение:** после Этапа 2 `SyncSupplierProjectsJob` читает **из snapshot'а на завтра**, а не из live `projects`. Гарантия: то, что в snapshot, то и пушим поставщику.

```php
// До (live):
$projects = Project::on(self::DB_CONNECTION)
    ->where('is_active', true)
    ->orderBy('id')
    ->get();

// После (читаем из snapshot, который SnapshotProjectRoutingJob создал в 18:02):
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
$projects = DB::connection(self::DB_CONNECTION)
    ->table('project_routing_snapshots AS snap')
    ->join('projects', 'projects.id', '=', 'snap.project_id')
    ->where('snap.snapshot_date', $tomorrow)
    ->select('projects.*', 'snap.daily_limit AS snapshot_daily_limit',
             'snap.regions AS snapshot_regions',
             'snap.delivery_days_mask AS snapshot_days_mask')
    ->orderBy('projects.id')
    ->get();
// NB: в группировке (§4.2.4b.2) используем snapshot_* поля, не live projects.*.
```

В `syncGroup()` (line 187) и `computeOrder()` параметрах — заменить `$p->daily_limit_target` на `$p->snapshot_daily_limit`, `$p->delivery_days_mask` на `$p->snapshot_days_mask`, `$p->regions` на `$p->snapshot_regions`.

**Что не меняется:**

- `BalancePreflightSweepJob` (18:00) — она пишет `frozen_by_balance_at` ПЕРЕД snapshot'ом, и `SnapshotProjectRoutingJob` уже отфильтрует frozen-проекты (см. §4.2.2 SQL). Поэтому SyncSupplierProjectsJob получит из snapshot ровно non-frozen non-blocked проекты — фильтр Spec C `collectEligibleProjects` становится **избыточным**, но оставляем его как defence-in-depth.

**Что меняется в `SyncSupplierProjectJob` (single-project online edit):** ничего. Online edit — это «передать поставщику изменения после 18:00 МСК» — это **не часть нашего snapshot'а**, это отдельный канал доставки изменений. Online edit пушит live состояние сейчас; поставщик может (или нет) учесть. Snapshot на день N+1 уже зафиксирован в snapshot table — изменение применится только к slepok'у на день N+2 (по `appliesFrom`).

#### §4.2.5 Расширение `SupplierSnapshotGuard`

Файл: [SupplierSnapshotGuard.php](app/app/Services/Project/SupplierSnapshotGuard.php).

Сейчас защищает только `delete` / `change_source`. После этапа 2 защищает **все изменения, влияющие на slepok**:

- `pause` (is_active → false).
- `resume` (is_active → false → true).
- `daily_limit_target` (любое изменение, не только уменьшение).
- `delivery_days_mask`.
- `regions`.

**Не блокирует мгновенно**, а позволяет, но:

1. Записывает изменение в `Project` (UI отражает «применится с послезавтра»).
2. Slepok сегодняшнего дня не меняется (он зафиксирован вчера 21:00).
3. Slepok завтрашнего дня (который будет создан сегодня 21:00) — учтёт изменение.

**API change:**

```php
class SupplierSnapshotGuard {
    /**
     * Возвращает дату, с которой применится изменение настройки.
     * NULL = немедленно (нет slepok-защиты, проект без pivot или live-only поле).
     */
    public function appliesFrom(Project $project, string $field): ?CarbonImmutable {
        $hasLinks = DB::table('project_supplier_links')->where('project_id', $project->id)->exists();
        if (!$hasLinks) {
            return null;  // нет slepok'а у поставщика — нет grace
        }
        $isSlepokSensitive = in_array($field, [
            'is_active', 'daily_limit_target', 'delivery_days_mask', 'regions',
            'signal_identifier', 'sms_senders', 'sms_keyword',
        ], true);
        if (!$isSlepokSensitive) {
            return null;
        }
        // Slepok начинает работать в 21:00 МСК (по канону §0), а не в 00:00 МСК следующего дня.
        // Возвращаем точный момент активации, UI отобразит его клиенту.
        $nowMsk = CarbonImmutable::now('Europe/Moscow');
        $todayCutoff = $nowMsk->setTime(18, 0, 0);
        if ($nowMsk->gte($todayCutoff)) {
            // После 18:00 МСК дня N — правка попадёт в slepok №(N+1)Л (создан (N+1).18:00),
            // активен с (N+1).21:00 МСК → applies = (N+1).21:00 МСК.
            return $nowMsk->addDay()->setTime(21, 0, 0);
        }
        // До 18:00 МСК дня N — правка попадёт в slepok №NЛ (создан N.18:00),
        // активен с N.21:00 МСК → applies = N.21:00 МСК.
        return $nowMsk->setTime(21, 0, 0);
    }
}
```

**UX:**

Front-end после сохранения настроек показывает сообщение:
> «Сохранено. Изменения вступят в силу с {applies_date} 21:00 МСК.»

- Правка до 18:00 МСК дня N → `applies_date == today` 21:00 МСК (сегодня вечером).
- Правка после 18:00 МСК дня N → `applies_date == today + 1` 21:00 МСК (завтра вечером).

Формат показа клиенту — на усмотрение UI (например, «Сегодня в 21:00» / «Завтра в 21:00» / «27 мая 21:00»).

#### §4.2.6 Backfill для перехода на slepok

Один раз при выкатке Этапа 2:

- Создать `php artisan snapshot:backfill --date=today` — создаёт snapshot за **сегодня** (текущий день) из live состояния проектов. Это нужно, чтобы роутер не остался без snapshot между deploy и первым cron **18:02 МСК**.
- Cron **18:02 МСК** создаст snapshot за завтра.
- Дальше — естественный ход.

#### §4.2.7 Acceptance Этапа 2

- ✅ `project_routing_snapshots` существует на проде с RLS.
- ✅ Cron **18:02 МСК** (`SnapshotProjectRoutingJob`) работает, есть запись heartbeat.
- ✅ `LeadRouter` читает из snapshot для всех eligibility-полей кроме баланса.
- ✅ `RouteSupplierLeadJob` recheck'ает snapshot под lock + `is_active`.
- ✅ `SupplierSnapshotGuard.appliesFrom` отвечает корректно для всех 6 slepok-sensitive полей.
- ✅ UI показывает «изменения вступят в силу с DD.MM».
- ✅ Backfill отработал, snapshot за текущий день существует.
- ✅ Pest тесты на R-01, R-06, R-07, R-08, R-10, R-15 — закрыты с воспроизведением сценария и проверкой что фикс работает.

### §4.3 Этап 3 — фильтр frozen + блок в роутере + sync paused_at (≈ 1 день)

#### §4.3.1 R-03 — фильтр `frozen_by_balance_at`

Уже включён в SQL `LeadRouter` (Этап 2 §4.2.3). Доп. проверка: убедиться, что `LedgerService::chargeForDelivery` тоже не пройдёт для frozen tenant'а.

Файл: [LedgerService.php:51-70](app/app/Services/Billing/LedgerService.php). Перед `bcmath` проверкой добавить:

```php
if ($lockedTenant->frozen_by_balance_at !== null) {
    throw new InsufficientBalanceException(
        priceKopecks: $priceKopecks,
        balanceRub: (string) $lockedTenant->balance_rub,
    );
}
```

Это даст ту же auto-pause семантику для frozen — лид не уйдёт, проект пометится `is_active=false`.

#### §4.3.2 R-13 — `BalancePreflightSweepJob` синхронизирует `paused_at`

Файл: `BalancePreflightSweepJob.php` (spec-c).

При переходе active → frozen:

```php
$tenant->frozen_by_balance_at = now();
$tenant->save();
// Новое: пометить все проекты тенанта paused_at = NOW(), чтобы Snapshot Guard работал.
DB::connection('pgsql_supplier')->table('projects')
    ->where('tenant_id', $tenant->id)
    ->whereNull('paused_at')
    ->update(['paused_at' => now()]);
```

При переходе frozen → active:

```php
$tenant->frozen_by_balance_at = null;
$tenant->save();
// Откатить paused_at тенанта, чтобы Snapshot Guard перестал блокировать.
// NB: только проекты, у которых paused_at <= frozen_at — иначе можем сбросить ручную паузу клиента.
DB::connection('pgsql_supplier')->table('projects')
    ->where('tenant_id', $tenant->id)
    ->where('paused_at', '<=', $tenant->frozen_by_balance_at_was) // captured before save
    ->update(['paused_at' => null]);
```

#### §4.3.3 Acceptance Этапа 3

- ✅ Frozen tenant НЕ получает лиды (роутер фильтрует + LedgerService rejects).
- ✅ Frozen tenant защищён Snapshot Guard от удаления / change_source.
- ✅ Unfreeze откатывает `paused_at` корректно.
- ✅ Pest тесты на R-03, R-13.

### §4.4 Этап 4 — корректные расчёты (≈ 2 дня)

#### §4.4.1 R-17 — унификация `buildUniqueKey` ↔ `buildUniqueKeyAgnostic`

Файл: [SupplierProjectGrouping.php](app/app/Services/Supplier/SupplierProjectGrouping.php).

Удалить platform-specific метод `buildUniqueKey($project, $platform)`. Везде использовать `buildUniqueKeyAgnostic($project)`.

**Миграция данных** (одноразовая artisan-команда `php artisan supplier:rekey-orphans`):

- Найти все `supplier_projects` с `signal_type='sms'` и `unique_key=sender` (без keyword), для которых существует проект Лидерры с `sms_keyword != NULL`.
- Если для того же tenant'а уже есть `supplier_project` с `unique_key=sender+keyword` — старый row помечается dead, через `DeleteSupplierProjectJob` чистится у поставщика.
- Если не существует — UPDATE `unique_key = sender+keyword`.

Тест: сценарий R-17 в Pest.

#### §4.4.2 R-18 — фиксированная target_date в online sync

Файл: [SyncSupplierProjectJob.php:141 handleOnline](app/app/Jobs/SyncSupplierProjectJob.php#L141).

Изменить:

```php
// Было:
$targetWeekday = Carbon::tomorrow('Europe/Moscow')->isoWeekday();

// Стало (более явная семантика — после 21:00 МСК уже работает завтрашний slepok, target = послезавтра):
$msk = CarbonImmutable::now('Europe/Moscow');
$targetDate = $msk->hour >= 21
    ? $msk->addDays(2)->startOfDay()
    : $msk->addDay()->startOfDay();
$targetWeekday = $targetDate->isoWeekday();
```

То есть граница 00:00 МСК больше не дёргает `target_date` (она дёргает её в 21:00 МСК, что совпадает с моментом фиксации нашего slepok'а).

Тест: фиктивный clock на 23:59:59 МСК понедельника → target = вторник. На 00:00:01 МСК вторника → target = вторник (тот же, не среда). На 21:00:01 МСК вторника → target = четверг.

#### §4.4.3 R-19 — share-aware формула в `requiredLeadsForTomorrow`

Файл: [Tenant.php](app/app/Models/Tenant.php) (spec-c).

Текущая:

```php
public function requiredLeadsForTomorrow(): int {
    return (int) $this->projects()
        ->where('is_active', true)
        ->sum('daily_limit_target');
}
```

Новая — учитывает шеринг:

```php
public function requiredLeadsForTomorrow(): int {
    // Для каждого активного проекта тенанта: ожидаемый объём = его доля от order группы.
    // Формула:
    //   group_order = max(max(group_limits), ceil(Σ group_limits / 3))
    //   tenant_share = group_order × (tenant_limit / Σ group_limits)
    // Слабее, чем daily_limit_target, если в группе ≥ 2 других клиентов.

    $projects = $this->projects()->where('is_active', true)->get();
    if ($projects->isEmpty()) {
        return 0;
    }

    $total = 0;
    foreach ($projects as $p) {
        $groupLimits = DB::connection('pgsql_supplier')
            ->table('projects')
            ->where('is_active', true)
            ->where('signal_type', $p->signal_type)
            ->where(function ($q) use ($p) {
                // Тот же agnostic identifier
                if (in_array($p->signal_type, ['site', 'call'], true)) {
                    $q->where('signal_identifier', $p->signal_identifier);
                } else {
                    // sms — упрощённо, sender первый
                    $q->whereJsonContains('sms_senders', (string) ($p->sms_senders[0] ?? ''));
                    if ($p->sms_keyword !== null && $p->sms_keyword !== '') {
                        $q->where('sms_keyword', $p->sms_keyword);
                    } else {
                        $q->whereNull('sms_keyword');
                    }
                }
            })
            ->pluck('daily_limit_target')
            ->all();

        $sum = (int) array_sum(array_map('intval', $groupLimits));
        $max = (int) max($groupLimits);
        $groupOrder = max($max, (int) ceil($sum / 3));

        if ($sum > 0) {
            $share = (int) ceil($groupOrder * ((int) $p->daily_limit_target / $sum));
            $total += $share;
        }
    }
    return $total;
}
```

Тест: один источник, 3 шерящих клиента по 10 лимит → groupOrder=10, share per клиент = 10/3 ≈ 4. Старая формула давала 10. Закрывает overcharge preflight'а.

#### §4.4.4 R-05 + R-02 — CSV reconcile рефактор

Файл: [CsvReconcileJob.php](app/app/Jobs/Supplier/CsvReconcileJob.php).

Сейчас drift = `(missing - unparseable) / parseable_total`. Считает webhook-loss.

Новый drift (per snapshot_date, per tenant):

```sql
-- расхождение между "ожидали по snapshot" и "доставили в реальности"
SELECT
    snap.snapshot_date,
    snap.tenant_id,
    SUM(snap.expected_volume) AS expected,
    SUM(snap.delivered_count) AS delivered,
    CASE WHEN SUM(snap.expected_volume) > 0
         THEN (SUM(snap.expected_volume) - SUM(snap.delivered_count))::float / SUM(snap.expected_volume)
         ELSE 0 END AS shortfall_ratio
FROM project_routing_snapshots snap
WHERE snap.snapshot_date BETWEEN ? AND ?
GROUP BY snap.snapshot_date, snap.tenant_id
HAVING ... shortfall > threshold ...
```

Алёрт если shortfall > 20% (порог обсуждаем). Email = `tenant_business_drift_alert` — отдельно от webhook-loss CsvDriftAlertMail.

Старый webhook-loss check остаётся — он тоже нужен (R-05.1: лид прилетел, мы webhook'а не получили). Новый — поверх (R-05.2: лид прилетел, мы доставили не тому/никому).

#### §4.4.5 Acceptance Этапа 4

- ✅ `buildUniqueKey` удалён, везде `buildUniqueKeyAgnostic`.
- ✅ Миграция R-17 отработала на проде, dead rows у поставщика почищены.
- ✅ `target_date` в online sync не скачет на границе 00:00 МСК.
- ✅ Preflight для шерящихся клиентов не overcharges (R-19).
- ✅ CSV reconcile подсвечивает business-drift, а не только webhook-loss.
- ✅ Pest тесты на R-17, R-18, R-19.

### §4.5 Этап 5 — переключение online → batch (≈ 1 час + неделя наблюдения)

#### §4.5.1 Когда

После стабильной работы Этапов 1-4 на 10-15 клиентах в течение минимум 1 недели **без P0 инцидентов**. Решение принимает заказчик.

#### §4.5.2 Как

```sql
UPDATE system_settings
SET value = 'batch'
WHERE key = 'supplier_export_mode';
```

Все остальные механизмы (`BalancePreflightSweepJob::dispatchSupplierSyncIfOnline`, `SyncSupplierProjectJob::handle`, и т.д.) автоматически переходят на batch path. Никаких изменений в коде.

#### §4.5.3 Что меняется для пользователя

- Изменения настроек больше не «летят моментально» поставщику.
- UI продолжает показывать «применится с DD.MM» — теперь это работает **через ночной batch 18:05 МСК**, а не через моментальный online sync.
- Семантика для клиента: «вечером 18:05 МСК закрывается окно правок на завтрашний день».

#### §4.5.4 Acceptance Этапа 5

- ✅ `supplier_export_mode = 'batch'` на проде.
- ✅ 7 дней нет P0 инцидентов.
- ✅ Memory `project_billing_v2.md` и `project_slepok_protection.md` обновлены («целевой режим достигнут»).

---

## §5. Схема БД (полная сводка)

### §5.1 Новые таблицы

- `project_routing_snapshots` (см. §4.2.1).

### §5.2 Новые колонки

- Никаких (используем существующие `tenants.frozen_by_balance_at`, `projects.preflight_blocked_at`, `projects.paused_at`).

### §5.3 Удаляемые колонки (после Этапа 1 §4.1.2)

- `tenants.balance_leads` (через Spec B Phase B — не наш scope).

### §5.4 Индексы

- `project_routing_snapshots(snapshot_date, project_id)` PK.
- `project_routing_snapshots(tenant_id, snapshot_date)`.
- `project_routing_snapshots(snapshot_date, signal_type, lower(signal_identifier))`.

### §5.5 RLS

- `project_routing_snapshots` — стандартная `tenant_isolation` policy. См. §4.2.1.

### §5.6 Партиционирование

- `project_routing_snapshots` по `snapshot_date` (RANGE per month) — последовательно с существующими partitioned-таблицами (`deals`, `supplier_lead_costs`).
- Retention 90 дней через `partitions:drop-expired` cron (уже существует).

---

## §6. Изменения в коде по файлам

| Файл | Изменение | Этап |
|---|---|---|
| `app/app/Services/LeadRouter.php` | SQL переписан на JOIN со snapshot, фильтр frozen | §4.2.3, §4.3.1 |
| `app/app/Jobs/RouteSupplierLeadJob.php` | recheck snapshot, recheck is_active под lock | §4.2.4 |
| `app/app/Jobs/SnapshotProjectRoutingJob.php` | новый файл | §4.2.2 |
| `app/app/Services/Project/SupplierSnapshotGuard.php` | расширен на pause/limit/regions/days, `appliesFrom()` API | §4.2.5 |
| `app/app/Services/Project/ProjectService.php` | вызов `SupplierSnapshotGuard::appliesFrom` для всех slepok-полей | §4.2.5 |
| `app/app/Jobs/Billing/BalancePreflightSweepJob.php` | sync `paused_at` при freeze/unfreeze | §4.3.2 |
| `app/app/Services/Billing/LedgerService.php` | reject frozen tenant в `chargeForDelivery` | §4.3.1 |
| `app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php` | subquery через pivot | §4.1.4 |
| `app/app/Services/Supplier/SupplierProjectGrouping.php` | удалить platform-specific `buildUniqueKey` | §4.4.1 |
| `app/app/Jobs/SyncSupplierProjectJob.php` | fixed `target_date` (R-18) | §4.4.2 |
| `app/app/Models/Tenant.php` | share-aware `requiredLeadsForTomorrow` | §4.4.3 |
| `app/app/Jobs/Supplier/CsvReconcileJob.php` | + business-drift проверка | §4.4.4 |
| `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` | **читает из snapshot** (snapshot_date=tomorrow), а не live `projects` — для гарантии идемпотентности с нашим slepok | §4.2 (новый §4.2.4b) |
| `app/routes/console.php` | + `SnapshotProjectRoutingJob` @ **18:02 МСК** (между 18:00 sweep и 18:05 sync) | §4.2.2 |
| `app/resources/js/.../ProjectFormDialog.vue` (UI) | сообщение «применится с DD.MM» | §4.2.5 |

---

## §7. Тестирование

### §7.1 Unit

- `SupplierSnapshotGuard::appliesFrom` — 6 полей × 2 окна (до 21:00 / после).
- `Tenant::requiredLeadsForTomorrow` — 4 сценария (один проект / шеринг 2 / шеринг 3 / Σ=0).
- `SnapshotProjectRoutingJob` — идемпотентность, фильтры (frozen/blocked/paused/inactive day).

### §7.2 Feature (Pest)

Полный список сценариев из risk-map'а:

- **R-01 closure:** snapshot @ 21:00 → клиент paus'нул в 11:00 следующего дня → роутер берёт snapshot, клиент **получает** свои лиды до конца дня. Pause применяется только к следующему snapshot.
- **R-02 closure:** Лидерра по snapshot роутит к А; A paus'нул сегодня → завтрашний snapshot не включает A → лиды завтра не уходят шерящим вместо A.
- **R-03 closure:** frozen tenant — роутер фильтрует + LedgerService rejects + Snapshot Guard защищает удаление.
- **R-04 closure:** auto-pause всё ещё работает (live `is_active=false` под lock), но снимок дня уже закрыт — хвост слепка летит **только** до момента auto-pause; завтрашний snapshot уже без проекта.
- **R-05 closure:** CSV reconcile подсвечивает business-drift, не только webhook-loss.
- **R-06..R-08 closure:** изменения limit/days/regions не влияют на сегодняшний роутер, попадают в следующий snapshot.
- **R-09 closure:** lock recheck `is_active` — paused под lock пропускается.
- **R-10 closure:** Snapshot Guard расширен на change_source через `appliesFrom`.
- **R-15 closure:** create в 22:00 → следующий snapshot 21:00 МСК следующего дня → effect на день+1 предсказуем; UI сообщает.
- **R-16/R-17/R-18/R-19 closure:** unit + feature по §4.4.

### §7.3 Integration

- Полный цикл: snapshot create → 100 webhook лидов через сутки → проверить delivered_count в snapshot = sum(deals) для tenant'а.
- Race-тест: 10 concurrent webhook + 5 concurrent project updates → no double charges, no missed leads.

### §7.4 Регресс

- `composer test --parallel`: все Pest должны быть зелёные.
- `npm run test:vue`: Vitest зелёный.
- `php artisan migrate:fresh --seed --env=testing` + smoke full webhook → deal цепочка.

---

## §8. Миграция / Rollout

### §8.1 Порядок

1. **Этап 1** — самостоятельный PR, влить в main, deploy.
2. **Этап 2** — отдельный PR с миграциями, бэкфилл-командой, deploy в час малой нагрузки (например, ночь субботы 03:00 МСК).
   - Backfill `php artisan snapshot:backfill --date=today` сразу после deploy.
   - Регресс мониторинг 24h: snapshot за след. день создаётся в **18:02 МСК**, роутер работает корректно.
3. **Этап 3** — отдельный PR. Frozen-фильтр + paused_at sync.
4. **Этап 4** — отдельный PR. Корректные расчёты + миграция R-17 dead rows.
5. **Этап 5** — манипуляция в БД (`UPDATE system_settings`).

### §8.2 Rollback

- **Этап 2:** `LeadRouter` фоллбек на live (через feature flag `system_settings.routing_use_snapshot = false`). Backfill snapshot остаётся как dead data, retention 90 дней вытрет.
- **Этап 3:** убрать `frozen_by_balance_at IS NULL` фильтр.
- **Этап 4:** R-17 миграция нерверсивна (orphans удалены). R-18/R-19 ревёрсятся git-revert.

### §8.3 Feature flag

`system_settings.routing_use_snapshot` = `true` (default) | `false`.

При `false` — `LeadRouter` ходит по старому SQL (live). Это даёт оперативный rollback Этапа 2 без deploy. После 30 дней стабильности — feature flag удалить.

---

## §9. Открытые вопросы

### §9.1 OPEN-1 — ✅ ЗАКРЫТО (заказчик 26.05.2026)

> «С 21:00 поставщик строго работает только по завтрашнему слепку.»

В роутере: до 21:00 МСК — snapshot c `snapshot_date = today`; после 21:00 МСК — snapshot c `snapshot_date = today + 1`. Реализовано через CTE `active_slepok_date` в SQL (§4.2.3).

### §9.2 OPEN-2 — ✅ ЗАКРЫТО (заказчик 26.05.2026)

> «Если клиент 26.05.26 в 23:00 нажмёт возобновить проект, то он получит свои сделки и мы свои лиды в рамках слепков №2Л и №2П — в период с 27.05.26 21:00 по 28.05.26 20:59.»

Resume в любое время = эффект со следующего slepok'а. «Emergency-unpause» не реализуется (нарушит инвариант). UI должен явно показывать клиенту дату вступления изменений в силу через `SupplierSnapshotGuard::appliesFrom()`.

### §9.3 OPEN-3. Backfill стратегия при множестве клиентов (не критично)

На 10-15 клиентов backfill snapshot — <1сек. На 1000+ клиентов — пересмотр потребуется. На текущем масштабе откладываем; вернёмся при росте.

### §9.4 OPEN-4. Удаление snapshot при удалении tenant (не критично)

`ON DELETE CASCADE FROM tenants` — если tenant удаляется (soft-delete не cascadit'ся, hard-delete cascadit'ся). Для soft-delete (что у нас стандарт) — snapshot остаётся, что окей. Очистка через retention 90 дней.

### §9.5 OPEN-5 — ✅ ЗАКРЫТО вариантом A (заказчик 26.05.2026, ответ «в» = «не знаю, оставить пока»)

`effective_daily_limit_today` — admin override «временный лимит только на сегодня».

**Решение:** при `SnapshotProjectRoutingJob` create — если `effective_daily_limit_today IS NOT NULL`, использовать его как `daily_limit` в snapshot; иначе `daily_limit_target`. Cron `projects:reset-delivered-today` (00:00 МСК) дополнительно обнуляет `effective_daily_limit_today` (закрывает R-11 «override без TTL»).

Если в будущем выяснится, что админ не пользуется этим полем — отдельной задачей удалить (`Spec C cleanup` или `Spec D`).

---

## §10. Связано / Cross-refs

- Аудит-сессия Claude Code `135a4adf-75a7-4c0b-b731-3aa7d57a73da` (26.05.2026) — 19 рисков, code-grounded findings. Memory `project_slepok_protection.md`.
- SupplierSnapshotGuard plan — текущая защита delete/change_source (база для расширения).
- [Spec C plan](../plans/2026-05-24-billing-v2-spec-c-preflight-vtb.md) §3.10 — deferred item «отдать разморозившемуся через шеринг».
- Memory `project_billing_v2.md` — решение online → batch.
- [Pravila §4.7](../../Pravila_raboty_Claude_v1_1.md) — структура спек/план.

---

## §11. Оценка трудозатрат

| Этап | Срок | Сложность | Риск |
|---|---|---|---|
| 1 | **0.5–1 день** | средняя | средний (rebase 14↔50+ коммитов + сохранить `0da72778`) |
| 2 | 3-5 дней | **высокая** | средний (новая таблица + переключение роутера + UX) |
| 3 | 1 день | средняя | низкий |
| 4 | 2 дня | средняя | средний (миграция R-17 dead rows у поставщика) |
| 5 | 1 час + неделя | низкая | низкий |
| **Итого** | **~7.5–10 рабочих дней** | | |

**Не верифицированы на проде (unfalsified assumptions, уточняются при реализации):**

- R-11 — реальное использование `effective_daily_limit_today` (вариант A в спеке — сохраняем, потенциально удалить как Spec D cleanup если выяснится что не используется).
- R-16 — пропорция legacy FK vs pivot в `projects` (это влияет на тест-кейсы §4.1.4, но не на саму правку — она безопасна в обоих случаях).
- R-17 — число orphan SMS unique_keys без keyword (это влияет на размер артизан-команды миграции в §4.4.1).
- R-19 — реальное распределение шерящих клиентов на источник (это влияет на пороги preflight'а в §4.4.3).

SQL для верификации — см. session-id `135a4adf` (последняя turn'а — готовый блок). Может быть выполнено на этапе planning / pre-deploy в любой момент через SSH liderra.ru.

---

## §12. Acceptance criteria (полный список)

Закрыты все 19 рисков:

- [x] R-01..R-04 — Этап 2.
- [x] R-05 — Этап 4.4.4.
- [x] R-06..R-08 — Этап 2.
- [x] R-09 — Этап 2.4.
- [x] R-10 — Этап 2.5.
- [x] R-11 — Этап 9.5 OPEN-5 (решение в Этап 2).
- [x] R-12 — Этап 1.2.
- [x] R-13 — Этап 3.2.
- [x] R-14 — Этап 1.3.
- [x] R-15 — Этап 2 (UX-семантика).
- [x] R-16 — Этап 1.4.
- [x] R-17 — Этап 4.1.
- [x] R-18 — Этап 4.2.
- [x] R-19 — Этап 4.3.

Бизнес-инвариант §2.3 выполняется (verified Pest + integration).

---

**End of spec.**
