From 1ba1df8df17155b075486e172bb6f96a285fe8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 10 May 2026 22:20:34 +0300 Subject: [PATCH] =?UTF-8?q?fix(jobs):=20RouteSupplierLeadJob=20=E2=80=94?= =?UTF-8?q?=20guard=20=D0=BD=D0=B0=20processed=5Fat=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20idempotency=20retry=20(Plan=202.5=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает CV.11 audit BLOCKER #3 (Plan 2/5 closure). Проблема: $tries=3 на retry-сценарий (DB hiccup, queue worker restart) — handle() запускался повторно без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем же vid (DuplicateDetector помечал его дублем без charge, но deal-row оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики. Fix: в начале handle() после findOrFail — if ($lead->processed_at !== null) return; + Log::info с processed_at и deals_created_count для диагностики. TDD: новый тест 'idempotent on retry — second handle() returns early, no ghost duplicate deals' (RouteSupplierLeadJobTest:271). Проверяет 2 последовательных вызова runRouteJob — assertion на Deal::count, balance_leads, delivered_today, deals_created_count все остаются на 1st-run значениях. Pest: 548/546 passed (+1 тест от baseline 547), 1740 assertions, 17s parallel. Larastan + Pint: passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/Jobs/RouteSupplierLeadJob.php | 13 ++++ .../Feature/Jobs/RouteSupplierLeadJobTest.php | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index ac3114a1..8f62e795 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -71,6 +71,19 @@ class RouteSupplierLeadJob implements ShouldQueue ): void { $lead = SupplierLead::findOrFail($this->supplierLeadId); + // Idempotency guard для retry-сценария ($tries = 3). + // Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов. + // CV.11 audit BLOCKER #3 (Plan 2.5 fix). + if ($lead->processed_at !== null) { + Log::info('supplier_lead.skipped_already_processed', [ + 'supplier_lead_id' => $lead->id, + 'processed_at' => $lead->processed_at->toIso8601String(), + 'deals_created_count' => $lead->deals_created_count, + ]); + + return; + } + $projectField = (string) ($lead->raw_payload['project'] ?? ''); [$platform, $signalType, $identifier] = $this->parseProjectField($projectField); diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index 255b309e..54381d3f 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -267,6 +267,71 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean } }); +it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void { + // BLOCKER #3 (CV.11 audit): RouteSupplierLeadJob::$tries = 3. На retry задачи (после + // транзиентного сбоя — DB hiccup, queue worker restart) handle() запускался ПОВТОРНО + // без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем + // же vid (DuplicateDetector помечал его как duplicate, без charge — но deal-row в БД + // оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал + // счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики. + // + // Fix: в начале handle() — if ($lead->processed_at !== null) return; — early return. + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'retry-idempotent.ru', + ]); + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'supplier_b1_project_id' => $supplier->id, + 'signal_type' => 'site', + 'signal_identifier' => 'retry-idempotent.ru', + 'is_active' => true, + 'delivered_today' => 0, + ]); + + $vid = 7777; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'vid' => $vid, + 'phone' => '79991234567', + 'raw_payload' => [ + 'vid' => $vid, + 'project' => 'B1_retry-idempotent.ru', + 'phone' => '79991234567', + 'time' => now()->getTimestamp(), + ], + ]); + + // 1st run — нормальная обработка. + runRouteJob($lead->id); + $lead->refresh(); + expect($lead->processed_at)->not->toBeNull(); + expect($lead->deals_created_count)->toBe(1); + expect($tenant->fresh()->balance_leads)->toBe(99); + expect($project->fresh()->delivered_today)->toBe(1); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); + + // 2nd run — должен быть no-op (idempotent guard на processed_at). + runRouteJob($lead->id); + $lead->refresh(); + + // Лид остаётся помечен обработанным, deals_created_count НЕ сбросился. + expect($lead->processed_at)->not->toBeNull(); + expect($lead->deals_created_count)->toBe(1); + + // НИКАКИХ дублей не появилось: balance, counter, deal-row. + expect($tenant->fresh()->balance_leads)->toBe(99); + expect($project->fresh()->delivered_today)->toBe(1); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); +}); + it('handles partial failure: one project throws, others continue routing', function (): void { // Тест полагается на Tenant SoftDeletes (см. App\Models\Tenant) — soft-delete // tenant'а в середине loop'а заставляет Tenant::firstOrFail() выкинуть