Files
portal/docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md
T
Дмитрий ebbeab3a5e docs(spec): design — переделка миграции проектов + распределения лидов
Закрыты 5 под-вопросов brainstorming + P1 (Вся РФ = 1 пул + предупреждение
с подтверждением) + P2 (один связный spec). Ядро: 3-FK слоты -> M:N pivot,
per-субъект supplier_projects (subject_code), формула заказа max(наиб, ceil(Σ/3)),
cap=3 рандом из недобравших, ручной экран очистки в админке, режимы экспорта
online/batch (глобальный тумблер). R2 уже 18:00. R-SAVE = вариант а (дочитать
listProjects). Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:08:31 +03:00

20 KiB
Raw Blame History

Design: переделка миграции проектов + распределения лидов

Дата: 2026-05-20 · Тип: FEATURE epic · Статус: дизайн на ревью заказчика. Discovery-brief: docs/discovery/2026-05-20-project-migration-redesign-brief.md (требования R1–R7 + алгоритм заказа/распределения).

1. Контекст

Два связанных изменения вокруг жизненного цикла «проект Лидерры → проект(ы) у поставщика crm.bp-gr.ru → входящие лиды»:

  1. Экспорт проекта сейчас отложенный и неполный: онлайн ставится только каркас (лимит 0, регионы пусто), параметры дописывает ночной крон. Поле regions на портал уходит пустым (gap зафиксирован в recon §2).
  2. Распределение лидов не имеет потолка получателей — один номер может уйти многим клиентам (владелец номера «сходит с ума»).

Гранулярность региона — субъект РФ (коды 1..89), уже реализована в ЛК (Plan 6): resources/js/constants/regions.ts, NewProjectDialog.vue:88-109. projects.regions INT[] — коды субъектов; legacy region_mask/region_mode (8 ФО) — DEPRECATED, использовался только phone-фильтром.

2. Закрытые решения (brainstorming 2026-05-20)

# Под-вопрос Решение
1 Scope тумблера режима экспорта Глобально (SaaS)system_settings, единое поведение для всех тенантов.
2 Онлайн-режим при недоступном портале Ярус-3 очередь — переиспользовать существующий FailoverProjectChannel (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь). Пользователь не блокируется.
3 «Вся РФ» / взрыв проектов Создание per-субъект оставить как есть; от взрыва — ручной экран очистки в админке. Регион в ЛК — обязателен (валидация).
4 Имя проекта на портал Строго = источник (signal_identifier: домен/телефон/отправитель); тег = регион; отдельного человекочитаемого поля нет.
5 Ключ конкуренции (источник+регион+день) Сопоставляется структурно — через supplier_project (= источник × субъект). Phone-фильтр region_mask в LeadRouter убрать (доверяем региону проекта; для мобильных он всё равно no-op).
P1 «Вся РФ» при «требовать регион» Явная опция «Вся РФ» в ЛК → один пул (1 save × 3 платформы = 3 проекта, native regions=[], tag="РФ"). При выборе «Вся РФ» — предупреждение «вы выбрали всю Россию» с обязательным подтверждением. Конкретные субъекты → per-субъект save.
P2 Структура Один связный spec (этот файл); фазы — в плане.

3. Архитектура изменений (обзор)

Ядро — переход от модели «1 Лидерра-проект ↔ 3 supplier_projects (по слоту на платформу)» к «1 Лидерра-проект ↔ N supplier_projects (субъект × платформа), M:N». supplier_project становится ключом конкуренции (источник × субъект × платформа); вокруг него крутятся заказ, распределение и очистка.

ЛК (выбор субъектов / «Вся РФ» + подтверждение)
   ↓ create/edit
ProjectService → (режим: онлайн|пакетный)
   ↓
SyncSupplierProjectJob (онлайн, сразу, полные параметры)   SyncSupplierProjectsJob (пакетный крон 18:00)
   ↓ per (источник × субъект): один save с флагами B1+B2+B3 (R5), портал делит лимит (R6)
