diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index 4b0b1f57..1510da1a 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -245,6 +245,57 @@ class RouteSupplierLeadJob implements ShouldQueue } $project = $lockedProject; + // Phase 2 fix: merge с CSV-recovered deal если webhook догоняет. + // Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered + // from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они + // создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если: + // - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего; + // - csv-recovered deal существует за последние 24h, тот же phone+project+tenant; + // - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook). + // При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries, + // БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery). + $existingMergeable = null; + if ($lead->vid !== null) { + $existingMergeable = Deal::query() + ->where('tenant_id', $tenant->id) + ->where('phone', (string) $lead->phone) + ->where('project_id', $project->id) + ->whereNull('source_crm_id') + ->where('received_at', '>=', now()->subDay()) + ->lockForUpdate() + ->first(); + } + if ($existingMergeable !== null) { + // Заполняем supplier_lead.id у обоих SupplierLead → одному Deal + DB::table('supplier_lead_deliveries')->insert([ + 'supplier_lead_id' => $lead->id, + 'tenant_id' => $tenant->id, + 'deal_id' => $existingMergeable->id, + 'created_at' => now(), + ]); + // Обновляем source_crm_id и опционально received_at через + // DB::table (надёжнее Eloquent save() на партиционированной таблице). + $newReceivedAt = ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at)) + ? $lead->received_at + : null; + $updateData = ['source_crm_id' => $lead->vid, 'updated_at' => now()]; + if ($newReceivedAt !== null) { + $updateData['received_at'] = $newReceivedAt; + } + DB::table('deals') + ->where('id', $existingMergeable->id) + ->where('received_at', $existingMergeable->received_at) + ->update($updateData); + + Log::info('supplier_lead.merged_into_csv_recovered', [ + 'supplier_lead_id' => $lead->id, + 'merged_into_deal_id' => $existingMergeable->id, + 'tenant_id' => $tenant->id, + ]); + + return true; // считаем «доставленным», но без второго списания + } + // Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз. // insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery). $locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([ diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 2332d042..18f5d25b 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-25T14:37:46.338Z +Last updated: 2026-05-25T14:54:22.281Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,13 +8,13 @@ Last updated: 2026-05-25T14:37:46.338Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ⚠️ | 429 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro | +| C5 Observer-coverage | ⚠️ | 414 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync | ## Метрики (информационные, не алерты) -- Observer evidence: 429 episodes this month, 0 observer_error markers, 72 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 290 +- Observer evidence: 414 episodes this month, 0 observer_error markers, 59 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 275 - Last /brain-retro: 1 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). @@ -24,17 +24,17 @@ Baseline дисциплины роутера (этап 2 router discipline overh | Тип задачи | Эпизодов | % с триггер-матчем | % через скил | |---|---|---|---| -| analysis | 20 | 45.0% | 25.0% | +| analysis | 19 | 42.1% | 21.1% | | monitoring | 16 | 0.0% | 0.0% | | feature | 14 | 14.3% | 0.0% | -| planning | 11 | 18.2% | 27.3% | | bugfix | 11 | 36.4% | 45.5% | +| planning | 10 | 20.0% | 20.0% | | refactor | 1 | 0.0% | 0.0% | | cleanup | 1 | 0.0% | 0.0% | -Router step distribution: 1: 168, 2: 149, 3: 58, 5: 49 +Router step distribution: 1: 166, 2: 143, 3: 54, 5: 46 -Boundaries applied (ADR / границы): 70 of 424 эпизодов (16.5%). +Boundaries applied (ADR / границы): 64 of 409 эпизодов (15.6%). ## Активные многоэтапные проекты @@ -67,7 +67,7 @@ Episodes since last run: 0 / threshold: 10 ## Reviewer: субагент vs fallback -0 эпизодов проверено из 429. +0 эпизодов проверено из 414. ## Алерт-индикаторы