# Plan 3 (Supplier Sync) 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:** Закрыть BLOCKER #6 (`failed_webhook_jobs` RLS NULL tenant) + WARN #2/#3 (Plan 2.6 backlog про LeadRouter/ResetCmd под `crm_app_user`) и реализовать направление Лидерра → Поставщик: Playwright session manager, 20:30 МСК cron `SyncSupplierProjectsJob`, 180d cleanup, автоматический retry failed jobs.

**Architecture:** 9 Tasks в 2 фазах. Фаза I — Discovery (Tasks 1–2): через `mcp__playwright__browser_*` MCP записываем 5 HTTP-фиксаций реальных AJAX-команд поставщика crm.bp-gr.ru; обновляем parent spec §4.4. Фаза II — Implementation (Tasks 3–9): переключение supplier-flow на `pgsql_supplier` connection с `crm_supplier_worker` BYPASSRLS-ролью (закрывает BLOCKER #6 + WARN #2/#3 одной правкой), `SupplierPortalClient` HTTP-обёртка, `RefreshSupplierSessionJob` + Node.js Playwright subprocess, `SyncSupplierProjectsJob` + чистый allocator, `CleanupInactiveSupplierProjectsJob` с порядком Phase A→B→C, `RetryFailedSupplierJobsCommand`, E2E integration test (Linux CI only). 0 schema changes.

**Tech Stack:** PHP 8.3, Laravel 13.7, Pest 4, PostgreSQL 16 (`pgsql_supplier` connection с BYPASSRLS, role `crm_supplier_worker` уже создана Plan 2.6 #iv `7899071`), Redis 7 (Memurai на dev), Node.js + Playwright `chromium` (headless), Symfony Process (PHP-subprocess bridge), Mockery, Larastan, Unisender Go (email алерты).

**Parent spec:** [2026-05-11-plan3-supplier-sync-design.md](../specs/2026-05-11-plan3-supplier-sync-design.md) (commit `1a265b5`).
**Inherits from:** Plan 1 Foundation `001d781` + Plan 2 Webhook+Routing `d5aa972` + Plan 2.5 hotfix `c1ae195`+`1ba1df8` + Plan 2.6 cleanup `7899071`.

---

## Карта файлов

| Файл | Действие | Назначение | Task |
|---|---|---|---|
| `app/tests/Fixtures/SupplierPortal/login.json` | Create | Discovery HAR login → cookie+CSRF response | 1 |
| `app/tests/Fixtures/SupplierPortal/projects-load.json` | Create | Discovery rt-projects-load запрос+ответ | 1 |
| `app/tests/Fixtures/SupplierPortal/project-save.json` | Create | Discovery rt-project-save запрос+ответ | 1 |
| `app/tests/Fixtures/SupplierPortal/project-update.json` | Create | Discovery rt-project-update запрос+ответ | 1 |
| `app/tests/Fixtures/SupplierPortal/project-delete.json` | Create | Discovery rt-project-delete запрос+ответ | 1 |
| [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md) | Modify | Добавить §4.4 «AJAX endpoints — observed formats» + bump v1.0 → v1.1 | 2 |
| [app/config/database.php](../../app/config/database.php) | Modify | +ключ `pgsql_supplier` (BYPASSRLS connection) | 3 |
| [app/.env.example](../../app/.env.example) | Modify | +`DB_SUPPLIER_USERNAME`/`DB_SUPPLIER_PASSWORD`/`SUPPLIER_LOGIN`/`SUPPLIER_PASSWORD`/`SUPPLIER_PORTAL_URL`/`SUPPLIER_ALERT_EMAIL` | 3 |
| [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) | Modify | `protected $connection = 'pgsql_supplier'` + удалить inline-warnings 258-263 | 3 |
| [app/app/Services/LeadRouter.php](../../app/app/Services/LeadRouter.php) | Modify | `Project::on('pgsql_supplier')` + удалить inline-warnings 25-29 | 3 |
| [app/app/Console/Commands/ResetDeliveredTodayCommand.php](../../app/app/Console/Commands/ResetDeliveredTodayCommand.php) | Modify | `Project::on('pgsql_supplier')` + удалить inline-warnings 16-18 | 3 |
| `app/tests/Feature/Supplier/SupplierConnectionTest.php` | Create | Regression tests BLOCKER #6 + WARN #2 + WARN #3 | 3 |
| `app/app/Services/Supplier/Dto/SupplierProjectDto.php` | Create | Read-only DTO (platform/signal_type/unique_key/limit/workdays/regions/status) | 4 |
| `app/app/Exceptions/Supplier/SupplierException.php` | Create | Abstract base exception | 4 |
| `app/app/Exceptions/Supplier/SupplierAuthException.php` | Create | 401/403 sticky после refresh-retry | 4 |
| `app/app/Exceptions/Supplier/SupplierTransientException.php` | Create | 5xx/network/timeout retryable | 4 |
| `app/app/Exceptions/Supplier/SupplierClientException.php` | Create | 4xx 400/404/422 non-retryable | 4 |
| `app/app/Services/Supplier/SupplierPortalClient.php` | Create | HTTP-обёртка над rt-* + Redis cookie/CSRF + retry на 401 | 4 |
| `app/tests/Unit/Supplier/SupplierPortalClientTest.php` | Create | Unit tests через Http::fake | 4 |
| `app/playwright/package.json` | Create | Изолированная npm dep `playwright` | 5 |
| `app/playwright/refresh-session.js` | Create | Node subprocess: chromium → login → cookies+CSRF → stdout JSON | 5 |
| `app/app/Services/Supplier/PlaywrightBridge.php` | Create | Symfony Process обёртка над node subprocess | 5 |
| `app/app/Jobs/Supplier/RefreshSupplierSessionJob.php` | Create | Spawn PlaywrightBridge + Redis lock + 6h TTL cache | 5 |
| `app/app/Console/Commands/SupplierSessionRefreshCommand.php` | Create | `supplier:session:refresh` (ручной запуск) | 5 |
| `app/tests/Unit/Supplier/PlaywrightBridgeTest.php` | Create | Unit с mocked Symfony\Process | 5 |
| `app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php` | Create | Unit с mocked PlaywrightBridge | 5 |
| `app/app/Services/Supplier/SupplierQuotaAllocator.php` | Create | Pure function: B1/B2/B3 distribution + workdays/regions union | 6 |
| `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` | Create | 20:30 МСК cron + per-supplier_project isolation + mass-fail abort | 6 |
| `app/app/Mail/SupplierCriticalAlertMail.php` | Create | Mailable для critical alerts (email via Unisender) | 6 |
| `app/config/services.php` | Modify | Добавить `supplier.alert_email` ключ | 6 |
| `app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php` | Create | 9 unit-тестов на distribution-математику | 6 |
| `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` | Create | Integration tests с Http::fake | 6 |
| `app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php` | Create | Phase A re-activate → B mark → C delete 180d | 7 |
| `app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php` | Create | Integration + критический ordering test | 7 |
| `app/app/Console/Commands/RetryFailedSupplierJobsCommand.php` | Create | `supplier:retry-failed` hourly + лимит 10/24h | 8 |
| `app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php` | Create | Integration tests | 8 |
| `app/routes/console.php` | Modify | +5 Schedule entries | 8 |
| `app/tests/Browser/SupplierIntegrationE2ETest.php` | Create | Linux CI only mock-server → full flow | 9 |
| [lefthook.yml](../../lefthook.yml) | Modify | +2 jobs (playwright-fixtures-syntax + pest-supplier-fast) | 9 |

---

## Task 1: Discovery через Playwright MCP — записать 5 HTTP-фиксаций реальных AJAX-команд поставщика

**Files:**

- Create: `app/tests/Fixtures/SupplierPortal/login.json`
- Create: `app/tests/Fixtures/SupplierPortal/projects-load.json`
- Create: `app/tests/Fixtures/SupplierPortal/project-save.json`
- Create: `app/tests/Fixtures/SupplierPortal/project-update.json`
- Create: `app/tests/Fixtures/SupplierPortal/project-delete.json`

**Prerequisites (BLOCKER для старта):**

- Заказчик передал `SUPPLIER_LOGIN` + `SUPPLIER_PASSWORD` через защищённый канал (НЕ plaintext в чат).
- Заказчик дал явное локальное снятие Pravila §6 на эту discovery-сессию.

**Не TDD** — это manual probe; артефакт — 5 JSON-fixtures для использования в Task 4 `Http::fake()`.

- [ ] **Step 1: Получить credentials и сохранить в `app/.env`**

```bash
# Заказчик передаёт credentials. Записать в app/.env (не коммитить!):
# SUPPLIER_LOGIN=<login_от_заказчика>
# SUPPLIER_PASSWORD=<password_от_заказчика>
# SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
```

Verify: `grep -c "SUPPLIER_LOGIN" app/.env` должен вернуть 1.

- [ ] **Step 2: Создать директорию для фикстур**

```bash
mkdir -p app/tests/Fixtures/SupplierPortal
```

Verify: `ls -la app/tests/Fixtures/SupplierPortal` существует, пуст.

- [ ] **Step 3: Через Playwright MCP залогиниться, записать login fixture**

Использовать MCP-tools:

1. `mcp__playwright__browser_navigate` → `https://crm.bp-gr.ru/admin/user/login` (или другой login URL — узнаем при навигации)
2. `mcp__playwright__browser_snapshot` — увидеть DOM, найти input-names для login/password
3. `mcp__playwright__browser_fill_form` → заполнить login + password
4. `mcp__playwright__browser_network_requests` — записать ВСЕ requests за login flow
5. `mcp__playwright__browser_click` на submit
6. Снова `mcp__playwright__browser_network_requests` — увидеть login POST с cookie+CSRF response

Сохранить в `app/tests/Fixtures/SupplierPortal/login.json`:

```json
{
  "step": "login",
  "request": {
    "method": "POST",
    "url": "<observed-url>",
    "headers": {
      "<observed-headers>"
    },
    "body": "<observed-body-form-or-json>"
  },
  "response": {
    "status": 200,
    "headers": {
      "set-cookie": [
        "<observed-cookies>"
      ]
    },
    "body": "<observed-response-body-or-redirect>"
  },
  "extracted": {
    "session_cookie_name": "<PHPSESSID-или-JSESSIONID-или-другое>",
    "csrf_token_selector": "<meta[name=csrf-token] или hidden input или другое>",
    "csrf_token_value": "<observed-value>"
  },
  "discovered_at": "2026-05-11T..."
}
```

- [ ] **Step 4: Записать projects-load fixture (rt-projects-load)**

Через MCP:

1. `mcp__playwright__browser_navigate` → URL списка проектов (например `/admin/project/list`)
2. `mcp__playwright__browser_network_requests` — найти AJAX-запрос с `rt-projects-load` или аналог
3. Извлечь полный request+response

Сохранить в `app/tests/Fixtures/SupplierPortal/projects-load.json` тем же форматом (request/response).

- [ ] **Step 5: Записать project-save fixture (rt-project-save)**

Через MCP создать тестовый проект `__claude_probe_<unix_timestamp>`:

1. `mcp__playwright__browser_navigate` → форма создания проекта
2. `mcp__playwright__browser_fill_form` → заполнить минимум обязательных полей (name=`__claude_probe_<ts>`, signal_type=`site`, identifier=`example.com`, daily_limit=`1`, workdays=`Пн-Пт`)
3. `mcp__playwright__browser_click` Save
4. `mcp__playwright__browser_network_requests` — извлечь rt-project-save request+response (response должен содержать новый supplier_external_id)

Сохранить в `app/tests/Fixtures/SupplierPortal/project-save.json`. Запомнить `supplier_external_id` для Step 6/7.

- [ ] **Step 6: Записать project-update fixture (rt-project-update)**

Через MCP изменить daily_limit тестового проекта на 2:

1. Navigate в edit-форму
2. Изменить daily_limit
3. Submit
4. `mcp__playwright__browser_network_requests` — извлечь rt-project-update

Сохранить в `app/tests/Fixtures/SupplierPortal/project-update.json`.

- [ ] **Step 7: Записать project-delete fixture (rt-project-delete)**

Через MCP удалить тестовый проект `__claude_probe_<ts>`:

1. Найти Delete button или контекстное меню
2. Click Delete
3. `mcp__playwright__browser_handle_dialog` accept (если confirm dialog)
4. `mcp__playwright__browser_network_requests` — извлечь rt-project-delete

Сохранить в `app/tests/Fixtures/SupplierPortal/project-delete.json`. Запомнить: вернул ли поставщик 200/204/404, soft-delete или hard?

- [ ] **Step 8: Verify все 5 fixtures валидные JSON**

```bash
for f in app/tests/Fixtures/SupplierPortal/*.json; do
  echo "Checking: $f"
  python -m json.tool "$f" > /dev/null && echo "  OK" || echo "  INVALID JSON"
done
```

Expected: 5 «OK», 0 «INVALID JSON».

- [ ] **Step 9: Verify нет credentials в фикстурах**

```bash
grep -i "$SUPPLIER_LOGIN\|$SUPPLIER_PASSWORD" app/tests/Fixtures/SupplierPortal/*.json && echo "LEAK FOUND" || echo "Clean"
```

Expected: «Clean». Если LEAK FOUND — заменить значение на `<REDACTED>` или dummy `test_login`/`test_password`.

- [ ] **Step 10: Commit fixtures**

```bash
git add app/tests/Fixtures/SupplierPortal/
git commit -m "feat(supplier): Plan 3 Task 1 — discovery fixtures (5 rt-* HTTP-recordings)

Записаны через mcp__playwright__browser_network_requests на crm.bp-gr.ru
с явным локальным снятием Pravila §6 на discovery-сессию.

Артефакт: 5 JSON-фиксаций (login, projects-load, project-save,
project-update, project-delete) → baseline для Task 4 Http::fake().

Тестовый supplier-проект __claude_probe_<ts> создан и удалён за собой."
```

Verify: `git log --oneline -1` показывает новый commit.

---

## Task 2: Spec update — добавить §4.4 «AJAX endpoints — observed formats»

**Files:**

- Modify: [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md)

**Не TDD** — pure documentation.

- [ ] **Step 1: Открыть spec, добавить §4.4 после §4.3 «Резервирование push'а настроек»**

Контент §4.4 базируется на разобранных Task 1 фикстурах. Структура:

```markdown
### 4.4. AJAX endpoints — observed formats (v1.1, 2026-05-11)

Разобрано через Playwright MCP discovery 2026-05-11 (см. fixtures
`app/tests/Fixtures/SupplierPortal/*.json`).

**Login flow:**
- URL: `<observed>`
- Method: POST application/x-www-form-urlencoded
- Поля: `login` / `password` (или альтернативные имена)
- Success signal: `<observed redirect или cookie>`
- Session cookie: `<PHPSESSID|JSESSIONID|...>`, HttpOnly: true/false
- CSRF: `<meta name=csrf-token content=...>` ИЛИ hidden input ИЛИ header

**rt-projects-load:**
- URL: `<observed>`
- Method: GET/POST
- Response: JSON `[{id, name, signal_type, ...}]` или другой формат

**rt-project-save:**
- URL: `<observed>`
- Method: POST
- Required fields: `<observed>`
- Response: `{id: <new_id>, ...}` или другой

**rt-project-update:**
- URL: `<observed>`  
- Method: POST/PATCH
- Required fields: `<observed>`
- Принимает: полный объект ИЛИ diff

**rt-project-delete:**
- URL: `<observed>`
- Method: POST/DELETE
- Семантика: soft / hard
- Response при повторе: 200 / 404

**HTTP error semantics:**
- bad payload: <400|422>
- auth fail: <401|403>
- rate limit: <429 если есть>
```

- [ ] **Step 2: Bump версию spec'а v1.0 → v1.1 в шапке**

В начале файла найти `**Дата:** 2026-05-10` и заменить на:

```markdown
**Дата:** 2026-05-10 (v1.0) → 2026-05-11 (v1.1 — Plan 3 Task 2: добавлен §4.4 AJAX observed formats)
```

- [ ] **Step 3: Lint markdown локально**

```bash
npx markdownlint-cli2 docs/superpowers/specs/2026-05-10-supplier-integration-design.md
```

Expected: 0 errors. Если есть — fix.

- [ ] **Step 4: Commit**

```bash
git add docs/superpowers/specs/2026-05-10-supplier-integration-design.md
git commit -m "docs(specs): Plan 3 Task 2 — spec v1.0 → v1.1, добавлен §4.4 observed AJAX formats

Базируется на 5 фиксациях Task 1. Закрывает open question §11 spec'а
(«план реализации после подтверждения дизайна»)."
```

- [ ] **Step 5: STOP-GATE — ждать «ок, формат разобран корректно» от заказчика**

Перед стартом Task 4 (SupplierPortalClient) необходимо подтверждение что разобранные формы корректны. Task 3 можно начинать параллельно (он не зависит от формата AJAX).

---

## Task 3: Switch supplier-flow на `pgsql_supplier` (BYPASSRLS) — закрыть BLOCKER #6 + WARN #2 + WARN #3

**Files:**

- Modify: `app/config/database.php`
- Modify: `app/.env.example`
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
- Modify: `app/app/Services/LeadRouter.php`
- Modify: `app/app/Console/Commands/ResetDeliveredTodayCommand.php`
- Create: `app/tests/Feature/Supplier/SupplierConnectionTest.php`

- [ ] **Step 1: Добавить `pgsql_supplier` connection в `app/config/database.php`**

Найти секцию `'connections' => [...]` и добавить рядом с существующим `'pgsql' => [...]`:

```php
'pgsql_supplier' => array_merge(
    config('database.connections.pgsql', []),
    [
        'username' => env('DB_SUPPLIER_USERNAME', env('DB_USERNAME')),
        'password' => env('DB_SUPPLIER_PASSWORD', env('DB_PASSWORD')),
        'options' => array_merge(
            config('database.connections.pgsql.options', []),
            ['_role_purpose' => 'crm_supplier_worker (BYPASSRLS) for supplier-flow jobs']
        ),
    ]
),
```

- [ ] **Step 2: Добавить env-keys в `app/.env.example`**

В конец файла (или в соответствующую секцию):

```
# Supplier Sync (Plan 3) — crm_supplier_worker BYPASSRLS connection
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=

# Supplier Portal credentials (Playwright login)
SUPPLIER_LOGIN=
SUPPLIER_PASSWORD=
SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru

# Supplier alerts (email через Unisender Go relay)
SUPPLIER_ALERT_EMAIL=
```

Дублировать те же entries в `app/.env` локально (без коммита `.env`).

- [ ] **Step 3: Создать failing test — RouteSupplierLeadJob использует pgsql_supplier**

Create `app/tests/Feature/Supplier/SupplierConnectionTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Models\Project;
use App\Models\FailedWebhookJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

test('RouteSupplierLeadJob uses pgsql_supplier connection by default', function () {
    $job = new RouteSupplierLeadJob(supplierLeadId: 1);
    expect((new ReflectionProperty($job, 'connection'))->getValue($job))
        ->toBe('pgsql_supplier');
});
```

- [ ] **Step 4: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="uses pgsql_supplier connection" 2>&1
```

Expected: FAIL — `$connection` undefined or = `null` (default).

- [ ] **Step 5: Установить `$connection = 'pgsql_supplier'` в RouteSupplierLeadJob**

Открыть [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php). В начале класса добавить:

```php
class RouteSupplierLeadJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Plan 3 Task 3: supplier-flow работает под BYPASSRLS-роль crm_supplier_worker.
     * Закрывает BLOCKER #6 (NULL tenant в failed_webhook_jobs) + WARN #2/#3
     * (LeadRouter/ResetCmd под crm_app_user без tenant-context).
     * Brainstorm decision: вариант C, см. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
     */
    public $connection = 'pgsql_supplier';

    // ... rest of class
}
```

Также удалить inline-warnings lines 258-263 (старый комментарий о RLS-проблеме failed_webhook_jobs — теперь закрыто).

- [ ] **Step 6: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="uses pgsql_supplier connection" 2>&1
```

Expected: PASS.

- [ ] **Step 7: Failing test — failed_webhook_jobs accepts NULL tenant (BLOCKER #6 regression)**

В тот же `SupplierConnectionTest.php` добавить:

```php
test('failed_webhook_jobs INSERT with tenant_id=NULL succeeds under pgsql_supplier (BLOCKER #6)', function () {
    // Имитируем INSERT который раньше падал silent под crm_app_user RLS
    DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
        'tenant_id' => null,
        'payload' => json_encode(['vid' => 42, 'project' => 'B1_test.ru']),
        'error_message' => 'simulated failure',
        'failed_at' => now(),
    ]);

    expect(
        DB::connection('pgsql_supplier')
            ->table('failed_webhook_jobs')
            ->whereNull('tenant_id')
            ->where('error_message', 'simulated failure')
            ->exists()
    )->toBeTrue();
});
```

- [ ] **Step 8: Run test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="BLOCKER #6" 2>&1
```

Expected: PASS — `crm_supplier_worker` BYPASSRLS пропускает NULL tenant_id. Если на dev `pgsql_supplier` user = `postgres` (superuser) тоже BYPASSRLS, тест проходит.

- [ ] **Step 9: Failing test — LeadRouter sees all tenant projects under BYPASSRLS (WARN #2 regression)**

В тот же test-файл:

```php
test('LeadRouter::matchEligibleProjects sees projects across tenants under pgsql_supplier (WARN #2)', function () {
    // Создаём 3 tenant'а × 2 supplier-eligible projects (signal_type=site, has B1)
    $supplierProject = \App\Models\SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
    ]);

    $tenants = Tenant::factory()->count(3)->create();
    foreach ($tenants as $tenant) {
        Project::factory()->count(2)->create([
            'tenant_id' => $tenant->id,
            'status' => 'active',
            'signal_type' => 'site',
            'signal_identifier' => 'example.com',
            'supplier_b1_project_id' => $supplierProject->id,
            'delivered_today' => 0,
            'daily_limit' => 10,
        ]);
    }

    // БЕЗ SET LOCAL — LeadRouter должен видеть все 6 проектов через BYPASSRLS
    $router = app(\App\Services\LeadRouter::class);
    $eligible = $router->matchEligibleProjects(
        signalType: 'site',
        signalIdentifier: 'example.com',
        regionMask: 255,
    );

    expect($eligible)->toHaveCount(6);
});
```

- [ ] **Step 10: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #2" 2>&1
```

