dedaae5aaa
Компоненты:
- 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).
165 lines
6.5 KiB
PHP
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);
|
|
});
|