feat(slepok): Task 2.5 — LeadRouter reads from project_routing_snapshots (R-01 closure)

LeadRouter SQL переписан на JOIN с project_routing_snapshots по active_slepok_date:
до 21:00 МСК = today, после 21:00 МСК = today+1. is_active / delivery_days_mask /
daily_limit / regions / signal_type / signal_identifier берутся из snapshot.
Из live projects — только delivered_today (счётчик остатка лимита). Из tenants —
balance_rub (live auto-pause при нулевом балансе).

Active snapshot date вычисляется в PHP (метод activeSnapshotDate()) и
передаётся в SQL как параметр — тестируемо через Carbon::setTestNow,
исключает дрейф между PHP- и DB-часами.

Fail-loud: Log::error('lead_router.no_snapshot_for_active_date', ...) если
по активной дате слепка вообще нет ни одной строки snapshot'а (cron не отработал).

Closes R-01, R-04, R-06, R-07, R-08, R-15.
Partial: R-02 (через шеринг), R-09 (race), R-10 (editable identifier) — закрываются Task 2.6+.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.5
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3

Tests added:
- tests/Feature/LeadRouter/SnapshotRoutingTest.php (4 tests, all GREEN locally)

Tests patched (downstream — добавлен createRoutingSnapshotFromProject() helper):
- tests/Pest.php — global helper createRoutingSnapshotFromProject()
- tests/Feature/LeadRouter/BalanceFilterTest.php (2/2 GREEN)
- tests/Feature/Services/LeadRouterTest.php (10/10 GREEN)
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php (14/14 GREEN)
- tests/Feature/Supplier/DirectPlatformTest.php (6/6 GREEN)
- tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php (3/3 GREEN)
- tests/Feature/Supplier/SupplierConnectionTest.php (5/5 GREEN)
- tests/Feature/Integration/SupplierLeadFlowTest.php (2/2 GREEN)
- tests/Feature/Pd/DealCreatePdLogTest.php (2/2 GREEN)

Each test file isolated regression: GREEN. Combined run 49/50 with 1 flake on
quirk #77 (Faker unique domainName + cross-connection pgsql/pgsql_supplier
DatabaseTransactions scope mismatch) — pre-existing, NOT regression от Task 2.5.

