# Supplier Project Channel Failover — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Резервировать единственный канал миграции проектов Лидерра → поставщик crm.bp-gr.ru тремя ярусами (AJAX → авто-браузер формы «Мои проекты» → operator worklist) — на случай слома контракта `rt-project-*` AJAX, поломки эмуляции сессии или сбоев портала.

**Architecture:** `SupplierProjectChannel` interface с тремя имплементациями: `AjaxProjectChannel` (тонкий wrapper над существующим `SupplierPortalClient`), `FormProjectChannel` (Playwright водит UI «Мои проекты»), и оркестратор-декоратор `FailoverProjectChannel`, который пробует ярус 1 → классифицирует исход → ярус 2 / прыжок на ярус 3 (запись в новую SaaS-таблицу `supplier_manual_sync_queue` + алёрт). Оба job'а (`SyncSupplierProjectJob` singular, `SyncSupplierProjectsJob` plural) инжектят `FailoverProjectChannel` вместо `SupplierPortalClient`. Крон ночного sync переезжает 20:30 → 18:00 МСК (запас на эскалацию в рабочее время).

**Tech Stack:** PHP 8.3 + Laravel 13 + Pest 4 + Playwright (Node) + Vue 3 + Vuetify 3 + PostgreSQL 16 + Redis (Memurai). Дизайн в [`docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md`](../specs/2026-05-19-supplier-project-channel-failover-design.md).

---

## File Structure

**Create:**

- `app/app/Services/Supplier/Channel/SupplierProjectChannel.php` — interface (3 метода).
- `app/app/Services/Supplier/Channel/AjaxProjectChannel.php` — Ярус 1 adapter, делегирует `SupplierPortalClient`.
- `app/app/Services/Supplier/Channel/FormProjectChannel.php` — Ярус 2 PHP wrapper, дёргает `PlaywrightBridge` + Node-скрипт.
- `app/app/Services/Supplier/Channel/FailoverProjectChannel.php` — декоратор-оркестратор; локальный + портальный dedup, классификация исключений, эскалация.
- `app/app/Services/Supplier/Channel/Exceptions/WindowDeferredException.php` — защитная ветка временного окна.
- `app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php` — маркер эскалации на ярус 3.
- `app/app/Models/SupplierManualSyncQueue.php` — Eloquent model для очереди яруса 3.
- `app/database/migrations/2026_05_19_120000_create_supplier_manual_sync_queue.php` — миграция, raw SQL pattern (как `2026_05_18_140000_supplier_leads_vid_nullable.php`).
- `app/playwright/manage-project.js` — Node-скрипт водит форму «Мои проекты»; stdin/stdout JSON по образцу `refresh-session.js`.
- `app/tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php` — Http::fake тесты адаптера.
- `app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php` — матрица эскалации (приоритет №1).
- `app/tests/Feature/Supplier/Channel/FormProjectChannelTest.php` — bridge mock тесты.
- `app/tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php` — db smoke миграции.
- `app/tests/Feature/Admin/SupplierManualQueueTest.php` — endpoints контроллера.
- `app/tests/Feature/Schedule/SupplierScheduleTest.php` — проверка времени крона.
- `app/tests/fixtures/supplier-portal/rt-add-project-form.html` — HTML-фикстура формы для Node-теста (из снапшота разведки `rt-add-project-form.yml`).
- `app/playwright/manage-project.test.js` — Node-фикстурный тест.

**Modify:**

- `app/app/Services/Supplier/SupplierPortalClient.php` — после Task 1 (live discovery): возможны fixup'ы `rt-project-*` контракта; `ensureSupplierProject` оставляем здесь как утилиту.
- `app/app/Jobs/SyncSupplierProjectJob.php` (singular) — инжектим `SupplierProjectChannel`, заменяем прямой вызов `$client->ensureSupplierProject(...)` на channel.
- `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (plural) — инжектим `SupplierProjectChannel` вместо `SupplierPortalClient`.
- `app/app/Providers/AppServiceProvider.php` — bind `SupplierProjectChannel::class` → `FailoverProjectChannel` через DI.
- `app/routes/console.php` — 20:30 → 18:00 для `SyncSupplierProjectsJob`; 20:15 → 17:45 для daily `RefreshSupplierSessionJob`.
- `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` — +`manualQueueIndex()`, +`manualQueueResolve($id)`.
- `app/routes/api.php` — +2 роута.
- `app/resources/js/views/admin/AdminSupplierIntegrationView.vue` — +секция «Ручная очередь».
- Существующие тесты `SyncSupplierProjectsJobTest`, `SyncSupplierProjectJobTest` — обновить под mock channel'а.
- `db/schema.sql` — добавить определение `supplier_manual_sync_queue`.
- `db/CHANGELOG_schema.md` — запись новой версии.

**Out of scope (см. spec §10):** `rt-project-delete` через ярусы, pull-листинг как фоновый sync, замена AJAX на browser primary.

---

## Pre-flight (для исполнителя)

- [ ] **Step 0.1: Изолированный worktree.**

Если работаешь субагент-driven — `superpowers:using-git-worktrees` создаёт worktree автоматически. Если inline — рекомендуется:

```bash
cd "C:/моя/проекты/портал crm/Документация"
git worktree add ../worktree-supplier-project-failover -b feat/supplier-project-failover
cd ../worktree-supplier-project-failover
```

- [ ] **Step 0.2: Baseline Pest зелёный.**

```bash
cd app && composer test -- --parallel
```

Ожидаемо: ≥ 937/934 passed/0 failed (число от 19.05.2026 commit `59d3dd0`). Если RED — НЕ начинать.

---

## Task 1: Live discovery and Tier 1 contract verification

**Цель:** проверить и зафиксировать реальные `rt-project-*` endpoints на боевом портале (spec §1: «placeholder, не верифицирован»). Spec журнал решений запись 9.

**Files:**

- Modify: `app/app/Services/Supplier/SupplierPortalClient.php:24-28,103-121,346-358` (docblock + методы).
- Create: `app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php` — Http::fake тесты адаптированы под реальный контракт.

- [ ] **Step 1.1: Live recon — открыть Playwright MCP и записать сеть на портале.**

Через `mcp__playwright__browser_navigate` → `https://crm.bp-gr.ru/admin/visit/rt` → клик «Добавить проект» → заполнить минимальный тестовый проект (Тег: `_lidpotok_test`, Название: `LIDPOTOK_TEST_DELETE_ME`, B1+B2+B3, Источники сбора: Сайты, домен `lidpotok-test.local`, Лимит: 1, дни Пн–Вс) → нажать «Сохранить» → дождаться появления в таблице.

Параллельно держать `mcp__playwright__browser_network_requests` фильтр `/admin/rt-` и записать:

- URL (точный путь),
- HTTP-метод,
- `Content-Type` (form-urlencoded vs JSON),
- Полный payload (имена полей, формат `workdays`/`regions`),
- Тело ответа (где `id` — корень JSON или вложенный).

- [ ] **Step 1.2: Cleanup тестового проекта на портале.**

Найти `LIDPOTOK_TEST_DELETE_ME` в таблице → клик «Удалить» → подтвердить → убедиться, что строка ушла. Записать payload удаления (для будущей пометки `rt-project-delete`).

- [ ] **Step 1.3: Зафиксировать наблюдения в комментариях `SupplierPortalClient.php`.**

Заменить placeholder-докблок [SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28):

```php
/**
 * HTTP-обёртка над rt-* AJAX endpoints supplier-портала crm.bp-gr.ru.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
 *
 * Endpoints (verified live 2026-05-XX через Playwright MCP recon, Task 1):
 * - GET  /admin/<ACTUAL_PATH_FROM_RECON> — список проектов
 * - POST /admin/<ACTUAL_PATH_FROM_RECON> — создание (payload: <ACTUAL_FIELDS>)
 * - POST /admin/<ACTUAL_PATH_FROM_RECON> — обновление
 * - POST /admin/<ACTUAL_PATH_FROM_RECON> — удаление
 *
 * Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
 * На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
 */
```

Заменить `<ACTUAL_PATH_FROM_RECON>` и `<ACTUAL_FIELDS>` на реальные значения из Step 1.1.

- [ ] **Step 1.4: Обновить `saveProject()`/`updateProject()`/`listProjects()`/`toPayload()` если контракт расходится.**

Если recon показал, что endpoints/payload-shape ОТЛИЧАЕТСЯ от placeholder:

