diff --git a/docs/superpowers/specs/2026-05-26-supplier-platform-prefix-design.md b/docs/superpowers/specs/2026-05-26-supplier-platform-prefix-design.md new file mode 100644 index 00000000..0e063a8d --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-supplier-platform-prefix-design.md @@ -0,0 +1,205 @@ +# Supplier platform prefix on write — design + +**Дата:** 2026-05-26 +**Автор:** controller (Opus 4.7) совместно с заказчиком +**Статус:** approved (брейншторм закрыт, переход к writing-plans) +**Триггер:** заказчик заметил, что в админке поставщика `crm.bp-gr.ru` первые 11 наших проектов имеют названия без префикса `B1_/B2_/B3_`, в то время как старые ручные — с префиксом. + +--- + +## 1. Корневая причина (подтверждено кодом и живым API) + +`app/app/Services/Supplier/SupplierPortalClient.php::toPayload()` строка 468: + +```php +'name' => $dto->uniqueKey, +``` + +Отправляется голый `uniqueKey` (домен / телефон / sender+keyword). Платформа кодируется отдельными bool-флагами `srcrt` / `srcbl` / `srcmt`. Комментарий 435–437 утверждает: *«портал префиксует "B_" автоматически»*. **Это допущение неверно.** Живой ответ `/admin/visit/rt-projects-load?src=none` для номера `79135191264` (3 записи `id=12742042/43/44`) показал `name="79135191264"` у всех трёх — поставщик сохраняет `name` ровно так, как мы прислали. + +Origin allowed assumption: при recon 2026-05-19 разработчик увидел в `listProjects()` имена вида `B1_` и решил, что префиксует портал. Фактически — это были проекты, заведённые **вручную через UI** поставщика (старые `B2_Caranga`, `B3_Caranga`, `B3_EDA-PROMO+скидка`, `B6_78002000010`). + +Связанный костыль на read-side: `app/app/Services/Supplier/Channel/AjaxProjectChannel.php` строка 50 — `preg_match('/^(B[123])_/', $name, $m)` → для проектов без префикса возвращает `null`, и фикс 2026-05-26 (commit `0da72778..` цепочка) подставил `DIRECT` в качестве компенсации. Симптом лечили на чтении, корень — на отправке. + +--- + +## 2. Цель и инвариант + +**Цель.** В payload `/admin/visit/rt-project-save` поле `name` теперь несёт префиксованную форму `"B_"`, где `` — единственная активная площадка в этом POSTе. + +**Инвариант.** «Один POST `rt-project-save` = ровно одна платформа.» Это согласовано с явным комментарием в `toPayload()` (строки 430–433); фактический multi-flag в `saveProjectMultiFlag()` инвариант нарушал — приводим в соответствие. + +**Поле `content` остаётся равным `uniqueKey`** (без префикса) — на нём поставщик строит свои матчинги номера/домена и read-side в `saveProjectMultiFlag()` уже завязан на него. + +--- + +## 3. Архитектура изменений + +Один файл — `app/app/Services/Supplier/SupplierPortalClient.php`. Три точки правок + новый private helper. + +### 3.1. `prefixedName(SupplierProjectDto $dto): string` (новый helper) + +```php +private function prefixedName(SupplierProjectDto $dto): string +{ + $platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform]; + if (count($platforms) !== 1) { + throw new \LogicException( + 'prefixedName requires exactly one platform per payload; got '.count($platforms) + ); + } + return $platforms[0].'_'.$dto->uniqueKey; +} +``` + +Жёсткий throw при нарушении инварианта (Развилка 1 закрыта заказчиком — «громко падать»). Если кто-то в будущем снова попытается послать multi-platform DTO в `toPayload` — упадём с понятным сообщением, не запишем мусор в портал. + +### 3.2. `toPayload()` — подключение helper'а + +```php +// было: +'name' => $dto->uniqueKey, +// стало: +'name' => $this->prefixedName($dto), +``` + +Остальные поля payload без изменений (`content`, `srcrt/bl/mt`, `tag`, лимиты, регионы, расписание). + +### 3.3. `saveProjectMultiFlag(SupplierProjectDto $dto): array` — реструктуризация + +Было — один POST со всеми флагами `srcrt+srcbl+srcmt=true` + последующий `listProjects()` + матчинг по `content+tag`. + +Стало — цикл по `$dto->platforms`, один POST на каждую платформу, ID берётся прямо из ответа `rt-project-save`: + +```php +public function saveProjectMultiFlag(SupplierProjectDto $dto): array +{ + $platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform]; + $out = []; + foreach ($platforms as $platform) { + $perPlatformDto = new SupplierProjectDto( + platform: $platform, + signalType: $dto->signalType, + uniqueKey: $dto->uniqueKey, + limit: $dto->limit, + workdays: $dto->workdays, + regions: $dto->regions, + regionsReverse: $dto->regionsReverse, + status: $dto->status, + tag: $dto->tag, + platforms: [$platform], + ); + $response = $this->request( + 'POST', '/admin/visit/rt-project-save', + $this->toPayload($perPlatformDto, externalId: 0), + asJson: true, + ); + $this->assertStatusOk($response, '/admin/visit/rt-project-save'); + $out[$platform] = (int) ($response->json('id') ?? 0); + } + return $out; +} +``` + +**Побочные эффекты улучшения:** больше не нужен `listProjects()` после save (был костылём, поскольку multi-flag POST возвращал id только последнего созданного проекта). Минус один лишний запрос, плюс ID берётся напрямую из ответа. + +### 3.4. `updateProject(int $externalId, SupplierProjectDto $dto)` — без изменений сигнатуры + +Уже вызывается с per-platform DTO (`SyncSupplierProjectJob.php:307` и `SyncSupplierProjectsJob.php:402`). После правки `toPayload()` он автоматически кладёт префиксованный `name` — реализуется «нормализация на лету» для 11 уже существующих проектов без префикса (при следующем обычном update — лимит/регионы/расписание/статус — их имя на портале приводится к корректному виду без отдельного миграционного прохода). + +### 3.5. `saveProject(SupplierProjectDto $dto)` — без изменений + +Однопроектный save через тот же `toPayload()` — автоматически получает префикс. + +--- + +## 4. Закрытые развилки + +### Развилка 1: «странный» DTO в `toPayload` (0 или 2+ платформ) +**Решение:** throw `\LogicException`. Громко падать лучше, чем тихо записывать мусор. Прецедент — неделя тихого допущения «портал префиксует сам» (зафиксировано в комментарии 19.05.2026, выявлено на скриншоте от заказчика 26.05.2026; этот спек закрывает именно такую ситуацию). + +### Развилка 2: partial-failure в `saveProjectMultiFlag` +**Решение:** **ничего не откатывать.** Если POST для B1 прошёл, а для B2 упал — исключение поднимается наверх, Laravel job retry попробует снова → возможны дубли на портале (B1 будет создан второй раз). Это терпимо: +- Сценарий редкий (требует ошибки 500/таймаута поставщика именно между POSTами). +- Дубли видны глазами в админке поставщика, флоу cleanup уже отработан (2026-05-26, 26 пар дублей вычищены скриптом). +- Альтернатива — try/catch + deleteProject уже созданных — добавляет место отказа (само удаление может упасть) и тестов. На редкий кейс — лишний риск. + +--- + +## 5. Тесты + +### 5.1. Unit-test `toPayload()` / `prefixedName()` + +`app/tests/Unit/Services/Supplier/SupplierPortalClientPayloadTest.php` (новый файл, либо в существующий unit-тест клиента — проверить наличие): + +- `platforms=[B1]` → `name='B1_'`, `srcrt=true`, `srcbl=false`, `srcmt=false` +- `platforms=[B2]` → `name='B2_'`, `srcrt=false`, `srcbl=true`, `srcmt=false` +- `platforms=[B3]` → `name='B3_'`, `srcrt=false`, `srcbl=false`, `srcmt=true` +- `platforms=[]`, `platform='B1'` (fallback на одиночный) → `name='B1_'` +- `platforms=[B1,B2]` → `LogicException` +- `platforms=[]`, `platform=''` (вырожденный) → `LogicException` (или другая корректная диагностика) + +### 5.2. Feature-test `saveProjectMultiFlag()` с моком HTTP + +`app/tests/Feature/Supplier/SaveProjectMultiFlagTest.php` (или место рядом с существующими тестами клиента): + +- Мок Laravel `Http::fake()` для `/admin/visit/rt-project-save` → возвращает `{status:'OK', id:''}` инкрементальные. +- Вызов с `platforms=[B1,B2,B3]` → проверяем, что было **ровно 3 POST'а** к `/rt-project-save` (никаких `/rt-projects-load` после). +- Каждый POST содержит правильный `name` (`B1_X`, `B2_X`, `B3_X`) и правильную тройку флагов (один true, два false). +- Возвращаемый массив = `[B1=>id1, B2=>id2, B3=>id3]` в порядке появления. +- Вариант с одной площадкой `platforms=[B2]` → ровно 1 POST. + +### 5.3. Живая проверка на боевом (post-deploy smoke) + +После деплоя: +1. Через UI Лидерры создать тестовый проект (любой tenant, тестовый домен/телефон). +2. Через tinker на боевом — `SupplierPortalClient::listProjects()` → отфильтровать по `content == <тестовый identifier>`. +3. Убедиться: 3 записи, у каждой `name = "B_"`, `src` соответствует префиксу. +4. Удалить тестовый проект через UI Лидерры → убедиться, что у поставщика тоже удалилось. + +--- + +## 6. Деплой + +Стандартный для текущей фазы: + +1. Ветка `fix/supplier-platform-prefix`. +2. TDD: сначала падающий тест (unit + feature), потом фикс кода. +3. Local Pest + Vitest зелёные. +4. Pre-flight через агент `prod-deploy-validator` → GO/NO-GO. +5. Tar + scp + ssh extract + `php artisan optimize` под www-data + restart queue. **НЕ через `redeploy.sh`** (он не делает git pull). Лог по [memory feedback_environment.md квирк 107]. +6. Post-deploy smoke (см. 5.3). + +--- + +## 7. Что НЕ входит в scope + +- **Учебник К1 в `memory/project_webmaster.md`** — не правим. Брейншторм К1 на паузе по другому багу (baseline-баг маршрутизатора), вернёмся к К1 в его собственной сессии. +- **Старые 11 проектов без префикса** — не переименовываем ни руками, ни одноразовым скриптом. Нормализуются «на лету» при следующем `updateProject` каждого. +- **`AjaxProjectChannel::preg_match` (read-side)** — не трогаем. Логика «`DIRECT` для проектов без B-префикса» (commit 26.05) продолжает работать для legacy естественно: по мере прихода префиксов через update — доля `DIRECT` падает. +- **Структура `supplier_projects` в нашей БД** — не меняется. Матчинг внутри Лидерры по `external_id`, поле `name` не используется как ключ. + +--- + +## 8. Риски и наблюдения + +- **Нагрузка к поставщику.** Создание проекта теперь = 3 POST'а вместо 1. Создание новых проектов — единицы в день, разница ничтожна. +- **B3 transient delay.** При создании B3-площадка иногда появляется с задержкой (фиксировано в `cfe94d91`). Раньше это било внутри multi-flag POSTа; теперь — на конкретном per-platform POSTе, обработка ретраев та же. +- **Параллельность.** `saveProjectMultiFlag` теперь не атомарный (3 POSTа последовательно). Время выполнения метода × 3 — приемлемо, проектов в день мало. +- **Логирование.** Желательно при каждом POSTе писать debug-лог с парой `(platform, identifier)` — упрощает разбор partial-failure. Добавим в реализации, не в спеке. + +--- + +## 9. Связанные артефакты + +- Корневой файл: `app/app/Services/Supplier/SupplierPortalClient.php` +- Read-side, на который влияет: `app/app/Services/Supplier/Channel/AjaxProjectChannel.php` (не правим) +- Связанные джобы (используют `saveProjectMultiFlag` / `updateProject`): + - `app/app/Jobs/SyncSupplierProjectJob.php` + - `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` +- Память: + - `memory/project_supplier_integration.md` — фон по платформам + - `memory/project_supplier_webhook_fixes.md` — 26.05 фикс DIRECT-платформы (костыль на read-side) + - `memory/project_webmaster.md` — К1 портрет (НЕ правим) +- Спецификации: + - `docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md` — failover-канал, контекст архитектуры