# Plan 3 (Supplier Sync) — Implementation Design

**Дата:** 2026-05-11
**Статус:** черновик дизайна (brainstorming output, готов к writing-plans)
**Заказчик:** Дмитрий (владелец Лидерры)
**Parent spec:** [2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) (общий дизайн интеграции Лидерра ↔ crm.bp-gr.ru)
**Зависит от:** Plan 1 (Foundation) `001d781` + Plan 2 (Webhook+Routing) `d5aa972` + Plan 2.5 (concurrency+retry hotfix) `c1ae195`+`1ba1df8` + Plan 2.6 (cleanup CV.11) `7899071`
**Назначение:** implementation-уровень дизайна для Plan 3 — конкретные компоненты, data flow, error handling, testing strategy. Закрывает BLOCKER #6 (`failed_webhook_jobs` RLS NULL tenant) + WARN #2/#3 (LeadRouter/ResetCmd под `crm_app_user` без tenant-context) + добавляет supplier sync (Playwright session + 20:30 МСК cron + AJAX rt-* + 180d cleanup + автоматический retry).

---

## 1. Контекст и связь с parent spec

Plan 1 + Plan 2 + Plan 2.5 + Plan 2.6 закрыли направление **поставщик → Лидерра** (приём входящих лидов через webhook, sharing-routing, RLS-изоляция, idempotency, operational deploy gates). Plan 3 закрывает направление **Лидерра → поставщик** (управление supplier-проектами через AJAX-эмуляцию личного кабинета), плюс закрывает перетекающий backlog Plan 2.6: BLOCKER #6 и архитектурные WARN #2/#3 о работе jobs под `crm_app_user`.

**Scope Plan 3:** полный sync-блок без CSV reconcile.

- ✅ BLOCKER #6 закрывается через переключение supplier-flow на `crm_supplier_worker` BYPASSRLS-роль (создана в Plan 2.6 #iv `7899071`).
- ✅ WARN #2 (LeadRouter под crm_app_user не видит tenant projects) — закрывается тем же переключением.
- ✅ WARN #3 (ResetDeliveredTodayCommand тот же) — закрывается тем же переключением.
- ✅ Playwright session manager + Redis cache cookie/CSRF.
- ✅ 20:30 МСК cron `SyncSupplierProjectsJob` с distribution-логикой B1/B2/B3.
- ✅ 180d cleanup cron `CleanupInactiveSupplierProjectsJob`.
- ✅ Автоматический retry failed webhook jobs (Console command + hourly cron).

**Out of Plan 3 scope (перенесено):** CSV reconciliation (parent spec §5.2) → Plan 4 (billing/tariffs); frontend forms (signal-type wizards, region-picker) → Plan 5.

**Архитектурное решение по BLOCKER #6 — вариант C (BYPASSRLS-role):** согласовано с заказчиком 2026-05-11 в brainstorming-сессии. Альтернативы A (schema bump v8.18→v8.19 с INSERT WITH CHECK (true) policy) и B (отдельная SaaS-таблица supplier_failed_jobs) отвергнуты: C даёт 0 schema changes, 1 fix закрывает 3 элемента backlog'а, согласуется с архитектурным направлением Plan 2.6 #iv brainstorm-выбора (вариант C из 3 опций для queue worker).

**Архитектурное решение по headless browser — Playwright real (Node.js subprocess):** согласовано с заказчиком 2026-05-11. Альтернативы chrome-php (PHP-only) и Guzzle (без headless) отвергнуты: Playwright real даёт страховку при изменениях на стороне поставщика (Cloudflare/reCAPTCHA/JS-login/2FA в будущем), кросс-платформенно (Linux CI prod + Windows native dev через MCP), supported Microsoft.

**Discovery подход — вариант А с явным локальным снятием Pravila §6:** согласовано с заказчиком 2026-05-11. Я через `mcp__playwright__browser_*` MCP-tools залогинюсь в crm.bp-gr.ru с credentials заказчика (передаются в `app/.env` через Laravel encrypter, НЕ plaintext в чат), создам тестовый проект `__claude_probe_<timestamp>`, запишу 5 HTTP-фиксаций (login + projects-load + project-save + project-update + project-delete), удалю тестовый проект, сохраню фиксации как baseline.

---

## 2. Архитектура — 9 Tasks, 2 фазы, параллелизм

### 2.1. Фаза I — Discovery (Tasks 1–2), блокирует фазу II в основной части

| Task | Файлы | Зависит от |
|---|---|---|
| **Task 1 — Discovery через Playwright MCP** | `app/tests/Fixtures/SupplierPortal/*.json` (5 fixtures) | Credentials поставщика в `app/.env` (заказчик передаёт перед стартом) |
| **Task 2 — Spec update v1.0 → v1.1** | [2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) §4.4 «AJAX endpoints — observed formats» | Task 1 |

**Stop-gate после Task 2:** ждём заказчика «ок, формат разобран корректно» перед переходом в фазу II основной частью.

### 2.2. Фаза II — Implementation (Tasks 3–9)

