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(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); } // `linkProjectToSupplier` helper now lives in tests/Pest.php — single source. it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void { // Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1 // бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs -> // RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей). // «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен. $missingId = 999999; expect(SupplierLead::find($missingId))->toBeNull(); $countBefore = DB::table('deals')->count(); // Не должно бросать исключение (иначе сработает failed() -> retry-цикл). runRouteJob($missingId); // Никаких побочных эффектов — количество сделок не изменилось. expect(DB::table('deals')->count())->toBe($countBefore); }); 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_rub' => '100000.00']); $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, ])); linkProjectToSupplier($projects->last(), $supplier); createRoutingSnapshotFromProject($projects->last()); } $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('charges balance_rub for tenant after routing', function (): void { $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'test.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' => 'test.ru', 'is_active' => true, ]); linkProjectToSupplier($project, $supplier); createRoutingSnapshotFromProject($project); $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((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); }); 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('same phone pre-existing does not suppress new delivery (Spec B)', 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_rub' => '100000.00']); $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, ])); linkProjectToSupplier($projects->last(), $supplier); createRoutingSnapshotFromProject($projects->last()); } // Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет. $firstTenant = $tenants[0]; $firstProject = $projects[0]; DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'"); Deal::create([ 'tenant_id' => $firstTenant->id, 'source_crm_id' => 555, 'project_id' => $firstProject->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(); // Spec B: pre-existing master does NOT suppress — all 3 charged. expect($lead->deals_created_count)->toBe(3); // All 3 tenants: balance decremented, delivered_today incremented. foreach (range(0, 2) as $i) { $t = $tenants[$i]; $p = $projects[$i]; expect((string) $t->fresh()->balance_rub)->toBe('99500.00'); expect($p->fresh()->delivered_today)->toBe(1); } // 3 deal rows exist for this vid (one per tenant). expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3); }); 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_rub' => '100000.00']); $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, ]); linkProjectToSupplier($project, $supplier); createRoutingSnapshotFromProject($project); $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((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); 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((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); 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_rub' => '100000.00']); $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, ])); linkProjectToSupplier($projects->last(), $supplier); createRoutingSnapshotFromProject($projects->last()); } // 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((string) $tenants[0]->fresh()->balance_rub)->toBe('99500.00'); expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00'); }); it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void { // Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный // текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField // c anchored-regex '^[a-z0-9-]+(\.[a-z0-9-]+)+$' такой rest не матчил → классифицировал // как 'sms' → B1+sms → DomainException → 21 реальный лид застрял с error, 0 сделок. $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $domain, ]); $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' => $domain, 'is_active' => true, ]); linkProjectToSupplier($project, $supplier); createRoutingSnapshotFromProject($project); $vid = random_int(100000, 999999); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, 'phone' => '79991234567', 'raw_payload' => [ 'vid' => $vid, 'project' => $projectField, 'phone' => '79991234567', 'time' => now()->getTimestamp(), ], ]); runRouteJob($lead->id); $lead->refresh(); expect($lead->processed_at)->not->toBeNull(); expect($lead->supplier_project_id)->toBe($supplier->id); expect($lead->deals_created_count)->toBe(1); })->with([ 'carmoney embedded in free text' => ['B1_заявка carmoney.ru/', 'carmoney.ru'], 'caranga subdomain with path' => ['B1_Платежи cabinet.caranga.ru/login', 'cabinet.caranga.ru'], 'krk-finance with auth path' => ['B1_krk-finance.ru/cabinet/auth', 'krk-finance.ru'], ]); 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_rub' => '100000.00']); $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_rub НЕ списан. expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); // Deal-row не создался. DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0); }); it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void { // seeded distributor — детерминизм app()->bind(LeadDistributor::class, fn () => new LeadDistributor( new Randomizer(new Mt19937(7)) )); $sp = SupplierProject::query()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru', 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', ]); // 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом foreach (range(1, 5) as $i) { $t = Tenant::factory()->create(['balance_rub' => '100000.00']); $p = Project::factory()->create([ 'tenant_id' => $t->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, ]); linkProjectToSupplier($p, $sp); createRoutingSnapshotFromProject($p); } $lead = SupplierLead::factory()->create([ 'phone' => '79991234567', 'vid' => 555111, 'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111], 'processed_at' => null, 'supplier_project_id' => null, 'platform' => 'B1', ]); (new RouteSupplierLeadJob($lead->id))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); $deals = Deal::query()->where('source_crm_id', 555111)->get(); 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); createRoutingSnapshotFromProject($project); // 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); });