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:
@@ -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` проверяем, что версия команды на пилоте = собранная.
|
||||
Reference in New Issue
Block a user