seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); }); function runRouteJob(int $supplierLeadId): void { (new RouteSupplierLeadJob($supplierLeadId))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(DuplicateDetector::class), app(NotificationService::class), app(LedgerService::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('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void { // BLOCKER #3 (CV.11 audit): RouteSupplierLeadJob::$tries = 3. На retry задачи (после // транзиентного сбоя — DB hiccup, queue worker restart) handle() запускался ПОВТОРНО // без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем // же vid (DuplicateDetector помечал его как duplicate, без charge — но deal-row в БД // оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал // счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики. // // Fix: в начале handle() — if ($lead->processed_at !== null) return; — early return. $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'retry-idempotent.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' => 'retry-idempotent.ru', 'is_active' => true, 'delivered_today' => 0, ]); $vid = 7777; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => [ 'vid' => $vid, 'project' => 'B1_retry-idempotent.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp(), ], ]); // 1st run — нормальная обработка. runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(1); expect($tenant->fresh()->balance_leads)->toBe(99); expect($project->fresh()->delivered_today)->toBe(1); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); // 2nd run — должен быть no-op (idempotent guard на processed_at). runRouteJob($lead->id); $lead->refresh(); // Лид остаётся помечен обработанным, deals_created_count НЕ сбросился. expect($lead->processed_at)->not->toBeNull(); expect($lead->deals_created_count)->toBe(1); // НИКАКИХ дублей не появилось: balance, counter, deal-row. expect($tenant->fresh()->balance_leads)->toBe(99); expect($project->fresh()->delivered_today)->toBe(1); DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->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); }); it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void { // BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit // БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое // инкрементит) — окно для concurrent webhook'а: // worker A видит delivered_today=9, limit=10 → OK; createDealCopyForProject → 10. // worker B параллельно видит то же 9 → OK; createDealCopyForProject → 11. OVERCOMMIT. // // Симуляция: project уже at-limit (delivered_today=1, daily_limit_target=1) к // моменту createDealCopyForProject — мокнутый LeadRouter возвращает его как eligible // (так, будто matchEligibleProjects делал SELECT когда delivered_today=0). // // Fix #2: внутри createDealCopyForProject под lockForUpdate(Project) — recheck // delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target). // Если уже at-limit → return false без charge / counter / deal-row. $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'race-recheck.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' => 'race-recheck.ru', 'is_active' => true, 'daily_limit_target' => 1, 'effective_daily_limit_today' => null, // COALESCE → daily_limit_target=1 'delivered_today' => 1, // ALREADY AT LIMIT (race-window simulation) 'delivered_in_month' => 5, ]); $vid = 8888; $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => [ 'vid' => $vid, 'project' => 'B1_race-recheck.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp(), ], ]); // Подсунуть LeadRouter mock, который игнорирует filter и возвращает project, // как будто SELECT'нул его при snapshot delivered_today=0. $routerMock = M::mock(LeadRouter::class); $routerMock->shouldReceive('matchEligibleProjects') ->andReturn(new Collection([$project])); app()->instance(LeadRouter::class, $routerMock); runRouteJob($lead->id); $lead->refresh(); // После fix #2: deal НЕ создан (recheck под lock увидел limit) → 0 deals. expect($lead->deals_created_count)->toBe(0); // delivered_today остался 1 (НЕ инкрементнулся до 2). expect($project->fresh()->delivered_today)->toBe(1); // delivered_in_month НЕ инкрементнулся. expect($project->fresh()->delivered_in_month)->toBe(5); // balance_leads НЕ списан. expect($tenant->fresh()->balance_leads)->toBe(100); // Deal-row не создался. DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0); });