Закрыты 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>
20 KiB
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 → входящие лиды»:
- Экспорт проекта сейчас отложенный и неполный: онлайн ставится только каркас (лимит 0, регионы пусто), параметры дописывает ночной крон. Поле
regionsна портал уходит пустым (gap зафиксирован в recon §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}, defaultbatch(прод-безопасно). Тип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 DISTINCTPostgres 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_codeISO-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→ забрать все 3external_id. Fallback (б) «3 раздельных save» — только если smoke варианта а провалится (см. §8 R-SAVE). Текущий код шлёт по одному флагу — переписать. - R6 — убрать наш сплит
SupplierQuotaAllocator::distributeForPlatform(SupplierQuotaAllocator.php:73); слать один лимит на (источник×субъект), портал делит на B1/B2/B3 поровну (verified: лимит 15 → по 5). - R7 —
tag= имя субъекта (не_lidpotok, SupplierPortalClient.php:431); nativeregions=[subject_code]; per-субъект отдельный save. «Вся РФ» (P1) → один save,tag="РФ",regions=[]. - Q4 —
name=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
ProjectControllerstore/update + фронт. - Опция «Вся РФ»: вернуть в autocomplete (sentinel
code:0, сейчас отфильтрован NewProjectDialog.vue:154). Выбор «Вся РФ» — взаимоисключающий с конкретными субъектами. - Предупреждение (P1): при выборе «Вся РФ» — модал/диалог «Вы выбрали всю Россию — проект будет получать лиды по всем регионам. Подтвердить?» с обязательным подтверждением до сохранения.
5. Данные и миграция
- Добавить
supplier_projects.subject_code, новый UNIQUE-индекс, pivotproject_supplier_links,deals.subject_code, сидsystem_settings.supplier_export_mode. Запись вdb/CHANGELOG_schema.md(правило §4.2). - Бэкофилл pivot из существующих
projects.supplier_b{1,2,3}_project_id(для каждого ненулевого слота → строка pivot,subject_code=NULLдля legacy записей). - RLS/роли: pivot и новые колонки — под существующую модель (
crm_supplier_workerBYPASSRLS для sharing-flow читает pivot; tenant-проекты — под RLS).rls-reviewerна миграции. - Удаление
supplier_b{1,2,3}_project_id— follow-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с кириллицей-субъектом на больших объёмах.