- В `request()` вызовах ([SupplierPortalClient.php:105](../../../app/app/Services/Supplier/SupplierPortalClient.php#L105), [.php:112](../../../app/app/Services/Supplier/SupplierPortalClient.php#L112), [.php:120](../../../app/app/Services/Supplier/SupplierPortalClient.php#L120), [.php:98](../../../app/app/Services/Supplier/SupplierPortalClient.php#L98)) заменить пути на реальные.
- В `toPayload()` ([.php:346-358](../../../app/app/Services/Supplier/SupplierPortalClient.php#L346-L358)) заменить ключи/формат полей на реальные.
- Если ответ JSON структурно отличается (`id` не в корне) — поправить путь извлечения в `saveProject()` ([.php:107](../../../app/app/Services/Supplier/SupplierPortalClient.php#L107)).
- Если запрос JSON, а не form-urlencoded — добавить `asJson` параметр (паттерн уже есть для `save-report`, [.php:237-262](../../../app/app/Services/Supplier/SupplierPortalClient.php#L237-L262)).

Если контракт ИДЕНТИЧЕН placeholder — никаких правок кода, только убрать «placeholder» из докблока.

- [ ] **Step 1.5: Test — adapter contract verified.**

Создать `app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function (): void {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'test', 'csrf' => 'test',
    ], now()->addHour());
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

it('saveProject hits the real-portal endpoint and returns id from response', function (): void {
    // Заменить <ACTUAL_PATH> и форму ответа на верифицированные в Task 1 Step 1.1.
    Http::fake([
        'crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response(['id' => 700123], 200),
    ]);

    $dto = new SupplierProjectDto(
        platform: 'B1',
        signalType: 'site',
        uniqueKey: 'example.com',
        limit: 10,
        workdays: [1, 2, 3, 4, 5, 6, 7],
        regions: [],
        regionsReverse: false,
        status: 'active',
    );

    $externalId = app(SupplierPortalClient::class)->saveProject($dto);

    expect($externalId)->toBe(700123);
    Http::assertSent(fn (Request $r) => $r->url() === 'https://crm.bp-gr.ru/admin/<ACTUAL_PATH>');
});

it('updateProject sends id + payload to real-portal endpoint', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response('', 200),
    ]);

    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'example.com',
        limit: 20, workdays: [1, 5], regions: [77], regionsReverse: false, status: 'active',
    );
    app(SupplierPortalClient::class)->updateProject(700123, $dto);

    Http::assertSent(fn (Request $r) => $r['id'] === 700123);
});

it('listProjects returns array from real-portal endpoint', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response([
            ['id' => 700123, 'platform' => 'B1', 'unique_key' => 'example.com'],
        ], 200),
    ]);

    $list = app(SupplierPortalClient::class)->listProjects();

    expect($list)->toHaveCount(1);
    expect($list[0]['id'])->toBe(700123);
});
```

- [ ] **Step 1.6: Run tests.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php
```

Expected: PASS (3 tests).

- [ ] **Step 1.7: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Services/Supplier/SupplierPortalClient.php \
        app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php
git commit --only \
  app/app/Services/Supplier/SupplierPortalClient.php \
  app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php \
  -m "feat(supplier): verify rt-project-* contract live on crm.bp-gr.ru

Discovery через Playwright MCP recon:
- POST /admin/<ACTUAL> — saveProject, payload-shape подтверждён
- POST /admin/<ACTUAL> — updateProject
- GET  /admin/<ACTUAL> — listProjects
- Тестовый проект LIDPOTOK_TEST_DELETE_ME создан и удалён вручную.

Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: `SupplierProjectChannel` interface + `AjaxProjectChannel` adapter

**Files:**

- Create: `app/app/Services/Supplier/Channel/SupplierProjectChannel.php`
- Create: `app/app/Services/Supplier/Channel/AjaxProjectChannel.php`
- Create: `app/tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php`

- [ ] **Step 2.1: Write failing test.**

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function (): void {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'test', 'csrf' => 'test',
    ], now()->addHour());
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

it('AjaxProjectChannel implements SupplierProjectChannel', function (): void {
    expect(app(AjaxProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
});

it('createProject delegates to SupplierPortalClient::saveProject and returns external id', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response(['id' => 700777], 200),
    ]);

    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
        limit: 5, workdays: [1, 2, 3], regions: [], regionsReverse: false, status: 'active',
    );

    $id = app(AjaxProjectChannel::class)->createProject($dto);

    expect($id)->toBe(700777);
});

it('updateProject delegates to SupplierPortalClient::updateProject', function (): void {
    Http::fake(['crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response('', 200)]);
    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
        limit: 10, workdays: [1], regions: [], regionsReverse: false, status: 'active',
    );

    app(AjaxProjectChannel::class)->updateProject(700777, $dto);

    Http::assertSent(fn ($r) => $r['id'] === 700777);
});

it('listProjects delegates to SupplierPortalClient::listProjects', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/<ACTUAL_PATH>' => Http::response([['id' => 1]], 200),
    ]);

    $list = app(AjaxProjectChannel::class)->listProjects();

    expect($list)->toHaveCount(1);
});
```

- [ ] **Step 2.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php
```

Expected: FAIL with `Class "App\Services\Supplier\Channel\AjaxProjectChannel" not found`.

- [ ] **Step 2.3: Create interface.**

`app/app/Services/Supplier/Channel/SupplierProjectChannel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel;

use App\Services\Supplier\Dto\SupplierProjectDto;

/**
 * Контракт миграции проекта Лидерра → поставщик crm.bp-gr.ru.
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.1
 *
 * Реализации (ярусы резерва):
 *   - AjaxProjectChannel — rt-project-* HTTP (primary, быстрый).
 *   - FormProjectChannel — Playwright водит форму «Мои проекты» (fallback).
 *   - FailoverProjectChannel — декоратор-оркестратор (ярус 1 → ярус 2 → ярус 3 queue).
 */
interface SupplierProjectChannel
{
    /**
     * Создаёт проект на портале, возвращает supplier external_id.
     */
    public function createProject(SupplierProjectDto $dto): int;

    /**
     * Обновляет существующий проект (квота/дни/регионы).
     */
    public function updateProject(int $externalId, SupplierProjectDto $dto): void;

    /**
     * Список проектов с портала — для дедуп-сверки и закрытия яруса 3.
     *
     * @return array<int, array<string, mixed>>
     */
    public function listProjects(): array;
}
```

- [ ] **Step 2.4: Create adapter.**

`app/app/Services/Supplier/Channel/AjaxProjectChannel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel;

use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;

/**
 * Ярус 1: тонкий адаптер над SupplierPortalClient (rt-project-* AJAX).
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.2
 */
final class AjaxProjectChannel implements SupplierProjectChannel
{
    public function __construct(
        private readonly SupplierPortalClient $client,
    ) {}

    public function createProject(SupplierProjectDto $dto): int
    {
        return $this->client->saveProject($dto);
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $this->client->updateProject($externalId, $dto);
    }

    public function listProjects(): array
    {
        return $this->client->listProjects();
    }
}
```

- [ ] **Step 2.5: Run test — PASS.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php
```

Expected: PASS (4 tests).

- [ ] **Step 2.6: Run regression on supplier suite.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier --parallel
```

Expected: всё, что было до Task 2, всё ещё PASS.

- [ ] **Step 2.7: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Services/Supplier/Channel/SupplierProjectChannel.php \
        app/app/Services/Supplier/Channel/AjaxProjectChannel.php \
        app/tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php
git commit --only \
  app/app/Services/Supplier/Channel/SupplierProjectChannel.php \
  app/app/Services/Supplier/Channel/AjaxProjectChannel.php \
  app/tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php \
  -m "feat(supplier): SupplierProjectChannel interface + AjaxProjectChannel (Tier 1)

Тонкий адаптер над SupplierPortalClient. Существующий клиент не меняется —
он остаётся HTTP-плумбингом, адаптер реализует интерфейс контракта.

Spec §4.1, §4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: `supplier_manual_sync_queue` table

**Files:**

- Create: `app/database/migrations/2026_05_19_120000_create_supplier_manual_sync_queue.php`
- Create: `app/app/Models/SupplierManualSyncQueue.php`
- Create: `app/tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php`
- Modify: `db/schema.sql` (добавить CREATE TABLE + индексы + CHECK).
- Modify: `db/CHANGELOG_schema.md` (запись следующей версии).

- [ ] **Step 3.1: Write failing test (db smoke).**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

it('table supplier_manual_sync_queue exists with required columns', function (): void {
    $cols = collect(DB::select(
        "SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_manual_sync_queue'"
    ))->pluck('column_name')->all();

    expect($cols)->toContain(
        'id', 'project_id', 'platform', 'operation', 'external_id',
        'payload_snapshot', 'failure_reason', 'status',
        'resolved_by_user_id', 'created_at', 'resolved_at',
    );
});

it('platform CHECK constraint rejects non-B1/B2/B3', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
        'project_id' => $project->id,
        'platform' => 'B9',
        'operation' => 'create',
        'payload_snapshot' => json_encode([]),
        'failure_reason' => 'portal_unreachable',
        'status' => 'pending',
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('operation CHECK constraint rejects non-create/update', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
        'project_id' => $project->id,
        'platform' => 'B1',
        'operation' => 'delete',
        'payload_snapshot' => json_encode([]),
        'failure_reason' => 'portal_unreachable',
        'status' => 'pending',
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('status CHECK constraint rejects non-pending/resolved/cancelled', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
        'project_id' => $project->id,
        'platform' => 'B1',
        'operation' => 'create',
        'payload_snapshot' => json_encode([]),
        'failure_reason' => 'portal_unreachable',
        'status' => 'archived',
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('FK on project_id enforces referential integrity', function (): void {
    expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
        'project_id' => 999_999_999,
        'platform' => 'B1',
        'operation' => 'create',
        'payload_snapshot' => json_encode([]),
        'failure_reason' => 'portal_unreachable',
        'status' => 'pending',
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('Eloquent model SupplierManualSyncQueue creates row and casts payload_snapshot to array', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $row = SupplierManualSyncQueue::create([
        'project_id' => $project->id,
        'platform' => 'B1',
        'operation' => 'create',
        'payload_snapshot' => ['limit' => 10, 'workdays' => [1, 2, 3]],
        'failure_reason' => 'contract_break',
        'status' => 'pending',
    ]);

    expect($row->fresh()->payload_snapshot)->toBe(['limit' => 10, 'workdays' => [1, 2, 3]]);
});
```

- [ ] **Step 3.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php
```

Expected: FAIL — таблица не существует.

- [ ] **Step 3.3: Create migration.**

`app/database/migrations/2026_05_19_120000_create_supplier_manual_sync_queue.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

/**
 * Создаёт SaaS-level очередь яруса 3 резерва канала миграции проектов.
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
 *
 * Без tenant_id / RLS (как supplier_csv_reconcile_log) — доступ только SaaS-admin.
 */
return new class extends Migration
{
    public function up(): void
    {
        // Guard: после migrate:fresh schema.sql даёт таблицу первой. Idempotent.
        $exists = DB::selectOne(
            "SELECT to_regclass('public.supplier_manual_sync_queue') AS r"
        );
        if ($exists !== null && $exists->r !== null) {
            return;
        }

        DB::statement(<<<'SQL'
            CREATE TABLE supplier_manual_sync_queue (
                id BIGSERIAL PRIMARY KEY,
                project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
                platform VARCHAR(8) NOT NULL,
                operation VARCHAR(16) NOT NULL,
                external_id VARCHAR(64),
                payload_snapshot JSONB NOT NULL,
                failure_reason VARCHAR(64) NOT NULL,
                status VARCHAR(16) NOT NULL DEFAULT 'pending',
                resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                resolved_at TIMESTAMPTZ,
                CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
                CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
                CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
            );

            CREATE INDEX idx_smsq_status_created ON supplier_manual_sync_queue (status, created_at DESC);
            CREATE INDEX idx_smsq_project ON supplier_manual_sync_queue (project_id);
        SQL);
    }

    public function down(): void
    {
        DB::statement('DROP TABLE IF EXISTS supplier_manual_sync_queue');
    }
};
```

- [ ] **Step 3.4: Create Eloquent model.**

`app/app/Models/SupplierManualSyncQueue.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Очередь яруса 3 резерва канала миграции проектов.
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
 */
class SupplierManualSyncQueue extends Model
{
    use HasFactory;

    protected $table = 'supplier_manual_sync_queue';

    public $timestamps = false;

    protected $fillable = [
        'project_id', 'platform', 'operation', 'external_id',
        'payload_snapshot', 'failure_reason', 'status',
        'resolved_by_user_id', 'created_at', 'resolved_at',
    ];

    protected $casts = [
        'payload_snapshot' => 'array',
        'created_at' => 'datetime',
        'resolved_at' => 'datetime',
    ];

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    public function resolver(): BelongsTo
    {
        return $this->belongsTo(User::class, 'resolved_by_user_id');
    }
}
```

- [ ] **Step 3.5: Update `db/schema.sql` — append CREATE TABLE.**

Найти секцию `supplier_csv_reconcile_log` в `db/schema.sql` и добавить ниже идентичный по стилю блок для `supplier_manual_sync_queue` (см. SQL в Step 3.3). Header `db/schema.sql:4` bump до следующей версии (например v8.20).

- [ ] **Step 3.6: Update `db/CHANGELOG_schema.md`.**

Добавить запись наверх:

```markdown
## v8.20 от 2026-05-XX — supplier_manual_sync_queue

**+1 таблица** SaaS-level (без tenant_id / RLS, как supplier_csv_reconcile_log):
- `supplier_manual_sync_queue` — очередь яруса 3 резерва канала миграции проектов
  (spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5).
- **+3 CHECK:** chk_smsq_platform (B1/B2/B3), chk_smsq_operation (create/update),
  chk_smsq_status (pending/resolved/cancelled).
- **+2 индекса:** idx_smsq_status_created, idx_smsq_project.
- **+2 FK:** project_id → projects ON DELETE CASCADE;
  resolved_by_user_id → users ON DELETE SET NULL.
```

- [ ] **Step 3.7: Run migration + test.**

```bash
cd app && php artisan migrate
./vendor/bin/pest tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php
```

Expected: PASS (6 tests).

- [ ] **Step 3.8: Verify migrate:fresh.**

```bash
cd app && php artisan migrate:fresh --env=testing 2>&1 | tail -20
```

Expected: миграция применяется без ошибок (idempotent guard срабатывает на schema.sql baseline).

- [ ] **Step 3.9: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/database/migrations/2026_05_19_120000_create_supplier_manual_sync_queue.php \
        app/app/Models/SupplierManualSyncQueue.php \
        app/tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit --only \
  app/database/migrations/2026_05_19_120000_create_supplier_manual_sync_queue.php \
  app/app/Models/SupplierManualSyncQueue.php \
  app/tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php \
  db/schema.sql db/CHANGELOG_schema.md \
  -m "feat(supplier): supplier_manual_sync_queue table (Tier 3 queue)

SaaS-level (без tenant_id, без RLS, как supplier_csv_reconcile_log).
+3 CHECK (platform/operation/status), +2 индекса, +2 FK.

schema.sql bumped v8.19 → v8.20.

Spec §4.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: `FailoverProjectChannel` skeleton — Tier 1 + Tier 3 fallback

Без яруса 2 — добавим в Task 7. Сейчас: при сбое яруса 1 (любого) → сразу очередь яруса 3 (либо ярус 2-плейсхолдер для контракт-сбоев).

**Files:**

- Create: `app/app/Services/Supplier/Channel/Exceptions/WindowDeferredException.php`
- Create: `app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php`
- Create: `app/app/Services/Supplier/Channel/FailoverProjectChannel.php`
- Create: `app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php`

- [ ] **Step 4.1: Write failing tests (escalation matrix).**

```php
<?php

declare(strict_types=1);

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;

uses(DatabaseTransactions::class);

function makeDto(): SupplierProjectDto
{
    return new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
        limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
    );
}

function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $tier2 = null): FailoverProjectChannel
{
    return new FailoverProjectChannel(
        $tier1,
        $tier2 ?? new class implements SupplierProjectChannel {
            public function createProject(SupplierProjectDto $dto): int { throw new \RuntimeException('tier2 not configured'); }
            public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
            public function listProjects(): array { return []; }
        },
        app(\Illuminate\Contracts\Mail\Mailer::class),
    );
}

beforeEach(function (): void {
    Mail::fake();
});

it('createProject — Tier 1 success: returns id, no queue, no alert', function (): void {
    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int { return 700123; }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };

    $id = makeFailover($tier1)->createProject(makeDto());

    expect($id)->toBe(700123);
    expect(SupplierManualSyncQueue::count())->toBe(0);
    Mail::assertNothingQueued();
});

it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3 with portal_unreachable', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create(['signal_type' => 'site', 'signal_identifier' => 'foo.com']);

    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int {
            throw new SupplierTransientException('5xx exhausted', httpStatus: 503);
        }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };
    $tier2Called = false;
    $tier2 = new class($tier2Called) implements SupplierProjectChannel {
        public function __construct(public bool &$called) {}
        public function createProject(SupplierProjectDto $dto): int { $this->called = true; return 0; }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void { $this->called = true; }
        public function listProjects(): array { $this->called = true; return []; }
    };

    expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
        ->toThrow(\App\Services\Supplier\Channel\Exceptions\TierEscalatedException::class);

    expect($tier2Called)->toBeFalse();
    expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
    Mail::assertQueued(SupplierCriticalAlertMail::class);
});

it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int {
            throw new SupplierClientException('4xx contract break', httpStatus: 400);
        }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };
    $tier2 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int { return 800001; }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };

    $id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());

    expect($id)->toBe(800001);
    expect(SupplierManualSyncQueue::count())->toBe(0);
    Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
});