Expected: FAIL — LeadRouter использует default connection (`pgsql`), под `crm_app_user` без SET LOCAL вернёт 0 projects (или вернёт 6 если на dev `pgsql` user = `postgres` superuser; тогда тест зелёный изначально — это OK на dev, на prod тест отловит регрессию).

- [ ] **Step 11: Изменить LeadRouter на `Project::on('pgsql_supplier')`**

Открыть [app/app/Services/LeadRouter.php](../../app/app/Services/LeadRouter.php). В методе `matchEligibleProjects` заменить:

```php
// Было:
return Project::where('status', 'active')
    ->where('signal_type', $signalType)
    ->where('signal_identifier', $signalIdentifier)
    // ...
    ->get();

// Стало:
return Project::on('pgsql_supplier')
    ->where('status', 'active')
    ->where('signal_type', $signalType)
    ->where('signal_identifier', $signalIdentifier)
    // ...
    ->get();
```

Удалить inline-warnings lines 25-29 (старый комментарий о RLS-проблеме — теперь закрыто).

- [ ] **Step 12: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #2" 2>&1
```

Expected: PASS.

- [ ] **Step 13: Failing test — ResetDeliveredTodayCommand updates all tenants (WARN #3)**

В тот же test-файл:

```php
test('ResetDeliveredTodayCommand resets delivered_today across all tenants under pgsql_supplier (WARN #3)', function () {
    $tenants = Tenant::factory()->count(3)->create();
    foreach ($tenants as $tenant) {
        Project::factory()->create([
            'tenant_id' => $tenant->id,
            'delivered_today' => 5,
        ]);
    }

    // Запускаем команду БЕЗ SET LOCAL
    $this->artisan('projects:reset-delivered-today')->assertExitCode(0);

    // Все 3 tenant'а должны быть reset через BYPASSRLS
    $remaining = DB::connection('pgsql_supplier')
        ->table('projects')
        ->where('delivered_today', '>', 0)
        ->count();

    expect($remaining)->toBe(0);
});
```

- [ ] **Step 14: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #3" 2>&1
```

