ultimatepos/Modules/AdvancedPlanning/Services/FiniteCapacitySchedulerService.php

347 lines
12 KiB
PHP

<?php
namespace Modules\AdvancedPlanning\Services;
use App\Utils\ProductUtil;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\AdvancedPlanning\Models\CapacityResource;
use Modules\AdvancedPlanning\Models\ScheduleRun;
use Modules\AdvancedPlanning\Models\ScheduledOperation;
class FiniteCapacitySchedulerService
{
public function __construct(
protected IntegrationBridgeService $bridge,
protected ProductUtil $productUtil
) {
}
/**
* Run finite capacity scheduling for work orders within the planning horizon.
*
* @param array<string, mixed> $options
*/
public function run(
int $businessId,
Carbon $horizonStart,
Carbon $horizonEnd,
?string $scenarioName = null,
array $options = []
): ScheduleRun {
return DB::transaction(function () use ($businessId, $horizonStart, $horizonEnd, $scenarioName, $options) {
$refNo = $this->nextRefNo($businessId);
$userId = auth()->id();
$run = ScheduleRun::create([
'business_id' => $businessId,
'ref_no' => $refNo,
'horizon_start' => $horizonStart->toDateString(),
'horizon_end' => $horizonEnd->toDateString(),
'status' => 'running',
'scenario_name' => $scenarioName,
'created_by' => $userId,
'notes' => $options['notes'] ?? null,
]);
try {
$resources = $this->loadActiveResources($businessId);
if ($resources->isEmpty()) {
$this->bridge->syncCapacityResources($businessId);
$resources = $this->loadActiveResources($businessId);
}
$workOrders = $this->bridge->loadSchedulableWorkOrders($businessId, $horizonStart, $horizonEnd, $options);
$resourceTimelines = $this->initResourceTimelines($resources, $horizonStart);
$scheduled = [];
$unscheduled = [];
foreach ($workOrders as $workOrder) {
$operations = $this->bridge->extractRoutingOperations($workOrder);
if (empty($operations)) {
$operations = [$this->defaultOperationForWorkOrder($workOrder)];
}
$woStart = $workOrder->planned_start_date
? Carbon::parse($workOrder->planned_start_date)->startOfDay()
: $horizonStart->copy();
foreach ($operations as $index => $op) {
$resource = $this->resolveResource($resources, $op, $workOrder);
if (! $resource) {
$unscheduled[] = [
'work_order_id' => $workOrder->id,
'operation' => $op['operation_code'] ?? 'OP',
'reason' => 'no_matching_resource',
];
continue;
}
$durationHours = $this->operationDurationHours($op, (float) $workOrder->planned_quantity);
$slot = $this->findNextSlot(
$resourceTimelines[$resource->id],
$woStart,
$horizonEnd,
$durationHours,
(float) $resource->effectiveDailyHours()
);
if (! $slot) {
$unscheduled[] = [
'work_order_id' => $workOrder->id,
'operation' => $op['operation_code'] ?? 'OP',
'reason' => 'capacity_exceeded',
];
continue;
}
$scheduledOp = ScheduledOperation::create([
'schedule_run_id' => $run->id,
'ie_work_order_id' => $workOrder->id,
'resource_id' => $resource->id,
'sequence' => ($index + 1) * 10,
'planned_start' => $slot['start'],
'planned_end' => $slot['end'],
'quantity' => $workOrder->planned_quantity,
'status' => 'planned',
'operation_code' => $op['operation_code'] ?? null,
'operation_name' => $op['name'] ?? $op['operation_name'] ?? null,
'duration_hours' => $durationHours,
]);
$resourceTimelines[$resource->id][] = $slot;
$woStart = Carbon::parse($slot['end']);
$scheduled[] = $scheduledOp->id;
}
}
$utilization = $this->calculateUtilization($resources, $resourceTimelines, $horizonStart, $horizonEnd);
$run->update([
'status' => 'completed',
'results' => [
'scheduled_count' => count($scheduled),
'unscheduled_count' => count($unscheduled),
'unscheduled' => $unscheduled,
'resource_utilization' => $utilization,
'horizon_days' => $horizonStart->diffInDays($horizonEnd) + 1,
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'results' => ['error' => $e->getMessage()],
]);
throw $e;
}
return $run->fresh(['scheduledOperations.resource', 'scheduledOperations.workOrder']);
});
}
protected function loadActiveResources(int $businessId)
{
return CapacityResource::forBusiness($businessId)
->where('is_active', true)
->orderBy('name')
->get();
}
/**
* @return array<int, array<int, array{start: Carbon, end: Carbon}>>
*/
protected function initResourceTimelines($resources, Carbon $horizonStart): array
{
$timelines = [];
foreach ($resources as $resource) {
$timelines[$resource->id] = [];
}
return $timelines;
}
/**
* @param array<int, array{start: Carbon, end: Carbon}> $timeline
* @return array{start: Carbon, end: Carbon}|null
*/
protected function findNextSlot(
array &$timeline,
Carbon $earliest,
Carbon $horizonEnd,
float $durationHours,
float $dailyEffectiveHours
): ?array {
$cursor = $earliest->copy();
$remaining = $durationHours;
$slotStart = null;
$maxIterations = 366 * 24;
for ($i = 0; $i < $maxIterations && $cursor->lte($horizonEnd) && $remaining > 0; $i++) {
$dayEnd = $cursor->copy()->endOfDay();
if ($dayEnd->gt($horizonEnd)) {
$dayEnd = $horizonEnd->copy();
}
$dayCapacity = min($dailyEffectiveHours, max(0, $cursor->diffInMinutes($dayEnd) / 60));
if ($dayCapacity <= 0) {
$cursor = $cursor->copy()->addDay()->startOfDay();
continue;
}
$dayStart = $cursor->copy();
$occupied = $this->occupiedHoursOnDay($timeline, $dayStart);
$available = max(0, $dayCapacity - $occupied);
if ($available <= 0) {
$cursor = $cursor->copy()->addDay()->startOfDay();
continue;
}
$useHours = min($available, $remaining);
if ($slotStart === null) {
$slotStart = $dayStart->copy();
}
$remaining -= $useHours;
$cursor = $dayStart->copy()->addMinutes((int) round($useHours * 60));
if ($remaining <= 0) {
return [
'start' => $slotStart,
'end' => $cursor,
];
}
$cursor = $cursor->copy()->addDay()->startOfDay();
}
return null;
}
/**
* @param array<int, array{start: Carbon, end: Carbon}> $timeline
*/
protected function occupiedHoursOnDay(array $timeline, Carbon $dayStart): float
{
$dayEnd = $dayStart->copy()->endOfDay();
$hours = 0.0;
foreach ($timeline as $slot) {
$start = Carbon::parse($slot['start']);
$end = Carbon::parse($slot['end']);
if ($end->lte($dayStart) || $start->gte($dayEnd)) {
continue;
}
$overlapStart = $start->lt($dayStart) ? $dayStart : $start;
$overlapEnd = $end->gt($dayEnd) ? $dayEnd : $end;
$hours += $overlapStart->diffInMinutes($overlapEnd) / 60;
}
return $hours;
}
/**
* @param array<string, mixed> $op
*/
protected function resolveResource($resources, array $op, $workOrder): ?CapacityResource
{
$workCenterId = $op['work_center_id'] ?? ($op['work_center']['id'] ?? null);
if ($workCenterId) {
$match = $resources->firstWhere('ie_work_center_id', (int) $workCenterId);
if ($match) {
return $match;
}
}
$lineTemplateId = $workOrder->lineRevision?->line_template_id
?? $workOrder->line_revision?->line_template_id
?? null;
if ($lineTemplateId) {
$match = $resources->firstWhere('ie_line_template_id', (int) $lineTemplateId);
if ($match) {
return $match;
}
}
return $resources->firstWhere('resource_type', 'line') ?? $resources->first();
}
/**
* @param array<string, mixed> $op
*/
protected function operationDurationHours(array $op, float $quantity): float
{
$setup = (float) ($op['setup_time_hours'] ?? 0);
$run = (float) ($op['run_time_hours'] ?? 0);
return max(0.25, $setup + ($run * max(1, $quantity)));
}
/**
* @return array<string, mixed>
*/
protected function defaultOperationForWorkOrder($workOrder): array
{
return [
'operation_code' => 'ASSY',
'name' => __('advancedplanning::lang.default_assembly_op'),
'setup_time_hours' => 0.5,
'run_time_hours' => 1.0,
];
}
/**
* @param array<int, array<int, array{start: Carbon, end: Carbon}>> $timelines
* @return array<int, array<string, mixed>>
*/
protected function calculateUtilization($resources, array $timelines, Carbon $horizonStart, Carbon $horizonEnd): array
{
$days = max(1, $horizonStart->diffInDays($horizonEnd) + 1);
$result = [];
foreach ($resources as $resource) {
$scheduledHours = 0.0;
foreach ($timelines[$resource->id] ?? [] as $slot) {
$scheduledHours += Carbon::parse($slot['start'])->diffInMinutes(Carbon::parse($slot['end'])) / 60;
}
$available = (float) $resource->effectiveDailyHours() * $days;
$result[] = [
'resource_id' => $resource->id,
'resource_name' => $resource->name,
'scheduled_hours' => round($scheduledHours, 2),
'available_hours' => round($available, 2),
'utilization_pct' => $available > 0 ? round(($scheduledHours / $available) * 100, 1) : 0,
];
}
return $result;
}
protected function nextRefNo(int $businessId): string
{
$prefix = 'APS-'.now()->format('Ymd').'-';
$refCount = $this->productUtil->setAndGetReferenceCount('ap_schedule_run');
$sequence = str_pad((string) $refCount, 4, '0', STR_PAD_LEFT);
$latest = ScheduleRun::forBusiness($businessId)
->where('ref_no', 'like', $prefix.'%')
->orderByDesc('id')
->value('ref_no');
if ($latest && preg_match('/-(\d+)$/', (string) $latest, $matches)) {
$sequence = str_pad((string) (((int) $matches[1]) + 1), 4, '0', STR_PAD_LEFT);
}
return $prefix.$sequence;
}
}