Files
portal/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
T
Дмитрий dedaae5aaa feat(supplier): Plan 3 Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator
Компоненты:
- SupplierQuotaAllocator: pure function distribution-логики
  - site/call: B1=ceil(t/3), B2=ceil(r/2), B3=remainder
  - sms-with-keyword: B2+B3 only (B1=0, spec §2.2 — B1 не поддерживает СМС)
  - Workdays/regions union, weekday-фильтрация по Europe/Moscow
  - Возвращает null когда нет projects на targetWeekday
- SyncSupplierProjectsJob: 20:30 МСК cron
  - SupplierProject::on('pgsql_supplier') — cross-tenant видимость
  - whereNull('inactive_since') — sync только активные
  - Адаптер Project → stdClass: daily_limit_target → daily_limit,
    delivery_days_mask bits → workdays, region_mask bits → regions
    (mask=255 catch-all → regions=[])
  - per-supplier_project failure-isolation (continue на one bad)
  - mass-fail abort: 50 consecutive transient → SupplierCriticalAlertMail
    + Sentry + break
  - sticky auth → email('sticky_auth') + Sentry + throw
  - time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
  - supplier_sync_log per action (action='create'/'update', http_status,
    error_message)
- SupplierCriticalAlertMail: ShouldQueue Mailable + text template
  - Unisender Go SMTP relay через config('services.supplier.alert_email')

NOTE про connection: следуем Task 3 learning — не используем public \$connection
(это queue connection, не DB). Queries через Model::on('pgsql_supplier').

NOTE про DB::transaction: НЕ оборачиваем syncOne, т.к. HTTP-call к supplier
выходит за границы транзакции (атомарности всё равно нет). Два DB-write
последовательно; ошибка между ними recoverable через retry на следующем cron-tick
(supplier_external_id уже записан, скип через SupplierProjectDto::equals()).

+18 тестов (10 allocator + 8 sync job).

phpstan-baseline.neon: +7 entries для PHPStan template-covariance issue в
SupplierQuotaAllocatorTest — \`Collection<int, object{...literal}&stdClass>\` не
suptype \`Collection<int, stdClass>\` per PHPStan invariance rule. Production
code clean (0 baseline entries).
2026-05-11 06:46:13 +03:00

165 lines
6.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-16 — суббота (isoWeekday=6), 2026-05-17 — воскресенье (isoWeekday=7).
test('site signal distributes B1 ceil(total/3), B2 ceil(remainder/2), B3 remainder', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 10, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10)
->and($b1->limit)->toBe(4)
->and($b2->limit)->toBe(3)
->and($b3->limit)->toBe(3);
});
test('call signal same distribution as site (B1/B2/B3 split)', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 30, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'call', '79991234567', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->limit)->toBe(10);
});
test('sms with keyword distributes B2+B3 only (B1 returns 0)', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 4, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(0)
->and($b2->limit)->toBe(2)
->and($b3->limit)->toBe(2);
});
test('returns null when no active liderra projects on target weekday', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
]);
$allocation = SupplierQuotaAllocator::allocate(
'B1',
'site',
'example.com',
$projects,
Carbon::parse('2026-05-12'),
);
expect($allocation)->toBeNull();
});
test('workdays union deduplicates and sorts', function (): void {
// Targeting Wednesday (2026-05-13, isoWeekday=3): оба проекта содержат 3 → оба eligible,
// союз их workdays — [1,2,3,4,5].
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3], 'regions' => []],
(object) ['daily_limit' => 5, 'workdays' => [3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
expect($b1)->not->toBeNull()
->and($b1->workdays)->toBe([1, 2, 3, 4, 5]);
});
test('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]],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->regions)->toBe([50, 77, 78]);
});
test('empty regions stays empty (all regions semantics)', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->regions)->toBe([]);
});
test('single project with limit=1 sites to B1 only', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 1, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(1)
->and($b2->limit)->toBe(0)
->and($b3->limit)->toBe(0);
});
test('large scale: 1000 projects with limit 10 each = 10000 total', function (): void {
$projects = new Collection(array_fill(0, 1000, (object) [
'daily_limit' => 10,
'workdays' => [1, 2, 3, 4, 5],
'regions' => [],
]));
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10000)
->and($b1->limit)->toBe(3334);
});
test('odd total: 7 distributes B1=3, B2=2, B3=2', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 7, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(3)
->and($b2->limit)->toBe(2)
->and($b3->limit)->toBe(2);
});