Expected: FAIL on prod (или PASS на dev superuser).

- [ ] **Step 15: Изменить ResetDeliveredTodayCommand на `Project::on('pgsql_supplier')`**

Открыть [app/app/Console/Commands/ResetDeliveredTodayCommand.php](../../app/app/Console/Commands/ResetDeliveredTodayCommand.php). В `handle()` заменить:

```php
// Было:
$count = Project::where('delivered_today', '!=', 0)->update(['delivered_today' => 0]);

// Стало:
$count = Project::on('pgsql_supplier')
    ->where('delivered_today', '!=', 0)
    ->update(['delivered_today' => 0]);
```

Удалить inline-warnings lines 16-18.

- [ ] **Step 16: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php 2>&1
```

Expected: все 4 теста (uses-connection + BLOCKER#6 + WARN#2 + WARN#3) PASS.

- [ ] **Step 17: Запустить полный test-suite для regression**

```bash
cd app && php vendor/bin/pest --parallel 2>&1 | tail -20
```

Expected: ≥562/562 PASS (Plan 2.6 baseline 558/556 + 4 новых). 0 failed.

- [ ] **Step 18: Запустить larastan**

```bash
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: 0 errors above baseline.

- [ ] **Step 19: Commit**

```bash
git add app/config/database.php app/.env.example \
        app/app/Jobs/RouteSupplierLeadJob.php \
        app/app/Services/LeadRouter.php \
        app/app/Console/Commands/ResetDeliveredTodayCommand.php \
        app/tests/Feature/Supplier/SupplierConnectionTest.php

git commit -m "feat(supplier): Plan 3 Task 3 — switch supplier-flow на pgsql_supplier (BYPASSRLS)

Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
  (BYPASSRLS обходит RLS-политику отвергавшую NULL)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов

Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.

Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.

+4 теста (uses-connection + BLOCKER#6 + WARN#2 + WARN#3) в Feature/Supplier/SupplierConnectionTest.php.
0 schema changes."
```

---

## Task 4: SupplierPortalClient — HTTP-обёртка над rt-* с Redis cookie/CSRF + retry на 401

**Files:**

- Create: `app/app/Services/Supplier/Dto/SupplierProjectDto.php`
- Create: `app/app/Exceptions/Supplier/SupplierException.php`
- Create: `app/app/Exceptions/Supplier/SupplierAuthException.php`
- Create: `app/app/Exceptions/Supplier/SupplierTransientException.php`
- Create: `app/app/Exceptions/Supplier/SupplierClientException.php`
- Create: `app/app/Services/Supplier/SupplierPortalClient.php`
- Create: `app/tests/Unit/Supplier/SupplierPortalClientTest.php`

- [ ] **Step 1: Создать DTO SupplierProjectDto**

`app/app/Services/Supplier/Dto/SupplierProjectDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Dto;

use App\Models\SupplierProject;

/**
 * Read-only DTO для передачи aggregate-состояния supplier_project
 * между SupplierQuotaAllocator и SupplierPortalClient.
 */
final readonly class SupplierProjectDto
{
    public function __construct(
        public string $platform,           // B1 / B2 / B3
        public string $signalType,         // site / call / sms
        public string $uniqueKey,          // domain / phone / sender+keyword / sender
        public int $limit,                 // daily quota
        public array $workdays,            // [1,2,3,4,5,6,7] (Пн..Вс)
        public array $regions,             // массив кодов регионов РФ
        public bool $regionsReverse,       // false = include (default), true = exclude
        public string $status,             // active / paused
    ) {}

    public static function fromModel(SupplierProject $sp): self
    {
        return new self(
            platform: $sp->platform,
            signalType: $sp->signal_type,
            uniqueKey: $sp->unique_key,
            limit: $sp->current_limit,
            workdays: (array) ($sp->current_workdays ?? []),
            regions: (array) ($sp->current_regions ?? []),
            regionsReverse: false,
            status: $sp->inactive_since === null ? 'active' : 'paused',
        );
    }

    public function equals(self $other): bool
    {
        return $this->platform === $other->platform
            && $this->signalType === $other->signalType
            && $this->uniqueKey === $other->uniqueKey
            && $this->limit === $other->limit
            && $this->workdays === $other->workdays
            && $this->regions === $other->regions
            && $this->regionsReverse === $other->regionsReverse
            && $this->status === $other->status;
    }
}
```

- [ ] **Step 2: Создать abstract base exception**

`app/app/Exceptions/Supplier/SupplierException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

use RuntimeException;

abstract class SupplierException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $httpStatus = null,
        public readonly ?string $responseBody = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
```

- [ ] **Step 3: Создать 3 concrete exceptions**

`app/app/Exceptions/Supplier/SupplierAuthException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * Sticky 401/403 — Task 5 RefreshSupplierSessionJob не помог. Эскалация
 * через email + Sentry critical.
 */
final class SupplierAuthException extends SupplierException
{
}
```

`app/app/Exceptions/Supplier/SupplierTransientException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * 5xx, network timeout, connection refused — retryable на job-уровне.
 */
final class SupplierTransientException extends SupplierException
{
}
```

`app/app/Exceptions/Supplier/SupplierClientException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * 4xx (400/404/422) — наша ошибка payload'а, retry бесполезен.
 */
final class SupplierClientException extends SupplierException
{
}
```

- [ ] **Step 4: Failing test — attachSession adds cookies and CSRF header**

Create `app/tests/Unit/Supplier/SupplierPortalClientTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Exceptions\Supplier\SupplierClientException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'abc123sessid',
        'csrf' => 'csrf-token-xyz',
        'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));

    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

afterEach(function () {
    Cache::store('redis')->forget('supplier:session');
});

test('listProjects attaches PHPSESSID cookie and X-CSRF-Token header', function () {
    Http::fake([
        'crm.bp-gr.ru/admin/rt-projects-load*' => Http::response('[]', 200),
    ]);

    app(SupplierPortalClient::class)->listProjects();

    Http::assertSent(function ($request) {
        return str_contains($request->header('Cookie')[0] ?? '', 'PHPSESSID=abc123sessid')
            && ($request->header('X-CSRF-Token')[0] ?? '') === 'csrf-token-xyz';
    });
});
```

- [ ] **Step 5: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="attaches PHPSESSID" 2>&1
```

Expected: FAIL — `SupplierPortalClient` class not found.

- [ ] **Step 6: Создать минимальный SupplierPortalClient**

`app/app/Services/Supplier/SupplierPortalClient.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;

final class SupplierPortalClient
{
    public function __construct(
        private readonly HttpFactory $http,
    ) {}

    public function listProjects(): array
    {
        $response = $this->request('GET', '/admin/rt-projects-load');
        return $response->json() ?? [];
    }

    public function saveProject(SupplierProjectDto $dto): int
    {
        $response = $this->request('POST', '/admin/rt-project-save', $this->toPayload($dto));
        return (int) ($response->json('id') ?? 0);
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $this->request('POST', '/admin/rt-project-update', array_merge(
            ['id' => $externalId],
            $this->toPayload($dto)
        ));
    }

    public function deleteProject(int $externalId): void
    {
        $this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
    }

    private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
    {
        $session = $this->loadSession();
        $url = rtrim(config('services.supplier.portal_url'), '/') . $path;

        $request = $this->http
            ->withCookies(['PHPSESSID' => $session['phpsessid']], parse_url($url, PHP_URL_HOST))
            ->withHeaders(['X-CSRF-Token' => $session['csrf']])
            ->timeout(30)
            ->retry(0);  // retry handled here, не через Laravel HTTP retry

        try {
            $response = $request->send($method, $url, [
                'form_params' => $body,
            ]);
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
            throw new SupplierTransientException(
                "Network error: {$e->getMessage()}",
                previous: $e,
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            if ($isRetry) {
                throw new SupplierAuthException(
                    "Sticky auth failure on {$path}",
                    httpStatus: $response->status(),
                    responseBody: $response->body(),
                );
            }
            dispatch_sync(new RefreshSupplierSessionJob);
            return $this->request($method, $path, $body, isRetry: true);
        }

        if ($response->status() >= 500) {
            throw new SupplierTransientException(
                "Supplier server error {$response->status()} on {$path}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        if ($response->status() >= 400) {
            throw new SupplierClientException(
                "Supplier rejected {$path}: HTTP {$response->status()}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        return $response;
    }

    private function loadSession(): array
    {
        $session = Cache::store('redis')->get('supplier:session');
        if ($session === null || !isset($session['phpsessid'], $session['csrf'])) {
            dispatch_sync(new RefreshSupplierSessionJob);
            $session = Cache::store('redis')->get('supplier:session');
            if ($session === null) {
                throw new SupplierAuthException('Session refresh failed: cache still empty');
            }
        }
        return $session;
    }

    private function toPayload(SupplierProjectDto $dto): array
    {
        return [
            'platform' => $dto->platform,
            'signal_type' => $dto->signalType,
            'unique_key' => $dto->uniqueKey,
            'limit' => $dto->limit,
            'workdays' => $dto->workdays,
            'regions' => $dto->regions,
            'regions_reverse' => $dto->regionsReverse ? 1 : 0,
            'status' => $dto->status,
        ];
    }
}
```

⚠ **NOTE**: Payload-shape `toPayload()` — placeholder, точные поля будут адаптированы после Task 1 discovery (Task 2 spec update §4.4). При расхождении — обновить этот метод в отдельном commit'е перед Task 6.

- [ ] **Step 7: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="attaches PHPSESSID" 2>&1
```

Expected: PASS.

- [ ] **Step 8: Failing test — 401 triggers RefreshSupplierSessionJob and retries once**

В тот же test-файл:

```php
test('401 triggers RefreshSupplierSessionJob sync and retries once', function () {
    Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    Http::fakeSequence('crm.bp-gr.ru/*')
        ->push('Unauthorized', 401)
        ->push('[]', 200);

    app(SupplierPortalClient::class)->listProjects();

    Illuminate\Support\Facades\Bus::assertDispatchedSync(\App\Jobs\Supplier\RefreshSupplierSessionJob::class);
    Http::assertSentCount(2);
});

test('sticky 401 throws SupplierAuthException after one retry', function () {
    Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    Http::fakeSequence('crm.bp-gr.ru/*')
        ->push('Unauthorized', 401)
        ->push('Still unauthorized', 401);

    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierAuthException::class);
});
```

- [ ] **Step 9: Run tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1
```

Expected: PASS — все 3 теста зелёные. Реализация Step 6 уже покрывает retry-logic.

- [ ] **Step 10: Failing tests — 5xx → SupplierTransientException, 4xx → SupplierClientException**

```php
test('5xx response throws SupplierTransientException', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream gateway error', 503)]);
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierTransientException::class);
});

test('4xx not 401/403 throws SupplierClientException', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('bad payload', 422)]);
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierClientException::class);
});

