fix(supplier): Phase 2 merge — не обновлять deals.received_at (FK violation)
Регрессия 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user