From b18a3f5890e92f134aeda8f424d55cbbdb2fc642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 22 May 2026 09:14:09 +0300 Subject: [PATCH] =?UTF-8?q?docs(spec):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B0=D0=BD=D1=82=20info@lkomega.ru=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разовая 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) --- ...supplier-projects-import-lkomega-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md diff --git a/docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md b/docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md new file mode 100644 index 00000000..bf8652af --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md @@ -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_`), `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 $gibddCodes): array`** — инверсия существующей карты. + +Граница: 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` проверяем, что версия команды на пилоте = собранная.