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:
+102
-44
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-27T12:18:27.604Z
|
||||
Last updated: 2026-05-27T12:24:24.132Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user