Files
portal/docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md
T
Дмитрий f3cbebd330 docs(supplier): spec + 3 plans for webhook reliability (phases 1-3)
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
2026-05-25 16:25:22 +03:00

20 KiB
Raw Blame History

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 — 7
  • cabinet.caranga.ru — 3
  • cashmotor.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-префикса

Изменения:

  1. Миграция БД 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) если он мешает.

  2. Webhook regex SupplierWebhookController.php:86:

    'project' => ['required', 'string', 'max:255'], // снят regex
    
  3. parsePlatform SupplierWebhookController.php:183-188:

    private function parsePlatform(string $project): string
    {
        if (preg_match('/^(B[123])_/', $project, $m) === 1) {
            return $m[1];
        }
        return 'DIRECT';
    }
    
  4. 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'ам)
    }
    
  5. 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] в начале.

  6. SupplierProjectResolver — резолв по (platform=DIRECT, signal_type, identifier) создаёт/находит supplier_projects row с platform=DIRECT.

  7. 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. Деплой 1 (Phase 1): ~10 мин outage риск 0. Сразу после деплоя смотрим nginx logs — все POST → 422 или 202, нет 30x. Ждём 30 мин — drift_alert не должен подниматься.
  2. Деплой 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. Деплой 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=DIRECT rows — миграция 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_sms constraint конфликтует с 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»)