create(['balance_rub' => '1000.00']); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); Project::factory()->for($tenant)->create(['is_active' => false, 'daily_limit_target' => 100]); // не считается expect($tenant->requiredLeadsForTomorrow())->toBe(25); }); it('casts frozen_by_balance_at to datetime', function () { $tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]); expect($tenant->frozen_by_balance_at)->toBeInstanceOf(Carbon::class); }); it('casts project preflight_blocked_at to datetime', function () { $project = Project::factory()->create(['preflight_blocked_at' => now()]); expect($project->preflight_blocked_at)->toBeInstanceOf(Carbon::class); }); // --------------------------------------------------------------------------- // Stage 4 / Task 4.4 — R-19 (spec §4.4.3): share-aware requiredLeadsForTomorrow. // Before fix: simple SUM(daily_limit_target). Overcharges preflight when a tenant // shares a call/site signal with other tenants — supplier order is capped at // max(max(limits), ceil(Σ/3)) and split proportionally, so a single tenant's // share is typically much smaller than its raw limit. // Formula per project: // group_limits = limits of all is_active projects sharing the same // (signal_type, agnostic signal — phone/domain/sms-sender+keyword) // group_order = max(max(group_limits), ceil(Σ group_limits / 3)) // tenant_share = ceil(group_order × (project_limit / Σ group_limits)) // Legacy projects (signal_type=null — webhook-only, no supplier share) → full limit. // --------------------------------------------------------------------------- it('R-19 single call project (no sharing) — returns full daily_limit_target', function () { $phone = '7919'.Str::random(7); // unique per run to dodge any pre-existing leakage $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); Project::factory()->for($tenant)->asCallSignal($phone)->create([ 'is_active' => true, 'daily_limit_target' => 10, ]); // groupLimits = [10] (only this project) → sum=10, max=10, order=max(10, ceil(10/3))=10, // share = ceil(10 × 10/10) = 10. Same as legacy. expect($tenant->fresh()->requiredLeadsForTomorrow())->toBe(10); }); it('R-19 3 tenants sharing same call source — each tenant gets proportional share, not full limit', function () { $sharedPhone = '7929'.Str::random(7); // unique shared identifier per run // 3 tenants, same call source $sharedPhone, each daily_limit_target=10. // group_order = max(max([10,10,10]), ceil(30/3)) = max(10, 10) = 10. // share per tenant = ceil(10 × 10/30) = ceil(3.33) = 4. // Legacy formula would give 10 (4 vs 10 = the bug R-19 fixes). $tenants = []; foreach (range(1, 3) as $i) { $t = Tenant::factory()->create(['balance_rub' => '1000.00']); Project::factory()->for($t)->asCallSignal($sharedPhone)->create([ 'is_active' => true, 'daily_limit_target' => 10, ]); $tenants[] = $t; } expect($tenants[0]->fresh()->requiredLeadsForTomorrow())->toBe(4); }); it('R-19 legacy webhook projects (signal_type=null) — still summed as full limit (no shared group)', function () { // Regression-protection for existing TenantPreflightTest behavior. // Webhook-only projects don't participate in supplier sharing — their full limit counts. $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]); Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); expect($tenant->fresh()->requiredLeadsForTomorrow())->toBe(25); });