it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int {
            throw new SupplierClientException('4xx', httpStatus: 400);
        }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };
    $tier2 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int { throw new \RuntimeException('form_selector_break'); }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };

    expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
        ->toThrow(\App\Services\Supplier\Channel\Exceptions\TierEscalatedException::class);

    expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
    Mail::assertQueued(SupplierCriticalAlertMail::class);
});

it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int {
            throw new SupplierAuthException('sticky 401', httpStatus: 401);
        }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };
    $tier2 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int { return 900042; }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };

    $id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());

    expect($id)->toBe(900042);
});

it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (re-throws WindowDeferred)', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $tier1 = new class implements SupplierProjectChannel {
        public function createProject(SupplierProjectDto $dto): int {
            throw new \App\Services\Supplier\Channel\Exceptions\WindowDeferredException(
                'portal returned 22:00-00:00 window-block'
            );
        }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };

    expect(fn () => makeFailover($tier1)->createProjectForLiderra($project, makeDto()))
        ->toThrow(\App\Services\Supplier\Channel\Exceptions\WindowDeferredException::class);

    expect(SupplierManualSyncQueue::count())->toBe(0);
    Mail::assertNothingQueued();
});
```

- [ ] **Step 4.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
```

Expected: FAIL — classes not found.

- [ ] **Step 4.3: Create `WindowDeferredException`.**

`app/app/Services/Supplier/Channel/Exceptions/WindowDeferredException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel\Exceptions;

/**
 * Маркер «портал отказал по причине окна редактирования» (22:00-00:00 МСК).
 *
 * НЕ сбой канала — операция переносится. FailoverProjectChannel пропускает
 * эскалацию ярусов и не пишет в supplier_manual_sync_queue. Job-уровень
 * получает исключение и помечает попытку как deferred.
 *
 * Spec §8.
 */
final class WindowDeferredException extends \RuntimeException {}
```

- [ ] **Step 4.4: Create `TierEscalatedException`.**

`app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel\Exceptions;

/**
 * Брошен FailoverProjectChannel когда операция эскалирована на ярус 3.
 *
 * Job-уровень ловит и помечает текущую попытку как отложенную к ручному вмешательству.
 *
 * Spec §4.4 ("manual_required").
 */
final class TierEscalatedException extends \RuntimeException
{
    public function __construct(
        public readonly int $queueRowId,
        public readonly string $reason,
        string $message = '',
    ) {
        parent::__construct($message ?: "Escalated to manual queue (row #{$queueRowId}, reason: {$reason})");
    }
}
```

- [ ] **Step 4.5: Create `FailoverProjectChannel` (skeleton, без портального dedup — добавим в Task 5).**

