53fb7b7760
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
717 lines
29 KiB
PHP
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');
|