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:
@@ -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-канал, контекст архитектуры
|
||||
Reference in New Issue
Block a user