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() выкинуть