# Локаторы формы добавления rt-проекта crm.bp-gr.ru (recon 2026-05-19)

**Среда:** `https://crm.bp-gr.ru/admin/visit/rt`, кнопка «Добавить проект» (label `[title="Добавить проект"]`, классы `el-button deal-req-is-empty-btn el-button--default`) открывает диалог.

**Стек:** **смешанный** — внешний контейнер `v-dialog v-dialog--active v-dialog--persistent` (Vuetify), внутри форма `form.el-form.el-form--label-left` (Element UI).

**Метод записи:** Playwright MCP `browser_evaluate` querySelector + `closest('.el-form')` от `[for="srcrt"]`. 10 `.el-form-item` в форме (verified `form.querySelectorAll('.el-form-item').length === 10`).

## Маппинг формы → DTO

| # | label `for=` | UI-поле | DTO-поле | Контракт |
|---|---|---|---|---|
| 1 | `tag` | Тег | `dto.tag` | el-input text |
| 2 | `srcrt` | Источник данных | `dto.platform` ⇒ ровно 1 включённый из B1/B2/B3 | 3 el-checkbox с textContent `B1`/`B2`/`B3`. Initial — **все три checked**. Inputs **не имеют `name` атрибута**. Идентификация — по `textContent`. Состояние — `.is-checked` класс на родительском `.el-checkbox`. |
| 3 | `name` | Название проекта | `dto.uniqueKey` | el-input text |
| 4 | `type` | Источники сбора | `dto.signalType` | el-select **readonly input** (открывается кликом). 5 опций: `Сайты`, `Звонки`, `СМС`, `Ретро сайты`, `Ретро звонки`. **Только первые три используются**: `site → "Сайты"`, `call → "Звонки"`, `sms → "СМС"`. Initial value — `Сайты`. |
| 5 | (нет) | Период (slider 0–24, value=«HH-HH») | **НЕТ в DTO** — поле новое, отсутствует в `SupplierProjectDto` | `.el-slider[aria-valuemin=0][aria-valuemax=24]`, aria-valuetext формата `"10-18"`. Default — `10-18`. **Tier-2 оставляет default** (DTO не несёт это поле). |
| 6 | (нет) | switch «Включить/Исключить» — режим регионов | `dto.regionsReverse` (`true` ⇒ «Исключить») | **ИСПРАВЛЕНО live-дебагом 2026-05-19:** единственный `.el-switch` на форме — это **include/exclude регионов** (`regions_reverse`), а НЕ статус active/paused. Текст — «ВключитьИсключить» (две метки). Статус проекта (`status`) задаётся дефолтом формы (`true`); отдельного UI-switch для active/paused НЕТ. `manage-project.js` этот switch не трогает (regions skip в Tier-2 MVP). |
| 7 | `regions` | Регион | `dto.regions[]` + `dto.regionsReverse` | el-select multiple. Опции — **имена регионов** (например, `Республика Адыгея`), не id. **Архитектурный gap: DTO несёт int[] (id), форма требует имена** — нужен mapping id→name. См. секцию «Открытые вопросы». В рамках live-теста (Task 4) tested с **пустым** `regions=[]`. |
| 8 | `limit_off` | Разделять по проектам | **НЕТ в DTO** | el-checkbox. Initial unchecked. Tier-2 оставляет default. |
| 9 | `content` | Список сайтов / номеров / отправителей | `dto.uniqueKey` ⇒ textarea content | el-tabs `Список` (active) / `Файл`. Нам нужна вкладка `Список` (default active). Внутри textarea. Label меняется в зависимости от `type`: для `Сайты` — `Список сайтов`, для `Звонки` — `Список номеров`, для `СМС` — `Список отправителей` (не verified — см. Открытые вопросы). |
| 10 | `limit` | Лимит в день | `dto.limit` | `.el-input-number` ⇒ внутри `.el-input input.el-input__inner` (тип text, не number). Кнопки +/− — `.el-input-number__increase` / `.el-input-number__decrease`. Для надёжности — `fill(String(dto.limit))` в input напрямую. |

## Кнопки

