$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> */ protected function initResourceTimelines($resources, Carbon $horizonStart): array { $timelines = []; foreach ($resources as $resource) { $timelines[$resource->id] = []; } return $timelines; } /** * @param array $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 $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 $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 $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 */ 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> $timelines * @return array> */ 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; } }