14 KiB
Локаторы формы добавления 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)
// 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)
-
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, не идеальная замена.
- (a) После
-
regionsmapping 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.
- (a) В JS-bridge: жёстко зашить регионы id↔name в
-
Label вкладки «Список» меняется по типу. Verified: для
type=Сайтыlabel —Список сайтов. Дляtype=Звонки/Сайты/СМСметки textarea-вкладки могут отличаться. Ноfor="content"на label form-item стабильно — селекторfieldByFor(page, 'content')достаточен независимо от type. -
«Период» (slider 10-24). Default
10-18(часы активности). DTO не несёт, оставляем default. Если в будущем понадобится — расширять DTO + добавить slider-control в bridge. -
«Разделять по проектам» (
limit_off). Семантика не verified — оставляем unchecked (default). -
«Ретро сайты» / «Ретро звонки» 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-дебага
-
Портал валидирует формат домена.
content(домены для site-проекта) должен быть валидным хостом — lowercase, дефисы, без underscore, без uppercase. Невалидный (lidpotok-smoke-LIDERRA_FORM_SMOKE_NNN.example) →rt-project-saveотвечает{status:"Error",message:"Введите домены"}(HTTP 200). Для site-проектовSupplierProjectDto::uniqueKeyобязан быть валидным доменом. -
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 источника включены. -
Единственный
.el-switchна форме —regions_reverse(include/exclude регионов, текст «Включить/Исключить»), НЕ статус active/paused. Статус проекта (status) задаётся дефолтом формы (true), отдельного UI-switch нет. Recon row 6 (выше) скорректирован: switch ≠ status. -
type-select / клик вкладки ремоунтят content tab-pane. Element UI: re-click уже-активного значения select'а / вкладки пере-рендерит pane → textarea детачится.
manage-project.jsкликает type/вкладку только при реальной смене значения (commitb9791c5).
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.