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>
42 KiB
Карта логики и функционала боевого портала 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_target1..10000;delivery_days_mask1..127.- Создание: preflight баланса (409 /
force_save_blocked→preflight_blocked_at);is_active=true; сразуSyncSupplierProjectJob(online → реальный синк к поставщику). signal_typeиммутабелен;signal_identifiereditable (под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-flagservices.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 = triggeraudit_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/{секрет}craw_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. Открытые решения для плана
- Провижининг: P1 (свежие SQL) или P3 (сидовые).
- Число клиентов и персоны (предложение: 6 — разные регионы + шеринг×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 завтра). Из liveprojects— только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 слота.
- Фаза 1 — точное совпадение региона (
- 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
Порядок под локами:
lockForUpdateTenant.lockForUpdateProject + recheckdelivered_todayпод локом (CV.11 BLOCKER #2: между слепком и транзакцией конкурентный webhook мог добить лимит).- R-09: recheck
is_activeпод локом — паузнут после слепка → return false (не доставляем). - R-04/R-06: лимит из слепка (
project_routing_snapshots, lockForUpdate), не live. Нет слепка → skip.delivered_today >= snapshot.daily_limit→ skip. - Merge с CSV-recovery: если у webhook есть
vidи есть csv-recovered сделка (тот же phone+project+tenant, безsource_crm_id, за 24ч) → слияние, БЕЗ второго списания: UPDATEsource_crm_id, INSERT delivery; регион улучшается, если webhook-резолв (dadata/rossvyaz) выше рангом тега.received_atНЕ меняется (partition key + FK lead_charges ON UPDATE NO ACTION — регрессия 26.05). - Per-(supplier_lead, tenant) лок:
supplier_lead_deliveries insertOrIgnore→ 0 если уже есть → return false (одна поставка клиенту = один раз). - Deal::create:
status='new', phone +phones[](из payload),received_at(из payload.time),subject_code(на шаге 3 — подменён на регион клиента),city= имя РЕАЛЬНОГО резолвнутого региона (даже при подмене; null → пусто),region_substituted = (routing_step===3). LedgerService::chargeForDeliveryв той же транзакции →InsufficientBalanceException→ полный откат (ни сделки, ни charge).- Инкременты:
delivered_today,delivered_in_month,snapshot.delivered_count. 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зацепку: пока заморожен, клиент не удалит/не сменит источник); logfrozen;BalanceFrozenMail; online → sync per-project. - frozen→active (
passes && frozen): захватfrozenAtWas;frozen_by_balance_at=null; снятие паузы ТОЛЬКО у проектов сpaused_at >= frozenAtWas(которые паузила заморозка). Ручные паузы клиента ДО заморозки (paused_at < frozenAtWas) сохраняются. logunfrozen;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 Три уровня блока
- Блок лида получателю (per-lead, §19.4): лид НЕ доходит клиенту, если — лимит из слепка исчерпан (
delivered_today ≥ snapshot.daily_limit),balance_rub ≤ 0(не кандидат),frozen_by_balance_at(frozen), пауза под локом (R-09), нет слепка, уже доставлен (supplier_lead_deliveries), или баланс < цены → откат + автопауза. - Заморозка тенанта (вечерний sweep, §20):
required(завтра) > capacity(баланс→лиды)→ freeze. - Блок создания проекта (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_supplierBYPASSRLS): 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. На объёме лидов на шеринг-источнике видно красиво: распределение (жребий ∝ остатку), списания (ход по ступеням), блоки (лимит/баланс/заморозка по доле).