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

265 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 Carbon\Carbon;
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 D — delivery_days_mask filter verification.
*
* VERIFICATION test against existing production routing code.
* Proves (or disproves) that the delivery_days_mask filter correctly excludes
* projects from the snapshot when today's weekday bit is NOT set.
* NOT TDD — no prod code is modified. Differences vs plan → FINDINGS.
*
* Weekday-bit convention (confirmed from SnapshotProjectRoutingJob + SnapshotRebuildCommand):
* weekdayBit = 1 << (date->isoWeekday() - 1)
* isoWeekday(): Monday=1, Tuesday=2, ..., Sunday=7
* → Monday = 1<<0 = 1
* → Tuesday = 1<<1 = 2
* → ...
* → Sunday = 1<<6 = 64
* → Full week mask = 127 (bits 0-6 all set)
*
* SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate():
* - Before 21:00 MSK → today's date
* - From 21:00 MSK → tomorrow's date
* snapshot:rebuild filters by that date's isoWeekday bit.
*
* Setup:
* - One shared SupplierProject (B1 site signal).
* - Two tenants / projects on that supplier.
* - ACTIVE client: delivery_days_mask = 127 (all days — includes any day).
* - INACTIVE client: delivery_days_mask = full-week MINUS today's bit (excludes active date).
* - Both tenants have ample balance and no freeze.
* - SnapshotForge::rebuild() called AFTER masks are set.
* - One lead injected.
*
* Expected (per plan §6.2 D):
* - ACTIVE client receives the deal.
* - INACTIVE client receives ZERO deals (not in snapshot → invisible to LeadRouter).
*
* Subject code: Москва = 82 (порядковый, НЕ ГИБДД).
*
* Task 8 — Phase 1 Portal Client Imitation, Scenario D.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 D
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 8
*/
// ── SUBJECT CODE ──────────────────────────────────────────────────────────────
/** App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const D_MOSCOW_CODE = 82;
// ── SUPPLIER SIGNAL ───────────────────────────────────────────────────────────
const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru';
const D_SUPPLIER_PLATFORM = 'B1';
// ── DETERMINISTIC SEED ────────────────────────────────────────────────────────
const D_SEED = 19;
// ── PHONE NUMBERS ─────────────────────────────────────────────────────────────
/** Phone resolving to Москва via FakeDaDataPhoneClient. */
const D_PHONE_1 = '79270000099';
beforeEach(function (): void {
// 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 — FakeDaDataPhoneClient bypasses HTTP entirely.
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 (small cap — only 2 projects, so no real lottery needed,
// but we seed for reproducibility).
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(D_SEED))));
});
it('only delivers to the active-today client; the excluded-day client gets zero deals', function (): void {
// ── DETERMINE ACTIVE DATE AND ITS WEEKDAY BIT ────────────────────────────
//
// SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate().
// SnapshotRebuildCommand: weekdayBit = 1 << (date->isoWeekday() - 1).
// isoWeekday(): Monday=1 .. Sunday=7.
//
// We MUST compute the bit from the active-snapshot date (not wall-clock today),
// because after 21:00 MSK the active date flips to tomorrow.
$activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD'
$activeDateObj = Carbon::parse($activeDate, 'Europe/Moscow');
$todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4
// ACTIVE mask: full week (includes every day, including today).
$activeMask = 127; // 0b1111111
// INACTIVE mask: full week MINUS today's bit → excludes the active snapshot date.
// snapshot:rebuild WHERE (delivery_days_mask & weekdayBit) <> 0 will skip this project.
$inactiveMask = 127 & ~$todayBit; // clears the bit for the active date's weekday
// Sanity: active mask passes the filter, inactive mask does not.
expect(($activeMask & $todayBit) !== 0)->toBeTrue();
expect(($inactiveMask & $todayBit))->toBe(0);
// ── ARRANGE: SHARED SUPPLIER PROJECT ─────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => D_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => D_SUPPLIER_DOMAIN,
]);
// ── ARRANGE: ACTIVE TENANT / PROJECT ─────────────────────────────────────
$tenantActive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectActive = Project::factory()->create([
'tenant_id' => $tenantActive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $activeMask, // all days — included today
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectActive, $supplier);
// ── ARRANGE: INACTIVE TENANT / PROJECT ───────────────────────────────────
$tenantInactive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectInactive = Project::factory()->create([
'tenant_id' => $tenantInactive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectInactive, $supplier);
// ── REBUILD SNAPSHOT AFTER MASKS ARE SET ─────────────────────────────────
// SnapshotRebuildCommand: WHERE (delivery_days_mask & weekdayBit) <> 0
// → projectActive IS inserted (bit set)
// → projectInactive NOT inserted (bit cleared)
SnapshotForge::rebuild();
// Verify snapshot state directly: active project is in snapshot, inactive is not.
$snapshotActiveExists = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->where('project_id', $projectActive->id)
->exists();
$snapshotInactiveExists = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->where('project_id', $projectInactive->id)
->exists();
expect($snapshotActiveExists)->toBeTrue(
"FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) ".
"was expected in the snapshot for {$activeDate} but is absent. ".
'SnapshotRebuildCommand may have a bug in delivery_days_mask filtering.'
);
expect($snapshotInactiveExists)->toBeFalse(
"FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) ".
"was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. ".
'This means the delivery_days_mask filter is not working — the inactive project '.
'will receive leads it should not receive.'
);
// ── ARRANGE: FAKE DADATA CLIENT ──────────────────────────────────────────
$fake = (new FakeDaDataPhoneClient)->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
// ── ACT: INJECT ONE LEAD ─────────────────────────────────────────────────
(new LeadInjector)->site(
domain: D_SUPPLIER_DOMAIN,
phone: D_PHONE_1,
tag: 'Москва',
platform: D_SUPPLIER_PLATFORM,
vid: 88_000_000_001,
);
// ── ASSERT: DEALS DISTRIBUTION ───────────────────────────────────────────
// Active client MUST receive exactly 1 deal.
$activeDeals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantActive->id)
->count();
// Inactive client MUST receive 0 deals (not in snapshot).
$inactiveDeals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantInactive->id)
->count();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL.'=== SCENARIO D: DELIVERY DAYS FILTER REPORT ==='.PHP_EOL);
fwrite(STDOUT, sprintf('Active date: %s (isoWeekday=%d)%s',
$activeDate, $activeDateObj->isoWeekday(), PHP_EOL));
fwrite(STDOUT, sprintf('Today bit: %d (0b%s)%s',
$todayBit, str_pad(decbin($todayBit), 7, '0', STR_PAD_LEFT), PHP_EOL));
fwrite(STDOUT, sprintf('Active mask: %d (0b%s) → snapshot: %s%s',
$activeMask, str_pad(decbin($activeMask), 7, '0', STR_PAD_LEFT),
$snapshotActiveExists ? 'INCLUDED' : 'MISSING', PHP_EOL));
fwrite(STDOUT, sprintf('Inactive mask: %d (0b%s) → snapshot: %s%s',
$inactiveMask, str_pad(decbin($inactiveMask), 7, '0', STR_PAD_LEFT),
$snapshotInactiveExists ? 'PRESENT (BUG!)' : 'ABSENT (correct)', PHP_EOL));
fwrite(STDOUT, sprintf('Active client deals: %d (expected: 1)%s', $activeDeals, PHP_EOL));
fwrite(STDOUT, sprintf('Inactive client deals: %d (expected: 0)%s', $inactiveDeals, PHP_EOL));
fwrite(STDOUT, '=== END SCENARIO D ==='.PHP_EOL.PHP_EOL);
expect($activeDeals)->toBe(1,
"FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) ".
"expected 1 deal but got {$activeDeals}. ".
'Check LeadRouter eligibility query or LedgerService::chargeForDelivery.'
);
expect($inactiveDeals)->toBe(0,
"FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, ".
"project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. ".
'The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. '.
"This is a correctness bug: clients with today's bit cleared MUST be absent from the snapshot."
);
})->group('imitation');