* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project * → биллинг списывает второй раз. * * Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет * source_crm_id, привязывает webhook supplier_lead к существующему deal через * supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно. */ beforeEach(function (): void { $this->seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Shared supplier_project для всех тестов (B1, site, domain race-csv.ru). $this->sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'race-csv.ru', ]); $this->tenant = Tenant::factory()->create([ 'balance_rub' => '10000.00', 'delivered_in_month' => 0, ]); $this->project = Project::factory()->create([ 'tenant_id' => $this->tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'race-csv.ru', 'supplier_b1_project_id' => $this->sp->id, 'is_active' => true, 'daily_limit_target' => 100, 'effective_daily_limit_today' => 100, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); linkProjectToSupplier($this->project, $this->sp); createRoutingSnapshotFromProject($this->project, null, 'site', 'race-csv.ru', 100); createRoutingSnapshotFromProject($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString(), 'site', 'race-csv.ru', 100); }); /** * Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files. */ function runRaceJob(int $supplierLeadId): void { (new RouteSupplierLeadJob($supplierLeadId))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); } // --------------------------------------------------------------------------- // Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry // ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING). // --------------------------------------------------------------------------- it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void { $phone = '79991000001'; // ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ── // Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика, // но настоящего webhook_log'а нет → вид неизвестен (vid=null). $csvLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => null, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subHour()->getTimestamp(), ], 'received_at' => now()->subHour(), 'recovered_from_csv_at' => now()->subHour(), 'source' => 'csv_recovery', 'processed_at' => null, ]); // RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL. runRaceJob($csvLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); $csvDeal = Deal::where('phone', $phone)->first(); expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal'); expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL'); $chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count(); expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge'); $balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub; // ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ── // Это то, что создаёт дубль на проде: новый SupplierLead с vid != null, // phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal. $webhookLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => 1672819986, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 1672819986, 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subMinutes(15)->getTimestamp(), ], 'received_at' => now()->subMinutes(15), 'source' => 'webhook', 'processed_at' => null, ]); runRaceJob($webhookLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); // ── Assertions ── // Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен. $deals = Deal::where('phone', $phone)->get(); expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй'); expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid'); // Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды. $chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count(); expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть'); // Assertion 3: баланс НЕ списан второй раз. $balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub; expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться'); // Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id, // привязанных к ОДНОМУ deal_id. $deliveries = DB::table('supplier_lead_deliveries') ->where('deal_id', $csvDeal->id) ->get(); expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries'); $deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all(); expect($deliveredLeadIds)->toContain($csvLead->id); expect($deliveredLeadIds)->toContain($webhookLead->id); }); // --------------------------------------------------------------------------- // Test 1b — merge НЕ меняет received_at CSV-recovered сделки. // Regression-guard инцидента 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. Любое изменение received_at при merge // ломало FK на COMMIT → каскадный риск. Код (RouteSupplierLeadJob:371-393) сохраняет // received_at как есть; этот тест пиннит инвариант (раньше не assert'ился). // --------------------------------------------------------------------------- it('merge preserves CSV-recovered received_at unchanged (FK-partition safety, no extra charge)', function (): void { $phone = '79991000004'; $csvTime = now()->subHours(3); // ── Step 1: CSV-recovered сделка с известным received_at (3 часа назад) ── $csvLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => null, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => $csvTime->getTimestamp(), ], 'received_at' => $csvTime, 'recovered_from_csv_at' => $csvTime, 'source' => 'csv_recovery', 'processed_at' => null, ]); runRaceJob($csvLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); $csvDeal = Deal::where('phone', $phone)->first(); expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal'); $receivedAtBefore = $csvDeal->received_at; expect($receivedAtBefore->getTimestamp())->toBe($csvTime->getTimestamp(), 'received_at сделки = время CSV-лида'); // ── Step 2: догоняющий webhook с реальным vid и ДРУГИМ time (15 мин назад) ── $webhookLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => 1672819990, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 1672819990, 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subMinutes(15)->getTimestamp(), ], 'received_at' => now()->subMinutes(15), 'source' => 'webhook', 'processed_at' => null, ]); runRaceJob($webhookLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); // ── Assertions ── $deals = Deal::where('phone', $phone)->get(); expect($deals)->toHaveCount(1, 'merge: одна сделка, не две'); $merged = $deals->first(); // Ключевой инвариант: received_at НЕ изменился webhook-временем (15 мин назад), // остался временем CSV-лида (3 часа назад) — FK-partition safety. expect($merged->received_at->getTimestamp())->toBe( $csvTime->getTimestamp(), 'merge НЕ должен менять received_at (partition key + lead_charges FK)', ); expect((int) $merged->source_crm_id)->toBe(1672819990, 'source_crm_id обновлён webhook vid'); // Списание ровно одно — merge не доначисляет. expect(LeadCharge::where('deal_id', $merged->id)->count())->toBe(1, 'merge: второго списания нет'); }); // --------------------------------------------------------------------------- // Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design). // Наш Phase 2 fix НЕ должен блокировать это. // --------------------------------------------------------------------------- it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void { $phone = '79991000002'; // Первый webhook, vid=100. $lead1 = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => 100, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 100, 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subHour()->getTimestamp(), ], 'received_at' => now()->subHour(), 'source' => 'webhook', 'processed_at' => null, ]); runRaceJob($lead1->id); // Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект). $lead2 = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => 200, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 200, 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subMinutes(30)->getTimestamp(), ], 'received_at' => now()->subMinutes(30), 'source' => 'webhook', 'processed_at' => null, ]); runRaceJob($lead2->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); // Spec B: оба webhook'а имеют source_crm_id != null. // Условие merge (source_crm_id IS NULL) не срабатывает → два deal, // два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём. $deals = Deal::where('phone', $phone)->get(); expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)'); $sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all(); expect($sourceCrmIds)->toContain(100); expect($sourceCrmIds)->toContain(200); expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2); }); // --------------------------------------------------------------------------- // Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook. // Окно merge — 24h. Старый лид не считается «активным» duplicate. // --------------------------------------------------------------------------- it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void { $phone = '79991000003'; // CSV-recovered SupplierLead, обработанный 2 дня назад. $csvLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => null, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->subDays(2)->getTimestamp(), ], 'received_at' => now()->subDays(2), 'recovered_from_csv_at' => now()->subDays(2), 'source' => 'csv_recovery', 'processed_at' => null, ]); runRaceJob($csvLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); $csvDeal = Deal::where('phone', $phone)->first(); expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать'); // Сбросим processed_at у tenant-level проекта: delivered_today накопился, // нужно сбросить счётчик чтобы второй deal тоже прошёл лимит. $this->project->update(['delivered_today' => 0]); // Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится. $webhookLead = SupplierLead::factory()->create([ 'platform' => 'B1', 'phone' => $phone, 'vid' => 999, 'supplier_project_id' => $this->sp->id, 'raw_payload' => [ 'vid' => 999, 'project' => 'B1_race-csv.ru', 'phone' => $phone, 'time' => now()->getTimestamp(), ], 'received_at' => now(), 'source' => 'webhook', 'processed_at' => null, ]); runRaceJob($webhookLead->id); DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'"); // Два deal: старый CSV-recovered (2 дня назад) + новый от webhook. // Merge НЕ происходит — CSV-recovered вне 24h окна. $deals = Deal::where('phone', $phone)->get(); expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook'); });