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

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

## 1. Контекст

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

1. **Экспорт проекта** сейчас отложенный и неполный: онлайн ставится только каркас (лимит 0, регионы пусто), параметры дописывает ночной крон. Поле `regions` на портал уходит **пустым** (gap зафиксирован в [recon §2](../../discovery/2026-05-19-rt-project-form-locators.md)).
2. **Распределение лидов** не имеет потолка получателей — один номер может уйти многим клиентам (владелец номера «сходит с ума»).

Гранулярность региона — **субъект РФ (коды 1..89)**, уже реализована в ЛК (Plan 6): [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts), [NewProjectDialog.vue:88-109](../../../app/resources/js/views/projects/NewProjectDialog.vue#L88-L109). `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](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L59)). **Крон уже `dailyAt('18:00')`** ([console.php:52-54](../../../app/routes/console.php#L52)) — 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](../../../db/schema.sql#L902)):
  - +`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](../../../db/schema.sql#L808-L810)) — **DEPRECATED**, миграция данных в pivot (см. §5), затем удаление в follow-up.
- **`deals`** ([schema.sql:1573](../../../db/schema.sql#L1573)): регион-тег из `raw_payload['tag']` (имя субъекта) → код субъекта (через словарь `regions.ts`/PHP-зеркало) → пишем в **новую колонку `deals.subject_code SMALLINT NULL`** (не путать с `region_code` ISO-3166, который phone-derived и ненадёжен для мобильных). Источник истины региона сделки = тег поставщика, не телефон.
- **`system_settings`** ([schema.sql:566](../../../db/schema.sql#L566)) — сид `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](../../../app/app/Services/Supplier/SupplierPortalClient.php#L61)) и найти 3 свежесозданных по `name`+`tag` → забрать все 3 `external_id`. Fallback (б) «3 раздельных save» — только если smoke варианта а провалится (см. §8 R-SAVE). Текущий код шлёт по одному флагу — переписать.
- **R6** — убрать наш сплит `SupplierQuotaAllocator::distributeForPlatform` ([SupplierQuotaAllocator.php:73](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)); слать **один лимит** на (источник×субъект), портал делит на B1/B2/B3 поровну (verified: лимит 15 → по 5).
- **R7** — `tag` = имя субъекта (не `_lidpotok`, [SupplierPortalClient.php:431](../../../app/app/Services/Supplier/SupplierPortalClient.php#L431)); native `regions=[subject_code]`; per-субъект отдельный save. **«Вся РФ» (P1)** → один save, `tag="РФ"`, `regions=[]`.
- **Q4** — `name` = `signal_identifier` (как сейчас, [SyncSupplierProjectJob.php:159](../../../app/app/Jobs/SyncSupplierProjectJob.php#L159)); строго источник.
- `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](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L38)) — 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](../../../app/app/Services/LeadRouter.php#L46)) — кандидаты по **pivot** (вместо `match($platform)` по FK-колонке), фильтры `is_active`, workday, `delivered_today < лимит`, баланс. **Убрать phone-фильтр** `phoneMatchesRegions` ([:87-93](../../../app/app/Services/LeadRouter.php#L87-L93)) — Q5.
- `RouteSupplierLeadJob` ([RouteSupplierLeadJob.php:115](../../../app/app/Jobs/RouteSupplierLeadJob.php#L115)) — из 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](../../../app/app/Services/Supplier/SupplierPortalClient.php#L97)) + снять локальную запись (CASCADE по pivot). Удаление — **ручное** (Q3).
  - Уважать окно портала (22:00–00:00 правки запрещены) — при попытке в окне показать ошибку/деферить.
- **NB:** уже существует авто-джоб `CleanupInactiveSupplierProjectsJob` ([console.php:55](../../../app/routes/console.php#L55), 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](../../../app/resources/js/views/projects/NewProjectDialog.vue#L154)). Выбор «Вся РФ» — взаимоисключающий с конкретными субъектами.
- **Предупреждение (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](../../Pravila_raboty_Claude_v1_1.md)).
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_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` с кириллицей-субъектом на больших объёмах.
