f6072b2885
Tenant::requiredLeadsForTomorrow() previously summed raw daily_limit_target of
active projects, overcharging preflight when a tenant shared a call/site signal
with other tenants. Supplier caps the group at max(max(limits), ceil(Σ/3)) and
splits it across all clients on the same signal_identifier, so a single tenant's
real share is typically much smaller than its raw limit.
group_limits = limits of all is_active projects sharing
(signal_type, agnostic signal_identifier/sms_sender+keyword)
group_order = max(max(group_limits), ceil(Σ group_limits / 3))
tenant_share = ceil(group_order × (project_limit / Σ group_limits))
Legacy webhook projects (signal_type=null — no supplier sharing) still count
their full limit (regression-protected by existing 'sums daily_limit_target' test).
Empty groupLimits edge → conservative full-limit fallback (cross-conn race).
3 Pest tests: single project (legacy passthrough), 3-tenant share discriminator
(10→4), legacy webhook regression. Stage 4 §4.4.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
4.2 KiB
PHP
84 lines
4.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Str;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
it('sums daily_limit_target of active projects for required leads', function () {
|
||
$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]);
|
||
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);
|
||
});
|