diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php new file mode 100644 index 00000000..8e118f32 --- /dev/null +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -0,0 +1,259 @@ +whereNull('inactive_since') + ->orderBy('id') + ->get(); + + foreach ($projects as $sp) { + if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) { + Log::warning('supplier.sync.time_budget_reached', [ + 'processed_until' => $sp->id, + ]); + break; + } + + try { + $this->syncOne($sp, $client); + $consecutiveTransient = 0; + } catch (SupplierAuthException $e) { + Mail::to((string) config('services.supplier.alert_email')) + ->queue(new SupplierCriticalAlertMail( + alertType: 'sticky_auth', + details: $e->getMessage(), + )); + report($e); + throw $e; + } catch (SupplierTransientException $e) { + $consecutiveTransient++; + $this->logSyncFailure($sp, $e); + if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) { + Mail::to((string) config('services.supplier.alert_email')) + ->queue(new SupplierCriticalAlertMail( + alertType: 'mass_transient', + details: "Aborted after {$consecutiveTransient} consecutive transient failures.", + )); + report(new \RuntimeException('Supplier outage suspected: mass transient failures')); + break; + } + + continue; + } catch (SupplierClientException $e) { + $this->logSyncFailure($sp, $e); + report($e); + + continue; + } + } + } + + private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void + { + $fkColumn = $this->fkColumnForPlatform($sp->platform); + + /** @var EloquentCollection $liderraProjects */ + $liderraProjects = Project::on(self::DB_CONNECTION) + ->where($fkColumn, $sp->id) + ->where('is_active', true) + ->get(); + + if ($liderraProjects->isEmpty()) { + return; + } + + $adapted = $this->adaptProjectsForAllocator($liderraProjects); + + $allocation = SupplierQuotaAllocator::allocate( + platform: $sp->platform, + signalType: $sp->signal_type, + uniqueKey: $sp->unique_key, + activeLiderraProjects: $adapted, + targetDate: Carbon::tomorrow('Europe/Moscow'), + ); + + if ($allocation === null) { + return; + } + + $current = SupplierProjectDto::fromModel($sp); + if ($allocation->equals($current)) { + return; + } + + $isCreate = $sp->supplier_external_id === null; + + // NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за + // границы транзакционного контекста, атомарности всё равно нет. Два DB-write + // (supplier_project update + supplier_sync_log insert) на одной connection + // выполняются последовательно; ошибка между ними — recoverable through retry + // на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()). + if ($isCreate) { + $externalId = $client->saveProject($allocation); + $sp->forceFill([ + 'supplier_external_id' => (string) $externalId, + 'current_limit' => $allocation->limit, + 'current_workdays' => $allocation->workdays, + 'current_regions' => $allocation->regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ])->save(); + } else { + $client->updateProject((int) $sp->supplier_external_id, $allocation); + $sp->forceFill([ + 'current_limit' => $allocation->limit, + 'current_workdays' => $allocation->workdays, + 'current_regions' => $allocation->regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ])->save(); + } + + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => $isCreate ? 'create' : 'update', + 'http_status' => 200, + 'created_at' => now(), + ]); + } + + private function logSyncFailure(SupplierProject $sp, Throwable $e): void + { + $httpStatus = null; + if ($e instanceof SupplierException) { + $httpStatus = $e->httpStatus; + } + + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => $sp->supplier_external_id === null ? 'create' : 'update', + 'http_status' => $httpStatus, + 'error_message' => substr($e->getMessage(), 0, 1000), + 'created_at' => now(), + ]); + } + + /** + * Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions, + * которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent). + * + * Маппинг: + * daily_limit ← daily_limit_target + * workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7 + * regions ← биты region_mask (bit 0=Центральный, …, bit 7=Дальневосточный) → 1..8 + * + * @param EloquentCollection $projects + * @return Collection + */ + private function adaptProjectsForAllocator(EloquentCollection $projects): Collection + { + return $projects->map(function (Project $p): stdClass { + $obj = new stdClass; + $obj->daily_limit = (int) $p->daily_limit_target; + $obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7); + + // region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив + // у supplier ("без региональных ограничений"). Иначе — список выставленных битов. + $regionMask = (int) $p->region_mask; + $obj->regions = $regionMask === 255 + ? [] + : $this->bitmaskToList($regionMask, 8); + + return $obj; + })->values(); + } + + /** + * Bitmask → ordered list 1..maxBits для bits, выставленных в 1. + * + * @return array + */ + private function bitmaskToList(int $mask, int $maxBits): array + { + $out = []; + for ($i = 0; $i < $maxBits; $i++) { + if (($mask & (1 << $i)) !== 0) { + $out[] = $i + 1; + } + } + + return $out; + } + + private function fkColumnForPlatform(string $platform): string + { + return match ($platform) { + 'B1' => 'supplier_b1_project_id', + 'B2' => 'supplier_b2_project_id', + 'B3' => 'supplier_b3_project_id', + default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"), + }; + } +} diff --git a/app/app/Mail/SupplierCriticalAlertMail.php b/app/app/Mail/SupplierCriticalAlertMail.php new file mode 100644 index 00000000..ab79d647 --- /dev/null +++ b/app/app/Mail/SupplierCriticalAlertMail.php @@ -0,0 +1,51 @@ +alertType}", + ); + } + + public function content(): Content + { + return new Content( + text: 'emails.supplier_alert_text', + with: [ + 'alertType' => $this->alertType, + 'details' => $this->details, + 'now' => now()->timezone('Europe/Moscow')->toIso8601String(), + ], + ); + } +} diff --git a/app/app/Services/Supplier/SupplierQuotaAllocator.php b/app/app/Services/Supplier/SupplierQuotaAllocator.php new file mode 100644 index 00000000..daeda279 --- /dev/null +++ b/app/app/Services/Supplier/SupplierQuotaAllocator.php @@ -0,0 +1,111 @@ + $activeLiderraProjects объекты с полями daily_limit, workdays, regions + */ + public static function allocate( + string $platform, + string $signalType, + string $uniqueKey, + Collection $activeLiderraProjects, + Carbon $targetDate, + ): ?SupplierProjectDto { + $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday(); + + $eligibleProjects = $activeLiderraProjects->filter( + fn (\stdClass $p) => in_array($targetWeekday, (array) $p->workdays, true) + ); + + if ($eligibleProjects->isEmpty()) { + return null; + } + + $totalQuota = (int) $eligibleProjects->sum('daily_limit'); + $workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays')); + $regionsUnion = self::unionInts($eligibleProjects->pluck('regions')); + + $platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota); + + return new SupplierProjectDto( + platform: $platform, + signalType: $signalType, + uniqueKey: $uniqueKey, + limit: $platformLimit, + workdays: $workdaysUnion, + regions: $regionsUnion, + regionsReverse: false, + status: 'active', + ); + } + + private static function distributeForPlatform(string $signalType, string $platform, int $total): int + { + if ($signalType === 'sms') { + if ($platform === 'B1') { + return 0; + } + + return $platform === 'B2' + ? (int) ceil($total / 2) + : (int) floor($total / 2); + } + + $b1 = (int) ceil($total / 3); + $b2 = (int) ceil(($total - $b1) / 2); + $b3 = $total - $b1 - $b2; + + return match ($platform) { + 'B1' => $b1, + 'B2' => $b2, + 'B3' => $b3, + default => 0, + }; + } + + /** + * @param Collection $arrays + * @return array + */ + private static function unionInts(Collection $arrays): array + { + return $arrays + ->flatMap(fn ($a) => (array) $a) + ->map(fn ($v) => (int) $v) + ->unique() + ->sort() + ->values() + ->all(); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 4b3a865b..30fe55c8 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -983,3 +983,57 @@ parameters: identifier: argument.type count: 2 path: tests/Unit/Supplier/RefreshSupplierSessionJobTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 3 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 6 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 3 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\, Illuminate\\Support\\Collection\ given\.$#' + identifier: argument.type + count: 3 + path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php diff --git a/app/resources/views/emails/supplier_alert_text.blade.php b/app/resources/views/emails/supplier_alert_text.blade.php new file mode 100644 index 00000000..14770aa8 --- /dev/null +++ b/app/resources/views/emails/supplier_alert_text.blade.php @@ -0,0 +1,10 @@ +Supplier sync critical alert +============================= +Тип: {{ $alertType }} +Время: {{ $now }} (МСК) + +Детали: +{{ $details }} + +Это автоматическое сообщение от системы Лидерра. +Проверьте `supplier_sync_log` в БД и Sentry breadcrumbs для деталей. diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php new file mode 100644 index 00000000..b5fb10e5 --- /dev/null +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -0,0 +1,346 @@ +put('supplier:session', [ + 'phpsessid' => 'sess', + 'csrf' => 'csrf', + 'refreshed_at' => now()->toIso8601String(), + ], now()->addHours(6)); + + config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); + config(['services.supplier.alert_email' => 'ops@liderra.test']); +}); + +afterEach(function (): void { + Cache::store('redis')->forget('supplier:session'); + Carbon::setTestNow(); +}); + +test('creates supplier_project at supplier when supplier_external_id is null', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'create-flow.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'create-flow.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200), + ]); + + (new SyncSupplierProjectsJob)->handle(); + + $sp->refresh(); + expect($sp->supplier_external_id)->toBe('555') + ->and($sp->sync_status)->toBe('ok') + ->and($sp->current_limit)->toBe(3); + + Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save')); +}); + +test('updates when diff detected', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'update-flow.example.com', + 'supplier_external_id' => '12345', + 'current_limit' => 1, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'update-flow.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 30, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/rt-project-update' => Http::response([], 200), + ]); + + (new SyncSupplierProjectsJob)->handle(); + + $sp->refresh(); + expect($sp->current_limit)->toBe(10) + ->and($sp->sync_status)->toBe('ok'); + + Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-update')); +}); + +test('skips when no diff between current and computed allocation', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'no-diff.example.com', + 'supplier_external_id' => '999', + 'current_limit' => 9, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => [], + 'sync_status' => 'ok', + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'no-diff.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 27, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake(); + (new SyncSupplierProjectsJob)->handle(); + + Http::assertNothingSent(); +}); + +test('isolates failure: one bad supplier_project does not stop others', function (): void { + $tenant = Tenant::factory()->create(); + + $bad = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'bad.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + $good = SupplierProject::factory()->create([ + 'platform' => 'B2', + 'signal_type' => 'site', + 'unique_key' => 'good.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'bad.example.com', + 'supplier_b1_project_id' => $bad->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'good.example.com', + 'supplier_b2_project_id' => $good->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save') + ->push('bad request', 422) + ->push(['id' => 777], 200); + + (new SyncSupplierProjectsJob)->handle(); + + expect( + SupplierSyncLog::on('pgsql_supplier') + ->where('supplier_project_id', $bad->id) + ->whereNotNull('error_message') + ->exists() + )->toBeTrue(); + + expect($good->fresh()->supplier_external_id)->toBe('777'); +}); + +test('aborts after 50 consecutive transient failures and sends alert', function (): void { + Mail::fake(); + $tenant = Tenant::factory()->create(); + + for ($i = 1; $i <= 60; $i++) { + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => "host{$i}.example.com", + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => "host{$i}.example.com", + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + } + + Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); + + (new SyncSupplierProjectsJob)->handle(); + + Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { + return $mail->alertType === 'mass_transient'; + }); +}); + +test('writes supplier_sync_log row for each successful action', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'audit-log.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'audit-log.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200), + ]); + + (new SyncSupplierProjectsJob)->handle(); + + $log = SupplierSyncLog::on('pgsql_supplier') + ->where('supplier_project_id', $sp->id) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->action)->toBe('create') + ->and($log->http_status)->toBe(200) + ->and($log->error_message)->toBeNull(); +}); + +test('respects time budget by stopping at 20:55 МСК', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow')); + + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'time-budget.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'time-budget.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake(); + (new SyncSupplierProjectsJob)->handle(); + + Http::assertNothingSent(); +}); + +test('sticky auth error throws and sends critical alert email', function (): void { + Mail::fake(); + Bus::fake([RefreshSupplierSessionJob::class]); + + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'auth-fail.example.com', + 'supplier_external_id' => null, + 'current_limit' => 0, + 'current_workdays' => [], + 'current_regions' => [], + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'auth-fail.example.com', + 'supplier_b1_project_id' => $sp->id, + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + + Http::fake([ + 'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401), + ]); + + expect(fn () => (new SyncSupplierProjectsJob)->handle()) + ->toThrow(SupplierAuthException::class); + + Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { + return $mail->alertType === 'sticky_auth'; + }); +}); diff --git a/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php b/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php new file mode 100644 index 00000000..5398b0b3 --- /dev/null +++ b/app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php @@ -0,0 +1,164 @@ + 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); +});