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).