0; * snapshot есть (фаза 3 всё равно не возьмёт). * - Регион лида → Москва (code 82, qc=0, via FakeDaDataPhoneClient). * - Регион всех трёх проектов в snapshot → Москва (код 82) — это важно, чтобы * фаза 1 могла бы найти их, если бы не другие барьеры; при этом phase 3 * (any-region) тоже не найдёт — те же tenant/limit барьеры работают во всех фазах. * * Ожидания (из плана §Task 10): * - deals created = 0; * - lead_charges = 0, balance_transactions = 0 (деньги не тронуты); * - SupplierLead.processed_at IS NOT NULL (job завершился); * - SupplierLead.deals_created_count = 0; * - NO exception (job не упал); * - Непроданный лид виден в supplier_leads. */ const G3_MOSCOW_CODE = 82; const G3_SUPPLIER_DOMAIN = 'scenario-g3-orphan.ru'; const G3_SUPPLIER_PLATFORM = 'B1'; const G3_DAILY_LIMIT = 5; const G3_LEAD_PHONE = '79161234599'; beforeEach(function (): void { $this->seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Bind FakeDaDataPhoneClient: lead's phone resolves to Москва (qc=0, code=82). config([ 'services.dadata.enabled' => true, 'services.dadata.api_key' => 'fake-key', 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub(G3_LEAD_PHONE, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); }); it('orphan lead: no deals created, processed_at set, no exception, no money moved', function (): void { // ── ARRANGE ───────────────────────────────────────────────────────────────── // One shared SupplierProject (B1 site signal). $supplier = SupplierProject::factory()->create([ 'platform' => G3_SUPPLIER_PLATFORM, 'signal_type' => 'site', 'unique_key' => G3_SUPPLIER_DOMAIN, ]); $activeDate = SnapshotForge::activeDate(); // ── Client P1: limit exhausted — fillToLimit makes delivered_today = daily_limit_target. // queryCandidates: projects.delivered_today < snap.daily_limit → FALSE → excluded from all phases. $tenantP1 = Tenant::factory()->create([ 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP1 = Project::factory()->create([ 'tenant_id' => $tenantP1->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => G3_SUPPLIER_DOMAIN, 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP1, $supplier); createRoutingSnapshotFromProject( project: $projectP1, date: $activeDate, signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, regions: '{'.G3_MOSCOW_CODE.'}', ); // Exhaust the limit: delivered_today = G3_DAILY_LIMIT → SQL condition false in all phases. ConditionLevers::fillToLimit($projectP1); // ── Client P2: tenant frozen (frozen_by_balance_at IS NOT NULL). // queryCandidates: WHERE tenants.frozen_by_balance_at IS NULL → FALSE → excluded from all phases. $tenantP2 = Tenant::factory()->create([ 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP2 = Project::factory()->create([ 'tenant_id' => $tenantP2->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => G3_SUPPLIER_DOMAIN, 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP2, $supplier); createRoutingSnapshotFromProject( project: $projectP2, date: $activeDate, signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, regions: '{'.G3_MOSCOW_CODE.'}', ); // Freeze the tenant: frozen_by_balance_at IS NOT NULL → excluded from all phases. ConditionLevers::freeze($tenantP2); // ── Client P3: zero balance (balance_rub = 0). // queryCandidates: WHERE tenants.balance_rub > 0 → FALSE → excluded from all phases. $tenantP3 = Tenant::factory()->create([ 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP3 = Project::factory()->create([ 'tenant_id' => $tenantP3->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => G3_SUPPLIER_DOMAIN, 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP3, $supplier); createRoutingSnapshotFromProject( project: $projectP3, date: $activeDate, signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, regions: '{'.G3_MOSCOW_CODE.'}', ); // Drain balance: balance_rub = 0 → balance_rub > 0 condition fails in all phases. ConditionLevers::drainBalance($tenantP3); // Record counts BEFORE injection to detect any pre-existing rows. $dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count(); $chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count(); $balanceTxCountBefore = DB::connection('pgsql_supplier')->table('balance_transactions')->count(); // ── ACT — inject one lead and run the job synchronously ───────────────────── // We wrap in try/catch to detect exceptions — the plan says NO exception should bubble. $thrownException = null; $injectedLead = null; try { $injector = new LeadInjector; $injectedLead = $injector->site( domain: G3_SUPPLIER_DOMAIN, phone: G3_LEAD_PHONE, tag: 'Москва', platform: G3_SUPPLIER_PLATFORM, vid: 8_888_000_001, ); } catch (Throwable $e) { $thrownException = $e; } // ── ASSERT ────────────────────────────────────────────────────────────────── // 1. No exception bubbled — the job must complete cleanly. expect($thrownException)->toBeNull( 'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. '. 'Expected: no exception. Got: '.($thrownException?->getMessage() ?? 'none') ); // 2. The SupplierLead was created and is accessible. expect($injectedLead)->not->toBeNull( 'FINDING: LeadInjector returned null — SupplierLead was not created.' ); // Re-fetch fresh from DB to get updated processed_at / deals_created_count. /** @var SupplierLead $freshLead */ $freshLead = SupplierLead::find($injectedLead->id); expect($freshLead)->not->toBeNull( 'FINDING: SupplierLead id='.$injectedLead->id.' not found in DB after injection.' ); // 3. processed_at IS set — job stamped the lead as "processed" even though nobody received it. // WHERE: supplier_leads table, column processed_at — this is WHERE the orphan lead "rests". expect($freshLead->processed_at)->not->toBeNull( 'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. '. 'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, '. 'even when deals_created_count=0. The orphan lead should rest in supplier_leads '. 'with processed_at set (idempotency guard).' ); // 4. deals_created_count = 0 — no deals were created. expect((int) $freshLead->deals_created_count)->toBe(0, 'FINDING: SupplierLead.deals_created_count expected 0 but got '. $freshLead->deals_created_count.'. '. 'No eligible project was found — no deal should have been created.' ); // 5. No deals created across all three tenants. $newDealsCount = DB::connection('pgsql_supplier')->table('deals')->count() - $dealsCountBefore; expect($newDealsCount)->toBe(0, 'FINDING: '.$newDealsCount.' deal(s) were created despite all clients being ineligible. '. 'P1(limit-exhausted), P2(frozen), P3(zero-balance) should all fail LeadRouter SQL filter.' ); // 6. No lead_charges created — no money moved. $newChargesCount = DB::connection('pgsql_supplier')->table('lead_charges')->count() - $chargesCountBefore; expect($newChargesCount)->toBe(0, 'FINDING: '.$newChargesCount.' lead_charge row(s) created despite no eligible clients. '. 'LedgerService::chargeForDelivery should not have been called.' ); // 7. No balance_transactions created — balances untouched. $newBalanceTxCount = DB::connection('pgsql_supplier')->table('balance_transactions')->count() - $balanceTxCountBefore; expect($newBalanceTxCount)->toBe(0, 'FINDING: '.$newBalanceTxCount.' balance_transaction(s) created despite no eligible clients.' ); // 8. The orphan lead is visible/recorded — WHERE it rests. // It lives in `supplier_leads` with processed_at IS NOT NULL and deals_created_count = 0. $orphanCount = DB::table('supplier_leads') ->where('id', $freshLead->id) ->whereNotNull('processed_at') ->where('deals_created_count', 0) ->count(); expect($orphanCount)->toBe(1, 'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and '. 'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by '. 'processed_at IS NOT NULL + deals_created_count = 0 (no error column set).' ); // ── REPORT ────────────────────────────────────────────────────────────────── fwrite(STDOUT, PHP_EOL.'=== SCENARIO G3 ORPHAN LEAD REPORT ==='.PHP_EOL); fwrite(STDOUT, 'SupplierLead id: '.$freshLead->id.PHP_EOL); fwrite(STDOUT, 'processed_at: '.($freshLead->processed_at?->toIso8601String() ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'deals_created_count: '.$freshLead->deals_created_count.PHP_EOL); fwrite(STDOUT, 'error: '.($freshLead->error ?? 'NULL (no error)').PHP_EOL); fwrite(STDOUT, 'deals created (new): '.$newDealsCount.PHP_EOL); fwrite(STDOUT, 'lead_charges (new): '.$newChargesCount.PHP_EOL); fwrite(STDOUT, 'balance_transactions(new):'.$newBalanceTxCount.PHP_EOL); fwrite(STDOUT, PHP_EOL.'WHERE the orphan lead rests:'.PHP_EOL); fwrite(STDOUT, ' Table: supplier_leads'.PHP_EOL); fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0'.PHP_EOL); fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).'.PHP_EOL); fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).'.PHP_EOL); fwrite(STDOUT, '=== END G3 REPORT ==='.PHP_EOL.PHP_EOL); })->group('imitation');