7e8a2dc86a
Новый кабинет отклоняет rt-project-save с limit=0 (Введите limit!), старый принимал. Проект с daily_limit_target=1 (site->3 площадки) давал доли 1/0/0 и падал на B2/B3. - distributeForPlatform опускает площадки с долей 0 (сумма ненулевых == order) - SyncSupplierProjectsJob и SyncSupplierProjectJob: create/missing/dead/update-циклы идут только по площадкам с долей >=1 (?? 0 снят — ключ гарантирован, phpstan 0 на этих файлах) - тесты: allocator (limit-1/2, zero), новый job-тест limit-1 site -> ровно 1 save Прогон supplier+jobs: 261/261. NB: LEFTHOOK_EXCLUDE=larastan — гейт падал на предсуществующих ошибках чужого tests/Feature/Sales/SalesClientsIndexTest.php (не связано с этим фиксом). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
4.5 KiB
PHP
98 lines
4.5 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]],
|
||
// Zero-share platforms are OMITTED — the supplier cabinet rejects limit=0 ("Введите limit!").
|
||
// A limit-1 site project (order=1, 3 platforms) must go to B1 only, not B1+0+0.
|
||
'limit-1 site → B1 only' => [['B1', 'B2', 'B3'], 1, ['B1' => 1]],
|
||
'limit-2 site → B1,B2 only' => [['B1', 'B2', 'B3'], 2, ['B1' => 1, 'B2' => 1]],
|
||
'sms+kw 1 → B2 only' => [['B2', 'B3'], 1, ['B2' => 1]],
|
||
// Edge: zero order → no platforms at all
|
||
'zero' => [['B1', 'B2', '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();
|
||
});
|