`app/app/Services/Supplier/Channel/FailoverProjectChannel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Throwable;

/**
 * Декоратор-оркестратор: ярус 1 (AJAX) → ярус 2 (form-driving) → ярус 3 (manual queue).
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
 *
 * Bridge-методы createProjectForLiderra/updateProjectForLiderra принимают Project
 * (нужен для project_id в очереди яруса 3). Прямые createProject/updateProject
 * сохраняются для интерфейс-совместимости (без эскалации).
 */
final class FailoverProjectChannel implements SupplierProjectChannel
{
    public function __construct(
        private readonly SupplierProjectChannel $tier1,
        private readonly SupplierProjectChannel $tier2,
        private readonly Mailer $mailer,
    ) {}

    public function createProject(SupplierProjectDto $dto): int
    {
        return $this->tier1->createProject($dto);
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $this->tier1->updateProject($externalId, $dto);
    }

    public function listProjects(): array
    {
        return $this->tier1->listProjects();
    }

    /**
     * Create с эскалацией: использует Project для project_id в очереди яруса 3.
     */
    public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int
    {
        try {
            return $this->tier1->createProject($dto);
        } catch (WindowDeferredException $e) {
            throw $e;
        } catch (SupplierTransientException $e) {
            $this->escalateToTier3($project, 'create', null, $dto, 'portal_unreachable', $e);
        } catch (SupplierClientException|SupplierAuthException $e) {
            try {
                $id = $this->tier2->createProject($dto);
                $this->alertFailoverToForm($project, 'create', $e);

                return $id;
            } catch (Throwable $tier2Error) {
                $this->escalateToTier3(
                    $project, 'create', null, $dto,
                    $this->classifyTier2Failure($tier2Error), $tier2Error,
                );
            }
        }

        throw new \LogicException('Unreachable');
    }

    public function updateProjectForLiderra(Project $project, int $externalId, SupplierProjectDto $dto): void
    {
        try {
            $this->tier1->updateProject($externalId, $dto);

            return;
        } catch (WindowDeferredException $e) {
            throw $e;
        } catch (SupplierTransientException $e) {
            $this->escalateToTier3($project, 'update', $externalId, $dto, 'portal_unreachable', $e);
        } catch (SupplierClientException|SupplierAuthException $e) {
            try {
                $this->tier2->updateProject($externalId, $dto);
                $this->alertFailoverToForm($project, 'update', $e);

                return;
            } catch (Throwable $tier2Error) {
                $this->escalateToTier3(
                    $project, 'update', $externalId, $dto,
                    $this->classifyTier2Failure($tier2Error), $tier2Error,
                );
            }
        }
    }

    private function escalateToTier3(
        Project $project,
        string $operation,
        ?int $externalId,
        SupplierProjectDto $dto,
        string $reason,
        Throwable $cause,
    ): never {
        $row = SupplierManualSyncQueue::create([
            'project_id' => $project->id,
            'platform' => $dto->platform,
            'operation' => $operation,
            'external_id' => $externalId !== null ? (string) $externalId : null,
            'payload_snapshot' => [
                'signal_type' => $dto->signalType,
                'unique_key' => $dto->uniqueKey,
                'limit' => $dto->limit,
                'workdays' => $dto->workdays,
                'regions' => $dto->regions,
                'regions_reverse' => $dto->regionsReverse,
                'status' => $dto->status,
            ],
            'failure_reason' => $reason,
            'status' => 'pending',
            'created_at' => now(),
        ]);

        $this->mailer->to((string) config('services.supplier.alert_email'))
            ->queue(new SupplierCriticalAlertMail(
                alertType: 'manual_required',
                details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
            ));

        throw new TierEscalatedException($row->id, $reason);
    }

    private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
    {
        $this->mailer->to((string) config('services.supplier.alert_email'))
            ->queue(new SupplierCriticalAlertMail(
                alertType: 'failover_to_form',
                details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
            ));
    }

    private function classifyTier2Failure(Throwable $e): string
    {
        $msg = mb_strtolower($e->getMessage());
        if (str_contains($msg, 'auth') || str_contains($msg, 'login')) {
            return 'auth_failure';
        }
        if (str_contains($msg, 'selector') || str_contains($msg, 'form')) {
            return 'form_selector_break';
        }

        return 'form_save_error';
    }
}
```

- [ ] **Step 4.6: Verify `SupplierCriticalAlertMail` принимает `alertType` + `details`.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
grep -n "alertType\|details" app/app/Mail/SupplierCriticalAlertMail.php
```

Expected: constructor берёт `alertType` и `details` (паттерн из [SyncSupplierProjectsJob.php:88-103](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L88-L103)). Если конструктор другой — адаптировать вызовы в Step 4.5.

- [ ] **Step 4.7: Run test — PASS.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
```

Expected: PASS (6 tests).

- [ ] **Step 4.8: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Services/Supplier/Channel/Exceptions/WindowDeferredException.php \
        app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php \
        app/app/Services/Supplier/Channel/FailoverProjectChannel.php \
        app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
git commit --only \
  app/app/Services/Supplier/Channel/Exceptions/WindowDeferredException.php \
  app/app/Services/Supplier/Channel/Exceptions/TierEscalatedException.php \
  app/app/Services/Supplier/Channel/FailoverProjectChannel.php \
  app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php \
  -m "feat(supplier): FailoverProjectChannel skeleton — escalation matrix without dedup

Tier 1 → классификация исключения → ярус 2 (плейсхолдер) / ярус 3 (queue).
Без портального dedup (см. Task 5). Без реального Tier 2 (см. Task 7).
6 тестов матрицы эскалации зелёные.

Spec §4.4, §6, §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 5: `FailoverProjectChannel` — portal-side idempotency dedup

Защищает от дубля при полу-успехе яруса 1.

**Files:**

- Modify: `app/app/Services/Supplier/Channel/FailoverProjectChannel.php`
- Modify: `app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php`

- [ ] **Step 5.1: Write failing test.**

Добавить в `FailoverProjectChannelTest.php`:

```php
it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    $tier1CreateCalled = false;
    $tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel {
        public function __construct(public bool &$createCalled) {}
        public function createProject(SupplierProjectDto $dto): int { $this->createCalled = true; return 0; }
        public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
        public function listProjects(): array {
            return [
                ['id' => 555_555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'],
            ];
        }
    };

    $id = makeFailover($tier1)->createProjectForLiderra($project, makeDto());

    expect($id)->toBe(555_555);
    expect($tier1CreateCalled)->toBeFalse();
});
```

- [ ] **Step 5.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
```

Expected: новый тест FAIL — `createProject` всё ещё дёргается.

- [ ] **Step 5.3: Add dedup to `createProjectForLiderra`.**

В начале `createProjectForLiderra` (перед `try` яруса 1) добавить:

```php
public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int
{
    // Spec §4.4 шаг 2: портальная сверка через listProjects() до любого create.
    // Защита от дубля при полу-успехе яруса 1 в прошлом запуске.
    try {
        $existing = $this->findOnPortal($dto);
        if ($existing !== null) {
            return $existing;
        }
    } catch (Throwable) {
        // listProjects недоступен — продолжаем, ярус-эскалация и так покроет.
    }

    try {
        return $this->tier1->createProject($dto);
    } catch (WindowDeferredException $e) {
        throw $e;
    } catch (SupplierTransientException $e) {
        $this->escalateToTier3($project, 'create', null, $dto, 'portal_unreachable', $e);
    } catch (SupplierClientException|SupplierAuthException $e) {
        try {
            $id = $this->tier2->createProject($dto);
            $this->alertFailoverToForm($project, 'create', $e);

            return $id;
        } catch (Throwable $tier2Error) {
            $this->escalateToTier3(
                $project, 'create', null, $dto,
                $this->classifyTier2Failure($tier2Error), $tier2Error,
            );
        }
    }

    throw new \LogicException('Unreachable');
}

private function findOnPortal(SupplierProjectDto $dto): ?int
{
    foreach ($this->tier1->listProjects() as $row) {
        if (
            ($row['platform'] ?? null) === $dto->platform
            && ($row['signal_type'] ?? null) === $dto->signalType
            && ($row['unique_key'] ?? null) === $dto->uniqueKey
        ) {
            return (int) ($row['id'] ?? 0) ?: null;
        }
    }

    return null;
}
```

- [ ] **Step 5.4: Run test — PASS.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
```

Expected: 7/7 PASS.

- [ ] **Step 5.5: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Services/Supplier/Channel/FailoverProjectChannel.php \
        app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
git commit --only \
  app/app/Services/Supplier/Channel/FailoverProjectChannel.php \
  app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php \
  -m "feat(supplier): FailoverProjectChannel portal-side dedup before create

listProjects() матч по (platform, signal_type, unique_key) до create.
Защита от дубля при полу-успехе яруса 1.

Spec §4.4 шаг 2, §7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 6: `manage-project.js` Node script

Водит форму «Мои проекты»: `listProjects`, `createProject`, `updateProject`.

**Files:**

- Create: `app/playwright/manage-project.js`
- Create: `app/playwright/manage-project.test.js`
- Create: `app/tests/fixtures/supplier-portal/rt-add-project-form.html` — статический фикстур.

- [ ] **Step 6.1: Подготовить HTML-фикстуру формы.**

Создать `app/tests/fixtures/supplier-portal/rt-add-project-form.html` — минимальный HTML с теми же селекторами полей, что в реальной форме (label-ы + input/textarea/select). Селекторы взяты из снапшота разведки `rt-add-project-form.yml` строки 107-272.

Файл (упрощённый — без DOM-манипуляций через `innerHTML`, чтобы пройти security-guidance hook; имитирует поведение «новая строка появляется в таблице» через append с `appendChild` + `textContent`):

```html
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Test</title></head><body>
  <div id="add-project-modal" data-testid="add-project-form">
    <label><input type="checkbox" name="active" checked> Активный</label>
    <label>Тег <input type="text" name="tag" id="tag-input"></label>
    <fieldset>
      <legend>Источник данных</legend>
      <label><input type="checkbox" name="platform[]" value="B1" checked> B1</label>
      <label><input type="checkbox" name="platform[]" value="B2" checked> B2</label>
      <label><input type="checkbox" name="platform[]" value="B3" checked> B3</label>
    </fieldset>
    <label>Название проекта <input type="text" name="name" id="name-input" required></label>
    <label>Источники сбора <select name="signal_type" id="signal-type"><option>Сайты</option><option>Звонки</option><option>СМС</option></select></label>
    <fieldset>
      <legend>Регион</legend>
      <label><input type="radio" name="region_mode" value="include" checked> Включить</label>
      <label><input type="radio" name="region_mode" value="exclude"> Исключить</label>
      <input type="text" name="regions_filter" placeholder="Фильтр по регионам">
    </fieldset>
    <label>Список сайтов <textarea name="domains" id="domains-input"></textarea></label>
    <label>Лимит в день <input type="number" name="limit" id="limit-input" value="10"></label>
    <fieldset>
      <legend>Дни получения номеров</legend>
      <label><input type="checkbox" name="workdays[]" value="1" checked> Пн.</label>
      <label><input type="checkbox" name="workdays[]" value="2" checked> Вт.</label>
      <label><input type="checkbox" name="workdays[]" value="3" checked> Ср.</label>
      <label><input type="checkbox" name="workdays[]" value="4" checked> Чт.</label>
      <label><input type="checkbox" name="workdays[]" value="5" checked> Пт.</label>
      <label><input type="checkbox" name="workdays[]" value="6" checked> Сб.</label>
      <label><input type="checkbox" name="workdays[]" value="7" checked> Вс.</label>
    </fieldset>
    <button type="button" id="save-btn">Сохранить</button>
    <button type="button" id="cancel-btn">Отмена</button>
  </div>
  <table id="projects-table"><tbody></tbody></table>
  <script>
    document.getElementById('save-btn').addEventListener('click', function() {
      var tbody = document.querySelector('#projects-table tbody');
      var row = document.createElement('tr');
      row.dataset.id = String(Date.now());
      var tdId = document.createElement('td');
      tdId.textContent = row.dataset.id;
      var tdName = document.createElement('td');
      tdName.textContent = document.getElementById('name-input').value;
      row.appendChild(tdId);
      row.appendChild(tdName);
      tbody.appendChild(row);
      document.getElementById('add-project-modal').style.display = 'none';
    });
  </script>
</body></html>
```

