ultimatepos/Modules/AdvancedPlanning/Services/IntegrationBridgeService.php

353 lines
12 KiB
PHP

<?php
namespace Modules\AdvancedPlanning\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Modules\AdvancedPlanning\Models\CapacityResource;
class IntegrationBridgeService
{
public function isIndustrialEngineeringAvailable(): bool
{
return \Module::has('IndustrialEngineering')
&& \Module::isEnabled('IndustrialEngineering')
&& class_exists(\Modules\IndustrialEngineering\Models\ManufacturingWorkOrder::class);
}
public function isShopFloorAvailable(): bool
{
return \Module::has('ShopFloor') && \Module::isEnabled('ShopFloor');
}
public function isMaintenanceAvailable(): bool
{
return \Module::has('Maintenance')
&& \Module::isEnabled('Maintenance')
&& class_exists(\Modules\Maintenance\Models\Equipment::class);
}
/**
* Sync capacity resources from IE line templates, work centers, and CMMS equipment.
*
* @return array{created: int, updated: int}
*/
public function syncCapacityResources(int $businessId): array
{
$created = 0;
$updated = 0;
if ($this->isIndustrialEngineeringAvailable()) {
$result = $this->syncFromIndustrialEngineering($businessId);
$created += $result['created'];
$updated += $result['updated'];
}
if ($this->isMaintenanceAvailable()) {
$result = $this->syncFromMaintenanceEquipment($businessId);
$created += $result['created'];
$updated += $result['updated'];
}
if ($this->isShopFloorAvailable()) {
$result = $this->syncFromShopFloor($businessId);
$created += $result['created'];
$updated += $result['updated'];
}
return ['created' => $created, 'updated' => $updated];
}
/**
* @return Collection<int, object>
*/
public function loadSchedulableWorkOrders(
int $businessId,
Carbon $horizonStart,
Carbon $horizonEnd,
array $options = []
): Collection {
if (! $this->isIndustrialEngineeringAvailable()) {
return collect();
}
$woClass = \Modules\IndustrialEngineering\Models\ManufacturingWorkOrder::class;
$query = $woClass::forBusiness($businessId)
->whereIn('status', $options['statuses'] ?? ['planned', 'released', 'in_progress'])
->with(['lineRevision']);
if (! ($options['ignore_dates'] ?? false)) {
$query->where(function ($q) use ($horizonStart, $horizonEnd) {
$q->whereNull('planned_end_date')
->orWhereBetween('planned_start_date', [$horizonStart, $horizonEnd])
->orWhereBetween('planned_end_date', [$horizonStart, $horizonEnd]);
});
}
if (! empty($options['work_order_ids'])) {
$query->whereIn('id', $options['work_order_ids']);
}
return $query->orderBy('planned_start_date')->orderBy('id')->get();
}
/**
* @return array<int, array<string, mixed>>
*/
public function extractRoutingOperations($workOrder): array
{
$snapshot = $workOrder->routing_snapshot ?? [];
if (is_string($snapshot)) {
$snapshot = json_decode($snapshot, true) ?: [];
}
if (! is_array($snapshot) || empty($snapshot)) {
return [];
}
return collect($snapshot)
->sortBy('sequence')
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
public function loadMrpDemandForPeriod(int $businessId, Carbon $monthStart, Carbon $monthEnd): array
{
if (! class_exists(\Modules\IndustrialEngineering\Models\MrpRun::class)) {
return [];
}
$runs = \Modules\IndustrialEngineering\Models\MrpRun::forBusiness($businessId)
->whereBetween('created_at', [$monthStart, $monthEnd])
->with('workOrder')
->latest('id')
->limit(50)
->get();
return $runs->map(fn ($run) => [
'mrp_run_id' => $run->id,
'run_code' => $run->run_code,
'planned_quantity' => (float) $run->planned_quantity,
'work_order_id' => $run->work_order_id,
'work_order_number' => $run->workOrder?->work_order_number,
])->values()->all();
}
/**
* @return array{created: int, updated: int}
*/
protected function syncFromIndustrialEngineering(int $businessId): array
{
$created = 0;
$updated = 0;
$defaultHours = (float) config('advancedplanning.default_daily_capacity_hours', 8);
$defaultEff = (float) config('advancedplanning.default_efficiency_pct', 85);
if (class_exists(\Modules\IndustrialEngineering\Models\LineTemplate::class)) {
$lines = \Modules\IndustrialEngineering\Models\LineTemplate::forBusiness($businessId)
->productionLines()
->get();
foreach ($lines as $line) {
$attrs = [
'name' => $line->name,
'resource_type' => 'line',
'ie_line_template_id' => $line->id,
'maintenance_equipment_id' => $line->maintenance_equipment_id,
'daily_capacity_hours' => $defaultHours,
'efficiency_pct' => $defaultEff,
'is_active' => true,
];
$resource = CapacityResource::forBusiness($businessId)
->where('ie_line_template_id', $line->id)
->first();
if ($resource) {
$resource->update($attrs);
$updated++;
} else {
CapacityResource::create(array_merge($attrs, ['business_id' => $businessId]));
$created++;
}
}
}
if (class_exists(\Modules\IndustrialEngineering\Models\WorkCenter::class)) {
$centers = \Modules\IndustrialEngineering\Models\WorkCenter::forBusiness($businessId)
->where('is_active', true)
->get();
foreach ($centers as $center) {
$type = ((float) $center->machine_rate_per_hour > 0) ? 'equipment' : 'labor';
$attrs = [
'name' => $center->name,
'resource_type' => $type,
'ie_work_center_id' => $center->id,
'daily_capacity_hours' => $defaultHours,
'efficiency_pct' => $defaultEff,
'is_active' => true,
];
$resource = CapacityResource::forBusiness($businessId)
->where('ie_work_center_id', $center->id)
->first();
if ($resource) {
$resource->update($attrs);
$updated++;
} else {
CapacityResource::create(array_merge($attrs, ['business_id' => $businessId]));
$created++;
}
}
}
return ['created' => $created, 'updated' => $updated];
}
/**
* @return array{created: int, updated: int}
*/
protected function syncFromMaintenanceEquipment(int $businessId): array
{
$created = 0;
$updated = 0;
$equipment = \Modules\Maintenance\Models\Equipment::where('business_id', $businessId)->get();
$defaultHours = (float) config('advancedplanning.default_daily_capacity_hours', 8);
$defaultEff = (float) config('advancedplanning.default_efficiency_pct', 85);
foreach ($equipment as $item) {
$existing = CapacityResource::forBusiness($businessId)
->where('maintenance_equipment_id', $item->id)
->whereNull('ie_line_template_id')
->first();
$attrs = [
'name' => $item->name ?? $item->equipment_code ?? 'Equipment #'.$item->id,
'resource_type' => 'equipment',
'maintenance_equipment_id' => $item->id,
'daily_capacity_hours' => $defaultHours,
'efficiency_pct' => $defaultEff,
'is_active' => true,
];
if ($existing) {
$existing->update($attrs);
$updated++;
} else {
CapacityResource::create(array_merge($attrs, ['business_id' => $businessId]));
$created++;
}
}
return ['created' => $created, 'updated' => $updated];
}
/**
* Schedule work orders created by an MRP run within the given horizon.
*/
public function scheduleMrpWorkOrders(
int $businessId,
Carbon $horizonStart,
Carbon $horizonEnd,
FiniteCapacitySchedulerService $scheduler,
?int $mrpRunId = null
): ?\Modules\AdvancedPlanning\Models\ScheduleRun {
if (! $this->isIndustrialEngineeringAvailable()) {
return null;
}
$options = ['ignore_dates' => true];
if ($mrpRunId) {
$run = \Modules\IndustrialEngineering\Models\MrpRun::forBusiness($businessId)->find($mrpRunId);
if ($run?->work_order_id) {
$options['work_order_ids'] = [$run->work_order_id];
}
}
return $scheduler->run($businessId, $horizonStart, $horizonEnd, 'MRP Follow-up', $options);
}
/**
* @return array{created: int, updated: int}
*/
protected function syncFromShopFloor(int $businessId): array
{
$created = 0;
$updated = 0;
if (! class_exists(\Modules\ShopFloor\Models\WorkCenter::class)) {
return ['created' => 0, 'updated' => 0];
}
$defaultHours = (float) config('advancedplanning.default_daily_capacity_hours', 8);
$defaultEff = (float) config('advancedplanning.default_efficiency_pct', 85);
$centers = \Modules\ShopFloor\Models\WorkCenter::where('business_id', $businessId)
->where('status', 'active')
->get();
foreach ($centers as $center) {
$dailyHours = $defaultHours;
if ((float) $center->capacity_per_hour > 0) {
$dailyHours = min(24, (float) $center->capacity_per_hour * $defaultHours);
}
$efficiency = $this->shopFloorEfficiencyForCenter($businessId, (int) $center->id, $defaultEff);
$attrs = [
'name' => $center->name.' ('.$center->code.')',
'resource_type' => 'equipment',
'ie_line_template_id' => $center->ie_line_template_id,
'maintenance_equipment_id' => $center->maintenance_equipment_id,
'daily_capacity_hours' => round($dailyHours, 2),
'efficiency_pct' => $efficiency,
'is_active' => true,
];
$resourceQuery = CapacityResource::forBusiness($businessId);
if ($center->ie_line_template_id) {
$resource = (clone $resourceQuery)->where('ie_line_template_id', $center->ie_line_template_id)->first();
} elseif ($center->maintenance_equipment_id) {
$resource = (clone $resourceQuery)->where('maintenance_equipment_id', $center->maintenance_equipment_id)->first();
} else {
$resource = (clone $resourceQuery)->where('name', $attrs['name'])->first();
}
if ($resource) {
$resource->update($attrs);
$updated++;
} else {
CapacityResource::create(array_merge($attrs, ['business_id' => $businessId]));
$created++;
}
}
return ['created' => $created, 'updated' => $updated];
}
protected function shopFloorEfficiencyForCenter(int $businessId, int $workCenterId, float $fallback): float
{
if (! class_exists(\Modules\ShopFloor\Models\OeeSnapshot::class)) {
return $fallback;
}
$oee = \Modules\ShopFloor\Models\OeeSnapshot::where('business_id', $businessId)
->where('work_center_id', $workCenterId)
->orderByDesc('snapshot_date')
->value('oee_pct');
if ($oee === null || (float) $oee <= 0) {
return $fallback;
}
return max(1, min(100, round((float) $oee, 2)));
}
}