# Резервный канал миграции проектов Лидерра → поставщик — дизайн

**Дата:** 19.05.2026
**Статус:** утверждён заказчиком (4 секции согласованы поэтапно)
**Эпик:** интеграция с поставщиком crm.bp-gr.ru — резерв канала проектов
**Связано:** [2026-05-18-supplier-csv-reconcile-channel-design.md](2026-05-18-supplier-csv-reconcile-channel-design.md) (зеркальная архитектура: тот резервирует ВХОДЯЩЕЕ направление — лиды, этот резервирует ИСХОДЯЩЕЕ — миграцию проектов)

## 1. Цель

`SyncSupplierProjectJob` (singular, on-edit) и `SyncSupplierProjectsJob` (plural, ночной крон) сегодня мигрируют проект Лидерра → поставщик crm.bp-gr.ru только через AJAX `rt-project-save/update/delete` ([SupplierPortalClient.php:103-121](../../../app/app/Services/Supplier/SupplierPortalClient.php#L103-L121)). Этот канал — единственный, и он:

- В коде помечен как **placeholder, на реальном портале не верифицирован** ([SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28)).
- Реверс-инжиниринг внутреннего AJAX «Мои проекты» — может сломаться при правках портала без предупреждения (это не публичный контракт).
- Зависит от эмуляции сессии через `PlaywrightBridge` ([RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — отдельная точка отказа (логин-форма, креды).

Цель: **полный резерв канала миграции проектов**. Не точечная защита от одного сбоя — полная избыточность, по образцу резерва входящих лидов (webhook + CSV-сверка).

## 2. Граница и пределы резерва

Разведка портала 19.05.2026 (Playwright read-only по согласию заказчика, снапшоты `rt-projects-snapshot.yml`, `rt-add-project-form.yml`, `user-api-page.yml`) установила:

- **У поставщика нет API управления проектами.** Раздел «API» меню — это интеграции *доставки* лидов (Битрикс / amoCRM / Google-таблица / Unisender + webhook). `/admin/user/api` — только настройка webhook'а («Апи ссылка», протокол, статус) + docs формата входящих лидов.
- **Единственная дверь к проектам** — UI «Мои проекты» (`/admin/visit/rt`), за ним внутренний AJAX `rt-project-*`. Наш HTTP-канал бьёт именно туда.
- **Внеканальной приёмной стороны не существует** — у поставщика нет менеджера/поддержки, принимающей запросы по почте/мессенджеру (подтверждено заказчиком).

**Следствие — потолок резерва.** Резервный канал может быть только *вторым механизмом в ту же дверь*. Он переживёт слом контракта `rt-project-*`, поломку нашей сессионной авторизации, частичные сбои портала. Полный даун хоста crm.bp-gr.ru роняет оба пути одновременно — это принятый предел, не недоработка дизайна.

## 3. Архитектура — три яруса

| Ярус | Механизм | Скорость | Когда работает |
|---|---|---|---|
| 1 | **AJAX** `rt-project-*` (primary) | быстро | штатно |
| 2 | **Браузерная автоматизация** формы «Мои проекты» через `PlaywrightBridge` | медленно (Chromium boot + загрузки страниц) | контракт/auth яруса 1 сломан |
| 3 | **Operator worklist** — оператор Лидерры вносит руками в crm.bp-gr.ru | сколько займёт у человека | оба автоматических яруса упали ИЛИ хост недоступен |

`FailoverProjectChannel` — декоратор-оркестратор: пробует ярус 1 → классифицирует исход → ярус 2 / прыжок на 3. Это зеркало входящего дизайна: webhook (push, машинный) + CSV-сверка (другой механизм, тот же портал).

## 4. Компоненты

### 4.1. `SupplierProjectChannel` — интерфейс

Новый интерфейс `App\Services\Supplier\Channel\SupplierProjectChannel`:

```php
interface SupplierProjectChannel
{
    public function createProject(SupplierProjectDto $dto): int; // external_id
    public function updateProject(int $externalId, SupplierProjectDto $dto): void;
    public function listProjects(): array; // для дедуп-сверки
}
```

Контракт нейтрален к транспорту. Реализации — три (см. ниже).

### 4.2. `AjaxProjectChannel` — ярус 1, тонкий адаптер

Имплементирует интерфейс через существующий `SupplierPortalClient` (его `saveProject`/`updateProject`/`listProjects` + `request()`/`loadSession()` остаются HTTP-плумбингом — [SupplierPortalClient.php:103-121](../../../app/app/Services/Supplier/SupplierPortalClient.php#L103-L121)). Тонкий wrapper, чтобы не ломать существующий код CSV-канала, который тоже использует `SupplierPortalClient`.

### 4.3. `FormProjectChannel` — ярус 2, браузерная автоматизация

Новый класс `App\Services\Supplier\Channel\FormProjectChannel`. Зависит от `PlaywrightBridge` ([RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — тот уже водит headless Chromium для логина, инфра переиспользуется.

Новый Node-скрипт `app/playwright/manage-project.js` (рядом с `refresh-session.js`):

- Принимает аргументы `{operation, externalId|null, dto}`.
- Логин (как `refresh-session.js`, селекторы Yii2 `#loginform-username`/`#loginform-password`), переход на `/admin/visit/rt`, ожидание загрузки таблицы.
- Для `create`: клик «Добавить проект» → форма из 11 полей (снапшот разведки `rt-add-project-form.yml`) → заполнить (`textbox` Тег/Название, `checkbox` B1/B2/B3, `combobox` Источники сбора, `tree`/`switch` Регион include/exclude, `textarea` Список сайтов или вкладка «Файл» при необходимости, `spinbutton` Лимит в день, `checkbox` дни Пн-Вс) → «Сохранить» → дождаться появления новой строки в таблице → прочитать её `id` → вернуть.
- Для `update`: найти проект в таблице по `externalId` → открыть форму редактирования → изменить нужные поля → «Сохранить».
- Для `listProjects`: прочитать таблицу через snapshot/evaluate → массив `{id, platform, signal_type, unique_key, limit, workdays, regions, ...}`.

PHP-сторона — `FormProjectChannel` вызывает bridge с JSON-полезной нагрузкой и парсит ответ.

### 4.4. `FailoverProjectChannel` — декоратор

Новый класс `App\Services\Supplier\Channel\FailoverProjectChannel`. Конструктор: `AjaxProjectChannel`, `FormProjectChannel`, `ManualSyncQueueRepository`, `Mailer`.

`createProject(dto)`:

1. **Локальная проверка существования** (`SupplierProject::where(platform, signal_type, unique_key)`) — логика `ensureSupplierProject` ([SupplierPortalClient.php:54-91](../../../app/app/Services/Supplier/SupplierPortalClient.php#L54-L91)). Если есть — вернуть id.
2. **Портальная сверка** через `listProjects()` (кэш на прогон job) по тому же ключу — если на портале уже есть (полу-успех яруса 1 в прошлом запуске, или другой Лидерра-проект на тот же канал), «усыновить»: создать локальную строку с external_id, вернуть id.
3. Иначе — попытка яруса 1. Классификация исключения:
   - Успех → запись в `supplier_sync_log`, вернуть external_id.
   - `SupplierTransientException` (5xx, [SupplierPortalClient.php:291](../../../app/app/Services/Supplier/SupplierPortalClient.php#L291); сеть, [.php:263](../../../app/app/Services/Supplier/SupplierPortalClient.php#L263)) — `FailoverProjectChannel` сам ретраит транзиент (N попыток с backoff; точные значения пинуем в плане); после исчерпания → **хост недоступен** → прыжок на ярус 3 (ярус 2 по тому же хосту тоже не зайдёт), reason `portal_unreachable`. Существующий job-уровневый `$tries=3` ([SyncSupplierProjectJob.php:35](../../../app/app/Jobs/SyncSupplierProjectJob.php#L35)) — на catastrophic-сценарии вне канала (например, `FailoverProjectChannel` бросил неожиданное исключение).
   - `SupplierClientException` (4xx, [.php:299](../../../app/app/Services/Supplier/SupplierPortalClient.php#L299)) или неожиданная форма ответа (`saveProject` без `id` и т.п.) → **слом контракта** → ярус 2.
   - `SupplierAuthException` ([.php:270](../../../app/app/Services/Supplier/SupplierPortalClient.php#L270)) → ярус 2 (его свежий интерактивный логин может пройти, если протух только cookie).
4. Ярус 2 — `FormProjectChannel.createProject(dto)`. На успех → запись в `supplier_projects` + `supplier_sync_log`, алёрт `failover_to_form` (инфо). На любой сбой (логин не прошёл, селекторы формы не найдены, save error-тост, таймаут) → запись в `supplier_manual_sync_queue` (status `pending`) + алёрт `manual_required`.

`updateProject(externalId, dto)`: аналогично без шагов 1–2 (проект уже привязан).

`listProjects()`: ярус 1; на client-exc → ярус 2 (browser-чтение таблицы).

### 4.5. Очередь яруса 3 — `supplier_manual_sync_queue`

Новая SaaS-level таблица (как `supplier_csv_reconcile_log` — без `tenant_id`, без RLS; доступ только SaaS-admin через admin-controller):

| Поле | Тип | Описание |
|---|---|---|
| `id` | bigserial PK | |
| `project_id` | bigint NOT NULL, FK `projects` | Лидерра-проект |
| `platform` | varchar CHECK `B1`/`B2`/`B3` | |
| `operation` | varchar CHECK `create`/`update` | |
| `external_id` | varchar NULL | для update — `supplier_external_id` |
| `payload_snapshot` | jsonb NOT NULL | снимок DTO (signal_type, unique_key, limit, workdays, regions, regions_reverse, ...) — оператор видит точные значения для ввода на портале |
| `failure_reason` | varchar NOT NULL | `portal_unreachable` / `contract_break` / `auth_failure` / `form_selector_break` / `form_save_error` / ... |
| `status` | varchar CHECK `pending`/`resolved`/`cancelled` | |
| `resolved_by_user_id` | bigint NULL FK `users` | оператор, отметивший выполненным |
| `created_at`, `resolved_at` | timestamp | |

Миграция + обновление `db/schema.sql` + запись в `db/CHANGELOG_schema.md` обязательны (правило §4.2 Pravila).

### 4.6. Admin UI — worklist (расширение существующего)

Расширяем существующий админ-экран «Интеграция с поставщиком» (`AdminSupplierIntegrationController` + `AdminSupplierIntegrationView.vue`, созданы для CSV-канала, [spec 2026-05-18 §4.4](2026-05-18-supplier-csv-reconcile-channel-design.md)). Точное расположение контроллера — `app/app/Http/Controllers/Api/Admin/` (по конвенции; **не верифицировал именно файл в этой сессии**, запиную при планировании).

Новые endpoint'ы:

- `GET /api/admin/supplier-integration/manual-queue` — список pending записей с полным `payload_snapshot` (чтобы оператор видел, что вносить).
- `POST /api/admin/supplier-integration/manual-queue/{id}/resolve` — оператор отмечает done. Backend:
  1. Дёргает `listProjects()` (ярус 1; fallback на ярус 2 если ярус 1 не работает).
  2. Ищет проект по `(platform, signal_type, unique_key)` из снапшота.
  3. Если нашёл — создаёт/обновляет локальный `supplier_projects`, ставит FK на `projects.supplier_b{1,2,3}_project_id`, помечает строку очереди `resolved`.
  4. Если не нашёл — возвращает 409 «проект не найден на портале, проверьте, что вы действительно его создали» (оператор перепроверяет).

`AdminSupplierIntegrationView.vue` — +секция «Ручная очередь»:

- Таблица: project name, platform, operation, payload (читаемое представление), reason, created_at.
- Кнопка «Отметить выполнено» (с подтверждением).
- Счётчик pending в шапке экрана + колокольчик-уведомление при появлении новых записей.

### 4.7. Расписание

- `SyncSupplierProjectsJob` крон: 20:30 МСК → **18:00 МСК**.
- `RefreshSupplierSessionJob` ежедневный триггер: 20:15 → **17:45** МСК (15 мин до sync, [RefreshSupplierSessionJob.php:24](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L24)). Hourly-trigger остаётся.
- `TIME_BUDGET_CUTOFF = '20:55'` ([SyncSupplierProjectsJob.php:60](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L60)) — **остаётся** (страховка от правок после портального дедлайна 21:00; при 18:00-старте обычно недостижим, остаётся safety rail).

Рацио 18:00: эскалация на ярус 2 (медленный) или ярус 3 (ручной оператор) — в рабочее время, не поздно вечером. Запас ~3 часа до 21:00.

Точный файл scheduler-записи (`routes/console.php` либо `app/Console/Kernel.php` — Laravel 13) — **не верифицировал**, запиную при планировании.

## 5. Поток данных

**On project create/edit** ([ProjectService.php:212-234](../../../app/app/Services/Project/ProjectService.php#L212-L234)): `ProjectService::create/update` → dispatch `SyncSupplierProjectJob(projectId)` → для каждой нужной платформы ([SyncSupplierProjectJob.php:70-81](../../../app/app/Jobs/SyncSupplierProjectJob.php#L70-L81)) → `FailoverProjectChannel.createProject(dto)` → ярусы. FK `projects.supplier_b{1,2,3}_project_id` ставится только при успехе яруса 1 или 2. При прыжке на ярус 3 — FK пока null, операция в очереди; FK ставится при resolve.

**Ночной 18:00** ([SyncSupplierProjectsJob.php](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php)): итерация по supplier_projects → расчёт allocation через `SupplierQuotaAllocator` ([.php:134-141](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L134-L141)) → если изменилось ([.php:147-149](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L147-L149)) → ветвление: `external_id IS NULL` → `FailoverProjectChannel.createProject` ([.php:158](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L158)), иначе → `updateProject` ([.php:168](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L168)) — обе через ярусы. Окно 18:00–20:55.

**Закрытие яруса 3:** оператор внёс правки на портале → жмёт «Отметить выполнено» → backend reconcile через `listProjects()` → дописывает `supplier_projects` + FK, помечает строку очереди `resolved`.

## 6. Обработка ошибок и эскалация

Сжатая матрица (детали в §4.4):

| Ярус 1 исход | Куда |
|---|---|
| Успех | done |
| Транзиент исчерпан (5xx/сеть) | сразу ярус 3 (`portal_unreachable`) — ярус 2 пропускаем |
| 4xx или непарсимый ответ | ярус 2 |
| Sticky 401/403 | ярус 2 |
| Ярус 2 любой сбой | ярус 3 (`form_selector_break`/`auth_failure`/...) |

Алёрты: реюз `SupplierCriticalAlertMail` ([SyncSupplierProjectsJob.php:88](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L88)). Новые типы:

- `failover_to_form` — инфо: AJAX сломан, ярус 2 несёт нагрузку (команде сигнал: чинить primary).
- `manual_required` — ярус 3: оператор обязан вмешаться.

Дополнительно — отражение в админ-экране (плашка + колокольчик).

`$tries` у job'ов — пересмотр при планировании; повтор обеспечивает крон, ретрай-механизм внутри job'а не нужен.

## 7. Защита от дублей (идемпотентность)

**Опасность:** ярус 1 `createProject` полу-успешен — портал создал проект, но наш парсинг ответа упал → мы думаем «не вышло» → ярус 2 повторяет create → **дубль на портале**.

**Гард** в `FailoverProjectChannel.createProject` (шаги 1–2 §4.4): локальная проверка + портальная сверка через `listProjects()` (кэш на прогон job) до любой попытки create. Так create идемпотентен сквозь все ярусы и через ручное закрытие яруса 3.

Update — идемпотентен по своей природе (повтор `updateProject(externalId, dto)` с тем же DTO даёт то же состояние).

## 8. Временное окно поставщика

Портал на `/admin/visit/rt` показывает alert: «правки до 21:00 МСК; в 22:00–00:00 создание/редактирование запрещено» (зафиксировано снапшотом `rt-projects-snapshot.yml`). Заказчик уточнил 19.05.2026: это **UX-предупреждение пользователю** (для управления его ожиданиями), не технический забор; функционально портал работает 24/7. Соответственно:

- Канал и ярусы **не гейтятся** по времени.
- 18:00-крон — рационален «эскалация в рабочее время», не «успеть до 21:00».

**Защитная ветка** (на случай, если 22:00–00:00 всё-таки технический reject): `FailoverProjectChannel` классифицирует window-связанный отказ (специфичный 4xx/строка ошибки) как `WindowDeferred` — **не сбой, не эскалация**; операция переносится на следующий ретрай/тик. Если окно полностью мягкое (опыт заказчика подтвердит), ветка не сработает ни разу и стоит ноль.

**Не верифицировал:** технический ли это reject в 22:00–00:00 на `rt-project-save`. Час 22:00–00:00 живьём не протестировать в день разведки (09:37 МСК). Если из эксплуатации станет ясно, что отказ невозможен — защитную ветку удалить.

## 9. Тестирование

- **`FailoverProjectChannel` — приоритет №1**, мозг фичи. Чистое юнит-тестирование с тест-даблами ярусов 1 и 2 (поддельные каналы, бросающие сконфигурированные исключения). Покрыть матрицу эскалации (§4.4 / §6) целиком: успех — никаких эскалаций; transient-exhausted → прыжок на ярус 3 (ярус 2 НЕ зван); client-exc → ярус 2 → успех / провал → очередь; auth-exc → ярус 2; идемпотентность — портальный матч `listProjects` → create не зван, FK усыновлён; `WindowDeferred` → ни очереди, ни эскалации. Детерминированно, быстро.
- **`AjaxProjectChannel`** — Pest feature через `Http::fake()` (паттерн `CsvReconcileJobTest`): create/update/list, 4xx → `SupplierClientException`, 5xx → transient, 401/403 → auth. *Ограничение:* `Http::fake` проверяет НАШ код против предполагаемого контракта, не реальный портал; контракт верифицируется отдельным live-шагом (Задача 1 плана, см. §11 ниже).
- **`FormProjectChannel` (PHP)**: `PlaywrightBridge` мокается на PHP-границе (он DI-инжектируемый, [RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — тест проверяет правильность вызова bridge + обработку возвращаемых значений.
- **Node-скрипт `manage-project.js`**: прогон против сохранённого статического HTML-фикстура формы «Мои проекты» — снапшот разведки `rt-add-project-form.yml` используется как seed-фикстура. Покрывает логику заполнения полей без живого портала.
- **Миграция `supplier_manual_sync_queue`**: Pest feature/db — миграция применяется, `db/schema.sql` + `CHANGELOG_schema.md` синхронны, db-smoke (CHECK-constraint'ы, FK на `projects`).
- **Джобы** `SyncSupplierProjectJob`/`SyncSupplierProjectsJob` — существующие тесты обновляются под инъекцию `FailoverProjectChannel`. +Тест расписания (18:00 крон, 17:45 session-refresh).
- **`AdminSupplierIntegrationController`** — Pest feature: getJson worklist, postJson resolve + reconcile-эффект.
- **`AdminSupplierIntegrationView.vue`** — Vitest: новая секция worklist рендерит pending; кнопка «выполнено» дёргает endpoint; счётчик/колокольчик при появлении новой записи.
- **End-to-end `FormProjectChannel` против боевого crm.bp-gr.ru** — только **ручной smoke с живой сессией** (как webhook live-test / discovery T3). CI реальный портал не трогает: нет кредов, внешняя зависимость, создавал бы настоящие проекты. Live-smoke — отдельная gated-задача плана (read-mostly + один контролируемый create+откат через `deleteProject`).

Larastan baseline возможно потребует bump (новые классы) — зелёный larastan — обязательное условие коммита.

## 10. Что НЕ входит (YAGNI)

- Бэк-портирование `rt-project-delete` через ярусы — текущий код delete не зовёт нигде вне расширения channel'а; вероятно понадобится, но за рамками этого spec'а.
- Pull-листинг проектов с портала как фоновый sync (метод `listProjects` уже есть) — на сегодня не нужен; используется только для дедуп-сверки в моменте create.
- Out-of-band канал (e-mail / FTP / менеджер) — у поставщика приёма вне портала нет (см. §2).
- Замена AJAX на browser как primary — отвергнуто: один механизм не даёт «полного резерва».
- Параллельный browser-канал «для верификации каждого AJAX-вызова» — слишком дорого; cross-проверка делается на этапе discovery (Задача 1 плана) однократно.

## 11. Журнал решений

| # | Решение | Обоснование |
|---|---|---|
| 1 | Три яруса A+C (AJAX → авто-браузер → ручной worklist) | Максимальная избыточность в пределах единственной двери поставщика; зеркало webhook↔CSV |
| 2 | Ярус 1 (AJAX) остаётся primary | Быстро; ярус 2 медленный (Chromium boot + загрузки страниц) — нежелателен по умолчанию |
| 3 | Transient-exhausted → прыжок прямо на ярус 3 (ярус 2 пропускаем) | Если хост недоступен, браузер по тому же хосту тоже не зайдёт — пустая трата времени и контекста |
| 4 | Ярус 3 ручной с точным worklist (не алёрт-без-данных) | Без точного снимка payload оператор не знает, что вносить — ошибки и расхождения |
| 5 | Крон 18:00 МСК (был 20:30) | Эскалация на ярус 2/3 в рабочее время, не поздно вечером (запас ~3 часа до 21:00) |
| 6 | Временное окно — не гейт (заказчик 19.05.2026) | UX-предупреждение, а не технический забор; защитная ветка `WindowDeferred` на случай, если 22:00–00:00 окажется hard-reject |
| 7 | Идемпотентность через портальный `listProjects()` до create | Защита от дубля при полу-успехе яруса 1 / повторе ярусом 2 |
| 8 | Очередь яруса 3 — отдельная SaaS-таблица (не расширение `supplier_sync_log`) | `supplier_sync_log` — append-only log; ярус 3 — resolvable queue (pending/resolved), отдельная семантика |
| 9 | Discovery яруса 1 (верификация `rt-project-*` на боевом портале) — первая задача плана | AJAX endpoints placeholder, не верифицированы ([SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28)); fixup-commit при расхождении; `FormProjectChannel` (ярус 2) служит независимым оракулом сверки |
| 10 | Расширение существующего `AdminSupplierIntegrationView` (не новый экран) | Эта же админ-секция уже покрывает CSV-сверку; «здоровье интеграции с поставщиком» — один экран, две сущности (CSV + manual queue) |
