feat(supplier): SupplierOrderPlan::build — задуманный заказ по формуле
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* «Задуманный заказ» на targetDate — та же группировка и формула, что у робота
|
||||
* SyncSupplierProjectsJob (DRY через SupplierProjectGrouping + SupplierQuotaAllocator).
|
||||
*
|
||||
* Возвращает ['intended' => list<row>], где row = ['signal_type','identifier','platform','limit'],
|
||||
* только доли ≥1 (как distributeForPlatform).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2
|
||||
*/
|
||||
final class SupplierOrderPlan
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Project> $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions)
|
||||
* @return array{intended: list<array{signal_type:string,identifier:string,platform:string,limit:int}>}
|
||||
*/
|
||||
public static function build(Collection $eligibleProjects, Carbon $targetDate): array
|
||||
{
|
||||
$targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
|
||||
|
||||
// Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob.
|
||||
/** @var array<string, array{signal_type:string, identifier:string, platforms:list<string>, limits:list<int>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($eligibleProjects as $project) {
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// eligible-today (маска дня на targetDate).
|
||||
if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
$key = $project->signal_type.'|'.$identifier;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'platforms' => $platforms,
|
||||
'limits' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['limits'][] = (int) $project->daily_limit_target;
|
||||
}
|
||||
|
||||
$intended = [];
|
||||
foreach ($groups as $group) {
|
||||
$order = SupplierQuotaAllocator::computeOrder($group['limits']);
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']);
|
||||
foreach ($shares as $platform => $limit) {
|
||||
$intended[] = [
|
||||
'signal_type' => $group['signal_type'],
|
||||
'identifier' => $group['identifier'],
|
||||
'platform' => $platform,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['intended' => $intended];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Supplier\SupplierOrderPlan;
|
||||
use Carbon\Carbon;
|
||||
|
||||
it('строит intended по формуле: два клиента 3 и 2 на один call-источник → заказ 3, деление 1/1/1', function (): void {
|
||||
$tomorrow = Carbon::parse('2026-07-13'); // понедельник
|
||||
$mask = 1 << ($tomorrow->isoWeekday() - 1); // бит пн
|
||||
|
||||
$p1 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
|
||||
$p1->daily_limit_target = 3;
|
||||
$p1->delivery_days_mask = $mask;
|
||||
$p1->regions = [];
|
||||
|
||||
$p2 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
|
||||
$p2->daily_limit_target = 2;
|
||||
$p2->delivery_days_mask = $mask;
|
||||
$p2->regions = [];
|
||||
|
||||
$plan = SupplierOrderPlan::build(collect([$p1, $p2]), $tomorrow);
|
||||
|
||||
// order = max(3, ceil(5/3)=2) = 3; split B1/B2/B3 = 1/1/1
|
||||
expect($plan['intended'])->toEqualCanonicalizing([
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1],
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B2', 'limit' => 1],
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B3', 'limit' => 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('проект не eligible на завтра (нет бита дня) → не попадает в intended', function (): void {
|
||||
$tomorrow = Carbon::parse('2026-07-13'); // пн
|
||||
$notMondayMask = 1 << (Carbon::parse('2026-07-14')->isoWeekday() - 1); // вт
|
||||
|
||||
$p = new Project(['signal_type' => 'call', 'signal_identifier' => '7911']);
|
||||
$p->daily_limit_target = 5;
|
||||
$p->delivery_days_mask = $notMondayMask;
|
||||
$p->regions = [];
|
||||
|
||||
$plan = SupplierOrderPlan::build(collect([$p]), $tomorrow);
|
||||
|
||||
expect($plan['intended'])->toBe([]);
|
||||
});
|
||||
Reference in New Issue
Block a user