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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user