53fb7b7760
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
513 lines
27 KiB
PHP
513 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Verification tests — ScenarioX1X3_SubstitutionJournalTest (Task 12, Phase 1 imitation).
|
||
*
|
||
* PROVING tests against existing production routing code.
|
||
* Differences vs plan → FINDINGS captured in test output and comments.
|
||
* NO production code is modified. Only this one file is created.
|
||
*
|
||
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md
|
||
* → "Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника"
|
||
* Spec: §6.5 X1/X3 + §7 п.30/41
|
||
*
|
||
* ── KEY PRODUCTION CODE FACTS (verified from reading prod code, NOT guessing) ────
|
||
*
|
||
* RouteSupplierLeadJob::createDealCopyForProject() lines 433–453:
|
||
* $dealSubjectCode = ($routingStep < 3)
|
||
* ? $resolution->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');
|