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).
This commit is contained in:
Дмитрий
2026-05-11 02:35:13 +03:00
parent f298984055
commit dedaae5aaa
7 changed files with 995 additions and 0 deletions
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierQuotaAllocator;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use stdClass;
use Throwable;
/**
* Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
*
* Алгоритм (per spec §4.3):
* 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
* 2. Для каждого:
* a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
* b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
* c. Вызвать SupplierQuotaAllocator::allocate() pure distribution.
* d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
* e. saveProject() при supplier_external_id=null, иначе updateProject().
* f. Записать audit row в supplier_sync_log.
* 3. Failure-handling:
* - SupplierAuthException SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
* - SupplierTransientException log + continue. После 50 подряд mass_transient alert + break.
* - SupplierClientException log + continue.
* 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
*
* NOTE про connection: Job's $connection это queue connection, не DB. Используем
* Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
*
* Spec:
* - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
*/
class SyncSupplierProjectsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const TIME_BUDGET_CUTOFF = '20:55';
public const MASS_FAIL_THRESHOLD = 50;
public const DB_CONNECTION = 'pgsql_supplier';
public function handle(?SupplierPortalClient $client = null): void
{
$client ??= app(SupplierPortalClient::class);
$consecutiveTransient = 0;
$projects = SupplierProject::on(self::DB_CONNECTION)
->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<int, Project> $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<int, Project> $projects
* @return Collection<int, stdClass>
*/
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<int, int>
*/
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}"),
};
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Critical alert email при отказе supplier-sync.
*
* Триггеры (Plan 3 Task 6 SyncSupplierProjectsJob):
* - sticky_auth: SupplierAuthException после retry через RefreshSupplierSessionJob
* - mass_transient: 50+ последовательных SupplierTransientException (подозрение на outage)
*
* Отправляется через config('services.supplier.alert_email') (Unisender Go SMTP relay).
*
* Spec: docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4 Email alerts.
*/
final class SupplierCriticalAlertMail extends Mailable implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $alertType,
public readonly string $details,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[Лидерра CRITICAL] Supplier sync: {$this->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(),
],
);
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* Pure function: распределение квоты daily_limit между platform B1/B2/B3.
*
* Используется SyncSupplierProjectsJob для агрегирования daily_limit_target
* всех активных Лидерра-проектов на одного supplier_project и распределения
* суммарной квоты между B1/B2/B3 платформами.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
*
* Distribution-формулы:
* site/call:
* B1 = ceil(total/3)
* B2 = ceil((total - B1) / 2)
* B3 = total - B1 - B2
* sms-with-keyword (B1 не поддерживает СМС):
* B1 = 0
* B2 = ceil(total/2)
* B3 = floor(total/2)
*
* Workdays и regions союзы (deduplicated, sorted) активных Лидерра-проектов,
* eligible на targetDate (фильтр по weekday в Europe/Moscow).
*/
final class SupplierQuotaAllocator
{
/**
* @param Collection<int, \stdClass> $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<int, mixed> $arrays
* @return array<int, int>
*/
private static function unionInts(Collection $arrays): array
{
return $arrays
->flatMap(fn ($a) => (array) $a)
->map(fn ($v) => (int) $v)
->unique()
->sort()
->values()
->all();
}
}
+54
View File
@@ -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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, \(object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{77, 50\}\}&stdClass\)\|\(object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{50, 78\}\}&stdClass\)\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, \(object\{daily_limit\: 5, workdays\: array\{1, 2, 3\}, regions\: array\{\}\}&stdClass\)\|\(object\{daily_limit\: 5, workdays\: array\{3, 4, 5\}, regions\: array\{\}\}&stdClass\)\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 1, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{6, 7\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 30, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 4, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> 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\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 7, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
@@ -0,0 +1,10 @@
Supplier sync critical alert
=============================
Тип: {{ $alertType }}
Время: {{ $now }} (МСК)
Детали:
{{ $details }}
Это автоматическое сообщение от системы Лидерра.
Проверьте `supplier_sync_log` в БД и Sentry breadcrumbs для деталей.
@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Cache::store('redis')->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';
});
});
@@ -0,0 +1,164 @@
<?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);
});