supplier_projects (subject_code) ←M:N→ projects (pivot)
   ↓ заказ = max(наиб, ceil(Σ/3)) per supplier_project
crm.bp-gr.ru
   ↓ webhook лид (raw_payload[tag] = субъект)
RouteSupplierLeadJob → LeadRouter (по supplier_project, без phone-фильтра)
   ↓ cap=3 рандом из недобравших
deals (region tag из payload) + lead_charges

4. Компоненты

4.1. Режимы экспорта (R1/R2/R3)

  • Глобальный тумблер system_settings['supplier_export_mode']{online, batch}, default batch (прод-безопасно). Тип string. Сид-строка, без миграции схемы.
  • Пакетный (batch) — текущий ночной SyncSupplierProjectsJob (app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:59). Крон уже dailyAt('18:00') (console.php:52-54) — R2 фактически выполнен в failover-spec 19.05.2026; формулировка brief «20:30 → 18:00» устарела. В эпике R2 = только закрепить тестом (SupplierScheduleTest уже это проверяет).
  • Онлайн (online) — при create/edit проекта в ЛК ProjectService сразу диспатчит полный sync (лимит/дни/регионы/субъекты), не каркас. При недоступном портале — стандартная эскалация FailoverProjectChannel в ярус-3 очередь (Q2). Пользователь ЛК не ждёт результата.
  • Резолвер режима — единая точка (напр. SupplierExportMode::current()), читается и ProjectService, и админкой.

4.2. Модель данных

  • supplier_projects (schema.sql:902):
    • +subject_code SMALLINT NULL — субъект РФ (1..89); NULL = пул «Вся РФ».
    • UNIQUE index (platform, unique_key)(platform, unique_key, subject_code) (NULL-субъект уникален отдельно; под NULLS NOT DISTINCT Postgres 15+ — пул «Вся РФ» один на источник×платформу).
    • current_regions (JSONB) хранит native-regions, переданные порталу: [subject_code] для per-субъект, [] для пула.
  • Pivot project_supplier_links (новая таблица, заменяет 3 FK-слота):
    • project_id BIGINT, supplier_project_id BIGINT, platform VARCHAR(4), subject_code SMALLINT NULL, created_at.
    • PK/UNIQUE (project_id, supplier_project_id); FK на обе стороны (ON DELETE CASCADE со стороны project, ON DELETE CASCADE со стороны supplier_project).
    • Колонки projects.supplier_b{1,2,3}_project_id (schema.sql:808-810) — DEPRECATED, миграция данных в pivot (см. §5), затем удаление в follow-up.
  • deals (schema.sql:1573): регион-тег из raw_payload['tag'] (имя субъекта) → код субъекта (через словарь regions.ts/PHP-зеркало) → пишем в новую колонку deals.subject_code SMALLINT NULL (не путать с region_code ISO-3166, который phone-derived и ненадёжен для мобильных). Источник истины региона сделки = тег поставщика, не телефон.
  • system_settings (schema.sql:566) — сид supplier_export_mode.

4.3. Маппинг save (R5/R6/R7, Q4, P1)

  • R5 — один save с тремя флагами srcrt+srcbl+srcmt=true → портал создаёт 3 rt-проекта (B1/B2/B3). Решение заказчика: вариант а — ответ rt-project-save отдаёт id только последнего (recon §находка 2), поэтому после save дочитать listProjects (SupplierPortalClient.php:61) и найти 3 свежесозданных по name+tag → забрать все 3 external_id. Fallback (б) «3 раздельных save» — только если smoke варианта а провалится (см. §8 R-SAVE). Текущий код шлёт по одному флагу — переписать.
  • R6 — убрать наш сплит SupplierQuotaAllocator::distributeForPlatform (SupplierQuotaAllocator.php:73); слать один лимит на (источник×субъект), портал делит на B1/B2/B3 поровну (verified: лимит 15 → по 5).
  • R7tag = имя субъекта (не _lidpotok, SupplierPortalClient.php:431); native regions=[subject_code]; per-субъект отдельный save. «Вся РФ» (P1) → один save, tag="РФ", regions=[].
  • Q4name = signal_identifier (как сейчас, SyncSupplierProjectJob.php:159); строго источник.
  • unique_key — расширить субъектом: {signal_identifier} (как сейчас) + subject_code уходит в отдельную колонку (§4.2), уникальность — составным индексом.

