diff --git a/cspell-words.txt b/cspell-words.txt index a5de7e18..cd693e40 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1475,7 +1475,6 @@ DWC инжектим фикстурный роута - # Brain dashboard design spec (2026-05-19) визуализирующий анимируются @@ -1488,3 +1487,9 @@ DWC visualises AGD agg + +# Supplier migration follow-up (2026-05-19) +ретрая +детекта +Регэксп +фрэш diff --git a/docs/superpowers/plans/2026-05-19-supplier-migration-followup.md b/docs/superpowers/plans/2026-05-19-supplier-migration-followup.md new file mode 100644 index 00000000..81056391 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-supplier-migration-followup.md @@ -0,0 +1,1089 @@ +# Supplier Migration Follow-up Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Закрыть 5 хвостов миграции данных Лидерра ↔ crm.bp-gr.ru: defense-in-depth детект HTTP-200-логина в `SupplierPortalClient`, recon реальных локаторов формы rt-проекта, rewrite `manage-project.js` под Element UI с интерсептом ответа `rt-project-save` для надёжного захвата `external_id`, live-тест form-канала и полный 3-ярусный прогон `FailoverProjectChannel`. + +**Architecture:** Все правки — поверх существующего эпика `feat/supplier-project-failover` (origin/main `643e1a5`). Изменения: + +- Task 1: `SupplierPortalClient::request()` — после успешного HTTP 2xx распознавать страницу-логин Yii2 (тэги `loginform-username` / `LoginForm[username]`), форсить `RefreshSupplierSessionJob` + retry (новая ветка ретрая поверх существующей 401/403-логики). +- Task 2: recon-snapshot реальных локаторов 13 полей формы rt-проекта (Element UI / Vue 2) в `docs/discovery/2026-05-19-rt-project-form-locators.md` — input для Task 3. +- Task 3: переписать `app/playwright/manage-project.js` (Tier-2) на label-based / attribute-based локаторы Element UI; `external_id` получать через `page.waitForResponse('rt-project-save')` + парсинг тела ответа портала (надёжнее DOM-парсинга новой строки таблицы). +- Task 4: live-smoke create+delete через rewrite-bridge (без удаления — оставляем артефакт под Task 5). +- Task 5: tinker-скрипт `app/storage/_smoke_failover_3tier.php` гоняет 3 ветки: tier-1 live (AJAX) → принудительный fail-tier-1 (DI-стаб) → tier-2 live (form через переписанный bridge) → принудительный fail-tier-1+2 → tier-3 (manual queue + email-stub). + +**Tech Stack:** PHP 8.3 + Laravel 13 (Pest 4, Larastan), Playwright (node + chromium-1217), Element UI / Vue 2 (на стороне портала), Redis (Memurai), PostgreSQL 16. + +--- + +## Task 1: HTTP-200-логин детект в SupplierPortalClient + retry + +**Files:** + +- Modify: `app/app/Services/Supplier/SupplierPortalClient.php` (метод `request`, после успешного 2xx ответа — детект login page + retry path) +- Test: `app/tests/Unit/Supplier/SupplierPortalClientTest.php` (новые case'ы) + +**Контракт детекта:** Yii2 login-page содержит атрибут `id="loginform-username"` и `name="loginform-password"` (проверено в `app/playwright/refresh-session.js:34-35`). Регэксп `~loginform-(username|password)~i` устойчив (не появляется в легитимных API-ответах rt-* / отчётов). Применяем детект только к ответам с Content-Type начинающимся на `text/html` ИЛИ к ответам без `application/json` в заголовке — это режет false-positive на массивных JSON-ответах (отчёты, projects). + +**Поведение:** + +- При обнаружении login-page в `text/html` ответе → если `! $isRetry` → dispatch_sync(RefreshSupplierSessionJob) + recursive call с `$isRetry=true`. +- Если повтор тоже login-page → SupplierAuthException("Portal returned login page after refresh on {$path}"). +- 401/403-ветка остаётся как есть; новая логика — отдельный блок ПОСЛЕ существующей 401/403-проверки и ПЕРЕД проверкой `>= 500` (то есть только в успешной 2xx ветке). + +- [ ] **Step 1.1: Failing test — login page in HTML response triggers refresh + retry** + +Edit `app/tests/Unit/Supplier/SupplierPortalClientTest.php` — добавить в конец: + +```php +test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void { + Bus::fake([RefreshSupplierSessionJob::class]); + + Http::fakeSequence('crm.bp-gr.ru/*') + ->push( + '
', + 200, + ['Content-Type' => 'text/html; charset=utf-8'], + ) + ->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']); + + app(SupplierPortalClient::class)->listProjects(); + + Bus::assertDispatchedSync(RefreshSupplierSessionJob::class); + Http::assertSentCount(2); +}); + +test('sticky HTML login page after retry throws SupplierAuthException', function (): void { + Bus::fake([RefreshSupplierSessionJob::class]); + + Http::fakeSequence('crm.bp-gr.ru/*') + ->push( + '', + 200, + ['Content-Type' => 'text/html; charset=utf-8'], + ) + ->push( + '', + 200, + ['Content-Type' => 'text/html; charset=utf-8'], + ); + + expect(fn () => app(SupplierPortalClient::class)->listProjects()) + ->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh'); +}); + +test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void { + Http::fake([ + 'crm.bp-gr.ru/*' => Http::response( + '{"projects":[{"name":"loginform-username is just a string here"}]}', + 200, + ['Content-Type' => 'application/json'], + ), + ]); + + $result = app(SupplierPortalClient::class)->listProjects(); + + expect($result)->toHaveCount(1); + Http::assertSentCount(1); // no retry — JSON header skips login-detect +}); +``` + +- [ ] **Step 1.2: Run new tests to verify they fail** + +Run: + +```bash +cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="login page" -v +``` + +Expected: 3 FAIL — все тесты ожидают новое поведение, которого ещё нет. + +- [ ] **Step 1.3: Implement login-page detection в `request()`** + +Edit `app/app/Services/Supplier/SupplierPortalClient.php`: + +После строки 313 (`if ($response->status() >= 500) {`) — НЕТ. Login-detect должен быть в успешной ветке (после всех 4xx/5xx throws), ПЕРЕД финальным `return $response;` (строка 323). + +Заменить блок с строки 305 (после блока 401/403 retry) до строки 324 (`}`) — добавить новый блок между 4xx-throw и финальным return: + +```php + if ($response->status() >= 500) { + throw new SupplierTransientException( + "Supplier server error {$response->status()} on {$path}", + httpStatus: $response->status(), + responseBody: $response->body(), + ); + } + + if ($response->status() >= 400) { + throw new SupplierClientException( + "Supplier rejected {$path}: HTTP {$response->status()}", + httpStatus: $response->status(), + responseBody: $response->body(), + ); + } + + // Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей + // сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим + // refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username. + if ($this->isHtmlLoginPage($response)) { + if ($isRetry) { + throw new SupplierAuthException( + "Portal returned login page after refresh on {$path}", + httpStatus: $response->status(), + responseBody: $response->body(), + ); + } + try { + dispatch_sync(app(RefreshSupplierSessionJob::class)); + } catch (\Throwable $e) { + throw new SupplierAuthException( + "Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}", + httpStatus: $response->status(), + previous: $e, + ); + } + + return $this->request($method, $path, $body, isRetry: true, asJson: $asJson); + } + + return $response; + } + + private function isHtmlLoginPage(Response $response): bool + { + $contentType = (string) ($response->header('Content-Type') ?? ''); + if (! str_starts_with(mb_strtolower($contentType), 'text/html')) { + return false; + } + + return preg_match('~loginform-(username|password)~i', $response->body()) === 1; + } +``` + +NB: подпись `request()` — `bool $isRetry = false, bool $asJson = false` — рекурсивный вызов передаёт оба флага. + +- [ ] **Step 1.4: Run new tests to verify they pass** + +Run: + +```bash +cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="login page|loginform-username" -v +``` + +Expected: 3 PASS (новые тесты) + остальной файл должен остаться зелёным. + +- [ ] **Step 1.5: Run full SupplierPortalClientTest to check regression** + +Run: + +```bash +cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php -v +``` + +Expected: все тесты PASS (12+). + +- [ ] **Step 1.6: Larastan + Pint** + +Run: + +```bash +cd app && ./vendor/bin/pint app/Services/Supplier/SupplierPortalClient.php && ./vendor/bin/phpstan analyse app/Services/Supplier/SupplierPortalClient.php tests/Unit/Supplier/SupplierPortalClientTest.php +``` + +Expected: Pint clean (FIXED/no changes), Larastan 0 errors (или baseline-обновления отметить отдельно). + +- [ ] **Step 1.7: Commit** + +```bash +git add app/app/Services/Supplier/SupplierPortalClient.php app/tests/Unit/Supplier/SupplierPortalClientTest.php +git commit -m "feat(supplier): detect HTTP-200 HTML login page → force refresh+retry (defense-in-depth)" +``` + +NB: если pre-commit hooks (lefthook) включают larastan baseline-update — добавить и его в коммит. + +--- + +## Task 2: Recon точных локаторов 13 полей формы rt-проекта + +**Files:** + +- Create: `docs/discovery/2026-05-19-rt-project-form-locators.md` (recon-документ — input для Task 3) + +**Цель:** записать **реальные** локаторы для каждого UI-поля формы добавления rt-проекта на crm.bp-gr.ru `/admin/visit/rt` (Element UI + Vue 2). Без этого Task 3 — слепой код. + +**13 полей** (выведено из `manage-project.js fillForm` + `FormProjectChannel::mapDto`): + +1. `active` — переключатель «Активный/Неактивный» (el-switch или el-checkbox) +2. `tag` — поле «Тег» (el-input) +3. `platforms[B1]` — чекбокс «B1» +4. `platforms[B2]` — чекбокс «B2» +5. `platforms[B3]` — чекбокс «B3» +6. `name` — поле «Название» (el-input) +7. `signal_type` — селект «Тип сигнала» (el-select c опциями «Сайты» / «Звонки» / «СМС») +8. `region_mode` — переключатель include/exclude регионов (el-radio-group) +9. `regions[]` — мульти-селект регионов (el-select multiple) — **новое, не было в старом manage-project.js**, нужно для regions из DTO +10. `domains` — поле «Домены» (el-input textarea), только при `signal_type=site` +11. `limit` — поле «Лимит/день» (el-input-number) +12. `workdays[]` — чекбоксы дней недели (1..7, el-checkbox-group) +13. `save_button` — кнопка «Сохранить» + +- [ ] **Step 2.1: Login в портал через Playwright MCP** + +Через Playwright MCP в текущей сессии: + +``` +mcp__playwright__browser_navigate("https://crm.bp-gr.ru/admin/site/login") +``` + +Если редирект на форму логина — заполнить (login/password из `.env` `SUPPLIER_LOGIN`/`SUPPLIER_PASSWORD`): + +``` +mcp__playwright__browser_fill_form([ + {target: , name: "Логин", type: "textbox", value: ""}, + {target: , name: "Пароль", type: "textbox", value: ""}, +]) +mcp__playwright__browser_click() +``` + +Expected: после логина — главная админка `/admin/site/dashboard` или `/admin/visit/rt`. + +- [ ] **Step 2.2: Открыть форму «Добавить проект»** + +``` +mcp__playwright__browser_navigate("https://crm.bp-gr.ru/admin/visit/rt") +mcp__playwright__browser_snapshot +``` + +Найти кнопку «Добавить проект» по тексту в snapshot; кликнуть; дождаться появления формы. + +``` +mcp__playwright__browser_click() +mcp__playwright__browser_snapshot +``` + +NB: согласно памяти, форма — **inline-панель**, не модалка. Snapshot покажет фактическую структуру. + +- [ ] **Step 2.3: Извлечь DOM-локаторы каждого из 13 полей** + +Для каждого из 13 полей выполнить `browser_evaluate` с querySelector — записать **выбранный stable-локатор**, его тип (label-based / attribute / class), и **fallback**-локатор. + +Пример для поля `name`: + +```javascript +mcp__playwright__browser_evaluate({ + function: () => { + // Стратегия 1: label-based (предпочтительно для Element UI) + const labels = document.querySelectorAll('.el-form-item__label'); + const nameLabel = Array.from(labels).find(l => l.textContent.trim() === 'Название'); + const nameInput = nameLabel?.closest('.el-form-item')?.querySelector('input'); + + // Стратегия 2: attribute-based fallback + const byName = document.querySelector('input[placeholder*="название" i]'); + + return { + labelBasedFound: !!nameInput, + labelBasedHTML: nameInput?.outerHTML?.slice(0, 200), + fallbackFound: !!byName, + fallbackHTML: byName?.outerHTML?.slice(0, 200), + }; + } +}) +``` + +Аналогично для каждого из 13 полей. Для `signal_type` (el-select) — записать **точный текст опций** «Сайты», «Звонки», «СМС» (или их реальные варианты — могут отличаться). + +- [ ] **Step 2.4: Найти save-кнопку + интерсепт-сигнал** + +```javascript +mcp__playwright__browser_evaluate({ + function: () => { + const buttons = Array.from(document.querySelectorAll('button')); + const saveBtn = buttons.find(b => b.textContent.trim() === 'Сохранить' || b.textContent.trim() === 'Создать'); + return { + saveBtnFound: !!saveBtn, + saveBtnHTML: saveBtn?.outerHTML?.slice(0, 300), + }; + } +}) +``` + +- [ ] **Step 2.5: Записать recon в `docs/discovery/2026-05-19-rt-project-form-locators.md`** + +Шаблон документа: + +```markdown +# Локаторы формы rt-проекта crm.bp-gr.ru (recon 2026-05-19) + +**Среда:** crm.bp-gr.ru `/admin/visit/rt`, Element UI 2 + Vue 2, форма открыта по кнопке «Добавить проект» (inline-панель, НЕ модалка). + +**Снимок DOM:** Playwright MCP browser_snapshot 2026-05-19 (см. `rt-add-project-form.yml`). + +| № | Поле | Локатор (Playwright) | Fallback | Контракт | +|---|---|---|---|---| +| 1 | active | `<реальный селектор>` | `` | el-switch / el-checkbox | +| 2 | tag | `<реальный селектор>` | — | el-input, ввод текста | +| 3 | platforms[B1] | `<реальный селектор>` | — | el-checkbox value=B1 | +| 4 | platforms[B2] | `<реальный селектор>` | — | el-checkbox value=B2 | +| 5 | platforms[B3] | `<реальный селектор>` | — | el-checkbox value=B3 | +| 6 | name | `<реальный селектор>` | — | el-input | +| 7 | signal_type | `<реальный селектор>` | — | el-select; опции: «Сайты»/«Звонки»/«СМС» (точный текст) | +| 8 | region_mode | `<реальный селектор>` | — | el-radio-group; values: «Включить»/«Исключить» | +| 9 | regions[] | `<реальный селектор>` | — | el-select multiple | +| 10 | domains | `<реальный селектор>` | — | el-input textarea (`signal_type=site`) | +| 11 | limit | `<реальный селектор>` | — | el-input-number | +| 12 | workdays[1..7] | `<реальный селектор шаблон>` | — | el-checkbox-group, 7 чекбоксов | +| 13 | save_button | `<реальный селектор>` | — | el-button «Сохранить» | + +## Интерсепт ответа + +POST `/admin/visit/rt-project-save` — Playwright `page.waitForResponse(r => r.url().endsWith('rt-project-save') && r.request().method() === 'POST')`. Body — JSON-конверт `{status, message, result, id}`. external_id — `body.id` (строка, привести к int). + +## Известное + +- Форма Element UI → классы вида `el-input__inner`, `el-select__caret` — стабильны между минор-релизами Element UI 2.x. +- Label-based стратегия предпочтительна (текст «Название» / «Тип сигнала» — пользовательский, под контролем портала; меняется только при i18n-смене языка). +- Текст опций select'а нужно **записать точно** — code в Task 3 будет матчить по точному тексту. +``` + +- [ ] **Step 2.6: Закрыть форму без сохранения** + +``` +mcp__playwright__browser_click(<отмена-или-крестик>) +``` + +Не оставлять висящую форму в админке. Если есть кнопка «Отмена» — кликнуть; если нет — закрыть таб через `mcp__playwright__browser_tabs(action=close)`. + +- [ ] **Step 2.7: Commit recon-документа** + +```bash +git add docs/discovery/2026-05-19-rt-project-form-locators.md +git commit -m "docs(discovery): rt-project form locators recon (Element UI + Vue 2)" +``` + +--- + +## Task 3: Rewrite manage-project.js под Element UI + интерсепт rt-project-save + +**Files:** + +- Modify: `app/playwright/manage-project.js` (полный rewrite функций `fillForm`, `createOp`, `updateOp`, `listOp`) +- Test: `app/playwright/manage-project.test.js` (фикстура-режим + проверка контракта; если файл существует — обновить) + +**Контракт rewrite:** + +- Локаторы — из `docs/discovery/2026-05-19-rt-project-form-locators.md` (Task 2 output). Использовать label-based через `getByLabel`/`locator(':has-text("...")').locator(...)` где возможно. +- `external_id` для create — **из интерсепта ответа** `POST /admin/visit/rt-project-save` через `page.waitForResponse()`, не из DOM. Парсить body → `{status, message, id}` → если `status !== 'OK'` → throw с message. +- `listOp` — переписать через `page.evaluate` извлечение Vuex-state или скрейп строк таблицы (зависит от реального DOM в Task 2 snapshot). +- Login — оставить как есть (`refresh-session.js` уже работает; форма та же). +- Exit codes — без изменений (0/1/2/3/4). + +- [ ] **Step 3.1: Failing test — fillForm uses Element UI selectors** + +Edit `app/playwright/manage-project.test.js` (или создать если нет). Тест-стратегия: статическая HTML-фикстура с реальной разметкой Element UI (скопировать структуру из recon-документа Task 2), `skipLogin=true`, прогон `createOp` — assert на правильное заполнение полей через `page.evaluate`. + +NB: тест запускается через `node app/playwright/manage-project.test.js` (или vitest, если уже настроен в этой директории). Если нет — добавить минимальный test-runner через node:test или прямой assert. + +Шаблон: + +```javascript +// app/playwright/manage-project.test.js +const { spawn } = require('child_process'); +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'rt-form-element-ui.html'); + +function startFixtureServer() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const html = fs.readFileSync(FIXTURE_PATH, 'utf8'); + res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(html); + }).listen(0, () => resolve(server)); + }); +} + +function runManageProject(input) { + return new Promise((resolve) => { + const child = spawn('node', [path.join(__dirname, 'manage-project.js')], {stdio: ['pipe', 'pipe', 'pipe']}); + let stdout = '', stderr = ''; + child.stdout.on('data', d => stdout += d); + child.stderr.on('data', d => stderr += d); + child.on('close', code => resolve({code, stdout, stderr})); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +test('fillForm fills 13 fields via Element UI label-based locators', async () => { + const server = await startFixtureServer(); + try { + const url = `http://localhost:${server.address().port}`; + const result = await runManageProject({ + operation: 'create', url, skipLogin: true, + dto: {tag: '_lidpotok', name: 'example.com', platforms: ['B1'], signal_type: 'site', + limit: 5, workdays: [1,2,3,4,5], domains: ['example.com'], region_mode: 'include', active: true}, + }); + // Фикстура мокает rt-project-save → возвращает {status:"OK", id:"99001"} + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + const out = JSON.parse(result.stdout); + assert.strictEqual(out.external_id, '99001'); + } finally { + server.close(); + } +}); +``` + +Фикстура `app/playwright/fixtures/rt-form-element-ui.html` — копия реальной структуры из Task 2 snapshot + script-стаб для fetch `rt-project-save`. + +- [ ] **Step 3.2: Run test to verify it fails** + +Run: + +```bash +cd app/playwright && node --test manage-project.test.js +``` + +Expected: FAIL — старый код manage-project.js не работает с Element UI фикстурой (placeholder-селекторы не найдут поля). + +- [ ] **Step 3.3: Создать фикстуру `app/playwright/fixtures/rt-form-element-ui.html`** + +Минимальная HTML с структурой Element UI (классы `.el-form-item`, `.el-form-item__label`, `.el-input__inner` и т.д.) — копировать из Task 2 snapshot. Script-стаб мокает `rt-project-save` через fetch-override: + +```html + + +
+ +
+
+ + +
+ + +``` + +- [ ] **Step 3.4: Rewrite `fillForm` в `manage-project.js`** + +Edit `app/playwright/manage-project.js` — заменить `fillForm` функцию (строки 42-76). Использовать label-based стратегию из Task 2 recon. Шаблон (адаптировать под реальные локаторы): + +```javascript +// Helper: el-form-item с конкретным label +function fieldByLabel(page, label) { + return page.locator('.el-form-item', {has: page.locator('.el-form-item__label', {hasText: new RegExp(`^${label}$`)})}); +} + +async function fillForm(page, dto) { + // 1. active — el-switch + const activeField = fieldByLabel(page, 'Активный'); + const isActive = await activeField.locator('.el-switch.is-checked').count() > 0; + if (isActive !== !!dto.active) await activeField.locator('.el-switch').click(); + + // 2. tag + if (dto.tag) await fieldByLabel(page, 'Тег').locator('input').fill(dto.tag); + + // 3-5. platforms — checkboxes B1/B2/B3 + for (const p of ['B1', 'B2', 'B3']) { + const wanted = (dto.platforms || []).includes(p); + const cb = fieldByLabel(page, 'Платформы').locator('.el-checkbox', {hasText: p}); + const checked = await cb.locator('.is-checked').count() > 0; + if (checked !== wanted) await cb.click(); + } + + // 6. name + await fieldByLabel(page, 'Название').locator('input').fill(dto.name); + + // 7. signal_type — el-select + const signalLabel = {site: 'Сайты', call: 'Звонки', sms: 'СМС'}[dto.signal_type] || 'Сайты'; + await fieldByLabel(page, 'Тип сигнала').locator('.el-select').click(); + await page.locator('.el-select-dropdown__item', {hasText: signalLabel}).click(); + + // 8. region_mode — el-radio-group + if (dto.region_mode === 'exclude') { + await fieldByLabel(page, 'Регионы').locator('.el-radio', {hasText: 'Исключить'}).click(); + } + + // 9. regions[] — el-select multiple (skip если пуст) + if (dto.regions && dto.regions.length) { + // открыть мульти-селект и добавить теги — конкретно зависит от Task 2 recon + // Если регионы — числа (id), нужен mapping на отображаемые имена; если строки — прямо .fill(). + } + + // 10. domains — textarea (только site) + if (dto.domains && dto.domains.length) { + await fieldByLabel(page, 'Домены').locator('textarea').fill(dto.domains.join('\n')); + } + + // 11. limit — el-input-number + await fieldByLabel(page, 'Лимит').locator('input').fill(String(dto.limit)); + + // 12. workdays — checkboxes 1..7 + for (let d = 1; d <= 7; d++) { + const wanted = (dto.workdays || [1,2,3,4,5,6,7]).includes(d); + const cb = fieldByLabel(page, 'Дни недели').locator('.el-checkbox', {hasText: new RegExp(`^${d}$|${dayName(d)}`)}); + const checked = await cb.locator('.is-checked').count() > 0; + if (checked !== wanted) await cb.click(); + } +} + +function dayName(d) { + return ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'][d-1]; +} +``` + +**NB:** точные `hasText` строки берутся из Task 2 recon. Если recon показал другой текст лейбла — править соответствующе. + +- [ ] **Step 3.5: Rewrite `createOp` с интерсептом rt-project-save** + +В том же файле — заменить `createOp` (строки 78-100): + +```javascript +async function createOp(page, args) { + await login(page, args); + + if (!args.skipLogin) { + await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', {waitUntil: 'load', timeout: TIMEOUT_MS}); + // Кнопка «Добавить проект» — селектор из Task 2 recon + await page.locator('button:has-text("Добавить проект")').click(); + // Inline-панель появляется — ждать формы (label «Название») + await page.locator('.el-form-item__label:has-text("Название")').waitFor({state: 'visible', timeout: TIMEOUT_MS}); + } + + await fillForm(page, args.dto); + + // Кликаем «Сохранить» + интерсептим ответ rt-project-save + const [saveResponse] = await Promise.all([ + page.waitForResponse( + r => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST', + {timeout: TIMEOUT_MS}, + ), + page.locator('button:has-text("Сохранить")').click(), + ]); + + const body = await saveResponse.json(); + if (body.status !== 'OK') { + throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`); + } + const externalId = String(body.id ?? ''); + if (!externalId) { + throw new Error('Portal returned status=OK but empty id'); + } + + return {external_id: externalId}; +} +``` + +- [ ] **Step 3.6: Rewrite `updateOp` и `listOp` аналогично** + +`updateOp` — клик «редактировать» на строке таблицы (локатор из Task 2 recon), затем `fillForm`, затем интерсепт `rt-project-save` (теперь `body.status` — единственный success-критерий, `body.id` не используется — обновление того же id). + +`listOp` — если на странице есть `Vuex` state (можно достать через `window.__INITIAL_STATE__` или `window.app.$store.state`) — извлечь оттуда; иначе скрейп таблицы: + +```javascript +async function listOp(page, args) { + await login(page, args); + if (!args.skipLogin) { + await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', {waitUntil: 'load', timeout: TIMEOUT_MS}); + } + + // Стратегия 1: Vuex (если доступен) + const projects = await page.evaluate(() => { + if (window.app?.$store?.state?.projects) { + return window.app.$store.state.projects.map(p => ({id: parseInt(p.id, 10), name: p.name, platform: p.platform, signal_type: p.type, unique_key: p.content})); + } + return null; + }); + if (projects) return {projects}; + + // Стратегия 2: скрейп таблицы — конкретно зависит от DOM в Task 2 recon + const rows = await page.locator('<селектор таблицы из recon>').evaluateAll(nodes => nodes.map(n => ({ + id: parseInt(n.dataset.id ?? '0', 10), + name: n.querySelector('<селектор name-колонки>')?.textContent?.trim(), + }))); + return {projects: rows}; +} +``` + +- [ ] **Step 3.7: Run manage-project.test.js — verify pass** + +Run: + +```bash +cd app/playwright && node --test manage-project.test.js +``` + +Expected: PASS — фикстура с Element UI разметкой + мок-fetch отдают `{external_id: "99001"}`. + +- [ ] **Step 3.8: Verify FormProjectChannel integration test still passes** + +Run: + +```bash +cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php tests/Feature/Integration/SupplierProjectsAccessTest.php tests/Feature/Integration/SupplierLeadFlowTest.php -v +``` + +Expected: все PASS — rewrite manage-project.js не должен ломать PHP-сторону (контракт ввода/вывода stdin/stdout не менялся, только внутренние селекторы). + +- [ ] **Step 3.9: Commit** + +```bash +git add app/playwright/manage-project.js app/playwright/manage-project.test.js app/playwright/fixtures/rt-form-element-ui.html +git commit -m "feat(supplier): rewrite manage-project.js for Element UI + intercept rt-project-save response for external_id" +``` + +--- + +## Task 4: Live-тест create+delete тестового rt-проекта через form-канал + +**Files:** + +- Run: tinker-скрипт `app/storage/_smoke_form_channel.php` (untracked, под cleanup) + +**Цель:** убедиться, что переписанный `manage-project.js` действительно работает против ЖИВОГО портала crm.bp-gr.ru (а не только против фикстуры Task 3). Cleanup в этом же task'е — создаём тестовый проект, ловим `external_id`, удаляем через **AJAX-канал** (Tier 1 уже работает на delete), фиксируем срез логов. + +- [ ] **Step 4.1: Pre-flight — supplier session валидна** + +```bash +cd app && php artisan tinker --execute="dispatch_sync(new App\\Jobs\\Supplier\\RefreshSupplierSessionJob); echo Cache::store('redis')->has('supplier:session') ? 'OK' : 'FAIL';" +``` + +Expected: `OK`. Если FAIL — recon, что не так с refresh-session.js (но он уже валиден по `643e1a5`). + +- [ ] **Step 4.2: Создать tinker-скрипт `app/storage/_smoke_form_channel.php`** + +```php +createProject($dto); +echo " external_id={$externalId}\n"; + +if (!$externalId) { + exit(1); +} + +echo "Удаляю через Tier-1 AJAX (delete не реализован в form, удаляем напрямую)...\n"; +app(SupplierPortalClient::class)->deleteProject($externalId); +echo " deleted OK\n"; +``` + +- [ ] **Step 4.3: Запустить smoke** + +```bash +cd app && php artisan tinker storage/_smoke_form_channel.php 2>&1 | tee storage/logs/smoke-form-2026-05-19.log +``` + +Expected output: + +``` +Создаю через Tier-2 (form)... + external_id=<число> +Удаляю через Tier-1 AJAX... + deleted OK +``` + +Если падает — собрать stderr из `app/storage/logs/laravel.log` + Playwright stderr из `app/storage/logs/playwright-*.log` (если PlaywrightBridge их пишет), classify error per quirk-таблице и зафиксировать. + +- [ ] **Step 4.4: Verify в портале, что тестовый проект удалён** + +Через Playwright MCP (один проверочный тик): + +``` +mcp__playwright__browser_navigate("https://crm.bp-gr.ru/admin/visit/rt") +mcp__playwright__browser_evaluate({ + function: () => { + const rows = Array.from(document.querySelectorAll('<селектор строк rt-таблицы>')); + return rows.filter(r => r.textContent.includes('lidpotok-smoke-')).length; + } +}) +``` + +Expected: `0` (проект удалён, в таблице его нет). + +- [ ] **Step 4.5: Зафиксировать smoke-результат** + +В файле `docs/discovery/2026-05-19-rt-project-form-locators.md` (created Task 2) дописать секцию: + +```markdown +## Live-smoke 2026-05-19 + +- `_smoke_form_channel.php` — Tier-2 create через manage-project.js → external_id=, Tier-1 delete OK. +- В админке портала после delete — проект не виден (verified browser_evaluate). +``` + +Commit (если recon-документ ещё не запушен — добавить и smoke; если запушен — отдельный commit): + +```bash +git add docs/discovery/2026-05-19-rt-project-form-locators.md +git commit -m "docs(discovery): live-smoke form-channel create+delete 2026-05-19" +``` + +NB: `app/storage/_smoke_form_channel.php` НЕ коммитим — он untracked в gitignore-зоне `app/storage/`. Удалить после Task 5. + +--- + +## Task 5: Полный 3-ярусный прогон FailoverProjectChannel + +**Files:** + +- Create (untracked, временный): `app/storage/_smoke_failover_3tier.php` — tinker-скрипт с DI-стабами для force-fail tier 1 и 2 +- Create: `app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php` — Pest-обёртка для tier-3 (manual queue + mailable), tier-1/tier-2 — только tinker (требует live портала) + +**Цель:** end-to-end доказательство, что 3-ярусная эскалация работает: + +- Ярус 1 live — реальный AJAX create через `AjaxProjectChannel` (с cleanup). +- Force-fail ярус 1 → ярус 2 — реальный form-create через переписанный `manage-project.js` (cleanup). +- Force-fail ярус 1+2 → ярус 3 — row в `supplier_manual_sync_queue` + queued `SupplierCriticalAlertMail` + `TierEscalatedException`. + +- [ ] **Step 5.1: Pest test — Tier-3 эскалация (полностью моки, no live)** + +Edit `app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php` (создать): + +```php + 'ops@liderra.local']); + + $tenant = Tenant::factory()->create(); + $project = Project::factory()->for($tenant)->create(); + + $tier1 = mock(SupplierProjectChannel::class); + $tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка пустая + $tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail')); + + $tier2 = mock(SupplierProjectChannel::class); + $tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break')); + + $channel = new FailoverProjectChannel($tier1, $tier2, app(Illuminate\Contracts\Mail\Mailer::class)); + + $dto = new SupplierProjectDto( + platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example', + limit: 1, workdays: [1,2,3,4,5], regions: [], regionsReverse: false, status: 'active', + ); + + expect(fn () => $channel->createProjectForLiderra($project, $dto)) + ->toThrow(TierEscalatedException::class); + + expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1); + + Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required'); +}); + +test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void { + Mail::fake(); + config(['services.supplier.alert_email' => 'ops@liderra.local']); + + $tenant = Tenant::factory()->create(); + $project = Project::factory()->for($tenant)->create(); + + $tier1 = mock(SupplierProjectChannel::class); + $tier1->shouldReceive('listProjects')->andReturn([]); + $tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused')); + + $tier2 = mock(SupplierProjectChannel::class); + $tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2 + + $channel = new FailoverProjectChannel($tier1, $tier2, app(Illuminate\Contracts\Mail\Mailer::class)); + + $dto = new SupplierProjectDto( + platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example', + limit: 1, workdays: [1,2,3,4,5], regions: [], regionsReverse: false, status: 'active', + ); + + expect(fn () => $channel->createProjectForLiderra($project, $dto)) + ->toThrow(TierEscalatedException::class); + + $row = SupplierManualSyncQueue::where('project_id', $project->id)->first(); + expect($row->failure_reason)->toBe('portal_unreachable'); +}); +``` + +- [ ] **Step 5.2: Run new Pest tests — verify pass** + +Run: + +```bash +cd app && ./vendor/bin/pest tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php -v +``` + +Expected: 2 PASS. + +Если есть failures — может быть, что `FailoverProjectChannel` ожидает другие интерфейсы или mock-моделей. Зафиксировать file:line, починить ожидания тестов. + +- [ ] **Step 5.3: Live tinker-скрипт `_smoke_failover_3tier.php` для tier-1 и tier-2** + +Create `app/storage/_smoke_failover_3tier.php`: + +```php +id)->first(); +if (!$project) { + echo "Нет project для tenant {$tenant->id} — abort\n"; + exit(1); +} + +$mkDto = fn(string $key) => new SupplierProjectDto( + platform: 'B1', signalType: 'site', uniqueKey: $key, + limit: 1, workdays: [1,2,3,4,5], regions: [], regionsReverse: false, status: 'active', +); + +$client = app(SupplierPortalClient::class); + +// --- Прогон 1: чистый Tier-1 (AJAX) --- +echo "=== Прогон 1: Tier-1 live (AJAX) ===\n"; +$ajax = app(AjaxProjectChannel::class); +$key1 = 'failover-tier1-'.date('His').'.example'; +$id1 = $ajax->createProject($mkDto($key1)); +echo " Tier-1 OK, external_id={$id1}\n"; +$client->deleteProject($id1); +echo " cleanup OK\n\n"; + +// --- Прогон 2: force-fail tier-1, tier-2 (form) реально --- +echo "=== Прогон 2: force-fail tier-1 → tier-2 form live ===\n"; +$tier1Stub = new class($client) implements SupplierProjectChannel { + public function __construct(private SupplierPortalClient $client) {} + public function createProject(SupplierProjectDto $d): int { + throw new SupplierClientException('Forced tier-1 fail for smoke'); + } + public function updateProject(int $id, SupplierProjectDto $d): void { + throw new SupplierClientException('Forced tier-1 fail for smoke'); + } + public function listProjects(): array { + // дедуп-сверка должна работать как обычно — иначе FailoverProjectChannel + // упадёт в catch на самой первой проверке + return $this->client->listProjects(); + } +}; +$tier2 = app(FormProjectChannel::class); +$failover2 = new FailoverProjectChannel($tier1Stub, $tier2, app(Mailer::class)); +$key2 = 'failover-tier2-'.date('His').'.example'; +$id2 = $failover2->createProjectForLiderra($project, $mkDto($key2)); +echo " Tier-2 OK, external_id={$id2}\n"; +$client->deleteProject($id2); +echo " cleanup OK\n\n"; + +// --- Прогон 3: force-fail tier-1 + tier-2 → tier-3 manual queue --- +echo "=== Прогон 3: force-fail tier-1+2 → tier-3 manual queue ===\n"; +$tier2Stub = new class implements SupplierProjectChannel { + public function createProject(SupplierProjectDto $d): int { + throw new RuntimeException('Forced tier-2 fail for smoke'); + } + public function updateProject(int $id, SupplierProjectDto $d): void { + throw new RuntimeException('Forced tier-2 fail for smoke'); + } + public function listProjects(): array { return []; } +}; +$failover3 = new FailoverProjectChannel($tier1Stub, $tier2Stub, app(Mailer::class)); +$key3 = 'failover-tier3-'.date('His').'.example'; +try { + $failover3->createProjectForLiderra($project, $mkDto($key3)); + echo " ОШИБКА: ожидался TierEscalatedException, не выброшен\n"; + exit(1); +} catch (\App\Services\Supplier\Channel\Exceptions\TierEscalatedException $e) { + echo " Tier-3 OK, manual_queue_id={$e->queueId}, reason={$e->reason}\n"; + // verify row в БД + $row = \App\Models\SupplierManualSyncQueue::find($e->queueId); + echo " row.platform={$row->platform}, payload.unique_key={$row->payload_snapshot['unique_key']}\n"; + // cleanup row (наш мусор) + $row->delete(); + echo " cleanup queue row OK\n"; +} + +echo "\n=== Все 3 яруса прошли ===\n"; +``` + +NB: реальное имя property `queueId` / `reason` в `TierEscalatedException` — проверить в файле; если в коде они называются иначе — поправить шаблон. + +- [ ] **Step 5.4: Запустить live tinker (с queue worker + cloudflared up)** + +Pre-flight: + +```bash +# Tunnel поднят, supplier API на портале указывает на текущий URL — verified в ЭТАЛОН.md §3 +# session валидна (verified в Step 4.1) +``` + +Run: + +```bash +cd app && php artisan tinker storage/_smoke_failover_3tier.php 2>&1 | tee storage/logs/smoke-failover-2026-05-19.log +``` + +Expected output: + +``` +=== Прогон 1: Tier-1 live (AJAX) === + Tier-1 OK, external_id= + cleanup OK + +=== Прогон 2: force-fail tier-1 → tier-2 form live === + Tier-2 OK, external_id= + cleanup OK + +=== Прогон 3: force-fail tier-1+2 → tier-3 manual queue === + Tier-3 OK, manual_queue_id=, reason=form_save_error (или похожее) + row.platform=B1, payload.unique_key=failover-tier3-.example + cleanup queue row OK + +=== Все 3 яруса прошли === +``` + +- [ ] **Step 5.5: Run full Supplier test suite — verify no regression** + +Run: + +```bash +cd app && ./vendor/bin/pest --group=supplier --parallel +# или если group'ы не используются: +cd app && ./vendor/bin/pest tests/Feature/Supplier tests/Unit/Supplier tests/Feature/Integration --parallel +``` + +Expected: все PASS, 0 failed. + +Раскрыть конкретные числа в output: `Tests: X passed, Y skipped`. Все failures — выписать file:line. + +- [ ] **Step 5.6: Cleanup временных скриптов** + +```bash +rm app/storage/_smoke_form_channel.php app/storage/_smoke_failover_3tier.php +# логи оставить — артефакты доказательства +``` + +- [ ] **Step 5.7: Commit Pest-тестов** + +```bash +git add app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php +git commit -m "test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass" +``` + +- [ ] **Step 5.8: Финальная отметка в snapshot документе** + +Edit `docs/discovery/2026-05-19-rt-project-form-locators.md` — добавить: + +```markdown +## 3-tier failover smoke 2026-05-19 + +| Прогон | Результат | external_id / queue_id | +|---|---|---| +| Tier-1 live | OK | (удалён) | +| Tier-2 live (force-fail tier-1) | OK | (удалён) | +| Tier-3 manual queue (force-fail 1+2) | TierEscalatedException + queue row + alert mail | queue# (удалён) | + +Лог: `app/storage/logs/smoke-failover-2026-05-19.log`. +``` + +Commit: + +```bash +git add docs/discovery/2026-05-19-rt-project-form-locators.md +git commit -m "docs(discovery): 3-tier failover live-smoke 2026-05-19 — all tiers green" +``` + +--- + +## Самопроверка плана + +**1. Spec coverage:** 5 задач из спецификации пользователя: + +- ✅ Task 1: HTTP-200 login detection + Pest +- ✅ Task 2: 13 fields recon +- ✅ Task 3: Rewrite manage-project.js + intercept rt-project-save +- ✅ Task 4: Live create+delete form-канал +- ✅ Task 5: Полный 3-tier прогон с DI-стабами + +**2. Placeholder scan:** Метки `<реальный селектор>`, `<селектор строк rt-таблицы>` в Task 2/3/5 — это **выходы Task 2 recon**, не plan-placeholders. План явно объявляет Task 2 как «вход для Task 3» в Architecture-секции. Code-блоки в Task 3 — шаблоны, адаптируемые под факт recon. NB на это сделано прямо в Step 3.4. + +**3. Type consistency:** + +- `SupplierProjectDto` — те же поля во всех тасках: platform/signalType/uniqueKey/limit/workdays/regions/regionsReverse/status. +- `external_id` — string в JS-output `manage-project.js`, int в PHP DTO/DB. +- `TierEscalatedException` — public properties `queueId` и `reason` (см. file `app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php` — Task 5.3 NB просит проверить именно эти имена перед запуском). + +**4. Risk callouts:** + +- Task 2 невозможен без живой supplier-сессии; если refresh-session.js где-то не запустит логин — Task 2 заблокирован и весь план встанет. Mitigation: Step 4.1 pre-flight; если fail — fallback к ручному логину через Playwright MCP. +- Task 3 фикстура — упрощённая копия Element UI DOM; live-портал может иметь дополнительные элементы (тосты, оверлеи), которые блокируют клики. Mitigation: Task 4 Live-smoke ловит это до Task 5. +- Tier-2 cleanup в Task 4/5 — через Tier-1 AJAX delete; если AJAX delete на этих специфичных проектах падает — нужен ручной cleanup через UI. Mitigation: Step 5.4 NB про cleanup. +- 3-tier smoke (Step 5.4) трогает live портал — оставляет временные `LIDERRA_*` теги. Mitigation: cleanup на каждом шаге; если падает — следующий шаг видит остаток в `listProjects` и не дублирует. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-19-supplier-migration-followup.md`.** + +Два опции: + +**1. Subagent-Driven** — фрэш субагент на каждую Task, two-stage review между задачами. Подходит для Tasks 1, 3, 5 (mechanical TDD). Task 2, 4 — нужен Playwright MCP в текущей сессии, на субагента не делегируются. + +**2. Inline Execution** — выполнять Tasks подряд с checkpoints. Подходит для всех 5 (Tasks 2/4 требуют MCP, который доступен только в текущей сессии). + +**Рекомендация:** Inline для Task 2 (Playwright MCP recon) + Task 4 (Playwright MCP verify), subagent для Task 1/3/5 (чистый TDD).