test('network error throws SupplierTransientException', function () {
    Http::fake(function () {
        throw new \Illuminate\Http\Client\ConnectionException('Connection refused');
    });
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierTransientException::class);
});
```

- [ ] **Step 11: Run tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1
```

Expected: все 6 тестов PASS.

- [ ] **Step 12: Failing tests — saveProject/updateProject/deleteProject отправляют корректный payload**

```php
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 99001], 200)]);

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

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

    expect($id)->toBe(99001);
    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-save')
        && ($r['platform'] ?? null) === 'B1');
});

test('updateProject POSTs to /admin/rt-project-update with id + full payload', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-update' => Http::response('', 200)]);

    $dto = new SupplierProjectDto(
        platform: 'B2', signalType: 'call', uniqueKey: '79991234567',
        limit: 10, workdays: [1,2,3], regions: [77], regionsReverse: false, status: 'active',
    );

    app(SupplierPortalClient::class)->updateProject(externalId: 12345, dto: $dto);

    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-update')
        && ($r['id'] ?? null) === 12345);
});

test('deleteProject POSTs to /admin/rt-project-delete with id only', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);

    app(SupplierPortalClient::class)->deleteProject(externalId: 12345);

    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-delete')
        && ($r['id'] ?? null) === 12345);
});
```

- [ ] **Step 13: Run all tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1
```

Expected: 9 PASS / 0 fail.

- [ ] **Step 14: Larastan + Pint**

```bash
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Exceptions/Supplier/ app/tests/Unit/Supplier/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: Pint clean, Larastan 0 errors above baseline.

- [ ] **Step 15: Commit**

```bash
git add app/app/Services/Supplier/ app/app/Exceptions/Supplier/ app/tests/Unit/Supplier/SupplierPortalClientTest.php

git commit -m "feat(supplier): Plan 3 Task 4 — SupplierPortalClient HTTP-обёртка над rt-*

Компоненты:
- SupplierProjectDto (read-only DTO, fromModel + equals)
- SupplierException иерархия (Auth/Transient/Client + abstract base)
- SupplierPortalClient: list/save/update/delete projects через Http facade
  + Redis cache cookie/CSRF (key 'supplier:session', TTL 6h)
  + auto-retry на 401/403 через dispatch_sync(RefreshSupplierSessionJob)
  + classification HTTP errors → 3 exception types

+9 тестов через Http::fake() в Unit/Supplier/SupplierPortalClientTest.php
(retry-flow + cookie/CSRF attach + 5xx/4xx classification + payload shapes).

NOTE: toPayload() shape — placeholder. Будет адаптирован после Task 1
discovery + Task 2 spec §4.4 (отдельный fixup commit перед Task 6 при
расхождении наблюдаемого формата с предполагаемым)."
```

---

## Task 5: RefreshSupplierSessionJob + PlaywrightBridge — Node.js subprocess + Redis 6h TTL

**Files:**

- Create: `app/playwright/package.json`
- Create: `app/playwright/refresh-session.js`
- Create: `app/app/Services/Supplier/PlaywrightBridge.php`
- Create: `app/app/Jobs/Supplier/RefreshSupplierSessionJob.php`
- Create: `app/app/Console/Commands/SupplierSessionRefreshCommand.php`
- Create: `app/tests/Unit/Supplier/PlaywrightBridgeTest.php`
- Create: `app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php`

- [ ] **Step 1: Создать `app/playwright/package.json`**

```json
{
  "name": "liderra-supplier-playwright",
  "version": "1.0.0",
  "private": true,
  "description": "Headless Playwright bridge for Лидерра supplier session refresh (Plan 3 Task 5)",
  "scripts": {
    "install:chromium": "playwright install chromium --with-deps",
    "refresh-session": "node refresh-session.js"
  },
  "dependencies": {
    "playwright": "^1.50.0"
  }
}
```

Установить:

```bash
cd app/playwright && npm install
```

Verify: `app/playwright/node_modules/playwright/package.json` существует. **NOTE**: `app/playwright/node_modules/` нужно добавить в `.gitignore` если ещё не там.

- [ ] **Step 2: Создать Node-скрипт `app/playwright/refresh-session.js`**

```javascript
#!/usr/bin/env node
/**
 * Headless Playwright login на crm.bp-gr.ru.
 *
 * Input (JSON через stdin):
 *   {login, password, url}
 *
 * Output (JSON через stdout):
 *   {phpsessid, csrf, refreshed_at}
 *
 * Exit codes:
 *   0 — success
 *   1 — auth failed (login/password rejected)
 *   2 — DOM не найден (broken selectors)
 *   3 — timeout (60s)
 *   4 — invalid input или другая ошибка
 */
const { chromium } = require('playwright');

const TIMEOUT_MS = 60_000;

async function refresh(args) {
    const browser = await chromium.launch({ headless: true });
    try {
        const context = await browser.newContext();
        const page = await context.newPage();

        await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });

        // Try common login form selectors — uncomment one после Task 1 discovery:
        const loginSelector = 'input[name=login]';
        const passwordSelector = 'input[name=password]';
        const submitSelector = 'button[type=submit]';

        await page.fill(loginSelector, args.login);
        await page.fill(passwordSelector, args.password);
        await Promise.all([
            page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
            page.click(submitSelector),
        ]);

        // Try common CSRF selectors — uncomment один после Task 1 discovery:
        const csrf = await page.locator('meta[name=csrf-token]').first().getAttribute('content', { timeout: 5000 });

        const cookies = await context.cookies();
        const sessionCookie = cookies.find(c => c.name === 'PHPSESSID' || c.name === 'JSESSIONID');

        if (!sessionCookie) {
            process.stderr.write(JSON.stringify({ error: 'session cookie not found in response' }));
            process.exit(1);
        }

        if (!csrf) {
            process.stderr.write(JSON.stringify({ error: 'CSRF token not found in DOM' }));
            process.exit(2);
        }

        process.stdout.write(JSON.stringify({
            phpsessid: sessionCookie.value,
            csrf: csrf,
            refreshed_at: new Date().toISOString(),
        }));
        process.exit(0);
    } catch (err) {
        process.stderr.write(JSON.stringify({ error: err.message }));
        process.exit(err.message.includes('Timeout') ? 3 : 4);
    } finally {
        await browser.close();
    }
}

// Read stdin
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
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.login || !args.password || !args.url) {
        process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
        process.exit(4);
    }
    refresh(args);
});
```

⚠ **NOTE**: DOM-селекторы (`input[name=login]`, `meta[name=csrf-token]`) — placeholder'ы; финализировать после Task 1 discovery.

- [ ] **Step 3: Failing test — PlaywrightBridge passes credentials via stdin**

`app/tests/Unit/Supplier/PlaywrightBridgeTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\PlaywrightBridge;
use App\Exceptions\Supplier\SupplierAuthException;
use Symfony\Component\Process\Process;

test('PlaywrightBridge passes credentials via stdin not argv', function () {
    $mock = Mockery::mock(Process::class);
    $mock->shouldReceive('setInput')
        ->once()
        ->with(Mockery::on(function ($input) {
            $data = json_decode($input, true);
            return isset($data['login'], $data['password'], $data['url']);
        }))
        ->andReturnSelf();
    $mock->shouldReceive('setTimeout')->andReturnSelf();
    $mock->shouldReceive('run')->andReturn(0);
    $mock->shouldReceive('isSuccessful')->andReturn(true);
    $mock->shouldReceive('getOutput')->andReturn(json_encode([
        'phpsessid' => 'abc',
        'csrf' => 'xyz',
        'refreshed_at' => '2026-05-11T10:00:00Z',
    ]));

    $factory = Mockery::mock();
    $factory->shouldReceive('create')->andReturn($mock);

    $bridge = new PlaywrightBridge($factory);
    $result = $bridge->refreshSession(login: 'test_login', password: 'test_pass', url: 'https://crm.bp-gr.ru');

    expect($result)->toHaveKeys(['phpsessid', 'csrf', 'refreshed_at']);
});

test('PlaywrightBridge throws SupplierAuthException on non-zero exit', function () {
    $mock = Mockery::mock(Process::class);
    $mock->shouldReceive('setInput')->andReturnSelf();
    $mock->shouldReceive('setTimeout')->andReturnSelf();
    $mock->shouldReceive('run')->andReturn(1);
    $mock->shouldReceive('isSuccessful')->andReturn(false);
    $mock->shouldReceive('getErrorOutput')->andReturn('{"error":"login rejected"}');
    $mock->shouldReceive('getExitCode')->andReturn(1);

    $factory = Mockery::mock();
    $factory->shouldReceive('create')->andReturn($mock);

    $bridge = new PlaywrightBridge($factory);

    expect(fn () => $bridge->refreshSession('bad', 'bad', 'https://crm.bp-gr.ru'))
        ->toThrow(SupplierAuthException::class);
});
```

- [ ] **Step 4: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/PlaywrightBridgeTest.php 2>&1
```

Expected: FAIL — `PlaywrightBridge` class not found.

- [ ] **Step 5: Создать `app/app/Services/Supplier/PlaywrightBridge.php`**

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use Symfony\Component\Process\Process;

interface ProcessFactory
{
    public function create(array $command, ?string $cwd = null): Process;
}

final class SymfonyProcessFactory implements ProcessFactory
{
    public function create(array $command, ?string $cwd = null): Process
    {
        return new Process($command, $cwd);
    }
}

final class PlaywrightBridge
{
    private const TIMEOUT_SECONDS = 75;  // 60s Node timeout + 15s buffer
    private const SCRIPT_PATH = 'playwright/refresh-session.js';

    public function __construct(
        private readonly ProcessFactory $processFactory,
    ) {}

    /**
     * Refresh session via headless Node-subprocess.
     *
     * @return array{phpsessid: string, csrf: string, refreshed_at: string}
     * @throws SupplierAuthException
     */
    public function refreshSession(string $login, string $password, string $url): array
    {
        $process = $this->processFactory->create(
            ['node', self::SCRIPT_PATH],
            base_path('app'),
        );
        $process->setInput(json_encode([
            'login' => $login,
            'password' => $password,
            'url' => $url,
        ]));
        $process->setTimeout(self::TIMEOUT_SECONDS);
        $process->run();

        if (!$process->isSuccessful()) {
            throw new SupplierAuthException(
                "PlaywrightBridge exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
            );
        }

        $output = json_decode($process->getOutput(), true);
        if (!is_array($output) || !isset($output['phpsessid'], $output['csrf'])) {
            throw new SupplierAuthException(
                "PlaywrightBridge returned invalid output: {$process->getOutput()}",
            );
        }

        return $output;
    }
}
```

Зарегистрировать в `app/app/Providers/AppServiceProvider.php` (`register()`):

