Files
portal/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
T
Дмитрий e6beff6aeb fix(supplier): делить лимит между B1/B2/B3, а не дублировать (×N переплата)
Портал поставщика НЕ делит лимит по площадкам сам (Plan 3 R6 «verified 15→5»
оказался ложным — проверено вживую 2026-05-21 через listProjects): каждый
B-проект честно набирает до своего лимита, поэтому одинаковый лимит на B1/B2/B3
= заказ ×N (звонки/сайт ×3, sms+keyword ×2) → переплата поставщику.

Восстановлен per-platform split (был удалён в R6):
- SupplierQuotaAllocator::distributeForPlatform(order, platforms) —
  largest-remainder, Σ долей == заказу (18→6/6/6, 10→4/3/3, 5→3/2).
- SyncSupplierProjectJob (online) + SyncSupplierProjectsJob (ночной):
  create / dead-donor / missing / update — по одной save на площадку с её долей.
  Online делит daily_limit_target; ночной делит групповой computeOrder.

Сторона выдачи клиенту не затронута (RouteSupplierLeadJob по-прежнему режет по
лимиту клиента). Утечка была только на стороне заказа у поставщика.

Tests: allocator 27/27, online job 9/9, nightly job 12/12, broad supplier
suite green. 2 SupplierPortalClient PlaywrightBridge-теста падают только в
worktree-окружении (нет node-модуля playwright) — pre-existing, доказано stash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:50:06 +03:00

93 lines
4.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Supplier\SupplierQuotaAllocator;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
// 2026-05-12 — вторник (isoWeekday=2 Europe/Moscow).
// 2026-05-13 — среда (isoWeekday=3).
it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
})->with([
'brief 1' => [[5, 5, 10, 20], 20],
'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5+10 → Σ85, наиб10, ceil(85/3)=29
'brief 3' => [[15, 15, 15], 15],
'brief 4' => [[15, 15, 15, 30], 30],
'brief 5' => [[10, 10, 10, 10], 14],
'single' => [[7], 7],
'empty' => [[], 0],
]);
// distributeForPlatform: split the group order across N supplier platforms so the
// SUM of per-platform limits == order (portal does NOT divide — verified live 2026-05-21,
// each B1/B2/B3 honors its own limit independently → must split ourselves). Largest-remainder.
it('distributeForPlatform splits order so per-platform limits sum to the order', function (array $platforms, int $order, array $expected): void {
expect(SupplierQuotaAllocator::distributeForPlatform($order, $platforms))->toBe($expected);
})->with([
// Even split (the common case — the owner reported 18 → 18/18/18 instead of 6/6/6)
'call/site 18→6/6/6' => [['B1', 'B2', 'B3'], 18, ['B1' => 6, 'B2' => 6, 'B3' => 6]],
'call/site 24→8/8/8' => [['B1', 'B2', 'B3'], 24, ['B1' => 8, 'B2' => 8, 'B3' => 8]],
'call/site 3→1/1/1' => [['B1', 'B2', 'B3'], 3, ['B1' => 1, 'B2' => 1, 'B3' => 1]],
// Uneven split — largest remainder: leading platforms get the +1, sum stays exact
'call/site 10→4/3/3' => [['B1', 'B2', 'B3'], 10, ['B1' => 4, 'B2' => 3, 'B3' => 3]],
'call/site 20→7/7/6' => [['B1', 'B2', 'B3'], 20, ['B1' => 7, 'B2' => 7, 'B3' => 6]],
// SMS+keyword (2 platforms)
'sms+kw 5→3/2' => [['B2', 'B3'], 5, ['B2' => 3, 'B3' => 2]],
'sms+kw 2→1/1' => [['B2', 'B3'], 2, ['B2' => 1, 'B3' => 1]],
// SMS without keyword (1 platform) — no split, full order
'sms 7→7' => [['B3'], 7, ['B3' => 7]],
// Edge: zero order
'zero' => [['B1', 'B2', 'B3'], 0, ['B1' => 0, 'B2' => 0, 'B3' => 0]],
]);
it('distributeForPlatform always conserves the order (sum invariant)', function (int $order, int $count): void {
$platforms = array_slice(['B1', 'B2', 'B3'], 0, $count);
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
expect(array_sum($shares))->toBe($order);
})->with([
[1, 3], [2, 3], [7, 3], [13, 3], [100, 3], [101, 2], [99, 1], [0, 3],
]);
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
it('workdays union deduplicates and sorts', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3], 'regions' => []],
(object) ['daily_limit' => 5, 'workdays' => [3, 4, 5], 'regions' => []],
]);
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
expect($dto)->not->toBeNull()
->and($dto->workdays)->toBe([1, 2, 3, 4, 5]);
});
it('regions union deduplicates and sorts', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [77, 50]],
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [50, 78]],
]);
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($dto)->not->toBeNull()
->and($dto->regions)->toBe([50, 77, 78]);
});
it('returns null when no active liderra projects on target weekday', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
]);
expect(SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12')))
->toBeNull();
});