| Действие | Локатор | Notes |
|---|---|---|
| Save | `.v-dialog--active button:has-text("Сохранить")` | `el-button el-button--default` (НЕ primary; нет цветового акцента). Сохраняет + POST на `/admin/visit/rt-project-save`. |
| Cancel | `.v-dialog--active button:has-text("Отмена")` | Закрывает диалог. |

## Канонические локаторы Playwright (для Task 3 manage-project.js)

```javascript
// Helper: form-item с конкретным `for=` атрибутом
function fieldByFor(page, attrFor) {
  return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
}

// 1. Tag — text input
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(dto.tag);

// 2. Platforms (srcrt) — sub-checkboxes B1/B2/B3 by textContent
const platformContainer = fieldByFor(page, 'srcrt');
for (const p of ['B1', 'B2', 'B3']) {
  const cb = platformContainer.locator('.el-checkbox', {hasText: new RegExp(`^${p}$`)});
  const wanted = (dto.platforms || []).includes(p);
  const isChecked = (await cb.getAttribute('class'))?.includes('is-checked');
  if (!!isChecked !== wanted) await cb.click();
}

// 3. Name
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(dto.name);

// 4. Type — el-select with label match
const typeLabel = {site: 'Сайты', call: 'Звонки', sms: 'СМС'}[dto.signal_type];
await fieldByFor(page, 'type').locator('.el-select input.el-input__inner').click();
// Wait for dropdown popup (rendered outside form into body)
await page.locator('.el-select-dropdown__item', {hasText: new RegExp(`^${typeLabel}$`)}).click();

// 6. Switch (active) — by class .el-switch in form-item without label-for
const switchItem = page.locator('.el-form-item').filter({has: page.locator('.el-switch span:has-text("Включить")')});
const switchEl = switchItem.locator('.el-switch');
const isActive = (await switchEl.getAttribute('class'))?.includes('is-checked');
if (!!isActive !== !!dto.active) await switchEl.click();

// 9. Content list — текстbox in active tab "Список"
await fieldByFor(page, 'content').locator('.el-tabs__item:has-text("Список")').click();  // ensure tab active
await fieldByFor(page, 'content').locator('textarea.el-textarea__inner').fill(dto.domains.join('\n'));

// 10. Limit
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));

// Save (intercept response)
const [saveResp] = await Promise.all([
  page.waitForResponse(r => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST'),
  page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
]);
const body = await saveResp.json();
if (body.status !== 'OK') throw new Error(`Portal rejected save: ${body.message}`);
const externalId = String(body.id);
```

## Открытые вопросы (gaps между формой и DTO)

1. **`workdays` отсутствует на форме create.** DTO имеет `workdays: int[1..7]` (дни недели). На форме add-project — **только slider «Период» (часы 0-24)**, дни недели отсутствуют. Возможные стратегии для Tier-2:
   - **(a)** После `rt-project-save` сделать дополнительный AJAX-апдейт через `SupplierPortalClient::updateProject` с workdays — но это противоречит идее Tier-2 как пути отказа от Tier-1 (если Tier-1 не работает, дополнительный AJAX от Tier-2 тоже скорее всего не сработает).
   - **(b)** Принять, что Tier-2 не выставляет workdays — портал применяет default (все 7 дней?). Зафиксировать в Tier-3 manual queue payload, чтобы оператор скорректировал вручную.
   - **(c)** Workdays задаются на странице **редактирования** rt-проекта, не создания — проверить.
   - **Решение принять в Task 3 design**. Скорее всего (b) — Tier-2 — fallback, не идеальная замена.

2. **`regions` mapping id → name.** DTO несёт `int[]` (id регионов), форма требует имена. Mapping должен быть:
   - **(a)** В JS-bridge: жёстко зашить регионы id↔name в `manage-project.js` (на ~89 регионов, ~3 KB словарь).
   - **(b)** В PHP: `FormProjectChannel::mapDto` конвертирует id→name перед отправкой в bridge.
   - **(c)** В Tier-2 — игнорировать regions (передавать пустой массив, регионы выставлять отдельным AJAX-апдейтом).
   - **Решение в Task 3 design.** Скорее всего (c) для MVP — Tier-2 редко используется, регионы — некритичный default.