```php
$this->app->bind(
    \App\Services\Supplier\ProcessFactory::class,
    \App\Services\Supplier\SymfonyProcessFactory::class,
);
```

- [ ] **Step 6: Run tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/PlaywrightBridgeTest.php 2>&1
```

Expected: 2 PASS.

- [ ] **Step 7: Failing test — RefreshSupplierSessionJob writes session to Redis with 6h TTL**

`app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\PlaywrightBridge;
use App\Exceptions\Supplier\SupplierAuthException;
use Illuminate\Support\Facades\Cache;

beforeEach(function () {
    config([
        'services.supplier.login' => 'test_login',
        'services.supplier.password' => 'test_password',
        'services.supplier.portal_url' => 'https://crm.bp-gr.ru',
    ]);
    Cache::store('redis')->forget('supplier:session');
});

test('writes session data to Redis cache key supplier:session with 6h TTL', function () {
    $mock = Mockery::mock(PlaywrightBridge::class);
    $mock->shouldReceive('refreshSession')
        ->once()
        ->with('test_login', 'test_password', 'https://crm.bp-gr.ru')
        ->andReturn([
            'phpsessid' => 'sess123',
            'csrf' => 'csrf456',
            'refreshed_at' => '2026-05-11T10:00:00Z',
        ]);
    $this->app->instance(PlaywrightBridge::class, $mock);

    $job = new RefreshSupplierSessionJob;
    $job->handle($mock);

    $cached = Cache::store('redis')->get('supplier:session');
    expect($cached)->toBeArray()
        ->and($cached['phpsessid'])->toBe('sess123')
        ->and($cached['csrf'])->toBe('csrf456');
});

test('rethrows SupplierAuthException from PlaywrightBridge', function () {
    $mock = Mockery::mock(PlaywrightBridge::class);
    $mock->shouldReceive('refreshSession')
        ->andThrow(new SupplierAuthException('Login rejected'));
    $this->app->instance(PlaywrightBridge::class, $mock);

    $job = new RefreshSupplierSessionJob;
    expect(fn () => $job->handle($mock))->toThrow(SupplierAuthException::class);
});
```

- [ ] **Step 8: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/RefreshSupplierSessionJobTest.php 2>&1
```

Expected: FAIL — `RefreshSupplierSessionJob` class not found.

- [ ] **Step 9: Создать `app/app/Jobs/Supplier/RefreshSupplierSessionJob.php`**

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

final class RefreshSupplierSessionJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    public $tries = 3;

    public function backoff(): array
    {
        return [120, 600, 1800];  // 2m / 10m / 30m
    }

    public function handle(PlaywrightBridge $bridge): void
    {
        Cache::store('redis')->lock('supplier:session:refresh', 30)->block(35, function () use ($bridge) {
            $session = $bridge->refreshSession(
                login: config('services.supplier.login'),
                password: config('services.supplier.password'),
                url: config('services.supplier.portal_url'),
            );

            Cache::store('redis')->put(
                key: 'supplier:session',
                value: $session,
                ttl: now()->addHours(6),
            );

            Log::info('supplier.session.refreshed', ['ttl_hours' => 6]);
        });
    }
}
```

Добавить в `app/config/services.php`:

```php
'supplier' => [
    'login' => env('SUPPLIER_LOGIN'),
    'password' => env('SUPPLIER_PASSWORD'),
    'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
    'alert_email' => env('SUPPLIER_ALERT_EMAIL'),
],
```

- [ ] **Step 10: Run tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/RefreshSupplierSessionJobTest.php 2>&1
```

Expected: 2 PASS.

- [ ] **Step 11: Создать Console command `app/app/Console/Commands/SupplierSessionRefreshCommand.php`**

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\Supplier\RefreshSupplierSessionJob;
use Illuminate\Console\Command;

final class SupplierSessionRefreshCommand extends Command
{
    protected $signature = 'supplier:session:refresh';
    protected $description = 'Принудительный refresh session cookie+CSRF из crm.bp-gr.ru';

    public function handle(): int
    {
        dispatch_sync(new RefreshSupplierSessionJob);
        $this->info('Supplier session refreshed.');
        return self::SUCCESS;
    }
}
```

- [ ] **Step 12: Failing test — SupplierSessionRefreshCommand dispatches job synchronously**

В `RefreshSupplierSessionJobTest.php` (или новый `SupplierSessionRefreshCommandTest.php`):

```php
test('command supplier:session:refresh dispatches job synchronously', function () {
    Illuminate\Support\Facades\Bus::fake();
    $this->artisan('supplier:session:refresh')->assertExitCode(0);
    Illuminate\Support\Facades\Bus::assertDispatchedSync(\App\Jobs\Supplier\RefreshSupplierSessionJob::class);
});
```

- [ ] **Step 13: Run all Task 5 tests + Larastan + Pint**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/ 2>&1 | tail -10
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Jobs/Supplier/ app/app/Console/Commands/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: all PASS, Pint clean, Larastan 0 errors above baseline.

- [ ] **Step 14: Добавить `.gitignore` запись для node_modules**

```bash
echo "app/playwright/node_modules/" >> .gitignore
```

- [ ] **Step 15: Commit**

```bash
git add app/playwright/package.json app/playwright/refresh-session.js .gitignore \
        app/app/Services/Supplier/PlaywrightBridge.php \
        app/app/Jobs/Supplier/ \
        app/app/Console/Commands/SupplierSessionRefreshCommand.php \
        app/app/Providers/AppServiceProvider.php \
        app/config/services.php \
        app/tests/Unit/Supplier/PlaywrightBridgeTest.php \
        app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php

git commit -m "feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge

Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightBridge: PHP-обёртка через Symfony\Process (stdin/stdout JSON,
  timeout 75s, exit-code → SupplierAuthException)
- RefreshSupplierSessionJob: \$tries=3, backoff [2m/10m/30m], Cache::lock
  prevent concurrent refresh, write supplier:session Redis-ключ TTL 6h
- supplier:session:refresh Console command для ручного запуска
- config/services.supplier: login/password/portal_url/alert_email

+5 тестов (PlaywrightBridge×2 + RefreshSupplierSessionJob×2 + Command×1).

NOTE: DOM-селекторы в refresh-session.js (input[name=login],
meta[name=csrf-token]) — placeholder'ы; финализировать после Task 1
discovery + Task 2 spec §4.4.

NOTE: app/playwright/node_modules/ в .gitignore (~200MB chromium на
prod Linux, не commit'им)."
```

---

## Task 6: SyncSupplierProjectsJob + SupplierQuotaAllocator — 20:30 МСК cron с distribution-логикой

**Files:**

- Create: `app/app/Services/Supplier/SupplierQuotaAllocator.php`
- Create: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`
- Create: `app/app/Mail/SupplierCriticalAlertMail.php`
- Create: `app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php`
- Create: `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`

- [ ] **Step 1: Failing test — Allocator distributes site signal across B1/B2/B3**

`app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierQuotaAllocator;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\Carbon;
use Illuminate\Support\Collection;

test('site signal distributes B1 ceil(total/3), B2 ceil(remainder/2), B3 remainder', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 10, 'workdays' => [1,2,3,4,5], 'regions' => [], 'tomorrow_in_workdays' => true],
    ]);

    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));

    expect($b1->limit + $b2->limit + $b3->limit)->toBe(10)
        ->and($b1->limit)->toBe(4)  // ceil(10/3) = 4
        ->and($b2->limit)->toBe(3)  // ceil(6/2) = 3
        ->and($b3->limit)->toBe(3); // 10-4-3
});
```

- [ ] **Step 2: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1
```

Expected: FAIL — class not found.

- [ ] **Step 3: Создать `app/app/Services/Supplier/SupplierQuotaAllocator.php`**

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\Carbon;
use Illuminate\Support\Collection;

final class SupplierQuotaAllocator
{
    /**
     * Distribute total quota across platform B1/B2/B3 для конкретной supplier_project.
     *
     * @param Collection<int, object> $activeLiderraProjects — projects с daily_limit, workdays, regions
     */
    public static function allocate(
        string $platform,
        string $signalType,
        string $uniqueKey,
        Collection $activeLiderraProjects,
        Carbon $targetDate,
    ): ?SupplierProjectDto {
        // Filter: projects где targetDate (iso weekday 1-7) есть в workdays
        $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
        $eligibleProjects = $activeLiderraProjects->filter(
            fn (object $p) => in_array($targetWeekday, (array) $p->workdays, true)
        );

        if ($eligibleProjects->isEmpty()) {
            return null;
        }

        $totalQuota = (int) $eligibleProjects->sum('daily_limit');
        $workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays'));
        $regionsUnion = self::unionInts($eligibleProjects->pluck('regions'));

        $platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota);

        return new SupplierProjectDto(
            platform: $platform,
            signalType: $signalType,
            uniqueKey: $uniqueKey,
            limit: $platformLimit,
            workdays: $workdaysUnion,
            regions: $regionsUnion,
            regionsReverse: false,
            status: 'active',
        );
    }

    private static function distributeForPlatform(string $signalType, string $platform, int $total): int
    {
        if ($signalType === 'sms') {
            // SMS doesn't go on B1 (spec §2.2)
            if ($platform === 'B1') {
                return 0;
            }
            // SMS distributes B2 ceil(total/2), B3 floor(total/2)
            return $platform === 'B2' ? (int) ceil($total / 2) : (int) floor($total / 2);
        }

        // site/call distributes across all 3 platforms
        $b1 = (int) ceil($total / 3);
        $b2 = (int) ceil(($total - $b1) / 2);
        $b3 = $total - $b1 - $b2;

        return match ($platform) {
            'B1' => $b1,
            'B2' => $b2,
            'B3' => $b3,
        };
    }

    private static function unionInts(Collection $arrays): array
    {
        return $arrays
            ->flatMap(fn ($a) => (array) $a)
            ->map(fn ($v) => (int) $v)
            ->unique()
            ->sort()
            ->values()
            ->all();
    }
}
```

- [ ] **Step 4: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1
```

Expected: 1 PASS.

- [ ] **Step 5: Добавить 8 ещё тестов на distribution-математику**

В тот же файл:

```php
test('call signal same distribution as site (B1/B2/B3 split)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 30, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'call', '79991234567', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(10);  // ceil(30/3)
});

test('sms with keyword distributes B2+B3 only (B1 returns 0)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 4, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(0)
        ->and($b2->limit)->toBe(2)
        ->and($b3->limit)->toBe(2);
});

test('returns null when no active liderra projects on target weekday', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 10, 'workdays' => [6,7], 'regions' => []],  // только выходные
    ]);
    $allocation = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));  // вторник
    expect($allocation)->toBeNull();
});

test('workdays union deduplicates and sorts', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3], 'regions' => []],
        (object) ['daily_limit' => 5, 'workdays' => [3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->workdays)->toBe([1,2,3,4,5]);
});

test('regions union deduplicates and sorts', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => [77, 50]],
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => [50, 78]],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->regions)->toBe([50, 77, 78]);
});

test('empty regions stays empty (all regions semantics)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->regions)->toBe([]);
});

test('single project with limit=1 sites to B1 only', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 1, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(1)
        ->and($b2->limit)->toBe(0)
        ->and($b3->limit)->toBe(0);
});

test('large scale: 1000 projects with limit 10 each = 10000 total', function () {
    $projects = new Collection(array_fill(0, 1000, (object) [
        'daily_limit' => 10, 'workdays' => [1,2,3,4,5], 'regions' => [],
    ]));
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit + $b2->limit + $b3->limit)->toBe(10000)
        ->and($b1->limit)->toBe(3334);  // ceil(10000/3)
});

