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:
@@ -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 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 у заказчика «реши сам, руководствуясь максимумом эффекта и минимумом риска как в моменте так и в дальнейшем».
|
||||
Reference in New Issue
Block a user