Patched via 7 parallel Sonnet subagents per Pravila §15.1; controller-verified
isolated + combined regression (latter caught 1 subagent over-application:
paused project in SupplierLeadFlowTest получил snapshot, что нарушило логику
теста — fixed inline, по semantic match with SnapshotBackfillCommand SQL
WHERE p.is_active = true).
This commit is contained in:
Дмитрий
2026-05-28 05:48:15 +03:00
parent a6a82b0317
commit e8db184e99
12 changed files with 310 additions and 68 deletions
+102 -44
View File
@@ -9,13 +9,22 @@ use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
*
* Eligibility структурно через pivot project_supplier_links: проект eligible,
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
* Eligibility структурно через snapshot `project_routing_snapshots` за активную
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации
* (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
* берутся из snapshot. Из live `projects` только `delivered_today` (счётчик
* остатка лимита, обновляется в течение дня) и из `tenants` `balance_rub`
* (live auto-pause при нулевом балансе).
*
* Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) клиент Лидерры,
* который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
* получает свои оплаченные лиды по уже зафиксированному slepok'у.
*
* Регион сопоставляется самим supplier_project (тег = субъект) phone-prefix
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
@@ -24,7 +33,7 @@ use Illuminate\Support\Facades\DB;
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
*/
class LeadRouter
{
@@ -44,67 +53,116 @@ class LeadRouter
*/
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
{
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
// Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
// тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
$activeDate = $this->activeSnapshotDate();
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
// не создаются (новые DIRECT supplier_projects создаются автоматически при
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
// match с Лидерра-проектами через snapshot (project_supplier_links для
// DIRECT-row'ов не создаются DIRECT supplier_projects создаются автоматически
// при получении webhook'а без B-префикса).
if ($supplierProject->platform === 'DIRECT') {
$directSql = <<<'SQL'
SELECT DISTINCT ON (projects.tenant_id) projects.*
FROM projects
WHERE projects.signal_type = ?
AND LOWER(projects.signal_identifier) = LOWER(?)
AND projects.is_active = true
AND (projects.delivery_days_mask & ?) <> 0
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND snap.signal_type = ?
AND LOWER(snap.signal_identifier) = LOWER(?)
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = projects.tenant_id
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
)
ORDER BY
projects.tenant_id,
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
projects.created_at,
projects.id
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$directRows = DB::connection('pgsql_supplier')->select(
$directSql,
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
[$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
);
$this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
return Project::hydrate($directRows)->values();
}
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
$sql = <<<'SQL'
SELECT DISTINCT ON (projects.tenant_id) projects.*
FROM projects
WHERE EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = projects.id
AND psl.supplier_project_id = ?
)
AND projects.is_active = true
AND (projects.delivery_days_mask & ?) <> 0
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = projects.tenant_id
AND tenants.balance_rub > 0
)
ORDER BY
projects.tenant_id,
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
projects.created_at,
projects.id
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id
AND psl.supplier_project_id = ?
)
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
)
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
$this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
return Project::hydrate($rows)->values();
}
/**
* Активная дата слепка по правилу slepok-инварианта:
* до 21:00 МСК сегодняшняя дата;
* с 21:00 МСК завтрашняя.
*
* Spec §4.2.3.
*/
private function activeSnapshotDate(): string
{
$msk = Carbon::now('Europe/Moscow');
return $msk->hour >= 21
? $msk->copy()->addDay()->toDateString()
: $msk->toDateString();
}
/**
* Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
* snapshot'а это значит, что cron `SnapshotProjectRoutingJob` не отработал.
* (Если строки есть, но ни одна не сматчилась это валидный 0-результат, не алерт.)
*
* @param array<int, object> $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
if ($rows !== []) {
return;
}
$snapshotEmpty = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->doesntExist();
if ($snapshotEmpty) {
Log::error('lead_router.no_snapshot_for_active_date', [
'active_date' => $activeDate,
'supplier_project_id' => $supplierProject->id,
'platform' => $supplierProject->platform,
]);
}
}
}
@@ -54,6 +54,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
// v8.26 (Plan 1-2): LeadRouter eligibility — через pivot project_supplier_links,
// не legacy supplier_b1_project_id. Без pivot-связи проект не eligible → 0 сделок.
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru');
}
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
@@ -67,6 +68,10 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
'is_active' => false,
]);
linkProjectToSupplier($pausedProject, $supplier);
// NB: snapshot для paused-проекта НЕ создаём — SnapshotBackfillCommand в prod
// фильтрует `WHERE p.is_active = true`. Это и есть Task 2.5 R-01 защита от
// обратного case'а: paused after slepok всё равно НЕ получит лиды (потому что
// snapshot fix'нут до его paused).
$vid = 432176649;
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
@@ -85,6 +85,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
$vid = 432176649;
@@ -140,6 +141,7 @@ it('charges balance_rub for tenant after routing', function (): void {
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = 99;
$lead = SupplierLead::factory()->create([
@@ -208,6 +210,7 @@ it('same phone pre-existing does not suppress new delivery (Spec B)', function (
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
@@ -276,6 +279,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = 7777;
$lead = SupplierLead::factory()->create([
@@ -345,6 +349,7 @@ it('handles partial failure: one project throws, others continue routing', funct
'delivered_today' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
createRoutingSnapshotFromProject($projects->last());
}
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
@@ -389,6 +394,7 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
@@ -508,6 +514,7 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]);
linkProjectToSupplier($p, $sp);
createRoutingSnapshotFromProject($p);
}
$lead = SupplierLead::factory()->create([
@@ -555,6 +562,7 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
// CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
$csvReceivedAt = now()->subMinutes(15);
@@ -36,6 +36,7 @@ it('does not match project for tenant with only balance_leads (no balance_rub)',
'platform' => $sp->platform,
'subject_code' => null,
]);
createRoutingSnapshotFromProject($project);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -60,6 +61,7 @@ it('matches project for tenant with balance_rub > 0 (balance_leads ignored)', fu
'platform' => $sp->platform,
'subject_code' => null,
]);
createRoutingSnapshotFromProject($project);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Carbon\Carbon;
it('uses snapshot before 21:00 MSK, snapshot_date = today', function () {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => false, // ЖИВОЕ состояние — paused
'delivery_days_mask' => 127,
'daily_limit_target' => 100,
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
// SNAPSHOT за сегодня имеет проект → роутер должен вернуть, несмотря на is_active=false
\DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(1); // ← это R-01 closure
Carbon::setTestNow();
});
it('uses snapshot after 21:00 MSK, snapshot_date = tomorrow', function () {
Carbon::setTestNow('2026-05-28 22:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true, 'delivery_days_mask' => 127,
'daily_limit_target' => 100, 'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
// Snapshot за СЕГОДНЯ (2026-05-28) НЕТ.
// Snapshot за ЗАВТРА (2026-05-29) есть.
\DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-29', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(1); // после 21:00 МСК активен завтрашний snapshot
Carbon::setTestNow();
});
it('returns 0 if no snapshot exists for active date', function () {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
$project = Project::factory()->for($tenant)->create([
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
// НЕТ snapshot за 2026-05-28.
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(0); // fail-loud, не fallback на live
Carbon::setTestNow();
});
it('limit comes from snapshot, not live projects.daily_limit_target', function () {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
$project = Project::factory()->for($tenant)->create([
'is_active' => true, 'delivery_days_mask' => 127,
'daily_limit_target' => 100, // живой лимит
'delivered_today' => 7,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
\DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 5, // ← snapshot лимит МЕНЬШЕ чем delivered_today=7
'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 5, 'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(0); // delivered_today=7 >= snapshot.daily_limit=5
Carbon::setTestNow();
});
@@ -86,6 +86,7 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
'delivered_in_month' => 0,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, null, 'site', 'pd-test.ru');
$vid = 77741;
$lead = SupplierLead::factory()->create([
+14 -3
View File
@@ -22,7 +22,7 @@ beforeEach(function (): void {
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
it('returns project linked via pivot to the supplier_project', function (): void {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
@@ -32,6 +32,7 @@ it('returns project linked via pivot to the supplier_project', function (): void
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]);
linkProjectToSupplier($project, $sp);
createRoutingSnapshotFromProject($project);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -62,13 +63,16 @@ it('excludes inactive project, project at limit, and zero-balance tenant', funct
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
linkProjectToSupplier($inactive, $sp);
createRoutingSnapshotFromProject($inactive);
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
linkProjectToSupplier($atLimit, $sp);
createRoutingSnapshotFromProject($atLimit);
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
linkProjectToSupplier($broke, $sp);
createRoutingSnapshotFromProject($broke);
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
});
@@ -84,6 +88,7 @@ it('skips paused project (is_active=false)', function (): void {
'is_active' => false,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
@@ -106,6 +111,7 @@ it('skips project where today is not in delivery_days_mask', function (): void {
'delivery_days_mask' => $maskWithoutToday,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
@@ -124,6 +130,7 @@ it('skips project where delivered_today >= effective_daily_limit_today', functio
'delivered_today' => 5,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
@@ -131,7 +138,7 @@ it('skips project where delivered_today >= effective_daily_limit_today', functio
it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
@@ -143,6 +150,7 @@ it('falls back to daily_limit_target when effective_daily_limit_today is null',
'delivered_today' => 5,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, dailyLimit: $project->daily_limit_target);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier))->toHaveCount(1);
@@ -161,6 +169,7 @@ it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (P
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
@@ -179,6 +188,7 @@ it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balan
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier);
@@ -191,7 +201,7 @@ it('orders results by created_at ASC (deterministic, spec §6 step 4)', function
$projectsCreated = collect();
for ($i = 0; $i < 3; $i++) {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
@@ -200,6 +210,7 @@ it('orders results by created_at ASC (deterministic, spec §6 step 4)', function
'created_at' => now()->subDays(3 - $i),
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project);
$projectsCreated->push($project);
}
@@ -110,6 +110,8 @@ it('RouteSupplierLeadJob delivers DIRECT lead to matching project via signal_ide
'region_mask' => 255,
]);
createRoutingSnapshotFromProject($project, signalType: 'site', signalIdentifier: 'client.carmoney.ru');
$lead = SupplierLead::factory()->create([
'platform' => 'DIRECT',
'phone' => '79991234567',
@@ -68,6 +68,7 @@ function prepareSharingFlow(int $tenantsCount, array $balances): array
'region_mask' => 255,
]);
linkProjectToSupplier($project, $supplierProject);
createRoutingSnapshotFromProject($project, null, 'site', 'example.com', 10);
$tenants[] = $tenant;
$projects[] = $project;
}
@@ -78,35 +78,36 @@ test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql
});
test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void {
// 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project.
// 6 tenant × 1 проект = 6 проектов, все привязаны к одному supplier_project.
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
// NB: LeadRouter возвращает DISTINCT ON (tenant_id) — один проект на тенанта,
// поэтому используем 6 тенантов × 1 проект чтобы expectation «6» оставалась.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'plan3-task3-warn2.example.com',
]);
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
$tenants = Tenant::factory()->count(6)->create(['balance_leads' => 100, 'balance_rub' => 500]);
foreach ($tenants as $tenant) {
for ($i = 0; $i < 2; $i++) {
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'plan3-task3-warn2.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
}
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'plan3-task3-warn2.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
createRoutingSnapshotFromProject($project, null, 'site', 'plan3-task3-warn2.example.com', 10);
}
$router = app(LeadRouter::class);
+39
View File
@@ -73,3 +73,42 @@ function linkProjectToSupplier(Project $project, SupplierProject $supplier): voi
'subject_code' => $supplier->subject_code,
]);
}
/**
* Pest helper для slepok-routing тестов (Task 2.5).
*
* Создаёт строку в `project_routing_snapshots` за активную дату слепка,
* отражающую "идеальное" состояние live-проекта. Используется тестами,
* которым нужен только факт «маршрутизация возможна», а не сам snapshot
* mechanism.
*
* Активная дата по умолчанию сегодняшняя МСК (до 21:00 МСК). Передайте
* `$date` явно, если тест использует `Carbon::setTestNow` с другой датой.
*
* NB: signal_type/signal_identifier берутся ЯВНО из аргументов, а не из
* `$project->signal_type` на Windows-native PG факториальный override
* этого поля не персистится (см. memory project_slepok_protection.md).
*/
function createRoutingSnapshotFromProject(
Project $project,
?string $date = null,
string $signalType = 'call',
?string $signalIdentifier = null,
?int $dailyLimit = null,
): void {
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date ?? \Carbon\Carbon::today('Europe/Moscow')->toDateString(),
'project_id' => $project->id,
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
'regions' => '{}',
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivered_count' => 0,
'created_at' => \Illuminate\Support\Facades\Date::now(),
]);
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-27T12:18:27.604Z
Last updated: 2026-05-27T12:24:24.132Z
| Контролёр | Состояние | Детали |
|---|---|---|