From 0da72778c38ffa664f689eaca7934f93e327b9e4 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: Tue, 26 May 2026 08:39:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20Phase=202=20merge=20=E2=80=94?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20deals.received=5Fat=20(FK=20violation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at: update or delete on table "deals_y2026_m05" violates foreign key constraint "lead_charges_deal_id_deal_received_at_fkey" on table "lead_charges" Корневая причина: lead_charges имеет FK на (deal_id, deal_received_at) с ON DELETE CASCADE, но ON UPDATE NO ACTION (default Postgres). Phase 2 merge (commit 8d037e1f) условно обновлял deals.received_at, если webhook пришёл позже CSV-recovered. Любое изменение received_at ломало FK даже в той же месячной партиции (DEFERRABLE INITIALLY DEFERRED только откладывал проверку до COMMIT — она всё равно падала). Фикс: убрать условное обновление received_at, оставить только source_crm_id + updated_at. CSV-recovered timestamp сохраняется как есть — отличие на минуты несущественно vs риск каскадного DELETE lead_charges. Тест: tests/Feature/Jobs/RouteSupplierLeadJobTest.php — новый 'merges webhook into csv-recovered deal even when received_at differs' воспроизводит баг (CSV-recovered deal с lead_charge → webhook с другим received_at → merge должен пройти без FK violation). NB: локальный verify-RED заблокирован env-drift testing-БД (auth_log partitions via pgsql_supplier, см. memory). Прод-смок: реретрай застрявших failed_jobs 25489+25492..25500 → должны пройти. Affected failed_jobs (для реретрая после деплоя): 25489, 25492, 25493, 25494, 25495, 25496, 25497, 25498, 25499, 25500 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/Jobs/RouteSupplierLeadJob.php | 20 ++-- .../Feature/Jobs/RouteSupplierLeadJobTest.php | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+), 10 deletions(-) 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); +});