Files
portal/docs/superpowers/specs/2026-06-18-prod-logic-map.md
T
Дмитрий 35c30ecce0 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

42 KiB
Raw Blame History

Карта логики и функционала боевого портала 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_blockedpreflight_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. Поставка лида (SupplierWebhookControllerRouteSupplierLeadJobLeadRouterLeadDistributor)

  • 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 Нет самозаписи клиента (registerTenant::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; из tenantsbalance_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::evaluatePreflightResult). Смотрит на ЗАВТРА — тенант может замёрзнуть с положительным балансом, если на завтра не хватает.

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_blockedpreflight_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. На объёме лидов на шеринг-источнике видно красиво: распределение (жребий ∝ остатку), списания (ход по ступеням), блоки (лимит/баланс/заморозка по доле).