docs(supplier): brainstorm — supplier platform prefix on write (spec)

Spec для фикса root-cause обнаруженной 26.05.2026 при разборе скриншота
админки поставщика: 11 из первых 12 наших проектов в crm.bp-gr.ru имеют
name без префикса B1_/B2_/B3_, в то время как старые ручные — с префиксом.

Корень в SupplierPortalClient::toPayload() строка 468: name=uniqueKey
без префикса. Допущение портал префиксует сам автоматически (комментарий
2026-05-19, recon Playwright) не подтверждено живым listProjects.

Решения брейншторма (заказчик подтвердил):
- toPayload префиксует name через helper prefixedName():
  "B<n>_<uniqueKey>" если platforms содержит ровно 1 элемент,
  иначе throw LogicException (инвариант 1 POST = 1 платформа).
- saveProjectMultiFlag реструктуризируется: один POST со всеми
  srcrt+srcbl+srcmt -> N последовательных POST'ов, по одному на платформу,
  external_id из ответа rt-project-save напрямую.
- updateProject без изменений сигнатуры -- уже вызывается per-platform,
  через тот же toPayload автоматически реализует нормализацию на лету
  для 11 legacy без префикса.
- partial-failure не откатываем: Laravel job retry создаст возможные
  дубли, чистим вручную (флоу отработан 26.05).
- К1 учебник вебмастера НЕ правим в этом скоупе.
- AjaxProjectChannel read-side не трогаем -- 26.05 фикс DIRECT для
  legacy продолжает работать естественно.

Tests: unit для toPayload, feature для saveProjectMultiFlag с моком HTTP,
live smoke на боевом через UI Лидерры + tinker listProjects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-26 16:33:26 +03:00
parent 418bd1fe70
commit d2100a9bab
@@ -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<n>_" автоматически»*. **Это допущение неверно.** Живой ответ `/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_<key>` и решил, что префиксует портал. Фактически — это были проекты, заведённые **вручную через 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<n>_<uniqueKey>"`, где `<n>` — единственная активная площадка в этом 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_<uniqueKey>'`, `srcrt=true`, `srcbl=false`, `srcmt=false`
- `platforms=[B2]``name='B2_<uniqueKey>'`, `srcrt=false`, `srcbl=true`, `srcmt=false`
- `platforms=[B3]``name='B3_<uniqueKey>'`, `srcrt=false`, `srcbl=false`, `srcmt=true`
- `platforms=[]`, `platform='B1'` (fallback на одиночный) → `name='B1_<uniqueKey>'`
- `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:'<N>'}` инкрементальные.
- Вызов с `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<n>_<identifier>"`, `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-канал, контекст архитектуры