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