3. **Label вкладки «Список» меняется по типу.** Verified: для `type=Сайты` label — `Список сайтов`. Для `type=Звонки` / `Сайты` / `СМС` метки textarea-вкладки могут отличаться. Но `for="content"` на label form-item стабильно — селектор `fieldByFor(page, 'content')` достаточен независимо от type.

4. **«Период» (slider 10-24).** Default `10-18` (часы активности). DTO не несёт, оставляем default. Если в будущем понадобится — расширять DTO + добавить slider-control в bridge.

5. **«Разделять по проектам» (`limit_off`).** Семантика не verified — оставляем unchecked (default).

6. **«Ретро сайты» / «Ретро звонки» type'ы.** Не в DTO (мы используем только site/call/sms). Зафиксировать как **не поддерживается** в FormProjectChannel — выкинуть `InvalidArgumentException` если DTO.signalType не в `{site,call,sms}`.

## Снимки страницы

Все Playwright snapshots в `.playwright-mcp/page-2026-05-19T13-2*.yml` (untracked, gitignored).

## Live-smoke (Task 4) — 2026-05-19

`_smoke_form_channel.php` (DTO platform B1 / site / limit 10): create через Tier-2
(`FormProjectChannel` → `manage-project.js`) → `external_id=12731690` → delete через
Tier-1 AJAX → **OK**. Form-канал доказан end-to-end против живого портала.

### Находки live-дебага

1. **Портал валидирует формат домена.** `content` (домены для site-проекта)
   должен быть валидным хостом — **lowercase, дефисы, без underscore, без
   uppercase**. Невалидный (`lidpotok-smoke-LIDERRA_FORM_SMOKE_NNN.example`) →
   `rt-project-save` отвечает `{status:"Error",message:"Введите домены"}` (HTTP 200).
   Для site-проектов `SupplierProjectDto::uniqueKey` обязан быть валидным доменом.

2. **Multi-source save создаёт N rt-проектов.** Если в форме включено несколько
   `srcrt`/`srcbl`/`srcmt` (B1/B2/B3), один `rt-project-save` создаёт по проекту
   на каждый источник; `id` в ответе — последний. `manage-project.js` снимает
   лишние чекбоксы под `dto.platform` (single) → ровно 1 проект. При работе
   напрямую с `SupplierPortalClient::saveProject` помнить: дефолт формы — все 3
   источника включены.

3. **Единственный `.el-switch` на форме — `regions_reverse`** (include/exclude
   регионов, текст «Включить/Исключить»), НЕ статус active/paused. Статус проекта
   (`status`) задаётся дефолтом формы (`true`), отдельного UI-switch нет. Recon
   row 6 (выше) скорректирован: switch ≠ status.

4. **type-select / клик вкладки ремоунтят content tab-pane.** Element UI: re-click
   уже-активного значения select'а / вкладки пере-рендерит pane → textarea
   детачится. `manage-project.js` кликает type/вкладку только при реальной смене
   значения (commit `b9791c5`).

## 3-tier failover live-smoke (Task 5b) — 2026-05-19

`_smoke_failover_3tier.php` против живого портала:

| Прогон | Сценарий | Результат |
|---|---|---|
| 1 | Tier-1 live (`AjaxProjectChannel`) | OK — `external_id=12732078`, удалён |
| 2 | force-fail tier-1 (DI-стаб) → Tier-2 form (`FormProjectChannel`) | OK — `external_id=12732091`, удалён |
| 3 | force-fail tier-1+2 → Tier-3 (`escalateToTier3`) | `TierEscalatedException` + `SupplierManualSyncQueue` row (`reason=form_save_error`) + alert mail; queue row удалён |

`FailoverProjectChannel` эскалация доказана end-to-end. Полный Supplier-suite
(`tests/Feature/Supplier` + `tests/Unit/Supplier` + `tests/Feature/Integration`)
— **156/156 passed**, 0 регрессий. Лог: `app/storage/logs/smoke-failover-2026-05-19.log`.
