* → 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); }); /** * 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 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'); });