docs(specs): Plan 3 (Supplier Sync) implementation design

Brainstorming output: 9-Tasks decomposition в 2 фазы (Discovery + Implementation),
0 schema changes, parent spec 2026-05-10-supplier-integration-design.md.

Архитектурные решения (delegated decision-making у заказчика):
- BLOCKER #6: вариант C (переключение supplier-flow на crm_supplier_worker
  BYPASSRLS-роль из Plan 2.6 #iv 7899071) — закрывает BLOCKER #6 + WARN #2 + WARN #3
  одной правкой, 0 schema bump
- Headless browser: real Playwright через Node.js subprocess (страховка от
  Cloudflare/reCAPTCHA/JS-login/2FA на стороне поставщика в будущем)
- Discovery method: вариант А (Playwright MCP + credentials в .env, явное
  локальное снятие Pravila §6 на одну сессию)

Tasks:
1. Discovery через Playwright MCP (5 HTTP-фиксаций)
2. Spec update v1.0 → v1.1 (§4.4 observed AJAX formats)
3. Switch supplier-flow на pgsql_supplier connection (закрывает BLOCKER #6 + WARN #2/#3)
4. SupplierPortalClient (HTTP-обёртка над rt-*, Redis cache cookie/CSRF)
5. RefreshSupplierSessionJob + PlaywrightBridge (Node subprocess, 6h Redis TTL)
6. SyncSupplierProjectsJob + SupplierQuotaAllocator (20:30 МСК cron, B1/B2/B3 distribution)
7. CleanupInactiveSupplierProjectsJob (Phase A re-activate → B mark → C delete 180d)
8. RetryFailedSupplierJobsCommand (hourly cron + лимит 10 attempts/24h)
9. E2E integration test (Linux CI only, skip на Windows)

Exception hierarchy (3 классa): Auth/Transient/Client + abstract base.
Алерт-каналы: Sentry + email через Unisender Go SMTP relay (без Telegram).
Метрики приёма: Pest >=608/610, Larastan 0 errors, gitleaks 0 leaks.

+JSESSIONID в cspell-words.txt (рядом с PHPSESSID) для будущих spec'ов
session-cookie семантики.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-11 00:29:12 +03:00
parent 93314604f2
commit 1a265b5a38
2 changed files with 619 additions and 0 deletions
+1
View File
@@ -896,6 +896,7 @@ symfony
логинится
encrypter
PHPSESSID
JSESSIONID
vashinvestor
# v1.78 — Economy hook bypass closure spec/plan terms
@@ -0,0 +1,618 @@
# 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 12), блокирует фазу 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 39)
| 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 38 |
**Параллелизм 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:3021: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)** | 6070% тестов Plan 3 | Native Windows dev + Linux CI | Pure-function logic (QuotaAllocator), HTTP-clients через `Http::fake`, Job-orchestration через mocked services |
| **Integration (real PG)** | 2025% | 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)** | 510% | **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:** +5070 новых тестов; 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.1CV.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 47, либо 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 у заказчика «реши сам, руководствуясь максимумом эффекта и минимумом риска как в моменте так и в дальнейшем».