Files
portal/docs/discovery/2026-05-19-rt-project-form-locators.md
T

14 KiB
Raw Blame History

Локаторы формы добавления 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 024, 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)

// 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 (FormProjectChannelmanage-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.