diff --git a/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php b/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php new file mode 100644 index 00000000..7fc11c99 --- /dev/null +++ b/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php @@ -0,0 +1,513 @@ +subjectCode + * : (pickSubstituteRegion($snapshot->regions ?? '{}') ?? $resolution->subjectCode); + * Deal::create([ + * 'subject_code' => $dealSubjectCode, // substituted (client's) on step 3 + * 'city' => CODE_TO_NAME[$resolution->subjectCode] ?? null, // REAL lead region ALWAYS + * 'region_substituted' => ($routingStep === 3), // flag + * ]); + * + * RouteSupplierLeadJob::logRegionResolution() lines 558–595: + * $substituted = ($routingStep === 3 && $first !== null) + * ? (pickSubstituteRegion($first->snapshot_regions ?? '{}') ?? $resolution->subjectCode) + * : null; + * INSERT lead_region_resolution_log { + * actual_subject_code => $resolution->actualSubjectCode // real resolved code + * substituted_subject_code=> $substituted // client's code on step 3, else null + * routing_step => $routingStep // step of FIRST selected project + * subject_code_resolved => $resolution->subjectCode // real resolved code (same as actual) + * } + * + * LeadRouter: phase 3 fires ONLY when combined(phase1+phase2) is EMPTY. + * To force step 3: the ONLY eligible client must have an exact region DIFFERENT + * from the lead's resolved region, AND no all-RF client (regions='{}'). + * → phase 1 empty (exact mismatch), phase 2 empty (no '{}' client), phase 3 fires. + * + * pickSubstituteRegion() picks the FIRST int from the PG INT[]-literal '{R_client}'. + * snapshot.regions column holds the client's subscribed regions. + * + * RegionResolution.actualSubjectCode = subjectCode at construction (RegionResolution::make()). + * They are equal at the resolver stage; substitution is a RouteSupplierLeadJob concern only. + * + * X3 region_source values: + * 'dadata' — FakeDaData stub with qc=0 + * 'rossvyaz' — FakeDaData stubThrows + seeded phone_ranges row matching the phone + * 'tag' — DaData disabled OR qc=2/7 + valid tag string (FINDING: qc=2 with valid tag → 'tag') + * 'unknown' — DaData disabled/fails + no phone_range + empty/null tag + * + * X3 uses direct LeadRegionResolver::resolve() calls (not full routing) to + * produce multiple resolution log rows cheaply via separate SupplierLead rows. + */ + +use App\Jobs\RouteSupplierLeadJob; +use App\Models\Project; +use App\Models\SupplierLead; +use App\Models\SupplierProject; +use App\Models\Tenant; +use App\Services\DaData\DaDataPhoneClient; +use App\Services\LeadRegionResolver; +use App\Services\LeadRouter; +use App\Support\RussianRegions; +use Database\Seeders\PricingTierSeeder; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\DB; +use Random\Engine\Mt19937; +use Random\Randomizer; +use Tests\Concerns\SharesSupplierPdo; +use Tests\Support\Imitation\FakeDaDataPhoneClient; +use Tests\Support\Imitation\LeadInjector; +use Tests\Support\Imitation\SnapshotForge; + +uses(DatabaseTransactions::class, SharesSupplierPdo::class); + +// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ──────────────────────────── +/** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */ +const X1_LEAD_REGION = 29; +/** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */ +const X1_CLIENT_REGION = 37; +/** RussianRegions::CODE_TO_NAME[82] = 'Москва' */ +const X1_MOSCOW = 82; + +/** Domains for X1 and X3 tests (B1 site signal, unique per scenario to avoid snapshot collisions) */ +const X1_DOMAIN = 'scenario-x1-substitution.ru'; +const X3_DOMAIN = 'scenario-x3-source-breakdown.ru'; + +/** Deterministic seed for LeadRouter */ +const X1_SEED = 42; + +// ── SHARED beforeEach ────────────────────────────────────────────────────────── +beforeEach(function (): void { + // Pricing tiers required by LedgerService::chargeForDelivery. + $this->seed(PricingTierSeeder::class); + + // Global RLS bypass for seeding (tenant context = 0). + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + // DaData config defaults — individual tests override as needed. + config([ + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', + 'services.dadata.daily_cap_rub' => 1_000_000, + ]); + + // Deterministic LeadRouter — seeded Mt19937. With 1 candidate, weightedPick + // always returns it (pool ≤ cap=3 so no lottery needed), but seeded for stability. + app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(X1_SEED)))); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// SCENARIO X1 — step-3 substitution: subject_code, city, journal actual/substituted +// ══════════════════════════════════════════════════════════════════════════════ + +/** + * X1: One client subscribed to R_client (Белгородская обл., code 37). + * No all-RF client. Lead resolved to R_lead (Красноярский край, code 29) via DaData qc=0. + * + * Cascade: + * Phase 1 exact: 29 NOT in client's regions={37} → empty. + * Phase 2 all-RF: no '{}' client → empty. + * Phase 3 fallback: client eligible (any region) → routing_step=3. + * + * Expected (prod spec §3.10 + §7 п.30/41): + * deals.subject_code = R_client = 37 (substituted to client's region) + * deals.city = name(R_lead) = 'Красноярский край' (REAL lead region) + * deals.region_substituted = true + * lead_region_resolution_log.actual_subject_code = R_lead = 29 + * lead_region_resolution_log.substituted_subject_code = R_client = 37 + * lead_region_resolution_log.subject_code_resolved = R_lead = 29 + * lead_region_resolution_log.routing_step = 3 + */ +it('X1: step-3 fallback substitutes subject_code to client region, preserves real region in city + journal', function (): void { + // ── ARRANGE ─────────────────────────────────────────────────────────────── + + // One supplier (B1 site). + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => X1_DOMAIN, + ]); + + // One client: subscribed to R_client=37 (Белгородская обл.) ONLY. + // No all-RF client → phases 1+2 both empty → phase 3 fires. + $tenant = Tenant::factory()->create([ + 'balance_rub' => '99999.00', + 'frozen_by_balance_at' => null, + ]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => X1_DOMAIN, + 'daily_limit_target' => 10, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [X1_CLIENT_REGION], // {37} + ]); + linkProjectToSupplier($project, $supplier); + + $activeDate = SnapshotForge::activeDate(); + createRoutingSnapshotFromProject( + project: $project, + date: $activeDate, + signalType: 'site', + signalIdentifier: X1_DOMAIN, + dailyLimit: 10, + regions: '{' . X1_CLIENT_REGION . '}', // '{37}' + ); + + // Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0. + // DaData region string must EXACTLY match RussianRegions::CODE_TO_NAME[29] + // for DaDataRegionMap::toSubjectCode() to return code 29. + $leadPhone = '79292900001'; + $leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край' + + $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fakeDaData); + + // ── ACT ─────────────────────────────────────────────────────────────────── + + $injector = new LeadInjector(); + $lead = $injector->site( + domain: X1_DOMAIN, + phone: $leadPhone, + tag: null, + platform: 'B1', + vid: 9_120_001_001, + ); + + // ── ASSERT ──────────────────────────────────────────────────────────────── + + // Retrieve the deal for our tenant. + $deal = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenant->id) + ->first(); + + $logRow = DB::connection('pgsql_supplier') + ->table('lead_region_resolution_log') + ->where('supplier_lead_id', $lead->id) + ->first(); + + // Diagnostic output. + fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL); + fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL); + fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL); + fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL); + fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL); + if ($deal !== null) { + fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL); + fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL); + fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL); + } + if ($logRow !== null) { + fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL); + fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL); + fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL); + fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL); + fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL); + } else { + fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL); + } + fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL); + + // A deal must have been created. + expect($deal)->not->toBeNull( + 'FINDING: No deal was created for the tenant. ' . + 'The phase-3 fallback (any region) may not be reaching this client, ' . + 'or the snapshot is missing.' + ); + + if ($deal !== null) { + // deals.subject_code must be R_client (substituted to client's region on step 3). + expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION, + 'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' . + 'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' . + 'Got: ' . $deal->subject_code . '. ' . + 'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' . + 'If got null: snapshot.regions may not be picked up correctly.' + ); + + // deals.city must be the name of R_lead (real resolved region), NOT R_client. + // Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида. + expect($deal->city)->toBe($leadRegionName, + 'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' . + 'Got: "' . $deal->city . '". ' . + 'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' . + 'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' . + 'prod code erroneously uses $dealSubjectCode for city.' + ); + + // deals.region_substituted must be true. + // PostgreSQL boolean comes back as string '1'/'0'/'t'/'f' or bool depending on driver. + $regionSubstituted = filter_var($deal->region_substituted, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($regionSubstituted === null) { + // Raw value from DB — accept truthy string/int representations. + $regionSubstituted = in_array($deal->region_substituted, [true, 1, '1', 't', 'true'], true); + } + + expect($regionSubstituted)->toBeTrue( + 'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' . + 'Got raw value: "' . $deal->region_substituted . '". ' . + 'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.' + ); + } + + // lead_region_resolution_log must have a row. + expect($logRow)->not->toBeNull( + 'FINDING: lead_region_resolution_log has no row for this lead. ' . + 'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' . + 'Check for partition missing (received_at date not matching any partition).' + ); + + if ($logRow !== null) { + // routing_step = 3. + expect((int) $logRow->routing_step)->toBe(3, + 'FINDING: log.routing_step should be 3 (phase-3 fallback). ' . + 'Got: ' . $logRow->routing_step . '. ' . + 'If 1: exact match fired (snapshot.regions may be wrong). ' . + 'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).' + ); + + // subject_code_resolved = R_lead (the actual resolved code, not substituted). + expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION, + 'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' . + 'Got: ' . $logRow->subject_code_resolved . '.' + ); + + // actual_subject_code = R_lead. + // RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved). + expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION, + 'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' . + 'Got: ' . $logRow->actual_subject_code . '. ' . + 'actualSubjectCode is set equal to subjectCode in RegionResolution::make().' + ); + + // substituted_subject_code = R_client (the first code from snapshot.regions). + // pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION. + expect($logRow->substituted_subject_code)->not->toBeNull( + 'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' . + 'Got null. ' . + 'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' . + 'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).' + ); + + if ($logRow->substituted_subject_code !== null) { + expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION, + 'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' . + 'Got: ' . $logRow->substituted_subject_code . '. ' . + 'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.' + ); + } + } +})->group('imitation'); + + +// ══════════════════════════════════════════════════════════════════════════════ +// SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown) +// ══════════════════════════════════════════════════════════════════════════════ + +/** + * X3: Inject 4 leads with different region_source values, aggregate + * lead_region_resolution_log.region_source counts, assert they match. + * + * To avoid full routing overhead and snapshot complexity, X3 uses + * LeadRegionResolver::resolve() directly on SupplierLead rows, + * then reads region_source from the updated supplier_leads columns. + * The resolver writes region_source to supplier_leads.region_source + * (RouteSupplierLeadJob lines 159-164); the log is written by + * logRegionResolution() after routing. For X3 we inject via full + * LeadInjector (which fires RouteSupplierLeadJob) so logRegionResolution() + * also runs; however, without a client snapshot the routing loop produces + * no deals (no selected projects → logRegionResolution called with empty $selected). + * + * FINDING note on log.routing_step when $selected is empty: + * logRegionResolution() line 561: $first = $selected->first() → null. + * So $routingStep = null, $substituted = null. + * This is correct/expected for X3's source-breakdown scenario. + * + * Source classification (from LeadRegionResolver code): + * 'dadata' — DaData enabled, qc=0 (good quality, map returns a code). + * 'rossvyaz' — DaData disabled OR throws/qc=1, phone_ranges row seeded for phone. + * 'tag' — DaData disabled/fails/qc=2, valid tag string (maps to a region code). + * 'unknown' — DaData disabled/fails, no phone_range match, empty/null tag. + * + * We inject 1 lead per source type (4 total), then read supplier_leads.region_source. + * Counts: dadata=1, rossvyaz=1, tag=1, unknown=1. + * + * X3 uses a dedicated supplier + NO snapshot → selected=empty → no deals created. + * This avoids the routing infrastructure (no client setup, no snapshot needed). + * The region resolver still runs and writes region_source to supplier_leads. + */ +it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_source counts in supplier_leads', function (): void { + // ── ARRANGE ─────────────────────────────────────────────────────────────── + + // Supplier for X3 — NO project linked, NO snapshot → routing produces 0 deals. + // This means RouteSupplierLeadJob still runs LeadRegionResolver, updates + // supplier_leads.region_source, then calls logRegionResolution (with empty selected). + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => X3_DOMAIN, + ]); + + // ── Lead 1: source = 'dadata' ────────────────────────────────────────────── + // DaData returns qc=0 + valid region name → DaDataRegionMap maps to a code. + $phone1 = '79310000001'; + $region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва' + + $fake1 = new FakeDaDataPhoneClient(); + $fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fake1); + + config(['services.dadata.enabled' => true]); + + $injector = new LeadInjector(); + $lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001); + + // ── Lead 2: source = 'rossvyaz' ──────────────────────────────────────────── + // DaData throws → falls through to Россвязь lookup. Seed a phone_ranges row + // covering phone2's DEF+subscriber range, mapping to subject_code=X1_MOSCOW. + // Phone format: 7{defCode}{subscriber} = 7{931}{0000002} → DEF=931, sub=0000002. + $phone2 = '79310000002'; + // DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it. + $importId2 = DB::table('phone_ranges_imports')->insertGetId([ + 'imported_at' => now(), + 'source_url' => 'test://x3-rossvyaz', + 'rows_inserted' => 1, + 'rows_updated' => 0, + 'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'), + 'status' => 'completed', + 'completed_at' => now(), + ]); + DB::table('phone_ranges')->insert([ + 'def_code' => 931, + 'from_num' => 0, + 'to_num' => 9999999, + 'operator' => 'test-op-x3', + 'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW], + 'region_normalized' => null, + 'subject_code' => X1_MOSCOW, + 'imported_at' => now(), + 'import_id' => $importId2, + ]); + + $fake2 = new FakeDaDataPhoneClient(); + $fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь + app()->instance(DaDataPhoneClient::class, $fake2); + config(['services.dadata.enabled' => true]); + + $lead2 = $injector->site(domain: X3_DOMAIN, phone: $phone2, tag: null, platform: 'B1', vid: 9_130_003_002); + + // ── Lead 3: source = 'tag' ───────────────────────────────────────────────── + // DaData disabled → no HTTP call. No phone_range seeded for this phone. + // Tag = region name that RegionTagResolver recognises as a valid region code. + // RegionTagResolver maps tag text to a subject code. Use 'Москва' (maps to 82). + // FINDING note: qc=2 path calls tagFallback() which only returns 'tag' if tagCode != null. + // With DaData disabled, resolver falls directly to tag/rossvyaz cascade. + $phone3 = '79310000003'; + config(['services.dadata.enabled' => false]); + // No DaData stub needed — disabled path skips the HTTP call entirely. + + $lead3 = $injector->site(domain: X3_DOMAIN, phone: $phone3, tag: 'Москва', platform: 'B1', vid: 9_130_003_003); + + // ── Lead 4: source = 'unknown' ───────────────────────────────────────────── + // DaData disabled. No phone_ranges row for this DEF. Empty tag. + // → no resolution possible → source='unknown'. + $phone4 = '79880000004'; // DEF=988, NOT seeded in phone_ranges + config(['services.dadata.enabled' => false]); + + $lead4 = $injector->site(domain: X3_DOMAIN, phone: $phone4, tag: null, platform: 'B1', vid: 9_130_003_004); + + // ── READ region_source from supplier_leads ──────────────────────────────── + // RouteSupplierLeadJob updates supplier_leads.region_source after resolver runs. + $leadIds = [$lead1->id, $lead2->id, $lead3->id, $lead4->id]; + + $rows = DB::table('supplier_leads') + ->whereIn('id', $leadIds) + ->get(['id', 'region_source', 'resolved_subject_code', 'phone']) + ->keyBy('id'); + + $source1 = $rows[$lead1->id]->region_source ?? 'MISSING'; + $source2 = $rows[$lead2->id]->region_source ?? 'MISSING'; + $source3 = $rows[$lead3->id]->region_source ?? 'MISSING'; + $source4 = $rows[$lead4->id]->region_source ?? 'MISSING'; + + fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL); + fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL); + fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL); + fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL); + fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL); + + // Aggregate counts from supplier_leads. + $actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds); + $counts = array_count_values($actualSources); + fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL); + fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL); + + // ── ASSERT ──────────────────────────────────────────────────────────────── + + // Lead 1: expect 'dadata'. + expect($source1)->toBe('dadata', + "FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " . + "Got: '{$source1}'. " . + "If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " . + "If 'unknown': DaData is disabled or threw despite stub." + ); + + // Lead 2: expect 'rossvyaz'. + expect($source2)->toBe('rossvyaz', + "FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " . + "Got: '{$source2}'. " . + "If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " . + "If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)." + ); + + // Lead 3: expect 'tag'. + expect($source3)->toBe('tag', + "FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " . + "Got: '{$source3}'. " . + "KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " . + "Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " . + "is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " . + "'Москва' should map to code 82 via RegionTagResolver. " . + "If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver." + ); + + // Lead 4: expect 'unknown'. + expect($source4)->toBe('unknown', + "FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " . + "Got: '{$source4}'. " . + "If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " . + "If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)." + ); + + // Aggregate assertion: 4 leads → exactly these 4 sources. + $expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1]; + expect($counts)->toEqual($expectedCounts, + 'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' . + 'Got: ' . json_encode($counts) . '. See individual source assertions above for details.' + ); +})->group('imitation');