fix(jobs): RouteSupplierLeadJob — guard на processed_at для idempotency retry (Plan 2.5 #3)

Закрывает 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-10 22:20:34 +03:00
parent 67cb2cc946
commit 1ba1df8df1
2 changed files with 78 additions and 0 deletions
+13
View File
@@ -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);
@@ -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() выкинуть