fix(lead-router): R-03 — exclude frozen tenants from eligible matches

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.
This commit is contained in:
Дмитрий
2026-05-28 13:41:42 +03:00
parent ff18acc5e7
commit bf48bde5ca
2 changed files with 157 additions and 1 deletions
+5 -1
View File
@@ -1,4 +1,4 @@
<?php
<?php
declare(strict_types=1);
@@ -76,6 +76,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,
@@ -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,
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow'); // pre-21:00 MSK window
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
afterEach(function (): void {
Carbon::setTestNow();
});
// ---------------------------------------------------------------------------
// Case 1 — B-platform: frozen tenant must NOT receive leads (R-03 §4.3.1)
// ---------------------------------------------------------------------------
it('does not match B-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,
]);
$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
});