Investigation 2026-05-25: for tenant client1 (tenant_id=2) on prod liderra.ru: - 205 leads at supplier (info@lkomega.ru, visit=rt) vs 160 deals on portal - 82 leads lost (76 via 302-redirect from ValidationException, mostly non-B-prefix projects: client.carmoney.ru, cashmotor.ru, etc.) - 37 duplicate deals (CSV-recovered SupplierLead vid=null + later webhook with real vid "create two Deals because supplier_lead_deliveries locks on supplier_lead_id, not phone+project) Three independent fixes, three plans, three deploys: Phase 1 (low risk): Always JSON 422 for webhook ValidationException Phase 2 (med risk, billing): merge webhook-after-CSV-recovered into existing deal, no double-charge Phase 3 (high risk, migration): accept non-B projects as platform=DIRECT end-to-end (controller + 4 services + migration) Phase 3 includes new LeadRouter fallback path: DIRECT-supplier_projects match Liderra projects via signal_type+signal_identifier directly (no project_supplier_links pivot required, since psl rows don't exist for auto-created DIRECT supplier_projects). Refs: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md
20 KiB
Supplier webhook reliability — design spec
Дата: 2026-05-25
Статус: draft → готов к плану
Ветка: feat/supplier-webhook-fixes
Связано: Спек B Phase 1 (docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md) — снят DuplicateDetector; данная спека закрывает race condition, оставшийся после Спека B.
1. Проблема
На боевом liderra.ru за сутки 25.05.2026 для тенанта client1 (tenant_id=2):
- Поставщик crm.bp-gr.ru отдал 205 уникальных лидов (учётка
info@lkomega.ru, страница/admin/visit/index-visit?visit=rt) - На портале — 160 сделок, из них 123 уникальных телефона (37 — дубликаты
phone+project) - Расхождения: 82 лида у поставщика не дошли до портала; 37 deals в портале дублированы
1.1. Корневая причина потерь (76 из 82)
Из 234 POST-запросов поставщика на /api/webhook/supplier/<secret> сегодня:
- 132 → 202 Accepted (приняты)
- 76 → 302 Found (Location:
https://liderra.ru) - 29 → 301 (http→https на
/)
Воспроизведено вручную: curl -X POST с пустым {} → 302 + Set-Cookie. Это дефолтный Laravel behavior: для запросов, где Accept НЕ содержит application/json, ValidationException рендерится через redirect()->back()->withErrors() — 302 на referer (которого нет у webhook-вызывающего) → fallback на /.
Запросы 302 — это webhook-и где project НЕ матчится regex 'project' => regex:/^B[123]_.+$/' (app/app/Http/Controllers/Api/SupplierWebhookController.php:86).
Конкретные «непринимаемые» проекты (видны в supplier rt-list):
client.carmoney.ru— 55 лидовB2_Caranga— 7cabinet.caranga.ru— 3cashmotor.ru— 2- остальные единичные:
73912346386,79135191264,78006009393,78007006600,79029248888,B2_drivezaim,B3_+7 (495) 023-66-52и т.п.
1.2. Корневая причина дублей (37)
app/app/Jobs/Supplier/CsvReconcileJob.php:146-155 каждые 30 мин создаёт «recovered» SupplierLead с vid: null, source: csv_recovery для лидов, найденных в CSV поставщика но отсутствующих в наших supplier_leads за окно.
Затем поставщик ретраит webhook с настоящим vid (численный) → создаётся новый SupplierLead (UNIQUE по vid, NULL ≠ NULL → не считается дублем) → RouteSupplierLeadJob создаёт второй Deal.
supplier_lead_deliveries уник-индекс на (supplier_lead_id, tenant_id) (app/app/Jobs/RouteSupplierLeadJob.php:249-262) не блокирует, потому что у CSV-recovered и webhook разные supplier_lead.id.
Раньше эту race-condition закрывал DuplicateDetector (24h-фильтр по phone+project), который был снят в Спеке B Phase 1 (commit ccfecd5e, 24.05) с обоснованием «за повторы поставщика берём».
1.3. Цепочка B-префикса (5 точек)
Regex B[123]_ встречается в коде в 5 точках, и все обязательны для текущего flow:
| # | Место | file:line | Поведение без B-префикса |
|---|---|---|---|
| 1 | Webhook validation | SupplierWebhookController.php:86 | ValidationException → 302 (см. 1.1) |
| 2 | parsePlatform fallback | SupplierWebhookController.php:183-188 | silent fallback 'B1' |
| 3 | parseProjectField | RouteSupplierLeadJob.php:172-200 | RuntimeException → retry 3x → failed_webhook_jobs |
| 4 | extractPlatform | CsvReconcileJob.php:237-244 | возвращает null → строка в unparseable_count (56 сегодня) |
| 5 | БД constraint | supplier_projects.platform CHECK IN (B1,B2,B3) |
нельзя сохранить platform=DIRECT |
2. Цели и не-цели
Цели
- C1. Webhook на
/api/webhook/supplier/*ВСЕГДА отвечает JSON (202/200/422/429/404), никогда не редиректит. ЛюбаяValidationExceptionдля этого URL — JSON 422 с полемerrors. - C2. Webhook, поступивший после CSV-recovered deal по тому же
(tenant_id, phone, project_id)в окне 24h, обновляет существующий deal (source_crm_id,received_atесли новее,phones), а не создаёт второй. Биллинг не списывает второй раз. - C3. Webhook на проекты без префикса
B[123]_(client.carmoney.ru,cashmotor.ru, числовые) принимается, проходит routing, создаёт Deal под новой платформойDIRECT.
Не-цели
- NG1. Восстановление 82 потерянных лидов 25.05 — оффлайн-операция после деплоя, через
php artisan supplier:reconcile-forceили ручное добавление по списку (вне scope этой спеки). - NG2. Очистка 37 текущих дублей в проде — отдельная миграция данных или ручной SQL (вне scope).
- NG3. Изменение бизнес-правил биллинга для DIRECT-платформы. Берётся та же тарификация, что для B1/B2/B3 (по умолчанию tier по
signal_type). Альтернативная цена для DIRECT — отдельный спек если потребуется. - NG4. Отказ от CSV reconcile job — он остаётся как safety net, но теперь дедупликация не приводит к дублям.
3. Решение
Три независимые фазы. Каждая фаза — отдельный PR, отдельный план, отдельный выкат на боевой. Между фазами — observation period (1-2 часа на проде, потом следующая фаза).
Phase 1 (низкий риск) — Always JSON 422 для webhook validation errors
Изменения:
- В app/bootstrap/app.php:35
withExceptions()добавить render:$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) { if ($request->is('api/webhook/supplier/*')) { return response()->json([ 'message' => 'Validation failed', 'errors' => $e->errors(), ], 422); } return null; // дефолтный рендер для остальных }); - Тест: POST с
Accept: text/html(имитация поставщика без JSON-Accept) на webhook с невалидным payload → assert 422 + JSON Content-Type + ошибка вerrors. - Существующие тесты
SupplierWebhookTest.php— всеpostJson(...)→ 422 уже работают. Добавляется один новый тест с обычнымpost().
Risk: низкий. Изменение не трогает control flow webhook'а, только формат ответа на ошибку.
Откатываемость: одной строчкой revert.
Phase 2 (средний риск) — Идемпотентность webhook ↔ CSV-recovered
Изменения:
- В app/app/Jobs/RouteSupplierLeadJob.php:207
createDealCopyForProject()ДО создания Deal — поиск:$existingDeal = Deal::query() ->where('tenant_id', $tenant->id) ->where('phone', (string) $lead->phone) ->where('project_id', $project->id) ->where('received_at', '>=', now()->subDay()) ->whereNull('source_crm_id') // только CSV-recovered ждут vid ->lockForUpdate() ->first(); - Если найден →
UPDATE deals SET source_crm_id = vid, received_at = MAX(...)+supplier_lead_deliveriesзапись + НЕ списываем баланс повторно (Ledger.alreadyChargedForDeal или просто отсутствие второгоchargeForDelivery) → возвратfalse/'merged'. - Если не найден → текущий путь создания нового Deal без изменений.
supplier_lead_deliveries.deal_idобновляется на найденный deal.id.
Биллинг safety:
LedgerService::chargeForDeliveryуже идемпотентен поsupplier_lead_id(PK lead_charges) — проверить.- Если не идемпотентен — добавить guard: SELECT lead_charges WHERE deal_id=$existingDeal->id; если есть — skip charge.
Тесты:
- TDD: CSV-recovered deal без vid → webhook на тот же phone+project → assert 1 deal (не 2), source_crm_id заполнен, lead_charges = 1 запись.
- Regression: повтор поставщика по тому же vid (память Спека B — «за повторы берём») → assert 2 deals (если разные supplier_lead с разными vid).
- Race: одновременный webhook и CSV-recovery → lockForUpdate гарантирует один deal.
Risk: средний — затрагивает биллинг. Нужно убедиться что chargeForDelivery не списывает второй раз.
Phase 3 (высокий риск) — DIRECT platform для проектов без B-префикса
Изменения:
-
Миграция БД
database/migrations/2026_05_25_120000_add_direct_platform.php:ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform; ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT')); ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform; ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'));Также снять constraint
chk_supplier_projects_b1_not_for_sms(он про B1+sms) если он мешает. -
Webhook regex SupplierWebhookController.php:86:
'project' => ['required', 'string', 'max:255'], // снят regex -
parsePlatform SupplierWebhookController.php:183-188:
private function parsePlatform(string $project): string { if (preg_match('/^(B[123])_/', $project, $m) === 1) { return $m[1]; } return 'DIRECT'; } -
parseProjectField RouteSupplierLeadJob.php:172-200 — добавить DIRECT branch:
private function parseProjectField(string $project): array { if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) { $platform = $m[1]; $rest = $m[2]; } else { $platform = 'DIRECT'; $rest = $project; // весь project считается identifier-частью } // далее существующая логика определения signal_type/identifier на $rest // (call / site / sms по тем же regex'ам) } -
extractPlatform CsvReconcileJob.php:237-244:
private function extractPlatform(string $project): string { if (preg_match('/^(B[123])_/', $project, $m) === 1) { return $m[1]; } return 'DIRECT'; }Логика
unparseable_countснимается для DIRECT-кейса; остаётся только для реального мусора (телефоны/URL в поле project). Различение через дополнительный regex проверки[a-z0-9]в начале. -
SupplierProjectResolver — резолв по
(platform=DIRECT, signal_type, identifier)создаёт/находитsupplier_projectsrow с platform=DIRECT. -
LeadRouter::matchEligibleProjects — DIRECT-platform fetches по тем же signal_type/identifier-полям проекта; никаких B1/B2/B3 специальных условий.
Тесты:
- Существующий тест
'rejects invalid project format with 422'(SupplierWebhookTest.php:95) переписать: теперь invalid_format → 202 (принят), platform=DIRECT. - Новый тест: webhook с
project: "client.carmoney.ru"→ 202, supplier_lead.platform=DIRECT, RouteSupplierLeadJob создаёт SupplierProject под DIRECT, Deal создаётся. - Существующие тесты RouteSupplierLeadJobTest / CsvReconcileJobTest — добавить DIRECT-кейсы.
- Регрессия: все B1/B2/B3 кейсы продолжают работать без изменений.
Risk: высокий — затрагивает миграцию БД, ⩾5 файлов кода, тесты, бизнес-семантику биллинга для DIRECT.
Сложность: одновременная правка должна быть атомарной — если деплоится миграция но не код, controller примет lid'ы которые job не сможет обработать. Один PR, один деплой, очередь queue:restart после.
4. Стратегия деплоя
Три отдельных деплоя на liderra.ru через redeploy.sh (per memory: «sudo -u www-data php artisan optimize в строке 9 скрипта»):
- Деплой 1 (Phase 1): ~10 мин outage риск 0. Сразу после деплоя смотрим nginx logs — все POST → 422 или 202, нет 30x. Ждём 30 мин — drift_alert не должен подниматься.
- Деплой 2 (Phase 2): ~10 мин outage риск 0. Смотрим что новые deals не дублируются (
SELECT phone, project_id, COUNT(*) FROM deals WHERE created_at > NOW()-interval'2h' GROUP BY 1,2 HAVING COUNT(*)>1). Ждём 1-2 часа. - Деплой 3 (Phase 3): включает миграцию БД. Сначала миграция (idempotent CHECK extension), затем код. Smoke: POST
project: "client.carmoney.ru"с правильным secret и IP → 202, supplier_lead создан, deal создан. Ждём 6 часов на наблюдение, после — закрытие задачи.
Перед каждым деплоем — обязательно агент prod-deploy-validator (per Pravila §2.4).
5. Тестирование
Pest unit/feature
Все три фазы — TDD: тест → fail → имплементация → pass → commit. Запуск composer test -- --filter='Supplier' после каждой фазы.
Существующие тесты, которые гарантированно адаптируются:
app/tests/Feature/Http/Webhook/SupplierWebhookTest.php— line 95 «invalid_format → 422» переписывается на «invalid_format → 202 DIRECT» в Phase 3.app/tests/Feature/Supplier/CsvReconcileJobTest.php— добавить кейс DIRECT в Phase 3.app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php— добавить «webhook после CSV-recovered не списывает второй раз» в Phase 2.app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php— добавить кейс «разные SupplierLead.id, тот же phone+project — не дубль» в Phase 2.
Регрессия
/regression full ПОСЛЕ каждой фазы (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Каждая фаза — отдельный коммит на ветке feat/supplier-webhook-fixes, отдельный PR, отдельный merge → отдельный redeploy.
Прод-smoke
После каждого деплоя — конкретные SQL-проверки в db/, описаны в каждом плане.
6. Откат
- Phase 1 — revert single commit.
- Phase 2 — revert commit + dedup кода. Миграции БД нет.
- Phase 3 — revert commit + миграция down:
DROP CONSTRAINT ... ADD CONSTRAINT ... CHECK IN (B1,B2,B3). Если в БД уже естьplatform=DIRECTrows — миграция down упадёт. Нужен seed-cleanup перед откатом.
7. Файлы (общий список)
Создать:
database/migrations/2026_05_25_120000_add_direct_platform.php(Phase 3)app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php(Phase 1, новый файл)app/tests/Feature/Supplier/CsvWebhookRaceTest.php(Phase 2, новый файл)app/tests/Feature/Supplier/DirectPlatformTest.php(Phase 3, новый файл)
Изменить:
app/bootstrap/app.php(Phase 1)app/app/Http/Controllers/Api/SupplierWebhookController.php(Phase 3)app/app/Jobs/RouteSupplierLeadJob.php(Phase 2 + Phase 3)app/app/Jobs/Supplier/CsvReconcileJob.php(Phase 3)app/app/Services/SupplierProjects/SupplierProjectResolver.php(Phase 3)app/app/Services/LeadRouter.php(Phase 3)app/tests/Feature/Http/Webhook/SupplierWebhookTest.php(Phase 3 — переписать line 95)db/schema.sql(Phase 3 — sync с миграцией)db/CHANGELOG_schema.md(Phase 3)
Возможно затронуть:
app/app/Services/Billing/LedgerService.php(Phase 2 — guard от двойного списания, если ещё не идемпотентен)
8. Открытые вопросы (на момент написания спеки)
- OQ-1. Идемпотентен ли
LedgerService::chargeForDeliveryпо(deal_id, lead_id)или может списать дважды? — выяснится в Phase 2 Task 1 (read code). - OQ-2.
supplier_projects.subject_code— обязательное поле для DIRECT? — выяснится в Phase 3 Task 2 (миграция). - OQ-3.
chk_supplier_projects_b1_not_for_smsconstraint конфликтует с DIRECT? — выяснится в Phase 3 Task 1.
Каждый вопрос разрешается inline во время реализации, не блокирует план.
9. Ссылки
- План Phase 1:
docs/superpowers/plans/2026-05-25-supplier-webhook-phase-1-json-422.md - План Phase 2:
docs/superpowers/plans/2026-05-25-supplier-webhook-phase-2-dedup.md - План Phase 3:
docs/superpowers/plans/2026-05-25-supplier-webhook-phase-3-direct-platform.md - Memory project_supplier_integration.md — историческая информация о supplier flow
- ADR-008 (если потребуется DIRECT — оформить как ADR-018 «Supplier DIRECT platform»)