| Task | Назначение | Зависит от |
|---|---|---|
| **Task 3 — Switch supplier-flow на pgsql_supplier (BYPASSRLS)** | Закрывает BLOCKER #6 + WARN #2 + WARN #3 | Task 1 (параллельно — не зависит от discovery, чисто DB-connection change) |
| **Task 4 — SupplierPortalClient** | HTTP-клиент над rt-* endpoints | Task 2 (нужны fixtures), Task 3 (использует pgsql_supplier connection) |
| **Task 5 — RefreshSupplierSessionJob + PlaywrightBridge** | Headless login + Redis cache cookie/CSRF | Task 2, Task 4 |
| **Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator** | 20:30 МСК cron, distribution B1/B2/B3 | Tasks 4, 5 |
| **Task 7 — CleanupInactiveSupplierProjectsJob** | Daily 02:00 МСК cron, Phase A re-activate → B mark inactive → C delete 180d | Tasks 4, 5 |
| **Task 8 — RetryFailedSupplierJobsCommand** | Console + hourly cron, авто-восстановление от transient outage | Task 3 |
| **Task 9 — E2E Integration test (Linux CI only)** | Mock supplier-server → полный flow | Tasks 3–8 |

**Параллелизм Task 1 ‖ Task 3:** discovery (Task 1) — отдельная ветка работы (HTTP-эксплорация поставщика), Task 3 — code change (DB connection switch в 3 файлах). Независимы. Можно идти в разных commit-сериях, но code-review subagent для Task 3 уходит до начала Task 4 (SupplierPortalClient зависит от Task 3 connection).

### 2.3. 0 schema changes

