handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(DuplicateDetector::class), app(NotificationService::class), ); } it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru', ]); $tenants = collect(); $projects = collect(); for ($i = 0; $i < 3; $i++) { $t = Tenant::factory()->create(['balance_leads' => 100]); $tenants->push($t); $projects->push(Project::factory()->create([ 'tenant_id' => $t->id, 'supplier_b1_project_id' => $supplier->id, 'signal_type' => 'site', 'signal_identifier' => 'vashinvestor.ru', 'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0, ])); } $vid = 432176649; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => [ 'vid' => $vid, 'project' => 'B1_vashinvestor.ru', 'tag' => 'tag', 'phone' => '79991234567', 'phones' => ['79991234567'], 'time' => now()->getTimestamp(), ], ]); runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(3); expect($lead->supplier_project_id)->toBe($supplier->id); foreach ($projects as $i => $p) { $tenant = $tenants[$i]; $p->refresh(); expect($p->delivered_today)->toBe(1); expect($p->delivered_in_month)->toBe(1); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $deals = Deal::query() ->where('tenant_id', $tenant->id) ->where('source_crm_id', $vid) ->get(); expect($deals)->toHaveCount(1); } }); it('decrements balance_leads for each tenant by 1', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'test.ru', ]); $tenant = Tenant::factory()->create(['balance_leads' => 100]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'supplier_b1_project_id' => $supplier->id, 'signal_type' => 'site', 'signal_identifier' => 'test.ru', 'is_active' => true, ]); $vid = 99; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJob($lead->id); expect($tenant->fresh()->balance_leads)->toBe(99); }); it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'test.ru', ]); $tenant = Tenant::factory()->create(['balance_leads' => 100]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'supplier_b1_project_id' => $supplier->id, 'signal_type' => 'site', 'signal_identifier' => 'test.ru', 'is_active' => true, 'delivered_today' => 0, ]); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $master = Deal::create([ 'tenant_id' => $tenant->id, 'source_crm_id' => 999, 'project_id' => $project->id, 'phone' => '79991234567', 'phones' => ['79991234567'], 'status' => 'new', 'received_at' => now()->subHours(2), ]); $vid = 1000; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJob($lead->id); expect($tenant->fresh()->balance_leads)->toBe(100); expect($project->fresh()->delivered_today)->toBe(0); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); $duplicate = Deal::where('source_crm_id', $vid)->first(); expect($duplicate)->not->toBeNull(); expect($duplicate->duplicate_of_id)->toBe($master->id); }); it('throws DomainException when payload encodes B1+SMS combo', function (): void { $vid = 1; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); expect(fn () => runRouteJob($lead->id))->toThrow(DomainException::class); }); it('handles orphan supplier_project (no matching liderra-projects) — 0 deals, lead processed', function (): void { $vid = 777; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_orphan.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(0); expect($lead->supplier_project_id)->not->toBeNull(); }); it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'mixed.ru', ]); $tenants = collect(); $projects = collect(); for ($i = 0; $i < 3; $i++) { $t = Tenant::factory()->create(['balance_leads' => 100]); $tenants->push($t); $projects->push(Project::factory()->create([ 'tenant_id' => $t->id, 'supplier_b1_project_id' => $supplier->id, 'signal_type' => 'site', 'signal_identifier' => 'mixed.ru', 'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0, ])); } // Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль. $masterTenant = $tenants[0]; $masterProject = $projects[0]; DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'"); $master = Deal::create([ 'tenant_id' => $masterTenant->id, 'source_crm_id' => 555, 'project_id' => $masterProject->id, 'phone' => '79991234567', 'phones' => ['79991234567'], 'status' => 'new', 'received_at' => now()->subHours(2), ]); $vid = 2222; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_mixed.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается // Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0 expect($masterTenant->fresh()->balance_leads)->toBe(100); expect($masterProject->fresh()->delivered_today)->toBe(0); DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'"); $dupDeal = Deal::query()->where('source_crm_id', $vid)->first(); expect($dupDeal->duplicate_of_id)->toBe($master->id); // Tenant #1, #2: balance списан, delivered_today инкрементирован foreach ([1, 2] as $i) { $t = $tenants[$i]; $p = $projects[$i]; expect($t->fresh()->balance_leads)->toBe(99); expect($p->fresh()->delivered_today)->toBe(1); } }); it('handles partial failure: one project throws, others continue routing', function (): void { // Тест полагается на Tenant SoftDeletes (см. App\Models\Tenant) — soft-delete // tenant'а в середине loop'а заставляет Tenant::firstOrFail() выкинуть // ModelNotFoundException, что симулирует per-Project failure без мокинга. // Если SoftDeletes когда-либо удалят с Tenant — этот тест нужно переписать // на runtime-mock или удалить (PHPStan не пропускает $this->markTestSkipped() // внутри Pest-closure). $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'partial-failure.ru', ]); $tenants = collect(); $projects = collect(); for ($i = 0; $i < 3; $i++) { $t = Tenant::factory()->create(['balance_leads' => 100]); $tenants->push($t); $projects->push(Project::factory()->create([ 'tenant_id' => $t->id, 'supplier_b1_project_id' => $supplier->id, 'signal_type' => 'site', 'signal_identifier' => 'partial-failure.ru', 'is_active' => true, 'delivered_today' => 0, ])); } // Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт. $tenants[1]->delete(); $vid = 3333; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => ['vid' => $vid, 'project' => 'B1_partial-failure.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], ]); runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал // Tenants 0 и 2 успешно списаны expect($tenants[0]->fresh()->balance_leads)->toBe(99); expect($tenants[2]->fresh()->balance_leads)->toBe(99); });