4.4. Регион → deals (R7)

В RouteSupplierLeadJob при создании Deal: взять raw_payload['tag'] → резолв в subject_code (PHP-словарь субъектов, зеркало regions.ts) → записать deals.subject_code. При теге "РФ"/неизвестном — NULL. Доступно для фильтров/аналитики сделок.

4.5. Формула заказа (алгоритм brief)

Per supplier_project (= источник×субъект), для eligible-сегодня клиентов (по pivot, workday-маска, активность):

Σ      = сумма daily_limit_target клиентов
наиб   = max(daily_limit_target)
заказ  = max( наиб , ceil(Σ / 3) )
  • ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз); наиб — крупнейший клиент должен добрать.
  • Заказ — потолок запроса; платим за фактически поступившие. Один лимит на (источник×субъект), портал делит (R6).
  • Переписать SupplierQuotaAllocator::allocate (:38) — pure-функция, агрегирует по pivot вместо суммы по платформам; distributeForPlatform удалить.
  • Примеры (verified в brief): [5,5,10,20]→20; 15×5+10 (16 кл.)→29; 3×15→15; 3×15+30→30; 4×10→14.

4.6. Распределение лида (cap=3, brief)

  • LeadRouter::matchEligibleProjects (LeadRouter.php:46) — кандидаты по pivot (вместо match($platform) по FK-колонке), фильтры is_active, workday, delivered_today < лимит, баланс. Убрать phone-фильтр phoneMatchesRegions (:87-93) — Q5.
  • RouteSupplierLeadJob (RouteSupplierLeadJob.php:115) — из eligible (остаток лимита > 0) выбрать 3 случайных (cap=3), создать Deal каждому. Группировка выкинута. Дедуп по source_crm_id/duplicate_of_id — сохраняется.
  • Детерминизм тестов: рандом через инъектируемый сидируемый источник (Randomizer/closure).

4.7. Админка

  • Тумблер режима экспорта (онлайн/пакетный) — в существующем разделе supplier-integration (AdminSupplierIntegrationView.vue + контроллер): GET текущий режим, POST смена → пишет system_settings.
  • Экран «Проекты у поставщика» (новый), строка на supplier_project:
    • источник (unique_key) · платформа · тег-субъект (subject_code→имя)
    • кто заказывал — тенанты/клиенты с активными projects, связанными через pivot
    • дата последней поставкиmax(supplier_leads.created_at) по проекту (или по deals)
    • чекбоксы + bulk «Удалить выбранные» → SupplierPortalClient::deleteProject(externalId) (SupplierPortalClient.php:97) + снять локальную запись (CASCADE по pivot). Удаление — ручное (Q3).
    • Уважать окно портала (22:00–00:00 правки запрещены) — при попытке в окне показать ошибку/деферить.
  • NB: уже существует авто-джоб CleanupInactiveSupplierProjectsJob (console.php:55, daily 02:00) — TTL-очистка по supplier_projects.inactive_since (180 дней). Ручной экран — дополнение (немедленная очистка orphan по решению админа), не замена; авто-TTL остаётся.

