e6beff6aeb
Портал поставщика НЕ делит лимит по площадкам сам (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>
93 lines
4.2 KiB
PHP
93 lines
4.2 KiB
PHP
<?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();
|
||
});
|