2026-05-24 11:54:18 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
|
use App\Models\Tenant;
|
2026-05-28 20:05:53 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
2026-05-24 11:54:18 +03:00
|
|
|
|
use Illuminate\Support\Carbon;
|
2026-05-28 20:05:53 +03:00
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
2026-05-24 11:54:18 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-28 20:05:53 +03:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|