# Карта логики и функционала боевого портала liderra.ru

**Снято:** 18.06.2026, построчным чтением **прод-кода** (`ssh liderra-prod`, `/var/www/liderra/app`), не локалки.
**Назначение:** прод-верная карта поведения портала как основа для приёмочного теста «глазами пользователя» перед передачей продажникам.
**Метод:** прочитаны все 28 контроллеров API, движок лидов/денег, все джобы, все сервисы, планировщик.

---

## 0. Суть в одном абзаце

Мультитенантный SaaS-CRM для **покупателей лидов**. Клиент заводит **проекты** (источники) → Лидерра агрегирует и **заказывает** у внешнего поставщика **crm.bp-gr.ru** → поставщик шлёт лиды **вебхуком** → роутер раздаёт каждый лид **до 3 клиентам** по региону/лимиту/балансу → создаёт **сделку** и **списывает деньги** по тарифной ступени. Изоляция клиентов — PostgreSQL **RLS** (`app.current_tenant_id`), supplier-джобы — роль `crm_supplier_worker` (BYPASSRLS).

---

## 1. Доступ (auth)

- Sanctum SPA (session-cookie), throttle на логине (5/15мин), 2FA TOTP опц.
- `register()` (`AuthController`) — **цепляет нового пользователя к `Tenant::first()`**, нового клиента НЕ создаёт. ⛔ **G1: самозаписи клиента нет** — новых клиентов заводят сид/админ/БД.
- 2FA: TOTP ±1 окно, 8 recovery-кодов (bcrypt), setup-wizard init→confirm (QR), disable/regenerate под паролем (`TwoFactorController`, `TwoFactorSetupController`).
- Сброс пароля: forgot (anti-enumeration unified-ответ + rate-limit), reset (Laravel Password facade, TTL 60мин) (`PasswordResetController`).
- RLS-контекст ставит middleware `tenant` (`SET app.current_tenant_id`).

## 2. Проект — что задаёт клиент (`StoreProjectRequest`, `ProjectController`, `ProjectService`)

- `signal_type` ∈ {site|call|sms}; site=домен (regex), call=`7XXXXXXXXXX`, sms=`sms_senders[]`(≤11) + опц `sms_keyword`; `regions[]` (1..89, пусто=вся РФ); `daily_limit_target` 1..10000; `delivery_days_mask` 1..127.
- Создание: preflight баланса (409 / `force_save_blocked`→`preflight_blocked_at`); `is_active=true`; **сразу `SyncSupplierProjectJob`** (online → реальный синк к поставщику).
- `signal_type` иммутабелен; `signal_identifier` editable (под `SupplierSnapshotGuard`). Уникальность имени и источника в тенанте.
- Правки источника/лимита/регионов/дней/паузы → resync. `delete`/смена источника блокируются guard'ом (grace до 21:00+24ч).
- bulk: pause/resume/delete/update_regions/update_days/update_limit (≤500).

## 3. Заказ поставщику (`SyncSupplierProjectJob`/`SyncSupplierProjectsJob`/`SupplierPortalClient`/каналы)

