# 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(
            '<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
            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(
            '<html><input id="loginform-username"></html>',
            200,
            ['Content-Type' => 'text/html; charset=utf-8'],
        )
        ->push(
            '<html><input id="loginform-username"></html>',
            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: <username-input>, name: "Логин", type: "textbox", value: "<SUPPLIER_LOGIN>"},
  {target: <password-input>, name: "Пароль", type: "textbox", value: "<SUPPLIER_PASSWORD>"},
])
mcp__playwright__browser_click(<submit-button>)
```

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(<add-project-button>)
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 | `<реальный селектор>` | `<fallback>` | 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
<!DOCTYPE html>
<html><head><meta charset="utf-8"></head><body>
<div class="el-form">
  <!-- 13 полей — точная структура из Task 2 recon -->
  <div class="el-form-item"><label class="el-form-item__label">Тег</label>
    <div class="el-form-item__content"><div class="el-input"><input class="el-input__inner" type="text"></div></div></div>
  <!-- ... остальные поля по recon-документу ... -->
  <button type="button" class="el-button el-button--primary" id="save-btn">
    <span>Сохранить</span>
  </button>
</div>
<script>
  // Мок ответа портала: при клике на save-btn — fire fetch к rt-project-save с правильным конвертом
  document.getElementById('save-btn').addEventListener('click', async () => {
    await fetch('/admin/visit/rt-project-save', {method:'POST', body: '{}'});
  });
  // Перехватываем fetch — возвращаем {status:"OK", id:"99001"}
  const _fetch = window.fetch;
  window.fetch = async (url, opts) => {
    if (String(url).includes('rt-project-save')) {
      return new Response(JSON.stringify({status:'OK', message:'', result:null, id:'99001'}),
        {status:200, headers:{'Content-Type':'application/json'}});
    }
    return _fetch(url, opts);
  };
</script>
</body></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
<?php
use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\Dto\SupplierProjectDto;

$tag = 'LIDERRA_FORM_SMOKE_'.date('His');
$dto = new SupplierProjectDto(
    platform: 'B1',
    signalType: 'site',
    uniqueKey: "lidpotok-smoke-{$tag}.example",
    limit: 1,
    workdays: [1, 2, 3, 4, 5],
    regions: [],
    regionsReverse: false,
    status: 'active',
);

echo "Создаю через Tier-2 (form)...\n";
$externalId = app(FormProjectChannel::class)->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=<N>, 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
<?php

declare(strict_types=1);

use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

uses(TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);

test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', 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([]);  // 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
<?php
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Models\Project;
use App\Models\Tenant;
use App\Exceptions\Supplier\SupplierClientException;
use Illuminate\Contracts\Mail\Mailer;

// Setup: take существующий tenant + project (или создать минимальный)
$tenant = Tenant::firstWhere('subdomain', 'demo') ?? Tenant::first();
$project = Project::where('tenant_id', $tenant->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=<N>
  cleanup OK

=== Прогон 2: force-fail tier-1 → tier-2 form live ===
  Tier-2 OK, external_id=<N>
  cleanup OK

=== Прогон 3: force-fail tier-1+2 → tier-3 manual queue ===
  Tier-3 OK, manual_queue_id=<N>, reason=form_save_error (или похожее)
  row.platform=B1, payload.unique_key=failover-tier3-<HHMMSS>.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 | <N> (удалён) |
| Tier-2 live (force-fail tier-1) | OK | <N> (удалён) |
| Tier-3 manual queue (force-fail 1+2) | TierEscalatedException + queue row + alert mail | queue#<N> (удалён) |

Лог: `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).