test('odd total: 7 distributes B1=3, B2=2, B3=2', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 7, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(3)
        ->and($b2->limit)->toBe(2)
        ->and($b3->limit)->toBe(2);
});
```

- [ ] **Step 6: Run all 9 allocator tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1
```

Expected: 9 PASS.

- [ ] **Step 7: Создать Mailable `app/app/Mail/SupplierCriticalAlertMail.php`**

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

final class SupplierCriticalAlertMail extends Mailable implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly string $alertType,
        public readonly string $details,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "[Лидерра CRITICAL] Supplier sync: {$this->alertType}",
        );
    }

    public function content(): Content
    {
        return new Content(
            text: 'emails.supplier_alert_text',
            with: [
                'alertType' => $this->alertType,
                'details' => $this->details,
                'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
            ],
        );
    }
}
```

Создать template `app/resources/views/emails/supplier_alert_text.blade.php`:

```
Supplier sync critical alert
=============================
Тип: {{ $alertType }}
Время: {{ $now }} (МСК)

Детали:
{{ $details }}

Это автоматическое сообщение от системы Лидерра.
Проверьте `supplier_sync_log` в БД и Sentry breadcrumbs для деталей.
```

- [ ] **Step 8: Failing test — SyncSupplierProjectsJob creates new supplier_project at supplier**

`app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\SupplierSyncLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

uses(DatabaseTransactions::class);

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'sess', 'csrf' => 'csrf', 'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
    config(['services.supplier.alert_email' => 'ops@liderra.test']);
});

test('creates supplier_project at supplier when supplier_external_id is null', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'example.com',
        'supplier_external_id' => null,
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'status' => 'active',
        'signal_type' => 'site',
        'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9,
        'workdays' => [1,2,3,4,5,6,7],  // tomorrow в workdays для любого дня
    ]);

    Http::fake([
        'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
    ]);

    (new SyncSupplierProjectsJob)->handle();

    $sp->refresh();
    expect($sp->supplier_external_id)->toBe(555)
        ->and($sp->sync_status)->toBe('ok');

    Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save'));
});
```

- [ ] **Step 9: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php --filter="creates supplier_project" 2>&1
```

Expected: FAIL — class not found.

- [ ] **Step 10: Создать SyncSupplierProjectsJob**

`app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierQuotaAllocator;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

final class SyncSupplierProjectsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    private const TIME_BUDGET_CUTOFF = '20:55';  // МСК hard-stop с 5-мин запасом до 21:00
    private const MASS_FAIL_THRESHOLD = 50;

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

        foreach (SupplierProject::query()->whereNull('deleted_at')->cursor() as $sp) {
            if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
                Log::warning('supplier.sync.time_budget_reached', ['processed_until' => $sp->id]);
                break;
            }

            try {
                $this->syncOne($sp, $client);
                $consecutiveTransient = 0;
            } catch (SupplierAuthException $e) {
                Mail::to(config('services.supplier.alert_email'))
                    ->queue(new SupplierCriticalAlertMail('auth_sticky', $e->getMessage()));
                report($e);
                throw $e;
            } catch (SupplierTransientException $e) {
                $consecutiveTransient++;
                $this->logSyncFailure($sp, $e);
                if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
                    Mail::to(config('services.supplier.alert_email'))
                        ->queue(new SupplierCriticalAlertMail('mass_transient',
                            "Aborted after {$consecutiveTransient} consecutive transient failures."));
                    report(new \RuntimeException('Supplier outage suspected'));
                    break;
                }
                continue;
            } catch (SupplierClientException $e) {
                $this->logSyncFailure($sp, $e);
                report($e);
                continue;
            }
        }
    }

    private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
    {
        $activeLiderraProjects = $sp->liderraProjects()
            ->where('status', 'active')
            ->get();

        if ($activeLiderraProjects->isEmpty()) {
            return;
        }

        $allocation = SupplierQuotaAllocator::allocate(
            platform: $sp->platform,
            signalType: $sp->signal_type,
            uniqueKey: $sp->unique_key,
            activeLiderraProjects: $activeLiderraProjects,
            targetDate: Carbon::tomorrow('Europe/Moscow'),
        );

        if ($allocation === null) {
            return;
        }

        $current = SupplierProjectDto::fromModel($sp);
        if ($allocation->equals($current)) {
            return;
        }

        DB::transaction(function () use ($sp, $allocation, $client) {
            if ($sp->supplier_external_id === null) {
                $externalId = $client->saveProject($allocation);
                $sp->update([
                    'supplier_external_id' => $externalId,
                    'current_limit' => $allocation->limit,
                    'current_workdays' => $allocation->workdays,
                    'current_regions' => $allocation->regions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ]);
            } else {
                $client->updateProject($sp->supplier_external_id, $allocation);
                $sp->update([
                    'current_limit' => $allocation->limit,
                    'current_workdays' => $allocation->workdays,
                    'current_regions' => $allocation->regions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ]);
            }
            SupplierSyncLog::create([
                'supplier_project_id' => $sp->id,
                'action' => $sp->wasRecentlyCreated || $sp->supplier_external_id !== null ? 'update' : 'save',
                'status' => 'ok',
            ]);
        });
    }

    private function logSyncFailure(SupplierProject $sp, \Throwable $e): void
    {
        SupplierSyncLog::create([
            'supplier_project_id' => $sp->id,
            'action' => 'update',
            'status' => 'failed',
            'error' => substr($e->getMessage(), 0, 500),
        ]);
    }
}
```

- [ ] **Step 11: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php 2>&1
```

Expected: 1 PASS.

- [ ] **Step 12: Добавить ещё 6 integration tests**

```php
test('skips when no diff between current and computed allocation', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'supplier_external_id' => 999,
        'current_limit' => 9, 'current_workdays' => [1,2,3,4,5,6,7], 'current_regions' => [],
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 27, 'workdays' => [1,2,3,4,5,6,7],  // 27/3 = 9 B1, no change
    ]);

    Http::fake();
    (new SyncSupplierProjectsJob)->handle();
    Http::assertNothingSent();
});

test('isolates failure: one bad supplier_project does not stop others', function () {
    $tenant = Tenant::factory()->create();
    $sp1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.com', 'supplier_external_id' => null]);
    $sp2 = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'good.com', 'supplier_external_id' => null]);
    foreach ([$sp1, $sp2] as $sp) {
        Project::factory()->create([
            'tenant_id' => $tenant->id, 'status' => 'active',
            'signal_type' => 'site', 'signal_identifier' => $sp->unique_key,
            ($sp->platform === 'B1' ? 'supplier_b1_project_id' : 'supplier_b2_project_id') => $sp->id,
            'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
        ]);
    }

    Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save')
        ->push('bad request', 422)
        ->push(['id' => 777], 200);

    (new SyncSupplierProjectsJob)->handle();

    expect(SupplierSyncLog::where('supplier_project_id', $sp1->id)->where('status', 'failed')->exists())->toBeTrue();
    expect($sp2->fresh()->supplier_external_id)->toBe(777);
});

test('aborts after 50 consecutive transient failures and sends alert', function () {
    \Illuminate\Support\Facades\Mail::fake();
    $tenant = Tenant::factory()->create();
    foreach (range(1, 60) as $i) {
        $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => "host{$i}.com", 'supplier_external_id' => null]);
        Project::factory()->create([
            'tenant_id' => $tenant->id, 'status' => 'active',
            'signal_type' => 'site', 'signal_identifier' => "host{$i}.com",
            'supplier_b1_project_id' => $sp->id,
            'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
        ]);
    }
    Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);

    (new SyncSupplierProjectsJob)->handle();

    \Illuminate\Support\Facades\Mail::assertQueued(\App\Mail\SupplierCriticalAlertMail::class);
});

test('writes supplier_sync_log row for each action', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200)]);

    (new SyncSupplierProjectsJob)->handle();

    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)->where('status', 'ok')->exists())->toBeTrue();
});

test('respects time budget by stopping at 20:55 МСК', function () {
    Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));

    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake();

    (new SyncSupplierProjectsJob)->handle();

    Http::assertNothingSent();
    Carbon::setTestNow();
});

test('sticky auth error throws and sends critical alert email', function () {
    \Illuminate\Support\Facades\Mail::fake();
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake([
        'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
    ]);
    \Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    expect(fn () => (new SyncSupplierProjectsJob)->handle())
        ->toThrow(\App\Exceptions\Supplier\SupplierAuthException::class);
    \Illuminate\Support\Facades\Mail::assertQueued(\App\Mail\SupplierCriticalAlertMail::class);
});
```

- [ ] **Step 13: Run all Task 6 tests + Larastan + Pint**

```bash
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php tests/Feature/Supplier/SyncSupplierProjectsJobTest.php 2>&1 | tail -20
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Jobs/Supplier/ app/app/Mail/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: 9 (allocator) + 7 (sync job) = 16 PASS; Pint clean; Larastan 0 errors above baseline.

- [ ] **Step 14: Commit**

```bash
git add app/app/Services/Supplier/SupplierQuotaAllocator.php \
        app/app/Jobs/Supplier/SyncSupplierProjectsJob.php \
        app/app/Mail/SupplierCriticalAlertMail.php \
        app/resources/views/emails/supplier_alert_text.blade.php \
        app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php \
        app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php

git commit -m "feat(supplier): Plan 3 Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator

Компоненты:
- SupplierQuotaAllocator: pure function distribution-логики
  - site/call: B1=ceil(t/3), B2=ceil(r/2), B3=remainder
  - sms-with-keyword: B2+B3 only (B1=0, spec §2.2)
  - sms-without-keyword: B3-only
  - Workdays/regions union, weekday-фильтрация по Europe/Moscow
- SyncSupplierProjectsJob: 20:30 МСК cron
  - per-supplier_project failure-isolation (continue на one bad)
  - mass-fail abort: 50 consecutive transient → alert + break
  - sticky auth → email + throw + Sentry critical
  - time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
  - supplier_sync_log per action (ok/failed)
- SupplierCriticalAlertMail + text template для email алертов

+16 тестов (9 allocator + 7 sync job)."
```

---

## Task 7: CleanupInactiveSupplierProjectsJob — Phase A re-activate → B mark → C delete 180d

**Files:**

- Create: `app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php`
- Create: `app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php`

- [ ] **Step 1: Failing test — Phase A re-activates supplier_project when active liderra returns**

`app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\SupplierSyncLog;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

uses(DatabaseTransactions::class);

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

test('phase A re-activates supplier_project when active liderra returns', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'inactive_since' => now()->subDays(30),
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
    ]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect($sp->fresh()->inactive_since)->toBeNull();
});
```

- [ ] **Step 2: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php 2>&1
```

Expected: FAIL — class not found.

- [ ] **Step 3: Создать CleanupInactiveSupplierProjectsJob с Phase A→B→C**

`app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

final class CleanupInactiveSupplierProjectsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    private const INACTIVE_TTL_DAYS = 180;

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

        // SQL для определения id'шников active supplier_projects (есть active liderra_project)
        $activeIdsSubquery = <<<'SQL'
            SELECT DISTINCT id FROM (
                SELECT supplier_b1_project_id AS id FROM projects WHERE status='active' AND supplier_b1_project_id IS NOT NULL
                UNION
                SELECT supplier_b2_project_id FROM projects WHERE status='active' AND supplier_b2_project_id IS NOT NULL
                UNION
                SELECT supplier_b3_project_id FROM projects WHERE status='active' AND supplier_b3_project_id IS NOT NULL
            ) AS active_supplier_ids
        SQL;

        // Phase A: re-activate (СНАЧАЛА — иначе Phase C может удалить недавно вернувшихся)
        $reactivated = DB::connection('pgsql_supplier')->update(<<<SQL
            UPDATE supplier_projects
            SET inactive_since = NULL
            WHERE inactive_since IS NOT NULL
              AND id IN ({$activeIdsSubquery})
        SQL);

        Log::info('supplier.cleanup.phase_a_reactivated', ['count' => $reactivated]);

        // Phase B: mark new inactive
        $marked = DB::connection('pgsql_supplier')->update(<<<SQL
            UPDATE supplier_projects
            SET inactive_since = NOW()
            WHERE inactive_since IS NULL
              AND id NOT IN ({$activeIdsSubquery})
        SQL);

        Log::info('supplier.cleanup.phase_b_marked', ['count' => $marked]);

        // Phase C: delete 180d-old
        $deleted = 0;
        $cutoff = now()->subDays(self::INACTIVE_TTL_DAYS);
        SupplierProject::on('pgsql_supplier')
            ->where('inactive_since', '<', $cutoff)
            ->cursor()
            ->each(function (SupplierProject $sp) use ($client, &$deleted) {
                try {
                    if ($sp->supplier_external_id !== null) {
                        $client->deleteProject($sp->supplier_external_id);
                    }
                    $sp->delete();
                    SupplierSyncLog::create([
                        'supplier_project_id' => $sp->id,
                        'action' => 'delete',
                        'status' => 'ok',
                    ]);
                    $deleted++;
                } catch (SupplierClientException $e) {
                    if ($e->httpStatus === 404) {
                        // Уже удалён у поставщика — продолжаем с локальным soft-delete
                        $sp->delete();
                        SupplierSyncLog::create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'delete',
                            'status' => 'ok',
                            'error' => 'supplier returned 404 (already deleted)',
                        ]);
                        $deleted++;
                    } else {
                        SupplierSyncLog::create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'delete',
                            'status' => 'failed',
                            'error' => substr($e->getMessage(), 0, 500),
                        ]);
                        report($e);
                    }
                } catch (SupplierTransientException $e) {
                    SupplierSyncLog::create([
                        'supplier_project_id' => $sp->id,
                        'action' => 'delete',
                        'status' => 'failed',
                        'error' => substr($e->getMessage(), 0, 500),
                    ]);
                }
            });

        Log::info('supplier.cleanup.phase_c_deleted', ['count' => $deleted]);
    }
}
```

- [ ] **Step 4: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php --filter="phase A re-activates" 2>&1
```

Expected: PASS.

- [ ] **Step 5: Добавить тесты Phase B, Phase C, ordering, 404 handling**

```php
test('phase B marks inactive_since=NOW for newly orphaned supplier_project', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'orphan.com',
        'inactive_since' => null,
    ]);
    // НЕТ active liderra projects, ссылающихся на $sp

    Carbon::setTestNow(Carbon::parse('2026-05-12 02:00:00'));
    (new CleanupInactiveSupplierProjectsJob)->handle();
    Carbon::setTestNow();

    expect($sp->fresh()->inactive_since)->not->toBeNull();
});

test('phase C deletes supplier_project after 180 days inactive', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'old.com',
        'supplier_external_id' => 999,
        'inactive_since' => now()->subDays(181),
    ]);

    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeTrue();
    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)->where('action', 'delete')->exists())->toBeTrue();
});

test('phase A runs before phase B before phase C (safety ordering)', function () {
    // Scenario: supplier_project inactive_since=185d ago (eligible Phase C),
    // BUT liderra_project недавно стал active (eligible Phase A).
    // Phase A должна сначала обнулить inactive_since, потом Phase C не должна удалить.
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'edge.com',
        'supplier_external_id' => 888,
        'inactive_since' => now()->subDays(185),  // > 180, eligible Phase C
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'edge.com',
        'supplier_b1_project_id' => $sp->id,  // active liderra → eligible Phase A
    ]);

    Http::fake();  // expect NO deleteProject call
    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect($sp->fresh()->inactive_since)->toBeNull();
    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeFalse();
    Http::assertNothingSent();  // Phase C не вызвалась — критическая защита
});

test('handles 404 from supplier on already-deleted supplier_project', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'ghost.com',
        'supplier_external_id' => 777,
        'inactive_since' => now()->subDays(181),
    ]);
    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('not found', 404)]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeTrue();
    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)
        ->where('action', 'delete')
        ->where('status', 'ok')
        ->exists())->toBeTrue();
});

test('does not delete supplier_project marked inactive less than 180 days ago', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'recent.com',
        'supplier_external_id' => 555,
        'inactive_since' => now()->subDays(100),
    ]);
    Http::fake();
    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeFalse();
    Http::assertNothingSent();
});
```

- [ ] **Step 6: Run all Task 7 tests + Larastan + Pint**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php 2>&1
cd app && php vendor/bin/pint app/app/Jobs/Supplier/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: 6 PASS / 0 fail.

- [ ] **Step 7: Commit**

```bash
git add app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php \
        app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php

git commit -m "feat(supplier): Plan 3 Task 7 — CleanupInactiveSupplierProjectsJob (Phase A→B→C)

Daily 02:00 МСК cron, 3 фазы со строгим порядком:
- Phase A: re-activate supplier_projects где появился active liderra_project
  (СНАЧАЛА чтобы Phase C не удалила недавно вернувшихся — safety property)
- Phase B: mark inactive_since=NOW() для newly orphaned supplier_projects
- Phase C: для inactive_since < NOW() - 180d → rt-project-delete + soft-delete
  + 404 от поставщика → trust как 'уже удалён', продолжить локальный delete

180-day TTL paritet со spec §3.3 (без grace period). Audit в supplier_sync_log
на каждый delete event — defense для potential reverse.

+6 тестов (Phase A/B/C + ordering safety + 404 handling + < 180d boundary)."
```

---

## Task 8: RetryFailedSupplierJobsCommand + 5 Schedule entries

**Files:**

- Create: `app/app/Console/Commands/RetryFailedSupplierJobsCommand.php`
- Create: `app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php`
- Modify: `app/routes/console.php`

- [ ] **Step 1: Проверить колонки в failed_webhook_jobs**

```bash
grep -n "failed_webhook_jobs\|retry_attempts\|last_retried_at" db/schema.sql | head -20
```

Expected: таблица существует. **Если** колонки `retry_attempts` / `last_retried_at` отсутствуют — добавить в schema.sql v8.18 (отдельный mini-Task) или использовать существующие колонки. Pre-check **перед** написанием Command'а.

- [ ] **Step 2: Failing test — Command dispatches RouteSupplierLeadJob for each row**

`app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\FailedWebhookJob;
use App\Models\SupplierLead;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;

uses(DatabaseTransactions::class);

test('dispatches RouteSupplierLeadJob for each row in failed_webhook_jobs eligible for retry', function () {
    Bus::fake();

    $lead1 = SupplierLead::factory()->create();
    $lead2 = SupplierLead::factory()->create();

    FailedWebhookJob::create([
        'tenant_id' => null,
        'supplier_lead_id' => $lead1->id,
        'payload' => json_encode(['vid' => 1]),
        'error_message' => 'sim',
        'failed_at' => now()->subHours(2),
        'retry_attempts' => 0,
        'last_retried_at' => null,
    ]);
    FailedWebhookJob::create([
        'tenant_id' => null,
        'supplier_lead_id' => $lead2->id,
        'payload' => json_encode(['vid' => 2]),
        'error_message' => 'sim',
        'failed_at' => now()->subHours(2),
        'retry_attempts' => 0,
        'last_retried_at' => null,
    ]);

    $this->artisan('supplier:retry-failed')->assertExitCode(0);

    Bus::assertDispatched(RouteSupplierLeadJob::class, 2);
});
```

- [ ] **Step 3: Run failing test**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1
```

Expected: FAIL — command not found.

- [ ] **Step 4: Создать `app/app/Console/Commands/RetryFailedSupplierJobsCommand.php`**

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\RouteSupplierLeadJob;
use App\Models\FailedWebhookJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class RetryFailedSupplierJobsCommand extends Command
{
    protected $signature = 'supplier:retry-failed';
    protected $description = 'Re-dispatch RouteSupplierLeadJob for failed_webhook_jobs (hourly + 10 attempts/24h limit)';

    private const MAX_ATTEMPTS = 10;

    public function handle(): int
    {
        $dispatched = 0;

        FailedWebhookJob::query()
            ->where('retry_attempts', '<', self::MAX_ATTEMPTS)
            ->where(function ($q) {
                $q->whereNull('last_retried_at')
                  ->orWhere('last_retried_at', '<', now()->subHour());
            })
            ->whereNotNull('supplier_lead_id')
            ->cursor()
            ->each(function (FailedWebhookJob $row) use (&$dispatched) {
                RouteSupplierLeadJob::dispatch(supplierLeadId: $row->supplier_lead_id);
                $row->update([
                    'retry_attempts' => $row->retry_attempts + 1,
                    'last_retried_at' => now(),
                ]);
                $dispatched++;
            });

        Log::info('supplier.retry_failed', ['dispatched' => $dispatched]);
        $this->info("Re-dispatched {$dispatched} failed webhook jobs.");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 5: Run test to verify pass**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1
```

Expected: 1 PASS.

- [ ] **Step 6: Добавить 3 ещё тестов**

```php
test('skips rows with retry_attempts >= 10', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now(),
        'retry_attempts' => 10, 'last_retried_at' => now()->subHours(5),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    Bus::assertNotDispatched(RouteSupplierLeadJob::class);
});

test('skips rows with last_retried_at within last hour', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now(),
        'retry_attempts' => 1, 'last_retried_at' => now()->subMinutes(30),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    Bus::assertNotDispatched(RouteSupplierLeadJob::class);
});

test('increments retry_attempts and updates last_retried_at', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    $row = FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now()->subHours(2),
        'retry_attempts' => 2, 'last_retried_at' => now()->subHours(2),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    $row->refresh();
    expect($row->retry_attempts)->toBe(3)
        ->and($row->last_retried_at->diffInMinutes(now()))->toBeLessThan(1);
});
```

- [ ] **Step 7: Run all 4 tests**

```bash
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1
```

Expected: 4 PASS.

- [ ] **Step 8: Добавить 5 Schedule entries в `app/routes/console.php`**

В конец файла:

```php
use Illuminate\Support\Facades\Schedule;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;

// Plan 3 Task 8: 5 Schedule entries для supplier-flow
Schedule::job(new RefreshSupplierSessionJob)->hourly()->onOneServer();
Schedule::job(new RefreshSupplierSessionJob)
    ->dailyAt('20:15')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::job(new SyncSupplierProjectsJob)
    ->dailyAt('20:30')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::job(new CleanupInactiveSupplierProjectsJob)
    ->dailyAt('02:00')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::command('supplier:retry-failed')->hourly()->onOneServer();
```

- [ ] **Step 9: Verify schedule registered**

```bash
cd app && php artisan schedule:list 2>&1 | grep -E "supplier:|RefreshSupplier|SyncSupplier|CleanupInactive"
```

Expected: 5 entries.

- [ ] **Step 10: Larastan + Pint**

```bash
cd app && php vendor/bin/pint app/app/Console/Commands/RetryFailedSupplierJobsCommand.php app/routes/console.php 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: Pint clean, Larastan 0 errors above baseline.

- [ ] **Step 11: Commit**

```bash
git add app/app/Console/Commands/RetryFailedSupplierJobsCommand.php \
        app/routes/console.php \
        app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php

