Compare commits

...

3 Commits

Author SHA1 Message Date
Дмитрий 6b2597ff4a docs(ПИЛОТ): 26.05 ночь — открытая работа supplier-platform-prefix (spec only, не на проде)
Заметка для следующей сессии: на ветке fix/supplier-platform-prefix
(origin) лежит spec фикса корневой причины с пустым префиксом name
у проектов на crm.bp-gr.ru. Кода ещё нет — следующий шаг writing-plans.

Также в той же ветке lежит инфра-fix хука extractTestMetrics
(распознавание Vitest passed | N skipped формата).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:45:55 +03:00
Дмитрий d2100a9bab 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>
2026-05-26 16:33:26 +03:00
Дмитрий 418bd1fe70 fix(hooks): extractTestMetrics — recognise Vitest "passed | N skipped" formats
Pre-fix all three regexes in extractTestMetrics fell through when Vitest
output contained " | N skipped" between "passed" and "(TOTAL)" — so any
test suite with .skip()'ed tests produced sentinel result=fail (false
negative), blocking subsequent git commit.

Two new patterns:
- "Tests  N passed | M skipped (TOTAL)"
- "Tests  X failed | N passed | M skipped (TOTAL)"

Companion tests in tools/enforce-verify-record.test.mjs (new file matches
TDD-gate basename heuristic) and tools/enforce-verify-before-push.test.mjs.

Verified RED to GREEN: 38/38 tests pass after fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:33:02 +03:00
5 changed files with 247 additions and 1 deletions
@@ -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-канал, контекст архитектуры
+13
View File
@@ -59,6 +59,19 @@ describe('enforce-verify-record / extractTestMetrics', () => {
tests_failed: 1, tests_passed: 631, tests_total: 632,
});
});
it('parses vitest passed with skipped', () => {
// Vitest 4.x summary when some tests are .skip()'ed:
// "Tests 924 passed | 3 skipped (927)"
// Previously fell through all regexes → result=fail (false negative).
expect(extractTestMetrics('Tests 924 passed | 3 skipped (927)')).toMatchObject({
tests_passed: 924, tests_failed: 0, tests_total: 927,
});
});
it('parses vitest failed+passed+skipped triplet', () => {
expect(extractTestMetrics('Tests 1 failed | 920 passed | 3 skipped (924)')).toMatchObject({
tests_failed: 1, tests_passed: 920, tests_total: 924,
});
});
});
describe('enforce-verify-before-push / decide', () => {
+9 -1
View File
@@ -24,9 +24,17 @@ import {
export function extractTestMetrics(stdout) {
const out = { tests_total: null, tests_passed: null, tests_failed: null };
if (typeof stdout !== 'string') return out;
// vitest summary lines: "Tests 3708 passed (3708)" or "Tests N failed | M passed (TOTAL)"
// vitest summary lines:
// "Tests 3708 passed (3708)"
// "Tests 924 passed | 3 skipped (927)" ← was missed pre-2026-05-26
// "Tests 1 failed | 631 passed (632)"
// "Tests 1 failed | 920 passed | 3 skipped (924)" ← was missed pre-2026-05-26
let m = stdout.match(/Tests\s+(\d+)\s+passed\s*\((\d+)\)/);
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[2]; out.tests_failed = 0; return out; }
m = stdout.match(/Tests\s+(\d+)\s+passed\s*\|\s*(\d+)\s+skipped\s*\((\d+)\)/);
if (m) { out.tests_passed = +m[1]; out.tests_failed = 0; out.tests_total = +m[3]; return out; }
m = stdout.match(/Tests\s+(\d+)\s+failed\s*\|\s*(\d+)\s+passed\s*\|\s*\d+\s+skipped\s*\((\d+)\)/);
if (m) { out.tests_failed = +m[1]; out.tests_passed = +m[2]; out.tests_total = +m[3]; return out; }
m = stdout.match(/Tests\s+(\d+)\s+failed\s*\|\s*(\d+)\s+passed\s*\((\d+)\)/);
if (m) { out.tests_failed = +m[1]; out.tests_passed = +m[2]; out.tests_total = +m[3]; return out; }
// Pest: "Tests: 742 passed (1908 assertions)"
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { extractTestMetrics } from './enforce-verify-record.mjs';
describe('enforce-verify-record / extractTestMetrics — Vitest skipped formats', () => {
it('parses vitest passed-only with skipped', () => {
// Vitest 4.x summary when some tests are .skip()'ed:
// "Tests 924 passed | 3 skipped (927)"
// Pre-fix all three regexes fell through → result=fail (false negative).
expect(extractTestMetrics('Tests 924 passed | 3 skipped (927)')).toMatchObject({
tests_passed: 924, tests_failed: 0, tests_total: 927,
});
});
it('parses vitest failed+passed+skipped triplet', () => {
expect(extractTestMetrics('Tests 1 failed | 920 passed | 3 skipped (924)')).toMatchObject({
tests_failed: 1, tests_passed: 920, tests_total: 924,
});
});
});
+2
View File
File diff suppressed because one or more lines are too long