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