- [ ] **Step 6.2: Write failing Node test.**

`app/playwright/manage-project.test.js`:

```javascript
const { test, expect } = require('@playwright/test');
const { execFile } = require('child_process');
const path = require('path');

const SCRIPT = path.resolve(__dirname, 'manage-project.js');
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');

function runScript(input) {
    return new Promise((resolve, reject) => {
        const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
            if (err && err.code !== undefined) return reject({ code: err.code, stdout, stderr });
            resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
        });
        child.stdin.write(JSON.stringify(input));
        child.stdin.end();
    });
}

test('createProject fills form and returns row id', async () => {
    const result = await runScript({
        operation: 'create',
        login: 'fixture-noop',
        password: 'fixture-noop',
        url: FIXTURE_URL,
        skipLogin: true,
        dto: {
            tag: 'TEST',
            name: 'Test Project',
            platforms: ['B1', 'B2'],
            signal_type: 'site',
            limit: 25,
            workdays: [1, 2, 3, 4, 5],
            regions: [],
            region_mode: 'include',
            domains: ['example.com'],
            active: true,
        },
    });

    const out = JSON.parse(result.stdout);
    expect(out.external_id).toBeTruthy();
    expect(out.external_id).toMatch(/^\d+$/);
});

test('listProjects returns array', async () => {
    const result = await runScript({
        operation: 'list',
        login: 'fixture-noop',
        password: 'fixture-noop',
        url: FIXTURE_URL,
        skipLogin: true,
    });

    const out = JSON.parse(result.stdout);
    expect(Array.isArray(out.projects)).toBe(true);
});
```

- [ ] **Step 6.3: Run Node test — FAIL.**

```bash
cd app/playwright && npx playwright test manage-project.test.js 2>&1 | head -50
```

Expected: FAIL — `manage-project.js` not found.

- [ ] **Step 6.4: Create `manage-project.js`.**

`app/playwright/manage-project.js`:

```javascript
#!/usr/bin/env node
/**
 * Headless Playwright водит UI «Мои проекты» supplier-портала crm.bp-gr.ru.
 *
 * Input (JSON через stdin):
 *   {operation: "create"|"update"|"list", login, password, url, skipLogin?, dto?, externalId?}
 *
 * Output (JSON через stdout):
 *   - create: {external_id: "12345"}
 *   - update: {ok: true}
 *   - list:   {projects: [...]}
 *
 * Exit codes:
 *   0 — success
 *   1 — auth failed
 *   2 — DOM/селектор не найден (контракт UI сменился — escalation cause)
 *   3 — timeout
 *   4 — invalid input или другая ошибка
 *
 * Spec §4.3.
 */
const { chromium } = require('playwright');

const TIMEOUT_MS = 90_000;

async function login(page, args) {
    if (args.skipLogin) return;
    await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
    await page.fill('#loginform-username', args.login);
    await page.fill('#loginform-password', args.password);
    await Promise.all([
        page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
        page.click('button[type=submit]'),
    ]);
}

async function fillForm(page, dto) {
    const activeChecked = await page.locator('input[name=active]').isChecked();
    if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();

    if (dto.tag) await page.fill('input[name=tag]', dto.tag);

    for (const p of ['B1', 'B2', 'B3']) {
        const wanted = (dto.platforms || []).includes(p);
        const sel = `input[name="platform[]"][value="${p}"]`;
        const checked = await page.locator(sel).isChecked();
        if (checked !== wanted) await page.locator(sel).click();
    }

    await page.fill('input[name=name]', dto.name);

    const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
    await page.selectOption('select[name=signal_type]', { label: signalLabel });

    if (dto.region_mode === 'exclude') {
        await page.locator('input[name=region_mode][value=exclude]').click();
    }

    if (dto.domains && dto.domains.length) {
        await page.fill('textarea[name=domains]', dto.domains.join('\n'));
    }

    await page.fill('input[name=limit]', String(dto.limit));

    for (let d = 1; d <= 7; d++) {
        const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
        const sel = `input[name="workdays[]"][value="${d}"]`;
        const checked = await page.locator(sel).isChecked();
        if (checked !== wanted) await page.locator(sel).click();
    }
}

async function createOp(page, args) {
    await login(page, args);

    if (!args.skipLogin) {
        await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
        await page.click('button:has-text("Добавить проект")');
        await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
    }

    await fillForm(page, args.dto);
    const beforeRows = await page.locator('#projects-table tbody tr').count();
    await page.click('#save-btn');
    await page.waitForFunction(
        (before) => document.querySelectorAll('#projects-table tbody tr').length > before,
        beforeRows,
        { timeout: TIMEOUT_MS },
    );

    const newRow = page.locator('#projects-table tbody tr').last();
    const externalId = await newRow.getAttribute('data-id');

    return { external_id: externalId };
}

async function updateOp(page, args) {
    await login(page, args);
    if (!args.skipLogin) {
        await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
    }

    const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
    await row.locator('button.edit').click();
    await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
    await fillForm(page, args.dto);
    await page.click('#save-btn');
    await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });

    return { ok: true };
}

async function listOp(page, args) {
    await login(page, args);
    if (!args.skipLogin) {
        await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
    }

    const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
        nodes.map((n) => ({
            id: parseInt(n.dataset.id, 10),
            name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
        })),
    );

    return { projects: rows };
}

async function run(args) {
    const browser = await chromium.launch({ headless: true });
    try {
        const ctx = await browser.newContext();
        const page = await ctx.newPage();
        let out;
        switch (args.operation) {
            case 'create': out = await createOp(page, args); break;
            case 'update': out = await updateOp(page, args); break;
            case 'list':   out = await listOp(page, args); break;
            default: throw new Error('Unknown operation: ' + args.operation);
        }
        process.stdout.write(JSON.stringify(out));
        process.exit(0);
    } catch (err) {
        process.stderr.write(JSON.stringify({ error: err.message }));
        if (err.message.includes('Timeout')) process.exit(3);
        if (err.message.toLowerCase().includes('selector') || err.message.toLowerCase().includes('locator')) process.exit(2);
        if (err.message.toLowerCase().includes('login') || err.message.toLowerCase().includes('auth')) process.exit(1);
        process.exit(4);
    } finally {
        await browser.close();
    }
}

let input = '';
process.stdin.on('data', (c) => { input += c; });
process.stdin.on('end', () => {
    let args;
    try { args = JSON.parse(input); }
    catch (e) { process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); process.exit(4); }
    if (!args.operation || !args.url) {
        process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
        process.exit(4);
    }
    run(args);
});
```

- [ ] **Step 6.5: Run Node test — PASS.**

```bash
cd app/playwright && npx playwright test manage-project.test.js
```

Expected: 2/2 PASS.

- [ ] **Step 6.6: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/playwright/manage-project.js \
        app/playwright/manage-project.test.js \
        app/tests/fixtures/supplier-portal/rt-add-project-form.html
git commit --only \
  app/playwright/manage-project.js \
  app/playwright/manage-project.test.js \
  app/tests/fixtures/supplier-portal/rt-add-project-form.html \
  -m "feat(supplier): manage-project.js — Playwright drives «Мои проекты» form

create/update/list через headless Chromium по образцу refresh-session.js.
Селекторы зафиксированы из recon-снапшота rt-add-project-form.yml.
Фикстурный тест против локального HTML — без живого портала.

Spec §4.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 7: `FormProjectChannel` PHP wrapper + wire into FailoverProjectChannel as Tier 2

**Files:**

- Create: `app/app/Services/Supplier/Channel/FormProjectChannel.php`
- Create: `app/tests/Feature/Supplier/Channel/FormProjectChannelTest.php`
- Modify: `app/app/Providers/AppServiceProvider.php`

- [ ] **Step 7.1: Write failing test.**

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\Channel\FormProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\PlaywrightBridge;

it('FormProjectChannel implements SupplierProjectChannel', function (): void {
    expect(app(FormProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
});

it('createProject calls PlaywrightBridge with operation=create and returns external_id', function (): void {
    $bridgeStub = new class {
        public ?array $lastArgs = null;
        public function run(array $args): array { $this->lastArgs = $args; return ['external_id' => '12345']; }
    };
    app()->instance(PlaywrightBridge::class, $bridgeStub);

    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
        limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
    );

    $id = app(FormProjectChannel::class)->createProject($dto);

    expect($id)->toBe(12345);
    expect($bridgeStub->lastArgs['operation'])->toBe('create');
    expect($bridgeStub->lastArgs['dto']['name'])->toBe('foo.com');
});

it('updateProject calls bridge with operation=update and externalId', function (): void {
    $bridgeStub = new class {
        public ?array $lastArgs = null;
        public function run(array $args): array { $this->lastArgs = $args; return ['ok' => true]; }
    };
    app()->instance(PlaywrightBridge::class, $bridgeStub);

    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
        limit: 20, workdays: [1], regions: [], regionsReverse: false, status: 'active',
    );
    app(FormProjectChannel::class)->updateProject(700123, $dto);

    expect($bridgeStub->lastArgs['operation'])->toBe('update');
    expect($bridgeStub->lastArgs['externalId'])->toBe(700123);
});