4.8. ЛК (Q3b, P1)

  • Валидация: нельзя сохранить проект без выбора региона (минимум один субъект или «Вся РФ»). Backend ProjectController store/update + фронт.
  • Опция «Вся РФ»: вернуть в autocomplete (sentinel code:0, сейчас отфильтрован NewProjectDialog.vue:154). Выбор «Вся РФ» — взаимоисключающий с конкретными субъектами.
  • Предупреждение (P1): при выборе «Вся РФ» — модал/диалог «Вы выбрали всю Россию — проект будет получать лиды по всем регионам. Подтвердить?» с обязательным подтверждением до сохранения.

5. Данные и миграция

  1. Добавить supplier_projects.subject_code, новый UNIQUE-индекс, pivot project_supplier_links, deals.subject_code, сид system_settings.supplier_export_mode. Запись в db/CHANGELOG_schema.md (правило §4.2).
  2. Бэкофилл pivot из существующих projects.supplier_b{1,2,3}_project_id (для каждого ненулевого слота → строка pivot, subject_code=NULL для legacy записей).
  3. RLS/роли: pivot и новые колонки — под существующую модель (crm_supplier_worker BYPASSRLS для sharing-flow читает pivot; tenant-проекты — под RLS). rls-reviewer на миграции.
  4. Удаление supplier_b{1,2,3}_project_idfollow-up после переключения всех читателей (LeadRouter, sync-jobs, admin) на pivot. В этом эпике колонки остаются (двойная запись) до зелёной регрессии — снижает риск.

6. Тестирование (TDD)

  • Pure: SupplierQuotaAllocator::allocate — 5 verified-примеров заказа + edge (1 клиент, недобор, sms-без-keyword).
  • LeadRouter/RouteSupplierLeadJob: cap=3 (4+ eligible → ровно 3), <3 eligible → все, лимит-потолок (недобравшие), сидируемый рандом, дедуп.
  • Sync: онлайн полный sync; пакетный 18:00; per-субъект save (tag, native regions); «Вся РФ» пул (1 save, regions=[], tag РФ); R5 multi-flag; R6 нет сплита; failover ярус-3.
  • region→deals: тег субъекта → deals.subject_code; «РФ»/неизвестный → NULL.
  • Админка: список (кто заказывал, дата последней поставки), bulk-delete, тумблер режима; окно портала.
  • ЛК (Vitest): валидация «регион обязателен»; «Вся РФ» предупреждение+подтверждение; взаимоисключение.
  • Регрессия: полный Supplier+Integration+Webhook suite зелёный; миграция migrate:fresh + бэкофилл.

7. Вне scope / будущее

  • Per-tenant режим экспорта (сейчас глобальный, Q1).
  • Удаление DEPRECATED supplier_b{1,2,3}_project_id и region_mask/region_mode (follow-up после переключения читателей).
  • Автоматическая очистка проектов (сейчас только ручная, Q3).
  • Расширение PhonePrefixService до полного справочника субъектов (регион теперь приходит тегом — потребность снижается).

8. Риски и известные ограничения

  • R-SAVE (med, решение принято — вариант а): ответ rt-project-save возвращает id последнего из 3 проектов (recon §находка 2). Подход: после мульти-save дочитать listProjects и сопоставить 3 свежих по name+tag. Task 1 плана = живой smoke, что список реально отдаёт все 3 свежесозданных; fallback (б) «3 раздельных save» — только если smoke провалится. До зелёного smoke прод-путь не переписывать.
  • R-PIVOT (high): замена 3-FK на M:N трогает LeadRouter, sync-jobs, admin, тесты — фазировать; двойная запись (колонки + pivot) до зелёной регрессии.
  • R-WINDOW: правки/удаления у поставщика запрещены 22:00–00:00 МСК — sync и cleanup уважают окно (WindowDeferredException уже есть).
  • R-RANDOM: cap=3 рандом должен быть детерминируемым в тестах (инъекция источника случайности).
  • Не верифицировано: реальный JSON-ответ multi-flag save (R-SAVE); поведение портала при tag с кириллицей-субъектом на больших объёмах.