Проверено [db/schema.sql:889](../../db/schema.sql#L889) — колонка `inactive_since TIMESTAMPTZ NULL` уже добавлена в v8.13 (Plan 1/5 Task 2). Индекс [db/schema.sql:908-909](../../db/schema.sql#L908-L909) `supplier_projects_inactive_since_index` тоже есть. Plan 3 — code-only, schema.sql не трогается. Это понижает риск ошибки: меньше surface for breakage, не трогаем RLS-политики, не нужен `migrate:fresh` + проверка метрик.

Готовые элементы schema, используемые Plan 3:

- [db/schema.sql:877-902](../../db/schema.sql#L877) — таблица `supplier_projects` (платформа, signal_type, unique_key, supplier_external_id, current_limit, current_workdays JSONB, current_regions JSONB, sync_status pending/ok/failed, last_synced_at, inactive_since, CHECK constraints).
- [db/schema.sql:904-905](../../db/schema.sql#L904) — UNIQUE INDEX `supplier_projects_platform_unique_key_unique (platform, unique_key)` — защита от race на create.
- [db/schema.sql:1819](../../db/schema.sql#L1819) — `supplier_sync_log` SaaS-level audit log.
- [db/00_create_roles.sql:70-73](../../db/00_create_roles.sql#L70) — `crm_supplier_worker` BYPASSRLS-роль v1.1.

---

## 3. Компоненты — файлы и изменения

### 3.1. Новый PG-connection

**Файл [app/config/database.php](../../app/config/database.php):** добавить ключ `pgsql_supplier` рядом с существующим `pgsql`:

```php
'pgsql_supplier' => array_merge(
    config('database.connections.pgsql'),
    [
        'username' => env('DB_SUPPLIER_USERNAME', 'crm_supplier_worker'),
        'password' => env('DB_SUPPLIER_PASSWORD'),
    ]
),
```

На dev (Windows native, `postgres` superuser) — env'ы могут совпадать с основными `DB_USERNAME`/`DB_PASSWORD`. На prod — отдельная роль `crm_supplier_worker` с BYPASSRLS.

### 3.2. Изменения существующих файлов (Task 3)

| Файл | Изменение |
|---|---|
| [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) | `protected $connection = 'pgsql_supplier'` на уровне класса. INSERT в `failed_webhook_jobs` в `failed()` callback автоматически идёт под BYPASSRLS → NULL `tenant_id` проходит. Inline-warnings lines 258-263 удаляем. |
| [app/app/Services/LeadRouter.php](../../app/app/Services/LeadRouter.php) | `Project::on('pgsql_supplier')->where(...)` в `matchEligibleProjects`. Inline-warnings lines 25-29 удаляем. |
| [app/app/Console/Commands/ResetDeliveredTodayCommand.php](../../app/app/Console/Commands/ResetDeliveredTodayCommand.php) | `Project::on('pgsql_supplier')->where('delivered_today', '!=', 0)->update(['delivered_today' => 0])`. Inline-warnings lines 16-18 удаляем. |
| `.env.example` | + `DB_SUPPLIER_USERNAME=crm_supplier_worker` + `DB_SUPPLIER_PASSWORD=` + `SUPPLIER_LOGIN=` + `SUPPLIER_PASSWORD=` + `SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru` + `SUPPLIER_ALERT_EMAIL=` |

### 3.3. Новые файлы

```
app/app/Services/Supplier/
├── SupplierPortalClient.php              # HTTP-клиент над rt-*, читает cookie/CSRF из Redis
├── SupplierQuotaAllocator.php            # Distribution-логика B1/B2/B3 (pure function)
├── PlaywrightBridge.php                  # Spawn Node.js subprocess, parse stdout JSON
└── Dto/
    └── SupplierProjectDto.php            # Read-only DTO: platform, signal_type, unique_key, limit, workdays, regions, regions_reverse, status

app/app/Exceptions/Supplier/
├── SupplierException.php                 # abstract base
├── SupplierAuthException.php             # 401/403 sticky после refresh-retry
├── SupplierTransientException.php        # 5xx, network, timeout
└── SupplierClientException.php           # 4xx 400/404/422 (наша ошибка payload'а)

app/app/Jobs/Supplier/
├── RefreshSupplierSessionJob.php         # Spawn PlaywrightBridge, put session в Redis 6h TTL
├── SyncSupplierProjectsJob.php           # 20:30 МСК cron, per-supplier_project failure isolation
└── CleanupInactiveSupplierProjectsJob.php  # Daily 02:00 МСК, Phase A→B→C

app/app/Console/Commands/
├── SupplierSessionRefreshCommand.php     # supplier:session:refresh (ручной запуск)
└── RetryFailedSupplierJobsCommand.php    # supplier:retry-failed (hourly cron + лимит 10/24h)

app/app/Mail/
└── SupplierCriticalAlertMail.php         # Mailable для critical alert: subject + body + details

app/playwright/
├── package.json                          # playwright npm dep (изолировано от app/package.json)
└── refresh-session.js                    # ~50 строк Node, stdin {login, password, url} → stdout {phpsessid, csrf, refreshed_at}

app/tests/Fixtures/SupplierPortal/        # из Task 1 discovery
├── login.json
├── projects-load.json
├── project-save.json
├── project-update.json
└── project-delete.json
```

### 3.4. Schedule entries

[app/routes/console.php](../../app/routes/console.php) (Laravel 13 syntax):

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

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();
```

`onOneServer()` требует `cache_locks` таблицу в схеме. Plan 2 deferred WARNING #5 — таблица должна быть добавлена при необходимости. Plan 3 это явно реализует (либо `php artisan cache:table` + миграция в schema.sql v8.18, либо использовать существующий Redis driver через `Cache::store('redis')` — простая опция).

**Решение:** использовать Redis driver для `onOneServer()` через `RATELIMITER`/`CACHE_STORE` env. Не требует таблицы. Это уточняем в Task 6/7 при implementation.

---

## 4. Data flow

### 4.1. Lead routing flow (после Task 3, при поступлении входящего лида)

```
POST /api/webhook/supplier/{secret}
   ↓ SupplierWebhookController::receive (под crm_app_user, RLS-active — НЕ меняется)
   ↓ verifySecret + verifyIpAllowlist + timestamp ±24h validation
   ↓ supplier_leads.insert (raw payload, без tenant)
   ↓ dispatch RouteSupplierLeadJob (queue, processed-async)
   ↓
RouteSupplierLeadJob::handle (под crm_supplier_worker BYPASSRLS — НОВОЕ Task 3)
   ↓ guard processed_at IS NULL (idempotency, fix Plan 2.5 #3)
   ↓ LeadRouter::matchEligibleProjects(...) — НА pgsql_supplier (НОВОЕ Task 3)
   ↓     ← теперь видит все tenant projects через BYPASSRLS (WARN #2 закрыт)
   ↓ foreach project:
   ↓     lockForUpdate(Tenant) (Plan 2.5 #2)
   ↓     lockForUpdate(Project) + recheck delivered_today (Plan 2.5 #2)
   ↓     createDealCopyForProject + LeadCharge + delivered_today++
   ↓     SET LOCAL app.current_tenant_id = $project->tenant_id  ← defense-in-depth
   ↓ supplier_lead.update processed_at=NOW(), deals_created_count=N
   ↓ если упало 3 раза → failed_webhook_jobs.insert(tenant_id=NULL) ← BLOCKER #6 ЗАКРЫТ
   ↓     ← BYPASSRLS пропускает NULL, no silent fail
```

Минимальное изменение: `protected $connection = 'pgsql_supplier'` в 3 классах. Бизнес-логика не меняется.

### 4.2. Session refresh flow (Task 5)

```
Triggers:
  [1] Schedule hourly (routes/console.php)
  [2] Schedule daily 20:15 МСК (15 мин до sync deadline)
  [3] SupplierPortalClient получил 401/403 → dispatch_sync(RefreshSupplierSessionJob)

RefreshSupplierSessionJob::handle
   ↓ Cache::lock('supplier:session:refresh', 30)->block(35, function() { ... })
   ↓     # защита от concurrent refresh; ждёт до 35s если другой воркер уже рефрешит
   ↓ PlaywrightBridge::refreshSession()
   ↓     spawn `node app/playwright/refresh-session.js` через Symfony\Process
   ↓     stdin (JSON):
   ↓         {login: env(SUPPLIER_LOGIN), password: env(SUPPLIER_PASSWORD), url: env(SUPPLIER_PORTAL_URL)}
   ↓     Node-скрипт (~50 lines):
   ↓         const {chromium} = require('playwright');
   ↓         const browser = await chromium.launch({headless: true});
   ↓         const page = await browser.newPage();
   ↓         await page.goto(args.url);
   ↓         await page.fill('input[name=login]', args.login);
   ↓         await page.fill('input[name=password]', args.password);
   ↓         await page.click('button[type=submit]');
   ↓         await page.waitForLoadState('networkidle');
   ↓         const csrf = await page.locator('meta[name=csrf-token]').getAttribute('content');
   ↓         const cookies = await page.context().cookies();
   ↓         const phpsessid = cookies.find(c => c.name === 'PHPSESSID')?.value;
   ↓         await browser.close();
   ↓         process.stdout.write(JSON.stringify({phpsessid, csrf, refreshed_at: Date.now()}));
   ↓     timeout 60s; non-zero exit → throw SupplierAuthException
   ↓ Cache::store('redis')->put('supplier:session', $data, now()->addHours(6))
   ↓ Log::info('supplier.session.refreshed', ['ttl' => 21600])
```

DOM-селекторы `input[name=login]`, `meta[name=csrf-token]` — placeholder'ы; точные селекторы будут известны после Task 1 discovery и могут потребовать корректировку refresh-session.js.

### 4.3. Sync flow (Task 6, 20:30 МСК cron)

```
Schedule::job(SyncSupplierProjectsJob)->dailyAt('20:30')->timezone('Europe/Moscow')->onOneServer()

SyncSupplierProjectsJob::handle (под crm_supplier_worker BYPASSRLS)
   ↓ $consecutiveTransientCount = 0;
   ↓ foreach SupplierProject::query()->whereNull('deleted_at')->cursor():
   ↓     $startTime = now();
   ↓     if ($startTime->copy()->timezone('Europe/Moscow')->format('H:i') >= '20:55') {
   ↓         Log::warning('supplier.sync.time_budget_reached');
   ↓         break;  // 5-мин запас до 21:00 deadline
   ↓     }
   ↓     try {
   ↓         $activeProjects = $supplierProject->liderraProjects()
   ↓                                            ->where('status', 'active')
   ↓                                            ->get();
   ↓         if ($activeProjects->isEmpty()) {
   ↓             continue;  // CleanupJob отдельно пометит inactive_since
   ↓         }
   ↓         $allocation = SupplierQuotaAllocator::allocate(
   ↓             platform: $supplierProject->platform,
   ↓             signalType: $supplierProject->signal_type,
   ↓             liderraProjects: $activeProjects,
   ↓             targetDate: Carbon::tomorrow('Europe/Moscow'),
   ↓         );
   ↓         $current = SupplierProjectDto::fromModel($supplierProject);
   ↓         if ($allocation->equals($current)) {
   ↓             continue;  // no diff, save API call
   ↓         }
   ↓         DB::transaction(function() use ($supplierProject, $allocation) {
   ↓             if ($supplierProject->supplier_external_id === null) {
   ↓                 $externalId = $portalClient->saveProject($allocation);
   ↓                 $supplierProject->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 {
   ↓                 $portalClient->updateProject($supplierProject->supplier_external_id, $allocation);
   ↓                 $supplierProject->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' => $supplierProject->id,
   ↓                 'action' => $supplierProject->wasRecentlyCreated ? 'save' : 'update',
   ↓                 'status' => 'ok',
   ↓             ]);
   ↓         });
   ↓         $consecutiveTransientCount = 0;
   ↓     } catch (SupplierAuthException $e) {
   ↓         Mail::to(config('services.supplier.alert_email'))->queue(new SupplierCriticalAlertMail('auth_sticky', $e));
   ↓         report($e);
   ↓         throw $e;  // фатально, останавливает весь job
   ↓     } catch (SupplierTransientException $e) {
   ↓         $consecutiveTransientCount++;
   ↓         SupplierSyncLog::create([
   ↓             'supplier_project_id' => $supplierProject->id,
   ↓             'action' => 'update',
   ↓             'status' => 'failed',
   ↓             'error' => substr($e->getMessage(), 0, 500),
   ↓         ]);
   ↓         if ($consecutiveTransientCount >= 50) {
   ↓             Mail::to(config('services.supplier.alert_email'))->queue(new SupplierCriticalAlertMail('mass_transient', $e));
   ↓             report(new \RuntimeException('Supplier outage suspected: 50 consecutive transient failures'));
   ↓             break;
   ↓         }
   ↓         continue;
   ↓     } catch (SupplierClientException $e) {
   ↓         SupplierSyncLog::create([
   ↓             'supplier_project_id' => $supplierProject->id,
   ↓             'action' => 'update',
   ↓             'status' => 'failed',
   ↓             'error' => substr($e->getMessage(), 0, 500),
   ↓         ]);
   ↓         report($e);  // warning to Sentry
   ↓         continue;  // одна bad payload не валит остальных
   ↓     }
```

**Failure-isolation per supplier_project:** один сбой не валит партию. Согласуется с Plan 2 Task 6 `605c457` per-Project isolation.

**Mass-fail mitigation:** 50 подряд `SupplierTransientException` → abort + email + Sentry.

**Time budget:** 20:30–21:00 = 30 мин до deadline'а поставщика, 5-мин safety margin до 20:55.

### 4.4. Cleanup flow (Task 7, daily 02:00 МСК) — Phase A → B → C

**Критический порядок фаз** (исправление черновика — порядок важен для safety):

```
CleanupInactiveSupplierProjectsJob::handle (под crm_supplier_worker)
   ↓ Phase A — re-activate (СНАЧАЛА, чтобы Phase C не удалила недавно вернувшихся):
   ↓     UPDATE supplier_projects SET inactive_since=NULL
   ↓     WHERE inactive_since IS NOT NULL
   ↓       AND id IN (
   ↓           SELECT DISTINCT supplier_b1_project_id FROM projects WHERE status='active' AND supplier_b1_project_id IS NOT NULL
   ↓           UNION SELECT DISTINCT supplier_b2_project_id FROM projects WHERE status='active' AND supplier_b2_project_id IS NOT NULL
   ↓           UNION SELECT DISTINCT supplier_b3_project_id FROM projects WHERE status='active' AND supplier_b3_project_id IS NOT NULL
   ↓       )
   ↓
   ↓ Phase B — mark new inactive:
   ↓     UPDATE supplier_projects SET inactive_since=NOW()
   ↓     WHERE inactive_since IS NULL
   ↓       AND id NOT IN ( … тот же DISTINCT SELECT … )
   ↓
   ↓ Phase C — delete 180d-old:
   ↓     foreach SupplierProject::where('inactive_since', '<', now()->subDays(180))->cursor():
   ↓         try {
   ↓             if ($sp->supplier_external_id) {
   ↓                 $portalClient->deleteProject($sp->supplier_external_id);
   ↓             }
   ↓             $sp->delete();  // soft-delete если SoftDeletes trait; иначе forceDelete
   ↓             SupplierSyncLog::create([
   ↓                 'supplier_project_id' => $sp->id,
   ↓                 'action' => 'delete',
   ↓                 'status' => 'ok',
   ↓             ]);
   ↓         } catch (SupplierClientException $e) {
   ↓             // 404 от поставщика = уже удалён → продолжаем с локальным soft-delete
   ↓             if ($e->httpStatus === 404) {
   ↓                 $sp->delete();
   ↓                 SupplierSyncLog::create([
   ↓                     'supplier_project_id' => $sp->id,
   ↓                     'action' => 'delete',
   ↓                     'status' => 'ok',
   ↓                     'note' => 'supplier returned 404 (already deleted)',
   ↓                 ]);
   ↓             } else {
   ↓                 SupplierSyncLog::create([...status=failed]);
   ↓                 report($e);
   ↓                 continue;
   ↓             }
   ↓         } catch (SupplierTransientException $e) {
   ↓             SupplierSyncLog::create([...status=failed]);
   ↓             continue;  // retry завтра
   ↓         }
```

**Safety property:** если Phase A не отработает по ошибке → Phase C не удалит активно используемые supplier_projects, потому что Phase C select-критерий — `inactive_since < NOW() - INTERVAL '180 days'`, а Phase B (которая ставит NOW()) выполняется ПОСЛЕ Phase A → новые inactive_since=NOW() даты сильно меньше 180-дневной отсечки.

**180 дней — paritет со spec §3.3.** Без grace-period. Защита от потенциального reverse'а на стороне поставщика — через `supplier_sync_log` audit (полная история «что/когда удалено / response поставщика»). При обнаружении ошибки восстановление через manual rt-project-save с тем же `unique_key` — UNIQUE INDEX [schema.sql:904-905](../../db/schema.sql#L904) защитит от дублей.

---

## 5. Error handling и retry strategy

### 5.1. Матрица ошибок

| Тип ошибки | Источник | Кто ловит | Retry | Эскалация |
|---|---|---|---|---|
| Network timeout / connection refused | Любой rt-* call | `SupplierPortalClient` → `SupplierTransientException` | Job-уровень: `$tries=3`, `backoff()=[60,300,900]` (1м/5м/15м) | После $tries → `failed_webhook_jobs` (Sync) ИЛИ `supplier_sync_log.status='failed'` (per-item) |
| HTTP 401/403 (session expired) | Любой rt-* call | `SupplierPortalClient` | Inline: `dispatch_sync(RefreshSupplierSessionJob)` + retry 1 раз | Повторный 401/403 → `SupplierAuthException` (sticky), job fails, email + Sentry critical |
| HTTP 5xx | Любой rt-* call | `SupplierPortalClient` → `SupplierTransientException` | Job-уровень (как network) | Same as network |
| HTTP 4xx (не 401/403): 400/404/422 | rt-project-save/update/delete | `SupplierPortalClient` → `SupplierClientException` | НЕТ retry (наша ошибка payload'а) | `supplier_sync_log.status='failed'` + Sentry warn + continue к следующему |
| Playwright subprocess timeout/crash | `PlaywrightBridge` | `RefreshSupplierSessionJob` → `SupplierAuthException` | Job-уровень: `$tries=3`, `backoff()=[120,600,1800]` (2м/10м/30м) | После $tries → email + Sentry critical |
| Redis недоступен | `SupplierPortalClient` чтение cache | Laravel native exception | Laravel automatic с queue worker | После N retries → fallback: synchronous `RefreshSupplierSessionJob` минуя cache |
| PG transient | DB calls в jobs | Laravel native retry | Auto retry на conn-level | Baseline noise, игнорируется |
| Supplier overcommit | Sync видит что поставщик отверг limit | `SupplierPortalClient::updateProject` → 422 | НЕТ retry | Email + Sentry + `supplier_sync_log` |
| 50 подряд `SupplierTransientException` в Sync | `SyncSupplierProjectsJob` aggregate | Job-уровень | Job aborts mid-loop | Email + Sentry critical |

### 5.2. Idempotency

| Операция | Идемпотентность |
|---|---|
| `RefreshSupplierSessionJob` | Естественно идемпотентна (write to Redis-ключ); защита от concurrent — `Cache::lock('supplier:session:refresh', 30)->block(35, ...)` |
| `SyncSupplierProjectsJob` (whole) | `->onOneServer()` через Redis driver не запускается параллельно |
| `SyncSupplierProjectsJob` per-item | save: `if supplier_external_id IS NULL → save, else update` + UNIQUE INDEX `(platform, unique_key)` [schema.sql:904](../../db/schema.sql#L904) защищает от race на нашей стороне; на стороне поставщика — дополнительный Redis lock per `supplier_projects.id` перед save для гарантии single-flight |
| `CleanupInactiveSupplierProjectsJob` | `->onOneServer()`; Phase A/B — идемпотентные UPDATE'ы; Phase C delete защищён per-row try/catch на 404 |
| `RouteSupplierLeadJob` | Уже идемпотентна — fix Plan 2.5 #3 `1ba1df8`, НЕ меняем |
| `RetryFailedSupplierJobsCommand` | Idempotent через `last_retried_at + retry_attempts < 10` фильтр |

### 5.3. Безопасность данных при failure

- **Save sequence:** HTTP-call к поставщику получает `external_id` → UPDATE `supplier_projects.supplier_external_id` в той же transaction'е, с retry на UPDATE отдельно (3 attempts). При полном failure UPDATE — `supplier_sync_log.action='save_orphan'` для manual recovery (search by `(platform, unique_key)`).
- **Delete sequence:** soft-delete в `supplier_projects` (locally) ПОСЛЕ успешного HTTP-call. Если HTTP fail — row остаётся с `inactive_since`, next-day Phase C retry. Если HTTP succeed но локальный delete failed — next-day Phase C видит row снова, повтор HTTP → поставщик 404 → catch'им как «уже удалён», локальный delete. Self-healing.

### 5.4. Алерт-каналы

| Severity | Event | Канал |
|---|---|---|
| `critical` | `SupplierAuthException` после retry | Sentry + email (`SupplierCriticalAlertMail`) |
| `critical` | Playwright subprocess crashed 3 раза подряд | Sentry + email |
| `critical` | 50 подряд transient в Sync (suspected outage) | Sentry + email |
| `warning` | per-item failure в Sync (один supplier_project) | Sentry + `supplier_sync_log` |
| `info` | rt-project-delete success | `supplier_sync_log` (audit для potential reverse) |
| `info` | session refreshed | `supplier_sync_log` + Sentry breadcrumb |

**Email через Unisender Go SMTP relay** ([CLAUDE.md §2](../../CLAUDE.md)), target из `config('services.supplier.alert_email')` (env `SUPPLIER_ALERT_EMAIL`). Telegram — НЕ используется (избегаем supply-chain risk от внешнего сервиса; controlled email survivable).

### 5.5. Exception hierarchy

```
\App\Exceptions\Supplier\SupplierException (abstract)
├── SupplierAuthException        // 401/403 sticky после refresh-retry
├── SupplierTransientException   // 5xx, network, timeout
└── SupplierClientException      // 4xx 400/404/422 (наша ошибка payload)
```

3 класса вместо одного с predicate-methods: idiomatic Laravel/PHP pattern, IDE autocomplete + Larastan static-check ловит unhandled paths. При добавлении новых типов (например, `SupplierRateLimitException` для 429) — +1 файл, не +1 ветка в predicate.

---

## 6. Testing strategy

### 6.1. Многоуровневая пирамида

| Уровень | Объём | Где запускается | Покрывает |
|---|---|---|---|
| **Unit (Mockery)** | 60–70% тестов Plan 3 | Native Windows dev + Linux CI | Pure-function logic (QuotaAllocator), HTTP-clients через `Http::fake`, Job-orchestration через mocked services |
| **Integration (real PG)** | 20–25% | Native Windows dev + Linux CI | Job → DB → connection switch (pgsql_supplier vs pgsql); RouteSupplierLeadJob под BYPASSRLS; CleanupJob Phase A/B/C SQL'и |
| **E2E (mock supplier server)** | 5–10% | **Linux CI only** (skip на Windows) | Полный flow liderra_project create → SyncJob → mock-server получает корректный rt-project-save payload |
| **Discovery fixtures** | Static JSON | Не запускаются | Артефакт Task 1 как baseline |

### 6.2. Per-Task testing

**Task 1 (Discovery):** 5 JSON-fixtures в `app/tests/Fixtures/SupplierPortal/`. Каждый fixture: `{request: {method, url, headers, body}, response: {status, headers, body}}`. Артефакт коммитится в репозиторий, не запускается.

**Task 3 (Switch supplier-flow):** `tests/Feature/Supplier/SupplierConnectionTest.php`:

- `it('uses pgsql_supplier connection by default in RouteSupplierLeadJob')` — assertion через reflection / Mockery
- `it('inserts failed_webhook_jobs with tenant_id=null without error')` — **regression-test для BLOCKER #6**. Сетап: имитировать throw в `createDealCopyForProject`, assert что `failed()` callback пишет row с `tenant_id = NULL`.
- `it('LeadRouter sees projects across tenants under BYPASSRLS')` — **regression-test WARN #2**. Создать 3 tenant'а × 2 projects, без SET LOCAL вызвать `matchEligibleProjects`, assert что видит все 6 projects.
- `it('ResetDeliveredTodayCommand updates all tenants under BYPASSRLS')` — **regression-test WARN #3**.

**Task 4 (SupplierPortalClient):** `tests/Unit/Supplier/SupplierPortalClientTest.php` через `Http::fake()`:

- `it('attaches PHPSESSID and CSRF cookies from cache')`
- `it('triggers RefreshSupplierSessionJob synchronously on 401')` + `it('retries once after refresh')` + `it('throws SupplierAuthException on sticky 401')`
- `it('throws SupplierTransientException on 5xx')`
- `it('throws SupplierClientException on 4xx not 401/403')`
- `it('parses rt-projects-load response into SupplierProjectDto collection')`
- Один тест per method: `saveProject` / `updateProject` / `deleteProject` / `listProjects` (используют fixtures Task 1)

**Task 5 (RefreshSupplierSessionJob + PlaywrightBridge):**

- Unit: `tests/Unit/Supplier/RefreshSupplierSessionJobTest.php`:
  - `it('writes session data to Redis with 6h TTL')` (mock PlaywrightBridge)
  - `it('throws SupplierAuthException if PlaywrightBridge returns null')`
  - `it('acquires Redis lock before refresh')` (prevent concurrent)
- Unit для PlaywrightBridge: `tests/Unit/Supplier/PlaywrightBridgeTest.php`:
  - Mock `Symfony\Process` через DI
  - `it('passes credentials to subprocess via stdin not argv')` (avoid leak in ps output)
  - `it('parses stdout JSON')`, `it('throws on non-zero exit')`, `it('throws on timeout')`
- Integration Linux CI only: `tests/Browser/SupplierPlaywrightBridgeTest.php`:
  - `it('actually launches chromium and returns cookies')` — `->skip(PHP_OS_FAMILY === 'Windows', ...)`
  - Mock supplier-server → real Node subprocess → assert cookies extracted

**Task 6 (SyncSupplierProjectsJob + SupplierQuotaAllocator):**

- Unit QuotaAllocator (pure function): `tests/Unit/Supplier/SupplierQuotaAllocatorTest.php`:
  - 9 тестов на distribution: B1+B2+B3 для site/call, B2+B3 для sms-with-keyword, B3-only для sms-without-keyword
  - Workdays union, regions union, regions_reverse=false
  - Edge cases: 0 active liderra (return null), 1 project с limit=1, 1000 projects с limit=10000
- Integration SyncJob: `tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`:
  - `it('creates supplier_project at supplier when supplier_external_id is null')` — Http::fake получает rt-project-save с правильным payload
  - `it('updates when diff detected')`
  - `it('skips when no diff')`
  - `it('isolates failure: one bad supplier_project does not stop others')` — критический тест per-item isolation
  - `it('aborts after 50 consecutive transient failures and alerts')` — mass-fail mitigation
  - `it('writes supplier_sync_log row for each action')`
  - `it('respects time budget 20:30-20:55')` — mock Carbon, assert exit при cutoff

**Task 7 (CleanupInactiveSupplierProjectsJob):** `tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php`:

- `it('phase A re-activates supplier_project when active liderra returns')`
- `it('phase B marks inactive_since=NOW for newly orphaned supplier_project')`
- `it('phase C deletes supplier_project after 180 days inactive')`
- **`it('phase A runs before phase B before phase C')`** — критический ordering test (safety от accidental delete активного supplier_project)
- `it('handles 404 from supplier on already-deleted supplier_project')`
- `it('writes audit row to supplier_sync_log on each delete')`
- Edge case: `it('does not delete supplier_project marked inactive < 180 days ago')`

**Task 8 (RetryFailedSupplierJobsCommand):** `tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php`:

- `it('dispatches RouteSupplierLeadJob for each row in failed_webhook_jobs')`
- `it('skips rows with retry_attempts >= 10')`
- `it('skips rows with last_retried_at within last hour')`
- `it('increments retry_attempts and updates last_retried_at')`

**Task 9 (E2E flow, Linux CI only):** `tests/Browser/SupplierIntegrationE2ETest.php`:

- Mock supplier-server на временном порту через Symfony HttpFoundation
- Создать liderra_project в БД, запустить SyncSupplierProjectsJob
- Assert mock-server получил rt-project-save с корректным payload
- Assert `supplier_projects.supplier_external_id` обновился
- Skip на Windows
- Опционально: проверить что после Task 3 connection switch не сломан Plan 2 E2E test `b6b5b0b`

### 6.3. Pre-commit lefthook расширение

Дополнения к [lefthook.yml](../../lefthook.yml):

- Job `playwright-fixtures-syntax`: `node app/playwright/check-fixtures.js` валидирует JSON-fixtures (на Linux); skip на Windows.
- Job `pest-supplier-unit-fast`: запускать только `tests/Unit/Supplier/` + `tests/Feature/Supplier/` на staged commit (~5s). Полный `composer test` остаётся на push-hook.

### 6.4. CI matrix

- **GitHub Actions Linux job:** добавить `npx playwright install chromium --with-deps` в setup, запуск `composer test --testsuite=Browser` для full E2E
- **Native Windows dev:** `composer test` пропускает Browser tests; всё остальное проходит

### 6.5. Метрики приёма Plan 3

Минимум для merge:

- **Pest:** +50–70 новых тестов; total после Plan 3 ≥ ~608/610 (Plan 2.6 baseline 558/556)
- **Larastan:** 0 errors на новом коде; baseline пополняется только Pest-Mockery-PhpDoc edge cases
- **Pint:** clean
- **squawk:** 0 issues (Plan 3 = 0 SQL миграций → squawk не активируется)
- **gitleaks-full-history:** 0 leaks. **Особое внимание:** `SUPPLIER_LOGIN`, `SUPPLIER_PASSWORD` НИКОГДА не в коде / fixtures / tests. Тесты используют dummy `SUPPLIER_LOGIN=test_login`.
- **lychee:** 0 broken links в spec + plan files

---

## 7. Acceptance criteria

Plan 3 считается готовым к FF-merge в main когда:

1. **BLOCKER #6 закрыт:** failed_webhook_jobs.insert с tenant_id=NULL не падает silent под `crm_supplier_worker` connection. Regression-test green.
2. **WARN #2 закрыт:** LeadRouter::matchEligibleProjects видит все tenant projects под BYPASSRLS. Regression-test green.
3. **WARN #3 закрыт:** ResetDeliveredTodayCommand обновляет projects всех tenant'ов. Regression-test green.
4. **Session refresh работает:** `php artisan supplier:session:refresh` успешно логинится в реальный crm.bp-gr.ru и записывает в Redis. Smoke check после deploy.
5. **SyncSupplierProjectsJob smoke:** один тестовый liderra_project → 20:30 cron создаёт supplier-side проект через rt-project-save → `supplier_projects.supplier_external_id` обновляется.
6. **Cleanup ordering:** Phase A → B → C проверен явным test'ом.
7. **Mass-fail mitigation:** 50 transient → abort + email + Sentry.
8. **Все per-Task тесты:** green на Linux CI, green на Windows dev (с E2E skip).
9. **Code-review subagent:** один прогон с финальным «Ready for FF-merge» без BLOCKERов.
10. **CV-gates (Comprehensive Verification):** 14 проверок (как в Plan 2 — CV.1–CV.14) green.

---

## 8. Не верифицировано в этом design'е

Согласно правилам экономии 0%, явный список limitations:

- **DOM-селекторы** для `refresh-session.js` (`input[name=login]`, `meta[name=csrf-token]`) — placeholder'ы. Точные селекторы будут известны после Task 1 discovery; могут потребовать корректировку Node-скрипта.
- **Mock supplier server library** в Pest Browser tests — предположил Symfony HttpFoundation для in-process mock-server'а. Альтернативы (`hammerstone/closure-server`, `react/http`) не проверял; финальный выбор — в Task 9.
- **Cache lock `Cache::lock('supplier:session:refresh', 30)->block(35, ...)`** для Redis driver — синтаксис Laravel 13 не проверил против актуальной `[config/cache.php](app/config/cache.php)` (memurai на dev). Возможна корректировка при implementation.
- **`onOneServer()` driver:** предполагаю Redis (через memurai). Если `CACHE_STORE` env-default — `file` (Plan 2 deferred WARNING #5 упоминает отсутствие `cache_locks` таблицы), нужна явная коррекция env или добавление `cache_locks` таблицы. Финал — в Task 6/7.
- **Larastan PhpDoc для `Symfony\Process` stub injection** — на Linux может работать иначе чем Windows. Не верифицировал.
- **Telegram-канал для алертов** — отвергнут в пользу email; альтернатива не реализуется в Plan 3, можно добавить в Sprint 5+ как extra канал.
- **Семантика rt-project-delete у поставщика (soft vs hard):** узнаем в Task 1 discovery. План предполагает наихудший случай (hard) и опирается на `supplier_sync_log` audit для potential reverse. При soft-семантике план не меняется — overhead минимален.
- **Cookie `PHPSESSID` имя:** предположение, реальное имя session-cookie у поставщика — узнаем в Task 1. Может быть `JSESSIONID`, `_session`, etc.
- **CSRF-токен механизм:** предположил `<meta name="csrf-token" content="...">`. Альтернативы: hidden input в каждой форме, custom header. Узнаем в Task 1.
- **Время для `onOneServer` lock TTL:** взят дефолт Laravel (~30s); если cron жёстко занимает >30s — race возможна. Уточним при implementation.

---

## 9. Открытые вопросы для Discovery (Task 1)

Список конкретных вопросов, на которые Task 1 должен дать ответ:

1. **Login flow:** обычный HTML-form POST или JS-rendered (SPA)? Какие input-name (`login`/`username`/`email`)?
2. **Cookie names:** `PHPSESSID`, `JSESSIONID`, custom? Какие attributes (HttpOnly, Secure, SameSite)?
3. **CSRF mechanism:** `<meta>` tag в head? Hidden input в каждой форме? Custom header (`X-CSRF-Token`)?
4. **rt-projects-load:** GET или POST? Pagination? Format JSON / form-urlencoded?
5. **rt-project-save:** обязательные поля? Валидация на сервере? Response format (id + status / только status / redirect)?
6. **rt-project-update:** включает все поля или только diff? Принимает PATCH или POST?
7. **rt-project-delete:** идемпотентен (повторный delete → 200 / 404)? Soft или hard?
8. **HTTP error semantics:** что возвращается на bad payload — 400 / 422? На auth fail — 401 / 403? На rate limit — 429?
9. **Rate limits:** есть ли вообще? Сколько RPS поддерживает personal account?
10. **Логин-страница URL:** прямой URL для login form?
11. **DOM селекторы успешного логина:** какой signal что login passed (redirect URL? presence of element? cookie change?)?
12. **Логаут timeout:** через сколько без активности cookie expires?

Эти ответы попадут в spec parent §4.4 после Task 2.

---

## 10. Следующий шаг

После approval этого design'а:

1. `superpowers:writing-plans` skill → создать [docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md](../plans/2026-05-11-supplier-sync-plan3.md) с детальной декомпозицией каждого из 9 Tasks (failing test → implementation → green → commit).
2. После писания plan'а — отдельный stop-gate user review.
3. Executing-plans (либо subagent-driven для Tasks 4–7, либо inline для Task 3 как самой простой).
4. После всех 9 Tasks → CV-gates → code-review subagent → FF-merge в main.

---

## История версий design'а

- **v1.0 от 2026-05-11** — первичный draft после brainstorming-сессии (5 секций обсуждено, 9 Tasks утверждены, 3 архитектурных решения: BLOCKER #6 = вариант C, headless browser = Playwright real, discovery = вариант А). Согласовано через delegated decision-making у заказчика «реши сам, руководствуясь максимумом эффекта и минимумом риска как в моменте так и в дальнейшем».