it('listProjects calls bridge with operation=list and returns array', function (): void {
    $bridgeStub = new class {
        public function run(array $args): array {
            return ['projects' => [['id' => 1, 'name' => 'A']]];
        }
    };
    app()->instance(PlaywrightBridge::class, $bridgeStub);

    $list = app(FormProjectChannel::class)->listProjects();

    expect($list)->toBe([['id' => 1, 'name' => 'A']]);
});
```

- [ ] **Step 7.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/FormProjectChannelTest.php
```

Expected: FAIL — class not found.

- [ ] **Step 7.3: Create `FormProjectChannel`.**

`app/app/Services/Supplier/Channel/FormProjectChannel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Channel;

use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\PlaywrightBridge;

/**
 * Ярус 2: водит форму «Мои проекты» supplier-портала через manage-project.js.
 *
 * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.3
 */
final class FormProjectChannel implements SupplierProjectChannel
{
    public function __construct(
        private readonly PlaywrightBridge $bridge,
    ) {}

    public function createProject(SupplierProjectDto $dto): int
    {
        $out = $this->callBridge('create', null, $dto);
        $id = (int) ($out['external_id'] ?? 0);
        if ($id === 0) {
            throw new \RuntimeException('FormProjectChannel: create returned empty external_id');
        }

        return $id;
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $out = $this->callBridge('update', $externalId, $dto);
        if (($out['ok'] ?? false) !== true) {
            throw new \RuntimeException('FormProjectChannel: update did not return ok=true');
        }
    }

    public function listProjects(): array
    {
        $out = $this->callBridge('list', null, null);

        return (array) ($out['projects'] ?? []);
    }

    private function callBridge(string $operation, ?int $externalId, ?SupplierProjectDto $dto): array
    {
        return $this->bridge->run([
            'script' => 'manage-project.js',
            'operation' => $operation,
            'externalId' => $externalId,
            'dto' => $dto !== null ? $this->mapDto($dto) : null,
            'login' => (string) config('services.supplier.login'),
            'password' => (string) config('services.supplier.password'),
            'url' => (string) config('services.supplier.portal_url'),
        ]);
    }

    private function mapDto(SupplierProjectDto $dto): array
    {
        return [
            'tag' => $dto->uniqueKey,
            'name' => $dto->uniqueKey,
            'platforms' => [$dto->platform],
            'signal_type' => $dto->signalType,
            'limit' => $dto->limit,
            'workdays' => $dto->workdays,
            'regions' => $dto->regions,
            'region_mode' => $dto->regionsReverse ? 'exclude' : 'include',
            'domains' => $dto->signalType === 'site' ? [$dto->uniqueKey] : [],
            'active' => $dto->status === 'active',
        ];
    }
}
```

- [ ] **Step 7.4: Verify `PlaywrightBridge::run()` сигнатура.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
grep -n "function run\|function refreshSession" app/app/Services/Supplier/PlaywrightBridge.php
```

Если метод называется не `run` или принимает не array — адаптировать `callBridge` соответственно. **Не верифицировал** сигнатуру `PlaywrightBridge` в этой сессии; при расхождении — поправить `FormProjectChannel::callBridge` или расширить bridge новым методом `runScript()`.

- [ ] **Step 7.5: Wire in `AppServiceProvider`.**

В `app/app/Providers/AppServiceProvider.php` добавить (в `register()`):

```php
$this->app->bind(
    \App\Services\Supplier\Channel\SupplierProjectChannel::class,
    function ($app) {
        return new \App\Services\Supplier\Channel\FailoverProjectChannel(
            $app->make(\App\Services\Supplier\Channel\AjaxProjectChannel::class),
            $app->make(\App\Services\Supplier\Channel\FormProjectChannel::class),
            $app->make(\Illuminate\Contracts\Mail\Mailer::class),
        );
    },
);
```

- [ ] **Step 7.6: Run all channel tests.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier/Channel/
```

Expected: PASS (Ajax 4 + Failover 7 + Form 4 = 15 tests).

- [ ] **Step 7.7: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Services/Supplier/Channel/FormProjectChannel.php \
        app/app/Providers/AppServiceProvider.php \
        app/tests/Feature/Supplier/Channel/FormProjectChannelTest.php
git commit --only \
  app/app/Services/Supplier/Channel/FormProjectChannel.php \
  app/app/Providers/AppServiceProvider.php \
  app/tests/Feature/Supplier/Channel/FormProjectChannelTest.php \
  -m "feat(supplier): FormProjectChannel (Tier 2) + DI binding

PHP wrapper над manage-project.js через PlaywrightBridge.
SupplierProjectChannel::class в DI резолвится в FailoverProjectChannel.

Spec §4.3, §4.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 8: Wire jobs to `FailoverProjectChannel`

**Files:**

- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (singular).
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (plural).
- Modify: существующие тесты этих job'ов (точные пути верифицировать grep'ом).

- [ ] **Step 8.1: Update `SyncSupplierProjectJob` (singular).**

В `app/app/Jobs/SyncSupplierProjectJob.php` заменить `handle()`:

Было ([SyncSupplierProjectJob.php:42-62](../../../app/app/Jobs/SyncSupplierProjectJob.php#L42-L62)):

```php
public function handle(SupplierPortalClient $client): void
{
    // ...
    $supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
```

Стало:

```php
public function handle(\App\Services\Supplier\Channel\SupplierProjectChannel $channel): void
{
    $project = Project::find($this->projectId);
    if ($project === null) {
        Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
        return;
    }

    $platforms = $this->resolvePlatforms($project);

    foreach ($platforms as $platform) {
        $uniqueKey = $this->buildUniqueKey($project, $platform);
        $dto = $this->buildDto($project, $platform, $uniqueKey);

        try {
            if ($channel instanceof \App\Services\Supplier\Channel\FailoverProjectChannel) {
                $supplierProjectId = $channel->createProjectForLiderra($project, $dto);
            } else {
                $supplierProjectId = $channel->createProject($dto);
            }
            $column = 'supplier_'.strtolower($platform).'_project_id';
            $project->{$column} = $supplierProjectId;
        } catch (\App\Services\Supplier\Channel\Exceptions\TierEscalatedException $e) {
            Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
        } catch (\App\Services\Supplier\Channel\Exceptions\WindowDeferredException $e) {
            Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
        }
    }

    $project->save();
}

private function buildDto(Project $project, string $platform, string $uniqueKey): \App\Services\Supplier\Dto\SupplierProjectDto
{
    return new \App\Services\Supplier\Dto\SupplierProjectDto(
        platform: $platform,
        signalType: (string) $project->signal_type,
        uniqueKey: $uniqueKey,
        limit: 0, // удар 1: лимит 0 (квота приедет ночным sync)
        workdays: [1, 2, 3, 4, 5, 6, 7],
        regions: [],
        regionsReverse: false,
        status: 'active',
    );
}
```

- [ ] **Step 8.2: Update `SyncSupplierProjectsJob` (plural).**

В `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` ([SyncSupplierProjectsJob.php:66](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L66)):

Было:

```php
public function handle(?SupplierPortalClient $client = null): void
{
    $client ??= app(SupplierPortalClient::class);
```

Стало:

```php
public function handle(?\App\Services\Supplier\Channel\SupplierProjectChannel $channel = null): void
{
    $this->channel = $channel ?? app(\App\Services\Supplier\Channel\SupplierProjectChannel::class);
```

Добавить свойство `private \App\Services\Supplier\Channel\SupplierProjectChannel $channel;`.

В `syncOne()` ([SyncSupplierProjectsJob.php:118-185](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L118-L185)) заменить прямые вызовы на channel:

Было ([:159](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L159), [:169](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L169)):

```php
$externalId = $client->saveProject($allocation);
// ...
$client->updateProject((int) $sp->supplier_external_id, $allocation);
```

Стало:

```php
$contextProject = $liderraProjects->first();

if ($isCreate) {
    if ($this->channel instanceof \App\Services\Supplier\Channel\FailoverProjectChannel) {
        $externalId = $this->channel->createProjectForLiderra($contextProject, $allocation);
    } else {
        $externalId = $this->channel->createProject($allocation);
    }
} else {
    if ($this->channel instanceof \App\Services\Supplier\Channel\FailoverProjectChannel) {
        $this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation);
    } else {
        $this->channel->updateProject((int) $sp->supplier_external_id, $allocation);
    }
}
```

Добавить catch'и в основной цикл ([:87-114](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L87-L114)):

```php
catch (\App\Services\Supplier\Channel\Exceptions\TierEscalatedException $e) {
    Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
    continue;
}
catch (\App\Services\Supplier\Channel\Exceptions\WindowDeferredException $e) {
    Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
    continue;
}
```

- [ ] **Step 8.3: Update existing tests.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
ls app/tests/Feature/Supplier/ | grep -i sync
```

Найти существующие тесты `Sync*Job` и заменить mock `SupplierPortalClient` на mock `SupplierProjectChannel`. Тесты, проверявшие `saveProject`/`updateProject`, теперь дёргают channel-методы.

- [ ] **Step 8.4: Run job tests.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Supplier --parallel
```

Expected: все existing supplier-тесты + новые тесты эскалации зелёные.

- [ ] **Step 8.5: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Jobs/SyncSupplierProjectJob.php \
        app/app/Jobs/Supplier/SyncSupplierProjectsJob.php \
        app/tests/Feature/Supplier/
git commit --only \
  app/app/Jobs/SyncSupplierProjectJob.php \
  app/app/Jobs/Supplier/SyncSupplierProjectsJob.php \
  app/tests/Feature/Supplier/ \
  -m "feat(supplier): wire jobs to FailoverProjectChannel

Оба job'а (singular + plural) принимают SupplierProjectChannel через DI.
Catch TierEscalatedException + WindowDeferredException добавлены.

Spec §5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 9: Schedule retiming

**Files:**

- Modify: `app/routes/console.php` ([:45-50](../../../app/routes/console.php#L45-L50)).
- Create: `app/tests/Feature/Schedule/SupplierScheduleTest.php`.

- [ ] **Step 9.1: Write failing test.**

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use Illuminate\Console\Scheduling\Schedule;

it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
    $schedule = app(Schedule::class);
    $events = collect($schedule->events());

    $sync = $events->first(fn ($e) =>
        str_contains($e->description ?? '', SyncSupplierProjectsJob::class)
        || str_contains((string) $e->command, 'SyncSupplierProjectsJob')
    );

    expect($sync)->not->toBeNull();
    expect($sync->expression)->toBe('0 18 * * *');
    expect($sync->timezone)->toBe('Europe/Moscow');
});

it('Daily RefreshSupplierSessionJob is scheduled at 17:45 MSK', function (): void {
    $schedule = app(Schedule::class);
    $events = collect($schedule->events());

    $daily = $events->first(fn ($e) =>
        (str_contains($e->description ?? '', RefreshSupplierSessionJob::class)
         || str_contains((string) $e->command, 'RefreshSupplierSessionJob'))
        && $e->expression === '45 17 * * *'
    );

    expect($daily)->not->toBeNull();
    expect($daily->timezone)->toBe('Europe/Moscow');
});
```

- [ ] **Step 9.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Schedule/SupplierScheduleTest.php
```

Expected: FAIL — текущее расписание 20:30 / 20:15.

- [ ] **Step 9.3: Edit `app/routes/console.php`.**

Заменить ([routes/console.php:45-50](../../../app/routes/console.php#L45-L50)):

```php
Schedule::job(new RefreshSupplierSessionJob)
    ->dailyAt('20:15')
    ->timezone('Europe/Moscow');
Schedule::job(new SyncSupplierProjectsJob)
    ->dailyAt('20:30')
    ->timezone('Europe/Moscow');
```

На:

```php
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время.
// Session refresh — на 15 мин раньше sync (17:45).
Schedule::job(new RefreshSupplierSessionJob)
    ->dailyAt('17:45')
    ->timezone('Europe/Moscow');
Schedule::job(new SyncSupplierProjectsJob)
    ->dailyAt('18:00')
    ->timezone('Europe/Moscow');
```

- [ ] **Step 9.4: Run test — PASS.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Schedule/SupplierScheduleTest.php
```

Expected: 2/2 PASS.

- [ ] **Step 9.5: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/routes/console.php app/tests/Feature/Schedule/SupplierScheduleTest.php
git commit --only \
  app/routes/console.php app/tests/Feature/Schedule/SupplierScheduleTest.php \
  -m "feat(supplier): retime supplier sync crons 20:30→18:00, 20:15→17:45

Запас ~3 часа до портального дедлайна 21:00 — эскалация на ярус 2/3
в рабочее время.

Spec §4.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 10: Admin backend — worklist endpoints

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` (+2 methods).
- Modify: `app/routes/api.php` (+2 routes).
- Create: `app/tests/Feature/Admin/SupplierManualQueueTest.php`.

- [ ] **Step 10.1: Write failing test.**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

function authAdmin(): User
{
    $admin = User::factory()->create(['is_saas_admin' => true]);
    test()->actingAs($admin);

    return $admin;
}

it('GET /api/admin/supplier-integration/manual-queue returns pending rows', function (): void {
    authAdmin();
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();

    SupplierManualSyncQueue::create([
        'project_id' => $project->id,
        'platform' => 'B1',
        'operation' => 'create',
        'payload_snapshot' => ['limit' => 10],
        'failure_reason' => 'contract_break',
        'status' => 'pending',
    ]);

    $r = test()->getJson('/api/admin/supplier-integration/manual-queue');

    $r->assertOk()
        ->assertJsonStructure(['queue' => [['id', 'project_id', 'platform', 'operation', 'payload_snapshot', 'failure_reason', 'created_at']]])
        ->assertJsonCount(1, 'queue');
});

it('GET excludes resolved rows', function (): void {
    authAdmin();
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();
    SupplierManualSyncQueue::create([
        'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
        'payload_snapshot' => [], 'failure_reason' => 'contract_break',
        'status' => 'resolved', 'resolved_at' => now(),
    ]);

    test()->getJson('/api/admin/supplier-integration/manual-queue')
        ->assertOk()->assertJsonCount(0, 'queue');
});

it('POST /resolve marks row resolved when listProjects matches', function (): void {
    $admin = authAdmin();
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();
    $row = SupplierManualSyncQueue::create([
        'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
        'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
        'failure_reason' => 'contract_break', 'status' => 'pending',
    ]);

    $channelMock = new class implements \App\Services\Supplier\Channel\SupplierProjectChannel {
        public function createProject(\App\Services\Supplier\Dto\SupplierProjectDto $dto): int { return 0; }
        public function updateProject(int $externalId, \App\Services\Supplier\Dto\SupplierProjectDto $dto): void {}
        public function listProjects(): array {
            return [['id' => 99_999, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com']];
        }
    };
    app()->instance(\App\Services\Supplier\Channel\SupplierProjectChannel::class, $channelMock);

    test()->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
        ->assertOk();

    expect($row->fresh()->status)->toBe('resolved');
    expect($row->fresh()->resolved_by_user_id)->toBe($admin->id);
    expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
});

it('POST /resolve returns 409 when listProjects does not match', function (): void {
    authAdmin();
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();
    $row = SupplierManualSyncQueue::create([
        'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
        'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
        'failure_reason' => 'contract_break', 'status' => 'pending',
    ]);

    $channelMock = new class implements \App\Services\Supplier\Channel\SupplierProjectChannel {
        public function createProject(\App\Services\Supplier\Dto\SupplierProjectDto $dto): int { return 0; }
        public function updateProject(int $externalId, \App\Services\Supplier\Dto\SupplierProjectDto $dto): void {}
        public function listProjects(): array { return []; }
    };
    app()->instance(\App\Services\Supplier\Channel\SupplierProjectChannel::class, $channelMock);

    test()->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
        ->assertStatus(409);

    expect($row->fresh()->status)->toBe('pending');
});
```

- [ ] **Step 10.2: Run test — FAIL.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Admin/SupplierManualQueueTest.php
```

Expected: 404 route not found.

- [ ] **Step 10.3: Add controller methods.**

В `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` добавить use'ы и методы:

```php
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Http\Request;

public function manualQueueIndex(): JsonResponse
{
    $rows = SupplierManualSyncQueue::where('status', 'pending')
        ->orderByDesc('id')
        ->limit(100)
        ->get(['id', 'project_id', 'platform', 'operation', 'external_id', 'payload_snapshot', 'failure_reason', 'created_at']);

    return response()->json(['queue' => $rows]);
}

public function manualQueueResolve(int $id, Request $request, SupplierProjectChannel $channel): JsonResponse
{
    $row = SupplierManualSyncQueue::findOrFail($id);
    if ($row->status !== 'pending') {
        return response()->json(['message' => 'already resolved or cancelled'], 409);
    }

    $payload = $row->payload_snapshot;
    $signalType = (string) ($payload['signal_type'] ?? '');
    $uniqueKey = (string) ($payload['unique_key'] ?? '');

    $found = null;
    foreach ($channel->listProjects() as $r) {
        if (
            ($r['platform'] ?? null) === $row->platform
            && ($r['signal_type'] ?? null) === $signalType
            && ($r['unique_key'] ?? null) === $uniqueKey
        ) {
            $found = (int) ($r['id'] ?? 0);
            break;
        }
    }

    if ($found === null) {
        return response()->json([
            'message' => 'Проект не найден на портале поставщика. Проверьте, что вы действительно его создали с теми же параметрами.',
        ], 409);
    }

    \App\Models\Project::where('id', $row->project_id)->update([
        'supplier_'.strtolower($row->platform).'_project_id' => $found,
    ]);

    $row->update([
        'status' => 'resolved',
        'resolved_by_user_id' => $request->user()->id,
        'resolved_at' => now(),
        'external_id' => (string) $found,
    ]);

    return response()->json(['resolved' => true, 'external_id' => $found]);
}
```

- [ ] **Step 10.4: Add routes.**

В `app/routes/api.php` — рядом с существующими supplier-admin роутами добавить:

```php
Route::middleware(['auth:sanctum', 'saas-admin'])->prefix('admin/supplier-integration')->group(function () {
    // existing routes here...
    Route::get('manual-queue', [AdminSupplierIntegrationController::class, 'manualQueueIndex']);
    Route::post('manual-queue/{id}/resolve', [AdminSupplierIntegrationController::class, 'manualQueueResolve']);
});
```

(точная цепочка middleware `saas-admin` — мирорить существующий supplier-integration блок; **не верифицировал** имя alias'а в этой сессии.)

- [ ] **Step 10.5: Run test — PASS.**

```bash
cd app && ./vendor/bin/pest tests/Feature/Admin/SupplierManualQueueTest.php
```

Expected: 4/4 PASS.

- [ ] **Step 10.6: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php \
        app/routes/api.php \
        app/tests/Feature/Admin/SupplierManualQueueTest.php
git commit --only \
  app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php \
  app/routes/api.php \
  app/tests/Feature/Admin/SupplierManualQueueTest.php \
  -m "feat(supplier): admin endpoints for Tier 3 manual queue

GET /api/admin/supplier-integration/manual-queue — pending список.
POST /manual-queue/{id}/resolve — reconcile через listProjects(),
ставит FK на supplier_b{1,2,3}_project_id, помечает resolved.

Spec §4.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 11: Admin frontend — worklist section

**Files:**

- Modify: `app/resources/js/views/admin/AdminSupplierIntegrationView.vue`.
- Create: `app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts`.

- [ ] **Step 11.1: Write failing Vitest spec.**

`app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminSupplierIntegrationView from '../AdminSupplierIntegrationView.vue';
import axios from 'axios';

vi.mock('axios');

const vuetify = createVuetify();

describe('AdminSupplierIntegrationView — manual queue section', () => {
    beforeEach(() => {
        vi.clearAllMocks();
        (axios.get as any).mockImplementation((url: string) => {
            if (url.endsWith('/manual-queue')) {
                return Promise.resolve({ data: { queue: [
                    { id: 1, project_id: 42, platform: 'B1', operation: 'create',
                      payload_snapshot: { limit: 10, signal_type: 'site', unique_key: 'foo.com' },
                      failure_reason: 'contract_break', created_at: '2026-05-19T10:00:00Z' },
                ] } });
            }
            return Promise.resolve({ data: { health: null, history: [] } });
        });
    });

    it('renders pending queue rows with payload + reason', async () => {
        const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));

        const text = wrapper.text();
        expect(text).toContain('foo.com');
        expect(text).toContain('contract_break');
        expect(text).toContain('B1');
    });

    it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
        (axios.post as any).mockResolvedValue({ data: { resolved: true, external_id: 700123 } });

        const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));

        const btn = wrapper.find('[data-testid="resolve-1"]');
        expect(btn.exists()).toBe(true);
        await btn.trigger('click');

        expect(axios.post).toHaveBeenCalledWith(
            expect.stringContaining('/manual-queue/1/resolve'),
        );
    });
});
```

- [ ] **Step 11.2: Run vitest — FAIL.**

```bash
cd app && npx vitest run resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts
```

Expected: FAIL — text не содержит foo.com / no resolve button.

- [ ] **Step 11.3: Extend `AdminSupplierIntegrationView.vue`.**

Добавить в template (после существующих секций):

```vue
<v-card class="mt-4">
  <v-card-title>
    Ручная очередь
    <v-chip v-if="manualQueue.length" color="warning" class="ml-2">
      {{ manualQueue.length }}
    </v-chip>
  </v-card-title>
  <v-card-text>
    <v-alert v-if="manualQueueError" type="error">{{ manualQueueError }}</v-alert>
    <p v-else-if="!manualQueue.length" class="text-medium-emphasis">
      Очередь пуста — авто-фейловер не понадобился.
    </p>
    <v-table v-else density="compact">
      <thead>
        <tr>
          <th>Project</th><th>Платформа</th><th>Операция</th>
          <th>Параметры</th><th>Причина</th><th>Создано</th><th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in manualQueue" :key="row.id">
          <td>#{{ row.project_id }}</td>
          <td>{{ row.platform }}</td>
          <td>{{ row.operation }}</td>
          <td><code>{{ row.payload_snapshot.unique_key }}</code> · limit {{ row.payload_snapshot.limit ?? '—' }}</td>
          <td>{{ row.failure_reason }}</td>
          <td>{{ formatDate(row.created_at) }}</td>
          <td>
            <v-btn size="small" color="primary"
              :data-testid="`resolve-${row.id}`"
              :loading="resolvingId === row.id"
              @click="resolveRow(row.id)">
              Отметить выполнено
            </v-btn>
          </td>
        </tr>
      </tbody>
    </v-table>
  </v-card-text>
</v-card>
```

В `<script setup lang="ts">`:

```typescript
import { ref, onMounted } from 'vue';
import axios from 'axios';

interface ManualQueueRow {
    id: number;
    project_id: number;
    platform: string;
    operation: string;
    external_id: string | null;
    payload_snapshot: Record<string, any>;
    failure_reason: string;
    created_at: string;
}

const manualQueue = ref<ManualQueueRow[]>([]);
const manualQueueError = ref<string | null>(null);
const resolvingId = ref<number | null>(null);

async function loadManualQueue() {
    try {
        const r = await axios.get('/api/admin/supplier-integration/manual-queue');
        manualQueue.value = r.data.queue;
    } catch (e: any) {
        manualQueueError.value = e?.response?.data?.message || e.message || 'Failed to load queue';
    }
}

async function resolveRow(id: number) {
    if (!confirm('Подтверждаете, что внесли изменения в crm.bp-gr.ru?')) return;
    resolvingId.value = id;
    try {
        await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
        await loadManualQueue();
    } catch (e: any) {
        alert(e?.response?.data?.message || 'Resolve failed');
    } finally {
        resolvingId.value = null;
    }
}

function formatDate(s: string): string {
    return new Date(s).toLocaleString('ru-RU');
}

onMounted(() => {
    // existing onMounted logic stays
    loadManualQueue();
});
```

- [ ] **Step 11.4: Run vitest — PASS.**

```bash
cd app && npx vitest run resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts
```

Expected: 2/2 PASS.

- [ ] **Step 11.5: Build frontend.**

```bash
npm --prefix app run build
```

Expected: build success.

- [ ] **Step 11.6: Commit.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git add app/resources/js/views/admin/AdminSupplierIntegrationView.vue \
        app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts
git commit --only \
  app/resources/js/views/admin/AdminSupplierIntegrationView.vue \
  app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.manual-queue.spec.ts \
  -m "feat(supplier): manual queue section in AdminSupplierIntegrationView

Таблица pending + кнопка «Отметить выполнено» с confirm-диалогом.
Реюз существующего админ-экрана интеграции с поставщиком.

Spec §4.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 12: Live smoke + final regression

**Files:** не правим код. Проверочный шаг.

- [ ] **Step 12.1: Live smoke — happy path Tier 1.**

Создать тестовый Лидерра-проект через UI Лидерры (admin@demo.local), убедиться, что `SyncSupplierProjectJob` отправил его на портал поставщика через Tier 1 (AJAX). Проверить в `/admin/visit/rt` на стороне поставщика: проект появился с реквизитами из Лидерры.

Cleanup: удалить тестовый проект на обеих сторонах.

- [ ] **Step 12.2: Live smoke — Tier 2 (форс fail Tier 1).**

Временно сломать Tier 1 (через переопределение `AjaxProjectChannel` в DI на throwing-stub): заставить его бросить `SupplierClientException`. Создать новый тестовый проект → убедиться, что Tier 2 (browser) подхватил, проект на портале появился, FK у Лидерра-проекта проставлен.

Cleanup: убрать override, удалить тестовый проект.

- [ ] **Step 12.3: Live smoke — Tier 3 escalation.**

Форс fail обоих ярусов (override обоих channel'ов на throwing-stub). Создать проект → убедиться, что строка появилась в `supplier_manual_sync_queue`, алёрт-письмо ушло (queued mailer), в UI админ-экрана видна pending-запись.

Через UI «Отметить выполнено» (предварительно вручную создав проект на портале с теми же параметрами) — убедиться, что reconcile сработал, FK проставлен, статус `resolved`.

Cleanup: убрать форс-фейлы, удалить тестовый проект и очередь.

- [ ] **Step 12.4: Full regression.**

```bash
cd app && composer test -- --parallel 2>&1 | tail -30
```

Ожидаемо: ранее + ~27 новых тестов зелёные.

```bash
cd app && composer stan
```

Ожидаемо: 0 errors (или bump baseline если новый класс).

```bash
cd app && npx vitest run 2>&1 | tail -10
```

Ожидаемо: ранее + 2 новых specs.

- [ ] **Step 12.5: Pre-push hooks.**

```bash
cd "C:/моя/проекты/портал crm/Документация"
./bin/gitleaks.exe detect --redact -v
npm --prefix app run lint:md && npm --prefix app run links
```

Ожидаемо: 0 leaks, 0 markdown errors, 0 broken links.

- [ ] **Step 12.6: Финальный smoke schema.sql.**

```bash
cd app && php artisan migrate:fresh --env=testing 2>&1 | tail -10
./vendor/bin/pest tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php
```

Ожидаемо: миграция применяется чисто, schema-тесты PASS.

- [ ] **Step 12.7: Финальный commit «closure marker».**

```bash
cd "C:/моя/проекты/портал crm/Документация"
git status
git commit --allow-empty -m "docs(supplier): closure — project channel failover epic

Все 12 задач плана docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md
выполнены. Регрессия зелёная: Pest + larastan 0 + Vitest + lefthook
pre-push + lychee. Live smoke прошёл по всем трём ярусам.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

- [ ] **Step 12.8: Push.**

```bash
git push origin feat/supplier-project-failover:main
```

Ожидаемо: lefthook pre-push (gitleaks-full + lychee) зелёный, push принят.

---

## Self-Review

**Spec coverage** — все секции §1-§11 покрыты:

- §1 Цель → Goal + Task 1 verification.
- §2 Граница и пределы → отражено в спеке-источнике, в плане не дублируется.
- §3 Архитектура трёх ярусов → Tasks 2 (interface+Ajax), 6+7 (Form), 4+5 (Failover+dedup).
- §4 Компоненты — все семь подпунктов: §4.1 interface (Task 2), §4.2 Ajax (Task 2), §4.3 Form (Task 6+7), §4.4 Failover (Task 4+5), §4.5 supplier_manual_sync_queue (Task 3), §4.6 Admin UI (Task 10+11), §4.7 Schedule (Task 9).
- §5 Поток данных → Task 8 (job wiring).
- §6 Обработка ошибок → Task 4 (escalation matrix).
- §7 Идемпотентность → Task 5.
- §8 Временное окно (WindowDeferred) → Task 4 (exception class + test).
- §9 Тестирование → каждая task имеет тест-шаг.
- §10 YAGNI → отражено как «Out of scope».
- §11 Журнал решений → решения отражены в коде/комментариях соответствующих task'ов.

**Placeholder-скан:** `<ACTUAL_PATH_FROM_RECON>` в Task 1 — намеренный шаблон, Step 1.3 заполняет фактическими значениями из живой разведки. Не plan-failure, а discovery-output.

**Type consistency:** `SupplierProjectChannel` интерфейс с `createProject/updateProject/listProjects` единообразно используется во всех task'ах. `createProjectForLiderra/updateProjectForLiderra` (bridge-методы) определены в Task 4 и используются в Task 8 — имена согласованы. `SupplierManualSyncQueue` модель — Task 3, используется в Task 4 (escalateToTier3), Task 10, Task 11. `TierEscalatedException` и `WindowDeferredException` — Task 4, ловятся в Task 8 jobs.

---

**Plan complete and saved to `docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md`.**

Два варианта исполнения:

**1. Subagent-Driven (recommended)** — свежий субагент на каждую задачу, двухстадийное ревью между задачами (spec compliance → code quality), быстрая итерация. Sonnet/Opus обязательны для git-задач (Pravila §15.1; см. memory `feedback_subagent_git_reliability.md`).

**2. Inline Execution** — батч в этой сессии через `superpowers:executing-plans`, чекпоинты для ревью.

Какой подход?