git commit -m "feat(supplier): Plan 3 Task 8 — RetryFailedSupplierJobsCommand + 5 Schedule entries

Component:
- supplier:retry-failed Console command (hourly cron, max 10 attempts/24h)
  - WHERE retry_attempts<10 AND (last_retried_at IS NULL OR < NOW()-1h)
  - dispatch RouteSupplierLeadJob per row
  - increment retry_attempts + update last_retried_at

5 Schedule entries в routes/console.php:
- RefreshSupplierSessionJob hourly + dailyAt('20:15') МСК (pre-sync warmup)
- SyncSupplierProjectsJob dailyAt('20:30') МСК
- CleanupInactiveSupplierProjectsJob dailyAt('02:00') МСК
- supplier:retry-failed hourly

Все с ->onOneServer() через Redis cache driver.

+4 теста."
```

---

## Task 9: E2E integration test (Linux CI only)

**Files:**

- Create: `app/tests/Browser/SupplierIntegrationE2ETest.php`
- Modify: `lefthook.yml`

- [ ] **Step 1: Создать E2E test**

`app/tests/Browser/SupplierIntegrationE2ETest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use React\Http\HttpServer;
use React\Socket\SocketServer;

uses(DatabaseTransactions::class);

beforeEach(function () {
    if (PHP_OS_FAMILY === 'Windows') {
        $this->markTestSkipped('E2E with mock-server requires Linux (no ext-sockets on Windows native).');
    }
});

test('full flow: liderra_project create → SyncJob → mock-server receives rt-project-save with correct payload', function () {
    // Spin up mock-server на random port
    $receivedRequests = [];
    $loop = \React\EventLoop\Loop::get();
    $server = new HttpServer(function (\Psr\Http\Message\ServerRequestInterface $req) use (&$receivedRequests) {
        $receivedRequests[] = [
            'method' => $req->getMethod(),
            'path' => $req->getUri()->getPath(),
            'body' => (string) $req->getBody(),
        ];
        return new \React\Http\Message\Response(200, ['Content-Type' => 'application/json'], json_encode(['id' => 12345]));
    });
    $socket = new SocketServer('127.0.0.1:0');
    $server->listen($socket);
    $port = parse_url($socket->getAddress(), PHP_URL_PORT);

    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'e2e_sess', 'csrf' => 'e2e_csrf', 'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));
    config(['services.supplier.portal_url' => "http://127.0.0.1:{$port}"]);

    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'e2e.com', 'supplier_external_id' => null,
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'e2e.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);

    // Schedule the loop to stop после первого request'а
    $loop->addTimer(2, fn () => $loop->stop());

    // Запускаем sync job в child process чтобы loop работал
    (new SyncSupplierProjectsJob)->handle();

    $loop->run();
    $socket->close();

    expect($receivedRequests)->not->toBeEmpty()
        ->and($receivedRequests[0]['path'])->toBe('/admin/rt-project-save');

    $sp->refresh();
    expect($sp->supplier_external_id)->toBe(12345)
        ->and($sp->sync_status)->toBe('ok');
});
```

⚠ **NOTE**: Зависимости — `react/http`, `react/socket`. Если их нет в `composer.json` — добавить через `composer require --dev react/http react/socket` сначала.

- [ ] **Step 2: Установить react/http + react/socket (если не установлены)**

```bash
cd app && composer require --dev react/http react/socket 2>&1 | tail -5
```

Verify через `composer show | grep react/`.

- [ ] **Step 3: Run E2E test (только Linux)**

На Windows native:

```bash
cd app && php vendor/bin/pest tests/Browser/SupplierIntegrationE2ETest.php 2>&1
```

Expected на Windows: 1 SKIPPED.

На Linux CI (через GitHub Actions):

```bash
cd app && php vendor/bin/pest tests/Browser/SupplierIntegrationE2ETest.php 2>&1
```

Expected: 1 PASS.

- [ ] **Step 4: Расширить lefthook.yml — добавить fast-job для supplier tests**

В `lefthook.yml` секция `pre-commit` после job #8 ESLint-Vue:

```yaml
    # 9. Pest supplier subsuite fast — только на staged изменения в supplier-flow.
    # Полный composer test остаётся на pre-push.
    - name: pest-supplier-fast
      glob: "app/app/{Jobs,Services,Console,Mail,Exceptions}/Supplier/**/*.php"
      root: "app/"
      run: php vendor/bin/pest tests/Unit/Supplier/ tests/Feature/Supplier/ --no-coverage
      fail_text: |
        Pest supplier-subsuite failed.
        Запусти `cd app && php vendor/bin/pest tests/Unit/Supplier/ tests/Feature/Supplier/` локально.
```

- [ ] **Step 5: Verify lefthook config**

```bash
npx lefthook validate 2>&1
```

Expected: «valid» / 0 errors.

- [ ] **Step 6: Final full-suite test run**

```bash
cd app && php vendor/bin/pest --parallel 2>&1 | tail -20
```

Expected: ≥608 PASS (Plan 2.6 baseline 558 + Task 3-9 новых ~50-60 tests). 0 failed (E2E test на Windows = 1 skipped, что OK).

- [ ] **Step 7: Larastan final check**

```bash
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10
```

Expected: 0 errors above baseline.

- [ ] **Step 8: gitleaks-full-history (Plan 3 готовится к push)**

```bash
./bin/gitleaks.exe detect --source . --no-banner --config .gitleaks.toml --redact 2>&1 | tail -10
```

Expected: 0 leaks.

- [ ] **Step 9: lychee link check**

```bash
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "*.md" 2>&1 | tail -20
```

Expected: 0 broken links. Если в spec'е/plan'е ссылки на ещё не существующие файлы — добавить в `.lychee.toml` exclude или поправить.

- [ ] **Step 10: Commit**

```bash
git add app/tests/Browser/SupplierIntegrationE2ETest.php app/composer.json app/composer.lock lefthook.yml

git commit -m "feat(supplier): Plan 3 Task 9 — E2E integration test + lefthook fast-job

Component:
- tests/Browser/SupplierIntegrationE2ETest.php: mock-server (react/http +
  react/socket) на random port, full flow liderra_project → SyncJob →
  rt-project-save payload verification. Skip на Windows native (no
  ext-sockets), Linux CI only.
- lefthook.yml: +pest-supplier-fast job — на изменения в
  app/app/{Jobs,Services,Console,Mail,Exceptions}/Supplier/**/*.php
  запускает только tests/Unit/Supplier/ + tests/Feature/Supplier/.

Composer: +react/http +react/socket (dev). 1 E2E test."
```

---

## Финальный flow перед FF-merge

После Task 9 — обязательные финальные шаги:

- [ ] **Step F1: Запуск Comprehensive Verification Gate (CV.1–CV.14)**

Аналогично Plan 2 / Plan 2.6 — 14 проверок: spec coverage, test count, larastan, pint, squawk, gitleaks (staged + full-history), lychee, schedule:list, route:list, config:show database.connections, migrate:fresh smoke, parallel test run, code-review subagent от 2026-05-XX (раздел audit), git log clean atomic commits.

- [ ] **Step F2: code-review subagent**

Dispatch fresh subagent с конкретным prompt'ом:

- Прочитать spec [2026-05-11-plan3-supplier-sync-design.md](../specs/2026-05-11-plan3-supplier-sync-design.md)
- Прочитать все 9 Task'овых коммитов
- Проверить BLOCKERов, WARNINGов, NITs
- Verdict: «Ready for FF-merge» или список fix'ов

Если subagent найдёт BLOCKER — fix отдельным commit'ом, новый subagent run.

- [ ] **Step F3: FF-merge в main**

```bash
git checkout main
git merge --ff-only supplier-sync-plan3
```

⚠ Plan 3 идёт на feature-branch `supplier-sync-plan3` (как Plan 2 на `supplier-webhook-plan2`). Создать ветку **в начале Task 3** (или Task 1):

```bash
git checkout -b supplier-sync-plan3
```

- [ ] **Step F4: Push с pre-push hooks**

```bash
git push -u origin supplier-sync-plan3
git push origin main  # после FF-merge
```

Pre-push: gitleaks-full-history + lychee. Если lychee пожалуется на `2026-05-11-supplier-sync-plan3.md` ссылку из spec'а (теперь plan-файл существует) — должно пройти.

- [ ] **Step F5: Update memory project_supplier_integration.md**

После merge — обновить memory с финальными метриками Plan 3 (commit'ы, новые tests count, обновлённый Plan 3 = DONE).

---

## Self-review (Step после написания plan'а)

**1. Spec coverage check (§1–§10 → Tasks):**

| Spec § | Task |
|---|---|
| §1 Контекст | covered Tasks 3 (BYPASSRLS) + 4–9 (sync block) |
| §2.1 Discovery + §2.2 Implementation | Tasks 1–2 + Tasks 3–9 |
| §2.3 0 schema changes | Plan 3 = code-only, верифицировано в Step F1 |
| §3.1 PG-connection | Task 3 Step 1 |
| §3.2 файлы под изменение | Task 3 Steps 5/11/15 |
| §3.3 новые файлы | Tasks 4–9 |
| §3.4 Schedule entries | Task 8 Step 8 |
| §4.1 Lead routing flow | Task 3 (по сути «set $connection») |
| §4.2 Session refresh flow | Task 5 |
| §4.3 Sync flow | Task 6 |
| §4.4 Cleanup flow (Phase A→B→C ordering) | Task 7 |
| §5 Error handling matrix | Tasks 4 (exceptions) + 6 (sync), 7 (cleanup) |
| §6 Testing pyramid | покрывается всеми Tasks; pre-commit fast в Task 9 |
| §7 Acceptance criteria | финальные шаги F1–F5 |
| §8 Limitations | вынесено в NOTE-блоки в Tasks 4 Step 6 (toPayload placeholder) + Task 5 Step 2 (DOM selectors) |
| §9 Discovery questions | Task 1 (probe собирает ответы) + Task 2 (spec update) |
| §10 Next step | этот plan-файл |

✅ Все секции spec'а покрыты.

**2. Placeholder scan:**

- 0 «TBD», 0 «TODO», 0 «implement later», 0 «fill in details», 0 «Add appropriate error handling», 0 «similar to Task N» без кода.
- 2 явных NOTE-блока (Tasks 4 Step 6 + 5 Step 2) с placeholder'ами для будущей корректировки после Task 1 discovery — это legitimate, заявлено явно с указанием когда фиксить.

**3. Type consistency:**

- `SupplierProjectDto` определён Task 4 Step 1, использован в Tasks 4 (Client) + 6 (Allocator + SyncJob) + 7 (Cleanup) — единые поля.
- `SupplierAuthException` / `Transient` / `Client` — определены Task 4 Step 3, использованы Tasks 4–7.
- `SupplierPortalClient` методы `listProjects/saveProject/updateProject/deleteProject` — определены Task 4 Step 6, использованы Tasks 6/7.
- `pgsql_supplier` connection — определён Task 3 Step 1, использован Tasks 3 + 5 + 6 + 7 + 8.
- Cache key `supplier:session` — Task 5 Step 9, читается Task 4 Step 6 (`SupplierPortalClient::loadSession`).
- `crm_supplier_worker` role — упомянута в Task 3 Step 1 (config) + Step 19 commit message, ссылка на Plan 2.6 #iv `7899071` корректна.

✅ Типы / имена / ссылки консистентны across all 9 Tasks.

---

Plan complete. Saved to `docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md`.