- Режим `system_settings.supplier_export_mode`: **online** (сразу полные параметры) / **batch** (каркас + ночной долив 18:05). **На проде = online.**
- Площадки (`resolvePlatforms`): **site/call→B1+B2+B3; sms+keyword→B2+B3; sms→B3** (B1+SMS запрещён constraint'ом).
- Группировка по (signal_type, identifier). **Заказ = `max(наиб.лимит, ⌈Σлимитов/3⌉)`** (`SupplierQuotaAllocator::computeOrder`), делится по площадкам largest-remainder (Σ==заказ).
- HTTP к `/admin/visit/rt-project-save` (сессия PHPSESSID+CSRF из Redis, обновляется `RefreshSupplierSessionJob` через Playwright). Регионы → коды поставщика (ГИБДД).
- Канал: ярус1 AJAX (`AjaxProjectChannel`) → ярус2 Playwright-форма (`FormProjectChannel`) → ярус3 ручная очередь (`FailoverProjectChannel` + алерт).
- Итог: rt-проекты **`B<n>_<identifier>`** в админке crm.bp-gr.ru ← «заказ глазами».

## 4. Слепок — ОСНОВА поставки (`SnapshotProjectRoutingJob`)

- **@18:02 МСК** пишет слепок на **завтра** (active + не-preflight_blocked + не-frozen + дни включают завтра). **ON CONFLICT по дате DO NOTHING** → в существующий слепок дня новый проект не добавит → нужен `snapshot:backfill`.
- Активная дата (роутер): до 21:00 МСК = сегодня, после = завтра.
- Поставщик фиксирует свой слепок в **21:00 МСК** и шлёт по нему весь следующий день. Канон-инвариант: правка до 18:00 → сегодня 21:00; после → завтра 21:00.

## 5. Поставка лида (`SupplierWebhookController`→`RouteSupplierLeadJob`→`LeadRouter`→`LeadDistributor`)

- `POST /api/webhook/supplier/{secret}`: secret(≥32) ИЛИ HMAC + IP-allowlist + rate 600/мин. Поля vid/project/phone(`7\d{10}`)/time(±24ч)/tag/phones[]. Идемпотентность по vid.
- `project`: `B[123]_…`→площадка, иначе **DIRECT**; rest: `7\d{10}`→call, домен→site, иначе→sms.
- Регион: **DaData→Россвязь→tag** (`LeadRegionResolver`, feature-flag `services.dadata.enabled`; кэш sha256(phone); бюджет-гард).
- Роутер (по слепку активной даты): кандидаты — остаток лимита>0, баланс>0, не frozen; B — через pivot `project_supplier_links`; **DIRECT — по signal_type+identifier** (без pivot/синка). Каскад: точный регион → вся РФ → fallback (подмена региона, `region_substituted`). Жребий по остатку лимита. **CAP=3** на лид.
- Каждому получателю (транзакция): lock tenant+project, recheck `is_active` под локом (R-09), лимит из слепка, `supplier_lead_deliveries` (одна поставка=раз) → **Deal** (city=реальный регион) → **списание** → инкременты delivered_today/month + snapshot.delivered_count → ActivityLog + уведомление.
- Сейф-нет: `CsvReconcileJob` (каждые 30мин) сверяет CSV-отчёт поставщика vs webhook-лиды, recovery пропущенных (`source=csv_recovery`), webhook-loss drift >5% → алерт, business-drift per (дата,тенант) shortfall >20% → алерт.

## 6. Деньги (`LedgerService` + биллинг-сервисы)

- Цена = ступень `pricing_tiers` по `(delivered_in_month+1)`. **Прод-ступени: T1 100×500₽, T2 200×450, T3 400×400, T4 800×350, T5 1500×300, T6 3000×270, T7 ∞×250** (`price_per_lead_kopecks`).
- frozen ИЛИ баланс<цены → `InsufficientBalanceException` → откат → автопауза проекта (`is_active=false`) + `ZeroBalancePausedMail` (rate 1/час).
- Списание: `balance_rub-=цена` (bcmath) + `lead_charges`(charge_source=rub) + `balance_transactions`(-amount) + `supplier_lead_costs`.
- `BalanceToLeadsConverter` (₽→лиды по ступеням), `RunwayCalculator` (affordable / средняя скорость 30д), `BalancePreflightService` (capacity≥required в лидах) — общий источник для дашборда/кошелька (F3/F5 починены 17.06, зависят от `pricing_tiers`, которые настроены).
- `topup` (`BillingTopupService`) — **заглушка** (мгновенный кредит, без шлюза, до Б-1). Счета (`invoices`) пусты (до Б-1).
- Карточка сделки `cost_kopecks` = снимок rub-списания (F2).

## 7. Заморозка (`BalancePreflightSweepJob` @18:00 + `BalanceFrozenReminderJob` @18:30)

- Для каждого тенанта: required на завтра vs capacity(баланс по ступеням). Не хватает → freeze (`frozen_by_balance_at` + `paused_at` на проектах) + письмо; хватает → unfreeze (с сохранением ручных пауз). online → sync.
- Повторные письма: reminder 24-48ч, final 72-96ч (throttle через `balance_freeze_log`).

## 8. Работа клиента (`DealController`, `DealBulkActionController`, `DealExportController`, уведомления, напоминания, отчёты, импорт)

- **Сделки**: лента (фильтры статус/проект/менеджер/поиск/даты, keyset/offset, RLS, is_test=false), карточка (+ до 50 событий + `cost_kopecks`), правка comment/manager/status → ActivityLog, ручное создание (без списания). Город = реальный регион.
- Воронка статусов (`StatusRuToSlugMapper`): **new / viewed / in_progress / won / lost** (Новая/Просмотрено/В работе/Сделка/Не реализовано). won = оплачено.
- bulk transition/destroy(soft)/restore (≤1000, аудит).
- Экспорт CSV/XLSX (OpenSpout стриминг): Телефон/Источник/Город/Статус/Комментарий/Поставлен, `;`+BOM.
- **Колокольчик** (`InAppNotification`): list/unread_count/mark-read/mark-all/delete; new_lead → inapp+push(default), email opt-in.
- **Напоминания** (`ReminderController`): CRUD + фильтры. ⛔ **G3: крона рассылки нет** (`reminders:dispatch-due` отсутствует в `console.php`) → уведомления по напоминаниям не шлются.
- **Отчёты** (`ReportJobController`+`GenerateReportJob`): deals_export/billing_summary/sources_summary/managers_summary × csv/xlsx/json (PDF stub→failed); квота 3, retry(3/7д)/cancel/delete, скачивание signed-URL 24ч.
- **Импорт CSV** (`ImportController`+`ImportLeadsJob`+`HistoricalImportService`): идемпотентный upsert через `webhook_dedup_keys`, **баланс не списывается**, мастер незнакомых статусов.
- **Деньги-лента** (`TenantChargesController`): read-only ledger (charged_at/deal_id/tier_no/charge_source/price_rub/balance_rub_after) + CSV.

## 9. Изоляция

- RLS на tenant-таблицах (`app.current_tenant_id`) + явный `where(tenant_id)` defense-in-depth. supplier-flow/SaaS-admin читают cross-tenant через `crm_supplier_worker` (BYPASSRLS).

## 10. Админ (SaaS) — провижининг и управление

- ⛔ **Создания тенанта НЕТ** (`AdminTenantsController`: index/show/updateBalance). **Баланс**: `PATCH /admin/tenants/{id}/balance` (set absolute, append-only + аудит). Тариф/заморозка/возврат (`AdminBillingController`).
- system_settings (`AdminSystemSettingsController`, reason ≥30 + hash-chain), pricing_tiers CRUD, supplier-prices (`AdminSuppliersController`), export-mode toggle, **список supplier_projects + bulk-delete у поставщика** (`AdminSupplierIntegrationController` — это и инструмент teardown rt-проектов).
- 152-ФЗ: инциденты + РКН-notify (`AdminIncidentsController`), обращения субъектов + анонимизация `PdErasureService` (`AdminPdSubjectRequestsController`).
- impersonation (`ImpersonationController`): 6-значный код. ⛔ **G7: email кода не шлётся, сессия не достроена**.
- API-ключи (`ApiKeyController`): один на тенант, scope read. ⛔ **G6: потребляющего endpoint нет**.
- Исходящие вебхуки (`WebhookSettingsController`): настройка+тест (SSRF-гард+DNS-rebind пиннинг). ⛔ **G2: реальной доставки событий нет** (pipeline не построен).

## 11. Планировщик (`console.php`) — 16 задач

00:00 reset-delivered-today · 1-е reset-monthly · daily partitions:create · Вс 03:00 drop-expired · 03:30 pd:scrub-soft-deleted · **18:00 preflight-sweep → 18:02 snapshot → 18:05 sync** · 18:30 frozen-reminder · hourly+17:45 refresh-session · 02:00 cleanup-inactive · hourly retry-failed · 30мин csv-reconcile · 10мин watch-failures · 04:00 verify-chains · hourly check-heartbeats. **Reminder-dispatch отсутствует (G3).**

## 12. Аудит / партиции / целостность

- Hash-chain (`AuditChainConfig`) на 6 audit-таблицах (per-tenant для tenant-таблиц / global для BYPASSRLS); writer = trigger `audit_chain_hash()`, verify = `audit:verify-chains` @04:00. UPDATE/DELETE заблокированы `audit_block_mutation`.
- Месячные партиции (`MonthlyPartitionManager`, 11 таблиц), heartbeat-трекинг (`SchedulerHeartbeatTracker`).

---

## 13. Go-live находки (после полного чтения)

| # | Находка | Статус | До продажи? |
|---|---|---|---|
| G1 | Нет самозаписи клиента (`register`→`Tenant::first()`) | подтв. | решить (онбординг вручную или wizard) |
| G2 | Исходящие вебхуки не доставляются | подтв. | если продаём как фичу — чинить |
| G3 | Напоминания не рассылаются (нет крона) | ✅ подтв. `console.php` | чинить (клиент-видимо) |
| G4 | Push мёртв (VAPID пустые) | ожидаемо post-MVP | к сведению |
| G5 | `topup` заглушка, счета пусты | до Б-1 | блок Б-1 |
| G6 | API-ключи без endpoint | подтв. | к сведению |
| G7 | impersonation email/сессия не достроены | подтв. | к сведению (админ) |
| OK | Ядро (вход/проекты/заказ/слепок/поставка/деньги/заморозка/сделки/экспорт/изоляция/колокольчик/отчёты/импорт) | работает | тестируем |

**Прод-состояние 18.06:** здоров, но спящий — 0 активных проектов, слепков нет с 03.06, 5 тенантов (реальный — lkomega/tenant 2, остальные сидовые), планировщик жив.

---

## 14. Фаза D — механика дочитана (весь прод-код прочитан построчно)

- **Supplier-каналы:** `FailoverProjectChannel` (ярус1 AJAX → ярус2 Playwright-форма → ярус3 ручная очередь+алерт), `AjaxProjectChannel`, `FormProjectChannel` (водит `manage-project.js`), `PlaywrightBridge` (Node-subprocess 75с), `RefreshSupplierSessionJob` (логин реальными кредами → сессия в Redis 6ч).
- **Supplier-import:** `SupplierProjectImporter` (разовое усыновление lkomega), `SupplierImportMapper` (src rt/bl/mt→B1/B2/B3; ГИБДД-регионы; sms split по `+`), `SupplierCsvParser` (Name;Tag;Phone).
- **Отчёты:** `GenerateReportJob` (→storage, expires 30д), 4 провайдера × csv/xlsx/json (pdf-stub→failed).
- **Импорт:** `HistoricalImportService` (идемпотентный upsert через `webhook_dedup_keys`, баланс не списывается), `CsvLeadsParser` (9 колонок, `Y/m/d H:i:s`), `ImportLeadsJob`.
- **Резолв региона:** `DaDataPhoneClient`/`RossvyazPrefixLookup`/`RegionTagResolver`/`DaDataBudgetGuard`.
- **Биллинг-математика:** `BalanceToLeadsConverter`/`RunwayCalculator`/`PricingTierResolver`/`BalancePreflightService`/`BillingTopupService`(stub)/`SupplierQuotaAllocator` (заказ=max(наиб,⌈Σ/3⌉)).
- **Reconcile/teardown:** `CsvReconcileJob`, `DeleteSupplierProjectJob` (авто-чистка rt-проектов), `CleanupInactiveSupplierProjectsJob` (TTL 180д).
- **ПДн/заморозка:** `PdErasureService`, `BalanceFrozenReminderJob`, `OperationsLogger`.
- **Модели:** `Tenant::requiredLeadsForTomorrow` (share-aware), `Project` (PostgresIntArray regions, supplier-FK+pivot), `SupplierProject`, `Deal` (composite PK `(id,received_at)`, партиции).
- **Команды (25):** snapshot:backfill/rebuild, reset-delivered-today/monthly, supplier:retry-failed/rekey-orphans, verify-chains, AuditRebuildChain, partitions:create/drop, billing:preflight-sweep/frozen-reminder, pd:scrub-soft-deleted, **reminders:dispatch-due** (существует!), и др.

## 15. Решающие выводы для теста (новое из фазы D)

- ⛔ **`imitation:seed` ЖЁСТКО запрещён на проде** (`environment('production')→FAILURE`). Сидером тест-клиентов на проде завести **нельзя**.
- ⛔ **Создания тенанта через продукт нет** → провижининг тест-клиентов только: **P1** прямой SQL (sudo postgres, escape) с маркером `TEST-` + скрипт удаления, либо **P3** переиспользовать сидовые тенанты (Demo/Компания 2/3/4; `lkomega`=tenant2 НЕ трогать).
- **G3 уточнён:** команда `reminders:dispatch-due` существует и работает, но **не подключена в `console.php`** → напоминания не рассылаются. Фикс = 1 строка `Schedule::command('reminders:dispatch-due')->everyMinute()`.
- **Рецепт инъекции (из сидера, для прода):** supplier_project (B2 site, `unique_key`=тест-домен) + project + pivot → `snapshot:backfill --date=сегодня` → `POST /api/webhook/supplier/{секрет}` c `raw_payload.project="B2_<тест-домен>"`. DIRECT: `project`=тест-домен без `B`-префикса (без pivot/синка). Любой путь требует строки слепка за активную дату.
- **Слепок-механика:** `snapshot:backfill` (идемпотентно) / `snapshot:rebuild` (DELETE+INSERT). Активная дата: до 21:00 МСК=сегодня, после=завтра.
- **Teardown:** удаление тест-проекта → `DeleteSupplierProjectJob` чистит rt-проект у поставщика; либо админ bulk-delete; тест-тенанты — DELETE по маркеру.

## 16. Финализированная тест-хартия (готова к writing-plans)

**Цель:** убедиться, что платящему клиенту портал реально работает, перед передачей продажникам.
**Метод (гибрид):** глазами в браузере на liderra.ru (Playwright + владелец на ключевых точках и на стороне crm.bp-gr.ru); поставка лидов — контролируемой инъекцией; деньги/изоляция — сверка в боевой БД (read-only).
**Матрица S0–S8:** S0 заказ дошёл до поставщика · S1 шеринг 1→3 (CAP) · S2 каскад региона · S3 лимит · S4 деньги/ступени · S5 заморозка/0-баланс · S6 пауза после слепка · S7 изоляция · S8 типы сигнала (site/call/sms/DIRECT).
**Доп. проверки:** G3 напоминания (подтвердить, что не шлются), отчёты, экспорт, импорт, колокольчик.
**Границы:** безопасность/атаки, нагрузка, юр — вне прогона.
**Критерий:** по каждому S «ожидали/получили/вывод», 0 расхождений в деньгах и изоляции; UX-находки отдельно.

## 17. Открытые решения для плана

1. Провижининг: **P1** (свежие SQL) или **P3** (сидовые).
2. Число клиентов и персоны (предложение: 6 — разные регионы + шеринг×3 + малый баланс под заморозку + пауза).
3. Реальные траты: только S0 (заказ, без живой поставки); остальное инъекцией.

---

## 18. Глубокое чтение движка (18.06.2026) — факты для приёмочного теста

Прочитано построчно с боевого: `SupplierQuotaAllocator`, `LeadRouter`, `LeadDistributor`, `SupplierPortalClient`, `RefreshSupplierSessionJob`, `SyncSupplierProjectJob`, `SupplierRegions`, конфиг очереди.

### 18.1 Формула заказа + деление по площадкам (`SupplierQuotaAllocator`)

- `computeOrder(limits) = max(max(limits), ceil(Σlimits / 3))`. `ceil(Σ/3)` — ёмкость шаринга (лид ≤3 раза); `max` — крупнейший клиент должен добрать.
- Заказ группы **делится** между площадками `distributeForPlatform` (largest-remainder): каждой `floor(order/N)`, остаток +1 первым по порядку. **Σ per-platform == order** (ни переплаты, ни недобора).
- **Сайт/звонок → 3 rt-проекта B1+B2+B3** (заказ÷3), смс+keyword → B2+B3, смс → B3. Пример: 7 клиентов по лимиту 10 → заказ = max(10, ⌈70/3⌉=24) = 24 → B1=8,B2=8,B3=8.
- Регионы/дни группы — объединение (union, dedup, sorted) eligible-на-дату проектов.

### 18.2 Каскад региона + жребий (`LeadRouter`, `LeadDistributor`)

- Источник eligibility — **слепок** `project_routing_snapshots` за активную дату (до 21:00 МСК сегодня, с 21:00 завтра). Из live `projects` — только `delivered_today`; из `tenants` — `balance_rub` + `frozen_by_balance_at`.
- Кандидат: `delivered_today < snap.daily_limit` И баланс>0 И не frozen. По **одному проекту на tenant** (`DISTINCT ON tenant_id`).
- **Каскад (3 фазы по убыванию точности):**
  - Фаза 1 — точное совпадение региона (`subject_code = ANY(snap.regions)`), только если резолвер дал код. `routing_step=1`.
  - Фаза 2 — «вся РФ» (`snap.regions = '{}'`), **добор недостающих слотов**, исключая уже выбранных tenant'ов. `routing_step=2`.
  - Фаза 3 — запасной канал (без фильтра региона), **только если фазы 1+2 пусты**; сделкам подменяется subject_code (`region_substituted`). `routing_step=3`.
  - ⚠️ «Вся РФ» — это **добор фазы 2**, а не равный кандидат с точным регионом: клиент «вся РФ» получит лид региона X только если точно-региональных не хватило на 3 слота.
- **CAP=3** (`LeadDistributor::CAP`) на лид.
- **Жребий внутри фазы при кандидатах > слотов** — взвешенный по остатку лимита без возврата: вес = `max(1, остаток)` (мелкие не отрезаются, шанс ∝ остатку). Кандидатов ≤ слотов → берутся все в SQL-порядке (детерминизм). **В проде Randomizer = CSPRNG (несидируемый)** → состав получателей недетерминирован.
  - **Вывод для теста:** проверяем ИНВАРИАНТЫ, не точный состав: каждый лид → ≤3 разных tenant'а; никто не превышает свой дневной лимит; за много лидов обслуживаются все шерящие; фаза региона корректна.

### 18.3 Регионы: Лидерра → поставщик (`SupplierRegions`)

- Лидерра нумерует субъекты по **конституционному порядку** (1..89); поставщик — по **ГИБДД-кодам**. Без перевода уходил чужой регион (Красноярский Лидерра-29 → у поставщика читался как Архангельск-29).
- `LIDERRA_TO_SUPPLIER` — **биекция на 79** кодов поставщика (79 листьев в дереве регионов формы поставщика).
- **10 субъектов Лидерры поставщик НЕ предлагает** → отбрасываются при переводе (warning): Московская обл.(56), Ленинградская обл.(53), Крым(13), Севастополь(84), ДНР(6), ЛНР(14), Запорожская(43), Херсонская(79), Ненецкий АО(86), Ямало-Ненецкий АО(89). Если это **единственный** регион проекта → у поставщика проект уходит **без гео-фильтра (вся РФ)**.
- NB: Москва-**город** (Лидерра 82 → поставщик 77) поддержана; Московская **область** (56) — нет. Санкт-Петербург (83→78) поддержан.
- **Покрытие 89 проверяется чтением этой таблицы, а не 89 клиентами.**

### 18.4 Одновременность канала (`SyncSupplierProjectJob`, очередь, сессия)

- **Очередь — ОДИН воркер:** systemd `liderra-queue.service` → один процесс `php artisan queue:work redis --tries=3 --max-time=3600 --timeout=300`. Шаблона multi-instance / Horizon / supervisor нет. ⇒ **задания синка выполняются последовательно, по одному.**
  - **Вывод:** «20 клиентов создают разом» → 20 заданий встают в очередь и идут друг за другом; поставщику бьют **последовательно, не параллельно**. Флуд поставщика через нормальное создание архитектурно невозможен. (Подтвердить живым наблюдением — один ли воркер реально.)
  - Нагрузочный тест = «как быстро один воркер разгребает завал из N заданий» (прогноз N/мин) + сбои/повторы/обновление сессии.
- `SyncSupplierProjectJob`: `tries=3`, `backoff=[15,60,300]`, роль `pgsql_supplier` (BYPASSRLS). Транзиентный недосбор площадки → throw → retry (partial-set recovery); escalation/window-defer — свой механизм, retry не вызывают.
- **Правка одного проекта пересчитывает и переписывает у поставщика ВСЮ группу** того же identifier (union регионов/дней, заказ группы) — online == ночной батч. При одном воркере это последовательно (без гонки), но визуально клиент видит результат с задержкой очереди.
- **Сессия поставщика:** Redis `supplier:session` (PHPSESSID+CSRF), TTL 6ч; обновление под замком `Cache::lock('supplier:session:refresh', 90)` block(95) — одновременных логинов нет, конкурентные 401 ждут на замке и переиспользуют свежую сессию. На 401/403 или HTML-логин-страницу (HTTP 200) → `dispatch_sync(RefreshSupplierSessionJob)` + один retry. На 5xx → transient. На HTTP 200 + `status:"Error"` → бизнес-ошибка портала.
- Online create — **по одному save на платформу** (single-flag, надёжный id через `listProjects`-матч); online update — через полный failover-канал; multi-flag только tier-1 AJAX.

**Итог для теста:** глубокая раздача (шеринг/каскад/лимит) проверяется ИНВАРИАНТАМИ на инъекции; заказ у поставщика для сайта = 3 rt-проекта; нагрузка ограничена одним воркером (последовательно) → безопасна; покрытие регионов — сверкой таблицы; 10 регионов без гео-фильтра — отдельная проверка.

---

## 19. Механизм распределения лида в сделки (`RouteSupplierLeadJob`) — построчно

Оркестратор: лид поставщика → до 3 сделок у разных клиентов + списания. `tries=3`, `backoff=60`, `timeout=60`, DB-роль `pgsql_supplier` (BYPASSRLS) для sharing-операций.

### 19.1 Вход и идемпотентность (анти-дубли)
- `lead==null` → **терминал**, return без исключения (иначе retry-шторм — был инцидент 25k записей по удалённому лиду №1).
- `processed_at != null` → skip (на retry не создаёт ghost-дубли сделок).
- Терминальная ошибка (`does not support` / `platform mismatch` / `no matching supplier_project`) → **fast-fail**: помечает `processed_at` + суффикс ошибки, return.

### 19.2 Парсинг поля `project` (`parseProjectField`)
- `B[123]_<rest>` → площадка; без префикса → **DIRECT** (весь project = identifier).
- `rest`: `^7\d{10}$` → call; чистый домен → site; **домен в свободном тексте** («заявка carmoney.ru/», «Платежи cabinet.caranga.ru/login») → site (извлекается домен, lowercase); иначе → sms.

### 19.3 Резолв региона (до цикла, вне транзакции)
- `LeadRegionResolver::resolve` (DaData ~150мс вне tenant-транзакции) → `resolved_subject_code` / `region_source` / `dadata_qc` / `phone_operator` пишутся на лид.
- Затем `LeadRouter::matchEligibleProjects(supplier, subjectCode)` → `LeadDistributor::selectRecipients` (≤CAP=3).

### 19.4 Создание сделки на получателя (`createDealCopyForProject`) — транзакция с `SET LOCAL app.current_tenant_id`
Порядок под локами:
1. `lockForUpdate` Tenant.
2. `lockForUpdate` Project + **recheck `delivered_today` под локом** (CV.11 BLOCKER #2: между слепком и транзакцией конкурентный webhook мог добить лимит).
3. **R-09:** recheck `is_active` под локом — паузнут после слепка → return false (не доставляем).
4. **R-04/R-06:** лимит из **слепка** (`project_routing_snapshots`, lockForUpdate), не live. Нет слепка → skip. `delivered_today >= snapshot.daily_limit` → skip.
5. **Merge с CSV-recovery:** если у webhook есть `vid` и есть csv-recovered сделка (тот же phone+project+tenant, без `source_crm_id`, за 24ч) → **слияние, БЕЗ второго списания**: UPDATE `source_crm_id`, INSERT delivery; регион улучшается, если webhook-резолв (dadata/rossvyaz) выше рангом тега. `received_at` НЕ меняется (partition key + FK lead_charges ON UPDATE NO ACTION — регрессия 26.05).
6. **Per-(supplier_lead, tenant) лок:** `supplier_lead_deliveries insertOrIgnore` → 0 если уже есть → return false (**одна поставка клиенту = один раз**).
7. **Deal::create:** `status='new'`, phone + `phones[]` (из payload), `received_at` (из payload.time), `subject_code` (на шаге 3 — подменён на регион клиента), **`city` = имя РЕАЛЬНОГО резолвнутого региона** (даже при подмене; null → пусто), `region_substituted = (routing_step===3)`.
8. **`LedgerService::chargeForDelivery`** в той же транзакции → `InsufficientBalanceException` → **полный откат** (ни сделки, ни charge).
9. Инкременты: `delivered_today`, `delivered_in_month`, `snapshot.delivered_count`.
10. `ActivityLog` (EVENT_DEAL_CREATED) + `PdAuditLogger` (152-ФЗ) + `notifyNewLead`.

### 19.5 Недостаток баланса → авто-пауза (`handleInsufficientBalance`)
Транзакция уже откатилась → `UPDATE projects.is_active=false` через `pgsql_supplier` (BYPASSRLS) + `ZeroBalancePausedMail` rate-limit **1/час/tenant** (Redis SETNX `Cache::add`) + log. return false (цикл продолжает раздачу остальным).

### 19.6 Аудит резолва + сбои
- `logRegionResolution`: одна строка на лид в `lead_region_resolution_log` (телефон маскирован `7916***4567`); **fail-safe** — сбой записи НЕ роняет доставку (revenue-critical).
- Все получатели упали (`createdCount==0` && все в `failures`) → throw → retry. `failed()` → `failed_webhook_jobs` (tenant_id=null) + `supplier_lead.error`.
- Частичный успех → `processed_at` + `deals_created_count`, целиком не падает.

**Итог для теста (пункт 4 «Распределение»):** проверяем инварианты — один лид раз (vid), одна поставка клиенту раз, merge без второго списания, CAP≤3, лимит/пауза под локом, атомарный откат при нехватке баланса + авто-пауза, город = реальный регион при подмене, следы (ActivityLog/PdAudit/уведомление), retry/failed-поведение.

---

## 20. Заморозка по балансу (`BalancePreflightSweepJob` + `BalanceFrozenReminderJob`) — построчно

Пять писем баланса: `ZeroBalancePausedMail` (мгновенная пауза в доставке, 1/час), `BalanceFrozenMail` (вечерняя заморозка), `BalanceUnfrozenMail` (разморозка), `BalanceFrozenReminderMail` (24–48ч), `BalanceFrozenFinalMail` (72–96ч).

### 20.1 Два триггера заморозки
- **Мгновенный (в доставке, §19.5):** баланс < цены лида → откат + `is_active=false` + `ZeroBalancePausedMail`, rate 1/час/tenant (Redis SETNX).
- **Вечерний sweep @18:00** (`BalancePreflightSweepJob`, команда `BillingPreflightSweepCommand`): бежит в системном контексте, `SET LOCAL app.current_tenant_id` пер-тенант (chunk 200). Для каждого: `requiredLeadsForTomorrow` (share-aware) vs ёмкость по ступеням (`BalancePreflightService::evaluate` → `PreflightResult`). **Смотрит на ЗАВТРА** — тенант может замёрзнуть с положительным балансом, если на завтра не хватает.

### 20.2 Переходы (идемпотентно)
- **active→frozen** (`!passes && !frozen`): `frozen_by_balance_at=now`; **все непаузнутые проекты → `paused_at=freezeAt`** (R-13 — даёт `SupplierSnapshotGuard` зацепку: пока заморожен, клиент не удалит/не сменит источник); log `frozen`; `BalanceFrozenMail`; online → sync per-project.
- **frozen→active** (`passes && frozen`): захват `frozenAtWas`; `frozen_by_balance_at=null`; **снятие паузы ТОЛЬКО у проектов с `paused_at >= frozenAtWas`** (которые паузила заморозка). **Ручные паузы клиента ДО заморозки (`paused_at < frozenAtWas`) сохраняются.** log `unfrozen`; `BalanceUnfrozenMail`; sync.
- Стабильное состояние → ничего (анти-спам).
- ⚠️ freeze/unfreeze в online диспатчит `SyncSupplierProjectJob` по каждому активному не-blocked проекту → пачка единственному воркеру (капасити-связь, слой D).

### 20.3 Повторные письма (`BalanceFrozenReminderJob` @18:30, `BillingFrozenReminderCommand`)
- Только замороженные тенанты; `hours = diffInHours(frozen_by_balance_at)`. Окна: reminder 24–48ч, final 72–96ч.
- Throttle: marker-row в `balance_freeze_log` (`reminder_sent`/`final_sent`), один на тип за 5 дней (`alreadySent`).
- **Пере-оценка `PreflightResult`** → письмо показывает АКТУАЛЬНЫЙ дефицит (частичное пополнение отразится).

**Итог для теста (пункт 9):** мгновенная пауза 1/час; вечерняя заморозка смотрит на завтра; разморозка не трогает ручные паузы; guard при заморозке; идемпотентность (анти-спам); повторные письма с актуальным дефицитом + throttle. Гоняется командами вручную + бэкдейт `frozen_by_balance_at` для окон reminder/final.

---

## 21. Блокировка / preflight (share-aware) — построчно (хитрая логика)

Три уровня «блокировки» + хитрый расчёт `required`.

### 21.1 Три уровня блока
1. **Блок лида получателю (per-lead, §19.4):** лид НЕ доходит клиенту, если — лимит из слепка исчерпан (`delivered_today ≥ snapshot.daily_limit`), `balance_rub ≤ 0` (не кандидат), `frozen_by_balance_at` (frozen), пауза под локом (R-09), нет слепка, уже доставлен (`supplier_lead_deliveries`), или баланс < цены → откат + автопауза.
2. **Заморозка тенанта (вечерний sweep, §20):** `required(завтра) > capacity(баланс→лиды)` → freeze.
3. **Блок создания проекта (preflight):** то же сравнение при создании — не хватает → 409 / `force_save_blocked` → `preflight_blocked_at`.

### 21.2 `Tenant::requiredLeadsForTomorrow` (R-19, share-aware) — ХИТРОСТЬ
`required` — НЕ сумма дневных лимитов клиента, а сумма его **пропорциональных долей** в заказах групп. Для каждого активного проекта:
- Если `signal_type` ∉ {site,call,sms} (legacy webhook-only) → полный `daily_limit_target`.
- Иначе: находит **всю группу** того же источника (по всем тенантам через `pgsql_supplier` BYPASSRLS): site/call → тот же `signal_identifier`; sms → первый sender + (keyword|NULL).
  - `Σ` = сумма `daily_limit_target` группы; `max` = наибольший.
  - `groupOrder = max(max, ⌈Σ/3⌉)` (= заказ поставщика).
  - **`share = ⌈groupOrder × (лимит_проекта / Σ)⌉`** → `total += share`.
- Edge: группа пуста в pgsql_supplier-вью (cross-conn race) → консервативно полный лимит.

**Следствие:** шерящий клиент нуждается в **малой доле** (труднее заморозить); одиночка на источнике — полный лимит. **Тест заморозки/блока (B-измерение) должен считать ожидаемый `required` ПО ЭТОЙ формуле доли, не по сырому лимиту.**

### 21.3 Capacity / converter / runway
- `BalancePreflightService::evaluate`: `required ≤ 0` → passes; иначе `capacity = BalanceToLeadsConverter.convert(...)['leads']`; `passes = capacity ≥ required`; `deficit = required − capacity`.
- `BalanceToLeadsConverter::convert(balanceRub, deliveredInMonth, tiers)`: сколько лидов купит баланс, проходя ступени с учётом уже доставленных в месяце (bcmath, копейки). Возвращает leads + breakdown + current_tier + next_tier. Источник и для калькулятора «хватит на лиды», и для preflight.
- `RunwayCalculator::daysLeft(tenantId, affordableLeads)`: `affordable ≤ 0 → 0`; `lead_charges за 30д = 0 → null` (нет истории); иначе `floor(affordable / (leads30/30))`. Единый источник «хватит на дни» для дашборда и кошелька (F3).

**Итог для теста (блокировка):** ожидаемые freeze/preflight-блоки считать через share-aware `required` vs tier-aware `capacity`; per-lead блоки — по 7 условиям §19.4. На объёме лидов на шеринг-источнике видно красиво: распределение (жребий ∝ остатку), списания (ход по ступеням), блоки (лимит/баланс/заморозка по доле).
