Files
portal/docs/superpowers/specs/2026-06-18-prod-logic-map.md
T
Дмитрий 3ba703d9c9 docs(приёмка): корпус приёмочного теста + поправка №15 + статусы реестра
F-CORPUS: ключевые документы приёмки liderra.ru лежали untracked — мастер-
хэндофф ссылался на отсутствующие в git файлы (битые ссылки в новом клоне).
Закоммичены: R0–R5 + stepbystep ранбуки, хартия, prod-logic-map, эфир-хэндофф,
imitation-checks-table, live-demo/ (эфир-плеер) + смежные specs/планы серий
f1-card/phase1/televizor/g1/g2 (решение владельца — «корпус + смежные»).

F-DELPROJ: пункт №15 checks-table → «удаление проекта со сделками запрещено
(422), сделки целы» (было неточно «сделки сохранены», сверено по
ProjectService::delete).

Реестр находок: статусы F-DEPTRAC/F-CSV/F-REMIND/F-DELPROJ/F-CORPUS → закрыто.
.gitleaks.toml: ранбуки приёмки добавлены в allowlist (синтетические тест-
телефоны, та же категория что plans/specs/audits).
live-demo HTML: stylelint --fix (#fff→#ffffff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:48:50 +03:00

298 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Карта логики и функционала боевого портала 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).
**Матрица S0S8:** 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` (2448ч), `BalanceFrozenFinalMail` (7296ч).
### 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 2448ч, final 7296ч.
- 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. На объёме лидов на шеринг-источнике видно красиво: распределение (жребий ∝ остатку), списания (ход по ступеням), блоки (лимит/баланс/заморозка по доле).