From bf48bde5ca4aef9101fe849f20c27420bfe1decb 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 13:41:42 +0300 Subject: [PATCH] =?UTF-8?q?fix(lead-router):=20R-03=20=E2=80=94=20exclude?= =?UTF-8?q?=20frozen=20tenants=20from=20eligible=20matches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 Task 3.0. Add 'AND tenants.frozen_by_balance_at IS NULL' to both EXISTS-on-tenants subqueries in matchEligibleProjects (DIRECT path + B path). Without this filter, a tenant frozen by BalancePreflightSweepJob continues to receive leads from the existing slepok, getting charged for deliveries they explicitly cannot fund. Spec §4.3.1 R-03. --- app/app/Services/LeadRouter.php | 6 +- .../Feature/LeadRouter/FrozenFilterTest.php | 152 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 app/tests/Feature/LeadRouter/FrozenFilterTest.php diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index 3da56fa8..a8febfd2 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -1,4 +1,4 @@ - 0 + -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) + AND tenants.frozen_by_balance_at IS NULL ) ORDER BY snap.tenant_id, (snap.daily_limit - projects.delivered_today) DESC, @@ -110,6 +112,8 @@ class LeadRouter SELECT 1 FROM tenants WHERE tenants.id = snap.tenant_id AND tenants.balance_rub > 0 + -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) + AND tenants.frozen_by_balance_at IS NULL ) ORDER BY snap.tenant_id, (snap.daily_limit - projects.delivered_today) DESC, diff --git a/app/tests/Feature/LeadRouter/FrozenFilterTest.php b/app/tests/Feature/LeadRouter/FrozenFilterTest.php new file mode 100644 index 00000000..615b697d --- /dev/null +++ b/app/tests/Feature/LeadRouter/FrozenFilterTest.php @@ -0,0 +1,152 @@ +create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => now(), // frozen — R-03 + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + 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' => 10, + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => null, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads +}); + +// --------------------------------------------------------------------------- +// Case 2 — DIRECT-platform: frozen tenant must NOT receive leads +// --------------------------------------------------------------------------- +it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => now(), // frozen — R-03 + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + + // DIRECT supplier_project matches via signal_type + unique_key + $sp = SupplierProject::factory()->create([ + 'platform' => 'DIRECT', + 'signal_type' => 'call', + 'unique_key' => 'direct-test-frozen-001', + ]); + + // Snapshot must carry signal_type + signal_identifier matching sp->unique_key + 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', + 'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads +}); + +// --------------------------------------------------------------------------- +// Case 3 (control) — B-platform, not frozen: MUST receive leads +// --------------------------------------------------------------------------- +it('matches B-platform project for non-frozen tenant (frozen_by_balance_at IS NULL)', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'frozen_by_balance_at' => null, // NOT frozen — should match + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + 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' => 10, + 'delivery_days_mask' => 127, + 'regions' => '{}', + 'signal_type' => 'call', + 'signal_identifier' => null, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => 10, + 'delivered_count' => 0, + 'created_at' => now(), + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(1); // control: non-frozen tenant with balance IS eligible +});