Files
portal/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
T
Дмитрий 7e8a2dc86a fix(supplier): не отправлять площадку с долей лимита 0 (кабинет crm.lead.store отклоняет)
Новый кабинет отклоняет 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>
2026-07-01 15:51:06 +03:00

98 lines
4.5 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]],
// 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();
});