docs(spec): импорт активных проектов поставщика в тенант info@lkomega.ru — дизайн

Разовая artisan-команда supplier:import-projects: усыновляет активные проекты
с crm.bp-gr.ru (lkomega) под тенант info@lkomega.ru по правилам Лидерры
(B1/B2/B3 → один проект, лимит = сумма площадок), без записи на портал.
dry-run по умолчанию, --commit для реальной записи.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-22 09:14:09 +03:00
parent 31e44412ff
commit b18a3f5890
@@ -0,0 +1,145 @@
# Дизайн: разовый импорт активных проектов поставщика в тенант info@lkomega.ru
**Дата:** 2026-05-22
**Статус:** утверждён заказчиком (brainstorming), готов к плану
**Ветка:** `feat/supplier-import-lkomega` (worktree от origin/main `4c80a58`)
**Среда выполнения:** боевой пилот liderra.ru = `111.88.246.137` (там тенант info@lkomega.ru и живая supplier-сессия)
## 1. Цель
Заказчик ведёт проекты вручную на портале поставщика crm.bp-gr.ru (логин `lkomega.ru`). Нужно один раз завести их как проекты в Лидерре под тенантом **info@lkomega.ru** («Компания 1»), **полностью по правилам Лидерры**: три площадки B1/B2/B3 одного источника = один проект Лидерры; лимит лидов и прочие настройки переносятся корректно.
Проекты **уже существуют** на портале и собирают лиды. Поэтому «перенести» = **усыновить** существующие записи портала (связать с ними проекты Лидерры), **не создавая дублей** на портале и **не меняя** его настройки.
## 2. Решения заказчика (brainstorming)
| Вопрос | Решение |
|---|---|
| Охват | Все активные проекты (`status` = включён; у всех `lim` > 0) |
| Сторона поставщика | **Не трогать** портал — только усыновить (никаких save/update/delete на портал) |
| Лимит в Лидерре | **Сумма** `lim` активных площадок группы (B1+B2+B3) |
| Способ | Артизан-команда с режимом «примерки» (dry-run по умолчанию) |
## 3. Исходные данные (recon read-only 2026-05-22)
`SupplierPortalClient::listProjects()` на пилоте: **472** проекта аккаунта lkomega.
- По типу: `calls`=322, `hosts`=135, `sms`=15.
- По источнику (`src`): `rt`=152, `bl`=160, `mt`=159, `dop2`=1.
- По статусу: активных (`status=true`)=375, выключенных=97. У всех `lim`>0.
- Группировка активных по `(content, type, tag)`**128 групп**: 120 троек B1/B2/B3, 6 пар, 2 одиночных.
Форма строки портала (ключевые поля): `id` (строка, внешний id), `tag`, `src` (rt/bl/mt/…), `type` (calls/hosts/sms), `content` (идентификатор — телефон/домен), `name` (`B<n>_<content>`), `lim` (строка, лимит на площадку), `workdays` (строки `["1".."7"]`, 1=Пн..7=Вс ISO), `regions` (строка кодов ГИБДД, через запятую; пусто = вся РФ), `regions_reverse` (bool), `status` (bool).
NB: на портале лимиты активных проектов **уже поделены** на B1/B2/B3 (re-split форсом, ПИЛОТ.md `029b19a`) — значит сумма площадок = корректный целевой total для Лидерры.
## 4. Маппинг портал → Лидерра
| Портал | Лидерра |
|---|---|
| `src` rt / bl / mt | platform B1 / B2 / B3 |
| `src` = `dop2` и любые иные | **пропуск** + строка в отчёт (вне модели B1/B2/B3) |
| `type` calls / hosts / sms | `signal_type` call / site / sms |
| `content` (для site/call) | `signal_identifier` |
| группа = (`content`, `type`, `tag`) | один `Project` Лидерры |
| Σ `lim` активных площадок группы | `daily_limit_target` |
| `regions` (коды ГИБДД, union по площадкам группы) | `Project.regions` INT[] (коды Лидерры, обратная карта `SupplierRegions`); пусто = вся РФ → `[]` |
| `regions_reverse=false` (include) | поддерживаем; `regions_reverse=true` (exclude) → **пропуск группы** + отчёт (модель Лидерры импорта — include) |
| union `workdays` строк | `delivery_days_mask` (бит 0=Пн..6=Вс; bit=`1<<(d-1)`) |
| `tag` | `Project.tag` (как есть); `Project.name` = производное от `tag` (+ суффикс идентификатора при коллизии имён) |
| `status=true` | `is_active=true` |
**Обратная карта регионов:** существующий `SupplierRegions::mapToSupplier()` — Лидерра→ГИБДД (биекция 79 субъектов). Импорту нужна инверсия `mapFromSupplier()` (ГИБДД→Лидерра); непереводимый код → лог-warning + пропуск кода (регион не добавляется).
**SMS (15 строк, особый случай):** модель Лидерры для sms: `sms_senders` + `sms_keyword`, площадки B2 (sender+keyword) / B3 (sender), `unique_key` по `SupplierProjectGrouping::buildUniqueKey`. На портале `content` sms-строки кодирует sender(+keyword). План: best-effort разбор `content``sms_senders[0]`/`sms_keyword`; группы sms, которые не разбираются однозначно, **выводятся в отчёт и пропускаются** для ручного решения (объём мал).
## 5. Группировка и идемпотентность
- **Группа** строится только из **активных** строк (`status=true`), у которых `src` ∈ {rt,bl,mt}. Группы с одной/двумя площадками — валидны (создаём проект с теми платформами, что есть).
- `unique_key` для `supplier_projects` вычисляется через `SupplierProjectGrouping::buildUniqueKey($project, $platform)` (консистентность с ночным джобом: будущие синки матчатся).
- **Идемпотентность Project:** если под тенантом info@lkomega.ru уже есть `Project` с тем же (`signal_type`, `signal_identifier`) [для sms — (`signal_type`, `sms_senders[0]`, `sms_keyword`)] → **пропуск** (в отчёт «уже существует»), не дубль.
- **Идемпотентность supplier_projects:** строка матчится по `supplier_external_id` (id портала) либо по unique-индексу `(platform, unique_key, subject_code=null)`. Есть → переиспользуем; нет → `forceCreate` с `sync_status='ok'`, `last_synced_at=now()`.
- Повторный запуск команды безопасен: создаёт только недостающее.
## 6. Архитектура / компоненты
1. **`App\Services\Supplier\SupplierProjectImporter`** — чистая логика, без побочных эффектов записи:
- `buildPlan(): ImportPlan` — читает `listProjects()`, фильтрует активные, группирует, реверс-маппит, считает суммы лимитов, помечает пропуски (dop2 / regions_reverse / нераспознанный sms / уже существующие). Возвращает структуру плана (список запланированных проектов + список пропусков). Зависимость `SupplierPortalClient` инжектится → тестируется на моках.
2. **`App\Console\Commands\ImportSupplierProjectsCommand`** (`supplier:import-projects {--tenant=} {--commit}`):
- Резолвит тенант по email (`User::where('email', …)->tenant_id`).
- Печатает план таблицей (имя, тип, регионы, лимит, площадки + external_id) + счётчики + список пропусков.
- Без `--commit` (по умолчанию) — только печать (dry-run), 0 записей.
- С `--commit` — пишет в транзакции (см. §7).
3. **`SupplierRegions::mapFromSupplier(array<int> $gibddCodes): array<int>`** — инверсия существующей карты.
Граница: importer НЕ знает про вывод в консоль; команда НЕ знает про парсинг портала. План — простая DTO-структура.
## 7. Путь записи (только при `--commit`)
Зеркалит create-ветку `SyncSupplierProjectsJob::syncGroup`, но **без HTTP на портал**`supplier_external_id` берётся из уже прочитанного `listProjects` (`id` строки).
Соединение: **`pgsql_supplier`** (BYPASSRLS, роль `crm_supplier_worker`) для всех записей — это паттерн supplier-джобов; `Project` пишется с **явным `tenant_id`** (BYPASSRLS обходит RLS, поэтому tenant_id задаётся в коде, не из GUC). `supplier_projects` и `project_supplier_links` — SaaS-level (без RLS).
На каждую группу в транзакции:
1. `Project::on('pgsql_supplier')->create([tenant_id, name, tag, signal_type, signal_identifier|sms_*, regions, delivery_days_mask, daily_limit_target=Σ, is_active=true, region_mode='include'])`.
2. На каждую активную площадку: upsert `supplier_projects` (`platform`, `signal_type`, `unique_key`, `subject_code=null`, `supplier_external_id`=id портала, `current_limit`=`lim` площадки, `current_workdays`, `current_regions`, `sync_status='ok'`, `last_synced_at=now()`).
3. `project_supplier_links` insertOrIgnore (`project_id`, `supplier_project_id`, `platform`, `subject_code=null`).
4. `SupplierSyncLog` action='create' (audit).
Легаси-FK `supplier_b{1,2,3}_project_id` **не заполняем** — текущий `LeadRouter` ходит через pivot `project_supplier_links` (Plan 2 redesign); консистентно с актуальным джобом.
## 8. Безопасность
- **dry-run по умолчанию** — реальная запись только с явным `--commit`.
- Перед `--commit` на пилоте — показ плана заказчику и его «ок».
- Запись в **одной транзакции** на пилоте; при ошибке — откат.
- Портал не трогаем (никаких save/update/delete) — нулевой риск дублей и переплаты.
- Телефоны/ПДн в выводе/логах команды маскируются (152-ФЗ): идентификаторы и имена с цифровыми хвостами усекаются в отчёте.
## 9. Тестирование (TDD)
`SupplierProjectImporterTest` на моках `SupplierPortalClient`:
- группировка троек B1/B2/B3 в один план-проект;
- сумма лимитов площадок → `daily_limit_target`;
- обратная карта регионов (ГИБДД→Лидерра), union, пусто=вся РФ;
- фильтр статуса (выключенные не попадают);
- пропуск `dop2` / `regions_reverse=true` / нераспознанного sms — с записью в отчёт;
- идемпотентность (существующий Project → skip; существующий supplier_project → reuse).
`ImportSupplierProjectsCommandTest` — smoke: dry-run ничего не пишет; `--commit` создаёт Project+supplier_projects+pivot (на тестовой БД, мок listProjects).
`SupplierRegions::mapFromSupplier` — unit: биекция-инверсия, непереводимый код.
## 10. Выполнение (порядок)
1. Команда + сервис + тесты в worktree `feat/supplier-import-lkomega` (от origin/main).
2. Зелёная регрессия (Pest целевой + relevant supplier suite, Pint, Larastan).
3. Деплой на пилот (scp файлов; команда — не воркер, restart очереди не нужен).
4. **Dry-run на пилоте** → показываю план заказчику → его «ок».
5. Реальный прогон `--commit` на пилоте.
6. Пост-проверка: число созданных Project под тенантом, выборочная сверка лимитов/регионов, отсутствие записей на портале (портал не дёргался).
7. Push ветки → main; merge по решению заказчика.
## 11. Уточнить при планировании (не блокеры)
- **Ключ группировки.** Ночной `SyncSupplierProjectsJob` группирует по `(signal_type, identifier)` **без тега**; recon считал по `(content, type, tag)` → 128. Если у одного `(content,type)` несколько тегов — счёт групп изменится, и будущий ночной синк слил бы их в одну. **Рекомендация:** группировать как ночной джоб — по `(signal_type, identifier)` без тега (консистентно с live-синком); при нескольких тегах на одном идентификаторе — взять один (первый/наиболее частый) + отчёт. Финальный счёт уточнить на реальных данных при планировании.
- **Семантика `tag`.** На портале у Дмитрия `tag` = кампания (напр. «Сфера Займов https://…»). Ночной синк Лидерры, наоборот, **пишет в портальный `tag` имя региона** (или «РФ»). Для импорта `Project.tag` = **сохранить кампанию из портала** (это данные заказчика); портал не трогаем, поэтому подмены тега не происходит. NB: если позже сделать resync этого проекта — штатный синк перезапишет портальный `tag` на регион (известный побочный эффект штатного поведения, вне scope импорта).
- Точные fillable/типы `SupplierProject` (платформенный CHECK uppercase B1/B2/B3; колонка `supplier_external_id` строка).
- Имя `Project.name`: формат из `tag` (тег бывает с URL — обрезать/нормализовать).
- SMS-разбор `content` → sender/keyword (15 строк) — формат подтвердить на реальных sms-строках портала (read-only).
- Резолв тенанта: `User` vs отдельная `Tenant` запись по email.
## 12. Вне scope (YAGNI)
- Импорт исторических лидов/сделок (отдельный CSV-эпик, уже частично сделан).
- Импорт выключенных проектов (`status=false`).
- Изменение чего-либо на портале crm.bp-gr.ru.
- UI для импорта (разовая операция — команда).
- Двусторонняя синхронизация (это разовый импорт; дальше работает штатный sync).
## 13. Риски
- **Несовпадение группировки портал↔Лидерра для sms** — митигируется пропуском нераспознанных + отчётом (объём 15).
- **Регионы exclude (`regions_reverse=true`)** — пропуск + отчёт (импорт only-include).
- **Параллельная сессия / §15** — worktree изолирован; коммиты явными путями; pre-flight sync перед нормативкой (нормативка тут не правится).
- **Расхождение кода ветки vs пилота** — строим от origin/main = код пилота; перед `--commit` проверяем, что версия команды на пилоте = собранная.