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