Files
portal/app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php
T

717 lines
29 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
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);
/**
* Scenarios B/C — Region Cascade Verification Tests.
*
* VERIFICATION tests against existing prod routing code (LeadRouter + RouteSupplierLeadJob).
* Proves (or disproves) the 3-phase cascade behaviour: exact → all-RF → fallback.
* NOT TDD — no prod code is modified. Cascade differences vs plan are FINDINGS.
*
* Subject codes are порядковые (1..89), NOT коды ГИБДД:
* 82 = Москва (App\Support\RussianRegions::CODE_TO_NAME[82])
* 50 = Костромская область (used as "foreign" region — nobody has it exactly)
* 1 = Республика Адыгея
* 83 = Санкт-Петербург
*
* Task 7 — Phase 1 Portal Client Imitation.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2/§6.7
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 7
*/
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const BC_MOSCOW = 82;
/** App\Support\RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург' */
const BC_SPB = 83;
/** App\Support\RussianRegions::CODE_TO_NAME[50] = 'Костромская область' — nobody subscribes */
const BC_FOREIGN = 50;
/** App\Support\RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея' */
const BC_ADYGEA = 1;
/**
* Deterministic Randomizer seed for LeadRouter weightedPick.
* With only 1-2 candidates, weightedPick returns all in order (no random needed),
* but we seed it anyway for reproducibility.
*/
const BC_SEED = 7;
/**
* Shared supplier domain (B1 site signal) for Scenario B.
*/
const BC_B_DOMAIN = 'scenario-b-cascade.ru';
/**
* Shared supplier domain (B1 site signal) for Scenario C.
*/
const BC_C_DOMAIN = 'scenario-c-cascade.ru';
beforeEach(function (): void {
// Seed pricing tiers — required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding phase (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config — real values irrelevant, FakeDaDataPhoneClient bypasses HTTP.
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 — with 1-2 candidates weightedPick always returns
// all of them in SQL order (pool count ≤ cap=3), but seeded Mt19937 ensures
// reproducibility if the implementation changes.
$seededRouter = new LeadRouter(new Randomizer(new Mt19937(BC_SEED)));
app()->instance(LeadRouter::class, $seededRouter);
});
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO B — exact → all-RF cascade
// ══════════════════════════════════════════════════════════════════════════════
/**
* Scenario B1: Lead with a subject matching client X's exact region → goes to X (step 1).
*
* Setup:
* - Client X: regions=[82] (Москва only)
* - Client Y: regions=[] (all-RF)
* - Lead resolves to Москва (code 82) via FakeDaData (qc=0, region='Москва')
*
* Expected: deal created for X (tenant_X), routing_step=1.
* deal NOT created for Y via step-1 (Y is all-RF, not exact-82).
* (Y may receive the lead if cap allows — this test has only 1 lead and cap=3
* so BOTH X and Y are eligible. Step-2 fills remaining slots.)
*
* Cascade logic (LeadRouter):
* Phase 1 exact: X matches (82 = ANY('{82}')), Y does NOT ('{}'≠'{82}').
* Phase 2 all-RF: Y matches ('{}' = '{}'), fills remaining cap slots.
* → selected = [X(step=1), Y(step=2)].
*
* So: X gets routing_step=1, Y gets routing_step=2.
* lead_region_resolution_log.routing_step = step of FIRST project (X→1).
*/
it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at step 2', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
]);
// Client X: only Москва (exact match for subject=82).
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_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' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF (regions='{}').
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_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' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Москва via FakeDaData (qc=0 → source=dadata, subject=82).
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79161000001', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector;
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79161000001',
tag: 'Москва',
platform: 'B1',
vid: 8_100_000_001,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
// Tenant X must have received a deal (exact Москва match, step 1).
$dealsX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->count();
// Tenant Y (all-RF) receives the deal at step 2 (cap allows both).
$dealsY = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantY->id)
->count();
fwrite(STDOUT, PHP_EOL.'=== B1 DISTRIBUTION ==='.PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL);
// Primary assertion: X gets the lead (routing_step=1 path exists).
expect($dealsX)->toBe(1,
'FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. '.
"Got dealsX={$dealsX}. The exact-match phase (step 1) may not be working."
);
// Check lead_region_resolution_log for routing_step=1.
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log region_source: '.($logRow?->region_source ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END B1 ==='.PHP_EOL.PHP_EOL);
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row for this lead. '.
'logRegionResolution() may have failed silently (fail-safe).'
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
'FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). '.
"Got: {$logRow->routing_step}. The log records step of first project in selected collection."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_MOSCOW,
'FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. '.
"Got: {$logRow->subject_code_resolved}."
);
}
// deals.subject_code for X's deal.
$dealX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->first();
if ($dealX !== null) {
expect((int) $dealX->subject_code)->toBe(BC_MOSCOW,
'FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. '.
"Got: {$dealX->subject_code}."
);
}
})->group('imitation');
/**
* Scenario B2: Lead with a subject nobody has exactly → goes to all-RF client (step 2).
*
* Setup:
* - Client X: regions=[82] (Москва only)
* - Client Y: regions=[] (all-RF)
* - Lead resolves to code 50 (Костромская область) — no client has this exact region.
*
* Expected:
* Phase 1 exact: nobody has code 50 → empty.
* Phase 2 all-RF: Y matches → Y receives the deal at step 2.
* X gets NO deal.
* lead_region_resolution_log.routing_step = 2.
*/
it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody has exact', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
]);
// Client X: regions=[82] (Москва) — will NOT match code 50.
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_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' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF — will match any subject at phase 2.
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_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' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Костромская область (code 50) — DaData qc=0, region name must
// be the exact string in RussianRegions::CODE_TO_NAME[50] = 'Костромская область'
// so that DaDataRegionMap::toSubjectCode() returns 50.
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79162000001', qc: 0, region: 'Костромская область', provider: 'Билайн');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector;
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79162000001',
tag: null,
platform: 'B1',
vid: 8_100_000_002,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->count();
$dealsY = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantY->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, PHP_EOL.'=== B2 DISTRIBUTION ==='.PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END B2 ==='.PHP_EOL.PHP_EOL);
// X must NOT receive the lead (code 50 is NOT in X's regions=[82]).
expect($dealsX)->toBe(0,
'FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). '.
"Got dealsX={$dealsX}. Phase-1 exact filter may be matching wrong subjects."
);
// Y must receive the lead (all-RF, step 2).
expect($dealsY)->toBe(1,
"FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. ".
"Got dealsY={$dealsY}. Phase-2 all-RF filter may not be working."
);
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed.'
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(2,
'FINDING: routing_step should be 2 (first project in selected is Y at step 2). '.
"Got: {$logRow->routing_step}."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_FOREIGN,
'FINDING: resolved subject_code should be 50 (Костромская область). '.
"Got: {$logRow->subject_code_resolved}."
);
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO C — each client gets only its own region (phase-1 isolation)
// ══════════════════════════════════════════════════════════════════════════════
/**
* Scenario C1: Two clients with different exact regions — each lead goes to only its own.
*
* Setup:
* - Client A: regions=[1] (Республика Адыгея)
* - Client B: regions=[83] (Санкт-Петербург)
*
* Lead 1 resolves to subject=1 → A gets it at step 1; B does NOT.
* Lead 2 resolves to subject=83 → B gets it at step 1; A does NOT.
*
* Phase 2 (all-RF) is empty here — neither A nor B has regions='{}',
* so there are no all-RF clients. If exact match returns 1 result and cap=3,
* phases 2+3 run for remaining slots. Since phase 2 (all-RF) is empty and
* phase 3 (any) would match BOTH — we verify that:
* - exactly 1 deal per lead is created (step-1 match);
* - OR phase 3 fires and the other client ALSO gets the lead (FINDING if so).
*
* This test asserts the strongest useful claim: each client sees only its own
* leads from step-1. Phase-3 fallback behaviour is reported as a FINDING if it
* fires (because no all-RF client exists, phase 2 is empty, and phase 3 is the
* "any" fallback which would give the lead to both — if that's what happens, it
* means the cascade reaches phase 3 even with 1 exact match at phase 1).
*
* NOTE: LeadRouter cap=3 and phase-1 picks 1 project. Since combined.isNotEmpty()
* after phase 1+2 → phase 3 is NOT entered (LeadRouter returns combined if
* combined.isNotEmpty()). So: lead→A at step 1 only (Y is not all-RF so phase 2
* returns nothing, but combined=[A] is NOT empty → phase 3 skipped). ✓
*/
it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to client B (regions=[83])', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
]);
// Client A: Республика Адыгея (code 1).
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_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' => [BC_ADYGEA], // code 1
]);
linkProjectToSupplier($projectA, $supplier);
// Client B: Санкт-Петербург (code 83).
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_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' => [BC_SPB], // code 83
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_ADYGEA.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_SPB.'}',
);
// FakeDaData: phone→Adygea (code 1).
// RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея'
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79163000001', qc: 0, region: 'Республика Адыгея', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector;
$leadAdygea = $injector->site(
domain: BC_C_DOMAIN,
phone: '79163000001',
tag: null,
platform: 'B1',
vid: 8_100_000_010,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsA = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantA->id)
->count();
$dealsB = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantB->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $leadAdygea->id)
->first();
fwrite(STDOUT, PHP_EOL.'=== C1 DISTRIBUTION (lead→Адыгея) ==='.PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END C1 ==='.PHP_EOL.PHP_EOL);
// A must receive the lead.
expect($dealsA)->toBe(1,
'FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. '.
"Got dealsA={$dealsA}. Phase-1 exact match may not be working for small subject codes."
);
// B must NOT receive the lead (step-1 only → combined=[A] is not empty → phase 3 skipped).
expect($dealsB)->toBe(0,
'FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. '.
"Got dealsB={$dealsB}. ".
"If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. ".
'This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, '.
'so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. '.
'If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch.'
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: routing_step should be 1 (A matched exactly). Got: {$logRow->routing_step}."
);
}
})->group('imitation');
/**
* Scenario C2: Lead with СПб subject goes only to client B, not to A.
*
* Mirror of C1 — proves bidirectional isolation.
*/
it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to client A (regions=[1])', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
]);
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_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' => [BC_ADYGEA],
]);
linkProjectToSupplier($projectA, $supplier);
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_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' => [BC_SPB],
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_ADYGEA.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_SPB.'}',
);
// FakeDaData: phone→СПб (code 83).
// RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург'
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79164000001', qc: 0, region: 'Санкт-Петербург', provider: 'Теле2');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector;
$leadSpb = $injector->site(
domain: BC_C_DOMAIN,
phone: '79164000001',
tag: null,
platform: 'B1',
vid: 8_100_000_011,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsA = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantA->id)
->count();
$dealsB = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantB->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $leadSpb->id)
->first();
fwrite(STDOUT, PHP_EOL.'=== C2 DISTRIBUTION (lead→СПб) ==='.PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END C2 ==='.PHP_EOL.PHP_EOL);
// B must receive the lead (exact СПб match at step 1).
expect($dealsB)->toBe(1,
'FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. '.
"Got dealsB={$dealsB}."
);
// A must NOT receive the lead.
expect($dealsA)->toBe(0,
'FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. '.
"Got dealsA={$dealsA}. Phase-3 fallback may have fired — investigate."
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: routing_step should be 1. Got: {$logRow->routing_step}."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_SPB,
'FINDING: resolved subject_code should be 83 (Санкт-Петербург). '.
"Got: {$logRow->subject_code_resolved}."
);
}
})->group('imitation');