diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index bf4e1808..9a185452 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -278,19 +278,19 @@ class RouteSupplierLeadJob implements ShouldQueue '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; - } + // Обновляем только source_crm_id + updated_at через DB::table. + // NB (регрессия 26.05.2026 04:12-05:03 UTC, 9 failed_jobs): + // received_at — partition key, и lead_charges имеет FK + // (deal_id, deal_received_at) с ON DELETE CASCADE, но + // ON UPDATE NO ACTION (default). Любое изменение received_at + // ломает FK даже в той же месячной партиции (даже DEFERRABLE + // INITIALLY DEFERRED не помогает — проверка падает на COMMIT). + // CSV-recovered received_at сохраняем как есть — отличие на минуты + // несущественно, чем риск каскадного DELETE lead_charges. DB::table('deals') ->where('id', $existingMergeable->id) ->where('received_at', $existingMergeable->received_at) - ->update($updateData); + ->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]); Log::info('supplier_lead.merged_into_csv_recovered', [ 'supplier_lead_id' => $lead->id, diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index e7bb0011..669b1dd5 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -532,3 +532,94 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload', expect($deals)->toHaveCount(3) ->and($deals->pluck('subject_code')->unique()->all())->toBe([82]); }); + +it('merges webhook into csv-recovered deal even when received_at differs (Phase 2 FK fix)', function (): void { + // Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с + // SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at. + // Причина — lead_charges имеет FK на (deal_id, deal_received_at) с + // ON DELETE CASCADE, но ON UPDATE NO ACTION (default). Даже DEFERRABLE INITIALLY + // DEFERRED не помогает — проверка падает на COMMIT. Фикс: оставить received_at + // CSV-recovered deal'а нетронутым (отличие на минуты несущественно). + + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'phase2-merge.ru', + ]); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'supplier_b1_project_id' => $supplier->id, + 'signal_type' => 'site', + 'signal_identifier' => 'phase2-merge.ru', + 'is_active' => true, + ]); + linkProjectToSupplier($project, $supplier); + + // CSV-recovered deal: source_crm_id=NULL, received_at в прошлом. + $csvReceivedAt = now()->subMinutes(15); + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + $csvDeal = Deal::create([ + 'tenant_id' => $tenant->id, + 'source_crm_id' => null, + 'project_id' => $project->id, + 'phone' => '79991234567', + 'phones' => ['79991234567'], + 'status' => 'new', + 'received_at' => $csvReceivedAt, + ]); + + // LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at. + \App\Models\LeadCharge::factory()->create([ + 'tenant_id' => $tenant->id, + 'deal_id' => $csvDeal->id, + 'deal_received_at' => $csvDeal->received_at, + 'charge_source' => 'rub', + ]); + + // Webhook lead: реальный vid, тот же phone+project, received_at позже CSV. + $webhookVid = 999111; + $webhookReceivedAt = now(); // > csvReceivedAt → старый код триггерил UPDATE received_at. + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'vid' => $webhookVid, + 'phone' => '79991234567', + 'received_at' => $webhookReceivedAt, + 'raw_payload' => [ + 'vid' => $webhookVid, + 'project' => 'B1_phase2-merge.ru', + 'phone' => '79991234567', + 'phones' => ['79991234567'], + 'time' => $webhookReceivedAt->getTimestamp(), + ], + ]); + + // Не должно бросать FK violation — merge обновляет ТОЛЬКО source_crm_id. + runRouteJob($lead->id); + + $lead->refresh(); + expect($lead->processed_at)->not->toBeNull(); + + // Deal обновлён: source_crm_id заполнен webhook vid, received_at не тронут. + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + $merged = Deal::query() + ->whereKey($csvDeal->id) + ->where('received_at', $csvReceivedAt) + ->first(); + expect($merged)->not->toBeNull(); + expect($merged->source_crm_id)->toBe($webhookVid); + + // Без второго списания — balance не изменился (chargeForDelivery в merge-ветке не вызывается). + expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); + + // supplier_lead_deliveries — линк создан. + $deliveryCount = DB::table('supplier_lead_deliveries') + ->where('supplier_lead_id', $lead->id) + ->where('tenant_id', $tenant->id) + ->count(); + expect($deliveryCount)->toBe(1); + + // Никаких дублей deals — только один с этим vid. + expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1); +});