2026-06-03 19:37:49 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
|
use Database\Seeders\PricingTierSeeder;
|
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
|
use Illuminate\Support\Facades\Artisan;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
use Tests\Support\Imitation\ImitationClientsSeeder;
|
|
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Schema bootstrap for the liderra_testing DB (idempotent, runs per-test).
|
|
|
|
|
|
*
|
|
|
|
|
|
* ─── WHY THIS EXISTS ────────────────────────────────────────────────────────
|
|
|
|
|
|
* `php artisan migrate:fresh` wraps each migration in a Laravel DB transaction.
|
|
|
|
|
|
* The initial migration calls `Artisan::call('partitions:create-months')` which
|
|
|
|
|
|
* opens a NEW pgsql_supplier connection. A new connection cannot see the
|
|
|
|
|
|
* uncommitted changes of the initial migration's pgsql connection in PostgreSQL
|
|
|
|
|
|
* READ COMMITTED isolation. Result: "table does not exist" when creating partitions.
|
|
|
|
|
|
*
|
|
|
|
|
|
* In addition, MonthlyPartitionManager::PARTITIONED_TABLES includes tables
|
|
|
|
|
|
* (project_routing_snapshots, lead_region_resolution_log) that are NOT in
|
|
|
|
|
|
* schema.sql but in later delta migrations — so partitions:create-months also
|
|
|
|
|
|
* fails because those parent tables don't exist yet.
|
|
|
|
|
|
*
|
|
|
|
|
|
* ─── FIX ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
* In beforeEach() (after SharesSupplierPdo has made pgsql_supplier share the
|
|
|
|
|
|
* same PDO as pgsql), we:
|
|
|
|
|
|
* 1. Check if the schema is already loaded (fast no-op if so).
|
|
|
|
|
|
* 2. If not: load schema.sql, then manually create the two delta-migration
|
|
|
|
|
|
* tables that partitions:create-months needs.
|
|
|
|
|
|
* 3. Then call Artisan::call('partitions:create-months') — with shared PDO,
|
|
|
|
|
|
* pgsql_supplier sees our in-transaction DDL.
|
|
|
|
|
|
* 4. Mark remaining delta migrations as "ran" so migrate won't re-run them.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Since DatabaseTransactions rolls back at test end, this is ephemeral.
|
|
|
|
|
|
* If liderra_testing was externally migrated the schema already exists →
|
|
|
|
|
|
* we skip and proceed directly.
|
|
|
|
|
|
*/
|
|
|
|
|
|
beforeEach(function (): void {
|
|
|
|
|
|
// Fast path: if tenants table exists the DB is already set up — skip setup.
|
|
|
|
|
|
$hasTenants = DB::selectOne(
|
|
|
|
|
|
"SELECT 1 FROM information_schema.tables
|
|
|
|
|
|
WHERE table_schema = 'public' AND table_name = 'tenants'"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if ($hasTenants === null) {
|
|
|
|
|
|
$repoRoot = dirname(base_path());
|
|
|
|
|
|
|
|
|
|
|
|
// Step 1: Load the base schema.sql (creates most tables, triggers, RLS, etc.)
|
2026-06-18 19:33:33 +03:00
|
|
|
|
$schemaPath = $repoRoot.DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
2026-06-03 19:37:49 +03:00
|
|
|
|
if (! is_readable($schemaPath)) {
|
|
|
|
|
|
throw new RuntimeException("schema.sql not found: {$schemaPath}");
|
|
|
|
|
|
}
|
|
|
|
|
|
DB::unprepared((string) file_get_contents($schemaPath));
|
|
|
|
|
|
|
|
|
|
|
|
// Step 2: Fix the webhook_dedup_keys FK that PDO sometimes swallows.
|
|
|
|
|
|
try {
|
|
|
|
|
|
DB::statement(<<<'SQL'
|
|
|
|
|
|
ALTER TABLE webhook_dedup_keys
|
|
|
|
|
|
ADD FOREIGN KEY (deal_id, deal_received_at)
|
|
|
|
|
|
REFERENCES deals (id, received_at)
|
|
|
|
|
|
ON DELETE CASCADE
|
|
|
|
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
|
|
|
|
SQL);
|
2026-06-18 19:33:33 +03:00
|
|
|
|
} catch (Throwable) { /* already exists — idempotent */
|
|
|
|
|
|
}
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
// Step 3: Create tables that are in PARTITIONED_TABLES but NOT in schema.sql
|
|
|
|
|
|
// (they were added via delta migrations). partitions:create-months needs them.
|
|
|
|
|
|
|
|
|
|
|
|
// 3a. project_routing_snapshots (delta migration 2026_05_27_120000)
|
|
|
|
|
|
DB::unprepared(<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS project_routing_snapshots (
|
|
|
|
|
|
snapshot_date DATE NOT NULL,
|
|
|
|
|
|
project_id BIGINT NOT NULL,
|
|
|
|
|
|
tenant_id BIGINT NOT NULL,
|
|
|
|
|
|
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
|
|
|
|
|
|
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
|
|
|
|
|
|
regions INT[] NOT NULL DEFAULT '{}',
|
|
|
|
|
|
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
|
|
|
|
|
|
signal_identifier TEXT,
|
|
|
|
|
|
sms_senders JSONB,
|
|
|
|
|
|
sms_keyword TEXT,
|
|
|
|
|
|
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
|
|
|
|
|
|
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
|
|
|
|
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
|
|
|
|
PRIMARY KEY (snapshot_date, project_id),
|
|
|
|
|
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
|
|
|
|
) PARTITION BY RANGE (snapshot_date)
|
|
|
|
|
|
SQL);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
DB::statement(
|
|
|
|
|
|
'CREATE INDEX project_routing_snapshots_tenant_date_idx
|
|
|
|
|
|
ON project_routing_snapshots (tenant_id, snapshot_date)'
|
|
|
|
|
|
);
|
2026-06-18 19:33:33 +03:00
|
|
|
|
} catch (Throwable) { /* idempotent */
|
|
|
|
|
|
}
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
DB::statement('ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY');
|
|
|
|
|
|
DB::statement(
|
|
|
|
|
|
"CREATE POLICY project_routing_snapshots_tenant_isolation
|
|
|
|
|
|
ON project_routing_snapshots
|
|
|
|
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)"
|
|
|
|
|
|
);
|
2026-06-18 19:33:33 +03:00
|
|
|
|
} catch (Throwable) { /* idempotent */
|
|
|
|
|
|
}
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
// 3b. phone_ranges_imports + phone_ranges + lead_region_resolution_log
|
|
|
|
|
|
// (delta migration 2026_05_31_100000)
|
|
|
|
|
|
DB::unprepared(<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS phone_ranges_imports (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
|
|
source_url TEXT NOT NULL,
|
|
|
|
|
|
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
rows_updated INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
checksum_sha256 TEXT NOT NULL,
|
|
|
|
|
|
status TEXT NOT NULL DEFAULT 'in_progress'
|
|
|
|
|
|
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
|
|
|
|
|
error TEXT,
|
|
|
|
|
|
completed_at TIMESTAMPTZ
|
|
|
|
|
|
)
|
|
|
|
|
|
SQL);
|
|
|
|
|
|
|
|
|
|
|
|
DB::unprepared(<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS phone_ranges (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
def_code SMALLINT NOT NULL,
|
|
|
|
|
|
from_num BIGINT NOT NULL,
|
|
|
|
|
|
to_num BIGINT NOT NULL,
|
|
|
|
|
|
operator TEXT NOT NULL,
|
|
|
|
|
|
region TEXT NOT NULL,
|
|
|
|
|
|
region_normalized TEXT,
|
|
|
|
|
|
subject_code SMALLINT,
|
|
|
|
|
|
imported_at TIMESTAMPTZ NOT NULL,
|
|
|
|
|
|
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
|
|
|
|
|
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
|
|
|
|
|
CONSTRAINT chk_phone_ranges_subject_code
|
|
|
|
|
|
CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
|
|
|
|
|
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
|
|
|
|
|
)
|
|
|
|
|
|
SQL);
|
|
|
|
|
|
|
|
|
|
|
|
DB::unprepared(<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS lead_region_resolution_log (
|
|
|
|
|
|
id BIGSERIAL,
|
|
|
|
|
|
supplier_lead_id BIGINT NOT NULL,
|
|
|
|
|
|
received_at TIMESTAMPTZ NOT NULL,
|
|
|
|
|
|
phone_masked TEXT NOT NULL,
|
|
|
|
|
|
subject_code_resolved SMALLINT,
|
|
|
|
|
|
subject_code_from_tag SMALLINT,
|
|
|
|
|
|
region_source TEXT NOT NULL
|
|
|
|
|
|
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
|
|
|
|
|
dadata_qc SMALLINT,
|
|
|
|
|
|
dadata_provider TEXT,
|
|
|
|
|
|
dadata_type TEXT,
|
|
|
|
|
|
dadata_response_masked JSONB,
|
|
|
|
|
|
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
|
|
|
|
|
actual_subject_code SMALLINT
|
|
|
|
|
|
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
|
|
|
|
|
substituted_subject_code SMALLINT
|
|
|
|
|
|
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
|
|
|
|
|
routing_step SMALLINT,
|
|
|
|
|
|
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
|
|
|
|
|
duration_ms INTEGER,
|
|
|
|
|
|
error TEXT,
|
|
|
|
|
|
PRIMARY KEY (id, received_at)
|
|
|
|
|
|
) PARTITION BY RANGE (received_at)
|
|
|
|
|
|
SQL);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
DB::statement('ALTER TABLE lead_region_resolution_log ENABLE ROW LEVEL SECURITY');
|
2026-06-18 19:33:33 +03:00
|
|
|
|
} catch (Throwable) { /* idempotent */
|
|
|
|
|
|
}
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
// Step 4: Create month partitions — with shared PDO, pgsql_supplier sees
|
|
|
|
|
|
// all the tables we just created within the same transaction.
|
|
|
|
|
|
Artisan::call('partitions:create-months', ['--ahead' => 2]);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 5: Mark the delta migrations as "ran" so they don't re-run if
|
|
|
|
|
|
// someone calls Artisan::call('migrate') later in the test suite.
|
|
|
|
|
|
$deltaRan = [
|
|
|
|
|
|
'2026_05_27_120000_create_project_routing_snapshots_table',
|
|
|
|
|
|
'2026_05_31_100000_create_phone_ranges_and_resolution_log',
|
|
|
|
|
|
];
|
|
|
|
|
|
foreach ($deltaRan as $migration) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
DB::table('migrations')->updateOrInsert(
|
|
|
|
|
|
['migration' => $migration],
|
|
|
|
|
|
['batch' => 1],
|
|
|
|
|
|
);
|
2026-06-18 19:33:33 +03:00
|
|
|
|
} catch (Throwable) { /* ok if migrations table doesn't exist */
|
|
|
|
|
|
}
|
2026-06-03 19:37:49 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
|
2026-06-18 19:33:33 +03:00
|
|
|
|
(new PricingTierSeeder)->run();
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
// Allow cross-tenant reads during seeding.
|
|
|
|
|
|
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Task 4 — ImitationClientsSeeder: single-project matrix (36 rows).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Matrix axes:
|
|
|
|
|
|
* signal ∈ {site, call} — 2
|
|
|
|
|
|
* regions ∈ {[], [82], [82,83]} — 3 (empty=all-RF, [82]=Moscow, [82,83]=Moscow+SPb)
|
|
|
|
|
|
* days ∈ {127 (7 days), 31 (Mon-Fri)} — 2
|
|
|
|
|
|
* limit ∈ {3, 30, 300} — 3
|
|
|
|
|
|
* Total: 2 × 3 × 2 × 3 = 36
|
|
|
|
|
|
*
|
|
|
|
|
|
* All project names are prefixed `IMIT-single-`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('seeds the single-project matrix', function (): void {
|
2026-06-18 19:33:33 +03:00
|
|
|
|
(new ImitationClientsSeeder)->run();
|
2026-06-03 19:37:49 +03:00
|
|
|
|
|
|
|
|
|
|
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
|
|
|
|
|
|
})->group('imitation');
|