From 57d64952718f76bc4e734b2be2e61a318cf73f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 10:20:01 +0300 Subject: [PATCH] =?UTF-8?q?docs(supplier):=20spec=20=E2=80=94=20project=20?= =?UTF-8?q?migration=20channel=20failover=20(3-tier=20resilience)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резерв канала миграции проектов Лидерра → crm.bp-gr.ru: AJAX rt-project-* → авто-браузер «Мои проекты» → operator worklist. 4 секции согласованы заказчиком поэтапно 19.05.2026. Зеркало входящего дизайна (webhook + CSV-сверка): 2026-05-18-supplier-csv-reconcile-channel-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...upplier-project-channel-failover-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md diff --git a/docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md b/docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md new file mode 100644 index 00000000..7ebc2994 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md @@ -0,0 +1,223 @@ +# Резервный канал миграции проектов Лидерра → поставщик — дизайн + +**Дата:** 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) |