From e8db184e992bc2d6584f231063dff047dad50685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 28 May 2026 05:48:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(slepok):=20Task=202.5=20=E2=80=94=20LeadRo?= =?UTF-8?q?uter=20reads=20from=20project=5Frouting=5Fsnapshots=20(R-01=20c?= =?UTF-8?q?losure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app/app/Services/LeadRouter.php | 146 ++++++++++++------ .../Integration/SupplierLeadFlowTest.php | 5 + .../Feature/Jobs/RouteSupplierLeadJobTest.php | 8 + .../Feature/LeadRouter/BalanceFilterTest.php | 2 + .../LeadRouter/SnapshotRoutingTest.php | 114 ++++++++++++++ app/tests/Feature/Pd/DealCreatePdLogTest.php | 1 + app/tests/Feature/Services/LeadRouterTest.php | 17 +- .../Feature/Supplier/DirectPlatformTest.php | 2 + .../RouteSupplierLeadJobBillingTest.php | 1 + .../Supplier/SupplierConnectionTest.php | 41 ++--- app/tests/Pest.php | 39 +++++ docs/observer/STATUS.md | 2 +- 12 files changed, 310 insertions(+), 68 deletions(-) create mode 100644 app/tests/Feature/LeadRouter/SnapshotRoutingTest.php diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index f67ed61a..3da56fa8 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -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 $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, + ]); + } + } } diff --git a/app/tests/Feature/Integration/SupplierLeadFlowTest.php b/app/tests/Feature/Integration/SupplierLeadFlowTest.php index 28a36320..f3e13250 100644 --- a/app/tests/Feature/Integration/SupplierLeadFlowTest.php +++ b/app/tests/Feature/Integration/SupplierLeadFlowTest.php @@ -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', [ diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php index 669b1dd5..26f4420b 100644 --- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php @@ -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); diff --git a/app/tests/Feature/LeadRouter/BalanceFilterTest.php b/app/tests/Feature/LeadRouter/BalanceFilterTest.php index d486624b..4ff415cb 100644 --- a/app/tests/Feature/LeadRouter/BalanceFilterTest.php +++ b/app/tests/Feature/LeadRouter/BalanceFilterTest.php @@ -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); diff --git a/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php b/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php new file mode 100644 index 00000000..66c2f9ef --- /dev/null +++ b/app/tests/Feature/LeadRouter/SnapshotRoutingTest.php @@ -0,0 +1,114 @@ +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(); +}); diff --git a/app/tests/Feature/Pd/DealCreatePdLogTest.php b/app/tests/Feature/Pd/DealCreatePdLogTest.php index b7a193aa..5915f24b 100644 --- a/app/tests/Feature/Pd/DealCreatePdLogTest.php +++ b/app/tests/Feature/Pd/DealCreatePdLogTest.php @@ -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([ diff --git a/app/tests/Feature/Services/LeadRouterTest.php b/app/tests/Feature/Services/LeadRouterTest.php index a54747bb..60a9103e 100644 --- a/app/tests/Feature/Services/LeadRouterTest.php +++ b/app/tests/Feature/Services/LeadRouterTest.php @@ -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); } diff --git a/app/tests/Feature/Supplier/DirectPlatformTest.php b/app/tests/Feature/Supplier/DirectPlatformTest.php index 1b08fe5c..070159d6 100644 --- a/app/tests/Feature/Supplier/DirectPlatformTest.php +++ b/app/tests/Feature/Supplier/DirectPlatformTest.php @@ -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', diff --git a/app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php b/app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php index 23e1a480..3bef2aba 100644 --- a/app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php +++ b/app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php @@ -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; } diff --git a/app/tests/Feature/Supplier/SupplierConnectionTest.php b/app/tests/Feature/Supplier/SupplierConnectionTest.php index cb3ce22d..703e1546 100644 --- a/app/tests/Feature/Supplier/SupplierConnectionTest.php +++ b/app/tests/Feature/Supplier/SupplierConnectionTest.php @@ -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); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index 86715d44..a62703e4 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -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(), + ]); +} diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index b74ba18b..e6cc6cca 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-27T12:18:27.604Z +Last updated: 2026-05-27T12:24:24.132Z | Контролёр | Состояние | Детали | |---|---|---|