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');