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

283 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
/**
* Scenario A — взвешенный жребий по объёму (§6.2 A + §6.5 X2).
*
* VERIFICATION test against existing prod routing code in LeadRouter.
* This proves (or disproves) the weighted-lottery distribution behaviour.
* NOT TDD — no prod code is modified. Findings are reported, not silently fixed.
*
* Setup:
* - 5 tenants/projects on ONE shared SupplierProject, region = Москва (code 82).
* - Daily limits: {300, 30, 30, 3, 3}. delivered_today = 0 (full capacity).
* - Deterministic seeded LeadRouter (Mt19937 seed 42) for reproducibility.
* - FakeDaDataPhoneClient: all phones → Москва (qc=0, subject_code=82).
* - N = 300 leads injected synchronously.
*
* Cap=3 means each lead is delivered to AT MOST 3 of the 5 projects (weighted pick
* from eligible candidates per phase). Projects drop out of eligibility once
* delivered_today >= daily_limit. So the two limit-3 projects each receive at most 3
* leads total, then become ineligible for the rest of the run.
*
* Assertions (per plan §6.5):
* (a) The biggest-limit project (300) receives the most deals.
* (b) Both smallest projects (limit=3) receive > 0 deals (weight≥1 guarantee).
* (c) The big-limit project's share is substantially larger than any small project's.
*
* Task 6 — Phase 1 Portal Client Imitation, Scenario A.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2/§6.5
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 6
*/
/**
* Москва subject code — порядковый (НЕ ГИБДД).
* Подтверждено: App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва'.
*/
const MOSCOW_SUBJECT_CODE = 82;
/**
* Deterministic seed for Mt19937 — same seed = same sequence of rolls.
*/
const LOTTERY_SEED = 42;
/**
* Number of leads to inject in the distribution run.
*/
const LEAD_COUNT = 300;
/**
* Daily limits for the 5 projects. Index → limit.
*/
const PROJECT_LIMITS = [300, 30, 30, 3, 3];
/**
* Shared supplier domain (B1 site signal).
*/
const SUPPLIER_DOMAIN = 'scenario-a-test.ru';
/**
* Shared supplier platform.
*/
const SUPPLIER_PLATFORM = 'B1';
beforeEach(function (): void {
// Seed pricing tiers required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global bypass RLS for seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Bind deterministic DaData fake: every phone maps to Москва (qc=0, code=82).
// We'll register stubs per phone inside the test.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Bind deterministic LeadRouter with seeded Mt19937.
$seededRouter = new LeadRouter(new Randomizer(new Mt19937(LOTTERY_SEED)));
app()->instance(LeadRouter::class, $seededRouter);
});
it('splits leads weighted by remaining limit, small clients are not cut off', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
// One shared supplier project (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => SUPPLIER_DOMAIN,
]);
// Create 5 tenants + projects, one per limit tier.
$limits = PROJECT_LIMITS;
$projects = [];
$tenants = [];
foreach ($limits as $idx => $limit) {
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00', // ample balance — billing must not block
'frozen_by_balance_at' => null,
]);
$tenants[] = $tenant;
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => SUPPLIER_DOMAIN,
'daily_limit_target' => $limit,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127, // all days
'preflight_blocked_at' => null,
// PostgresIntArray cast: pass PHP array, cast serialises to '{82}'.
'regions' => [MOSCOW_SUBJECT_CODE],
]);
// Link project to supplier.
linkProjectToSupplier($project, $supplier);
$projects[] = $project;
}
// Active date for snapshot — mirrors LeadRouter::activeSnapshotDate().
$activeDate = SnapshotForge::activeDate();
// Build routing snapshots for each project with Москва region (code 82)
// and the correct daily_limit. Using createRoutingSnapshotFromProject helper
// (defined in tests/Pest.php) with explicit dailyLimit and regions='{82}'.
foreach ($projects as $idx => $project) {
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: SUPPLIER_DOMAIN,
dailyLimit: $limits[$idx],
regions: '{'.MOSCOW_SUBJECT_CODE.'}',
);
}
// Build a FakeDaDataPhoneClient that maps all test phones to Москва (qc=0).
// We generate deterministic phone numbers: 7(916)000XXXX where XXXX = index 0001..0300.
$fakeDaData = new FakeDaDataPhoneClient;
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector;
$vidBase = 9_000_000_000; // high range, outside real supplier VIDs
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$injector->site(
domain: SUPPLIER_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: SUPPLIER_PLATFORM,
vid: $vidBase + $i,
);
}
// ── GATHER DISTRIBUTION ──────────────────────────────────────────────────────
// Count deals per project (each deal has a tenant_id; project-id is on the deal row).
// Using pgsql_supplier (BYPASSRLS) to see deals across all tenants.
$dealCounts = [];
foreach ($projects as $idx => $project) {
$count = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[$idx]->id)
->count();
$dealCounts[$idx] = $count;
}
$totalDeals = array_sum($dealCounts);
// ── REPORT ──────────────────────────────────────────────────────────────────
// Print observed distribution for the required report.
fwrite(STDOUT, PHP_EOL.'=== SCENARIO A DISTRIBUTION REPORT ==='.PHP_EOL);
fwrite(STDOUT, sprintf('Total leads injected: %d%s', LEAD_COUNT, PHP_EOL));
fwrite(STDOUT, sprintf('Total deals created: %d (≤ %d × cap=3 = %d)%s',
$totalDeals, LEAD_COUNT, LEAD_COUNT * 3, PHP_EOL));
fwrite(STDOUT, PHP_EOL.sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL));
fwrite(STDOUT, str_repeat('-', 45).PHP_EOL);
foreach ($projects as $idx => $project) {
$deals = $dealCounts[$idx];
$share = $totalDeals > 0 ? round($deals / $totalDeals * 100, 1) : 0.0;
fwrite(STDOUT, sprintf(
'%-10s %-12d %-10d %-10s%s',
"P{$idx} (lim={$limits[$idx]})",
$limits[$idx],
$deals,
"{$share}%",
PHP_EOL
));
}
fwrite(STDOUT, '=== END DISTRIBUTION ==='.PHP_EOL.PHP_EOL);
// ── ASSERTIONS ───────────────────────────────────────────────────────────────
// (a) The biggest-limit project (P0, limit=300) got the most deals.
// After the two limit-3 projects hit their cap, P0 competes with two limit-30
// projects; but even before that, its weight (300) vastly outweighs theirs.
$bigProjectDeals = $dealCounts[0]; // limit=300
$maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2
expect($bigProjectDeals)->toBeGreaterThan($maxSmallDeals,
'FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. '.
"Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. ".
'This would indicate the weighted lottery is not proportional.'
);
// (b) Both smallest projects (limit=3) received > 0 deals.
// Weight ≥1 guarantee means even tiny projects see some traffic.
// They CAN only receive at most 3 deals each (their limit), so they'll
// hit their limit early and then drop out of eligibility.
expect($dealCounts[3])->toBeGreaterThan(0,
'FINDING: small-limit project P3 (limit=3) received 0 deals. '.
'The spec guarantees weight≥1 so small clients are NOT cut off. '.
"Actual: P3={$dealCounts[3]}. This is a bug."
);
expect($dealCounts[4])->toBeGreaterThan(0,
'FINDING: small-limit project P4 (limit=3) received 0 deals. '.
'The spec guarantees weight≥1 so small clients are NOT cut off. '.
"Actual: P4={$dealCounts[4]}. This is a bug."
);
// (c) Proportionality: big-limit project's share vs small-limit projects.
// With limits {300,30,30,3,3} and cap=3 per lead:
// - The two limit-3 projects hit their cap and drop out early (after 3 leads each).
// - For the remaining 294 leads, it's P0(300), P1(30), P2(30) competing for 3 slots.
// - P0 weight starts at 300, P1/P2 at 30 each → P0 wins ~83% of selections per lead.
// - Total picks per lead: 3 (P0 + P1 + P2 all eligible for all 3 slots after picks).
// - Expected P0 deals ≈ 294 × 300/360 × ... roughly ~240+ (but with depletion it's less).
// - Tolerant check: P0 has at least 50% of all deals.
$p0Share = $totalDeals > 0 ? $bigProjectDeals / $totalDeals : 0.0;
expect($p0Share)->toBeGreaterThan(0.45,
'FINDING: big-limit project P0 (limit=300) has a share of '.
round($p0Share * 100, 1).'% which is below 45%. '.
'Expected substantially more than limit-30 projects given weights 300 vs 30. '.
'Limits: '.json_encode($limits).', Deals: '.json_encode($dealCounts)
);
// (d) Small projects each received exactly their limit (3) or fewer.
// They drop out once delivered_today == daily_limit, so max is 3.
expect($dealCounts[3])->toBeLessThanOrEqual(3,
"FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. ".
'The eligibility guard (delivered_today < daily_limit) should prevent this.'
);
expect($dealCounts[4])->toBeLessThanOrEqual(3,
"FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. ".
'The eligibility guard (delivered_today < daily_limit) should prevent this.'
);
})->group('imitation');