diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ChargingCoordinator.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ChargingCoordinator.java new file mode 100644 index 0000000..6bd2805 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ChargingCoordinator.java @@ -0,0 +1,65 @@ +package com.ruoyi.wcs.application.device; + +import com.ruoyi.wcs.domain.equipment.AvailableChargers; +import com.ruoyi.wcs.domain.equipment.ChargingPolicyDecision; +import com.ruoyi.wcs.domain.equipment.DeviceCapability; +import com.ruoyi.wcs.domain.equipment.PendingWorkload; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import com.ruoyi.wcs.port.out.ChargingPolicy; + +import java.util.Objects; + +public final class ChargingCoordinator implements ChargingPolicy { + private final int forcedChargeThreshold; + private final int opportunisticChargeThreshold; + + public ChargingCoordinator(int forcedChargeThreshold, + int opportunisticChargeThreshold) { + if (forcedChargeThreshold < 0 + || opportunisticChargeThreshold > 100 + || forcedChargeThreshold > opportunisticChargeThreshold) { + throw new IllegalArgumentException("charging thresholds are invalid"); + } + this.forcedChargeThreshold = forcedChargeThreshold; + this.opportunisticChargeThreshold = opportunisticChargeThreshold; + } + + @Override + public ChargingPolicyDecision decide(VehicleSnapshot vehicle, + PendingWorkload workload, + AvailableChargers chargers) { + Objects.requireNonNull(vehicle, "vehicle"); + Objects.requireNonNull(workload, "workload"); + Objects.requireNonNull(chargers, "chargers"); + if (vehicle.getConnectionState() != VehicleSnapshot.ConnectionState.ONLINE + || vehicle.getOperatingMode() != VehicleSnapshot.OperatingMode.AUTO + || vehicle.getHealthState() != VehicleSnapshot.HealthState.NORMAL + || vehicle.getOccupationState() != VehicleSnapshot.OccupationState.IDLE + || !vehicle.supports(DeviceCapability.CHARGING)) { + return new ChargingPolicyDecision( + ChargingPolicyDecision.Type.NOT_ELIGIBLE, null, + "ACTIVE_OR_UNHEALTHY_VEHICLE"); + } + boolean forced = vehicle.getBatteryPercent() <= forcedChargeThreshold; + boolean opportunistic = vehicle.getBatteryPercent() + <= opportunisticChargeThreshold && !workload.hasReadyWork(); + if (!forced && !opportunistic) { + return new ChargingPolicyDecision( + ChargingPolicyDecision.Type.CONTINUE_WORK, null, + workload.hasReadyWork() ? "READY_WORK_HAS_PRIORITY" + : "BATTERY_ABOVE_CHARGE_THRESHOLD"); + } + if (chargers.isEmpty()) { + return new ChargingPolicyDecision( + ChargingPolicyDecision.Type.NO_CHARGER, null, + "NO_HEALTHY_REACHABLE_CHARGER"); + } + AvailableChargers.Charger charger = chargers.nearest(); + return new ChargingPolicyDecision(forced + ? ChargingPolicyDecision.Type.FORCED_CHARGE + : ChargingPolicyDecision.Type.OPPORTUNISTIC_CHARGE, + charger.getChargerId(), forced + ? "BATTERY_AT_FORCED_THRESHOLD" + : "IDLE_CAPACITY_CHARGE"); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ConveyorCoordinator.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ConveyorCoordinator.java new file mode 100644 index 0000000..4d7bbe6 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/ConveyorCoordinator.java @@ -0,0 +1,77 @@ +package com.ruoyi.wcs.application.device; + +import com.ruoyi.wcs.domain.equipment.ConveyorPortState; +import com.ruoyi.wcs.domain.equipment.PortMode; +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; +import com.ruoyi.wcs.port.out.PortSelectionPolicy; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ConveyorCoordinator implements PortSelectionPolicy { + private final Map ports = + new LinkedHashMap(); + + public void register(ConveyorPortState port) { + if (port == null) { + throw new IllegalArgumentException("port must not be null"); + } + ConveyorPortState existing = ports.put(port.getPortId(), port); + if (existing != null && existing != port) { + ports.put(port.getPortId(), existing); + throw new DomainRuleViolation("port is already registered"); + } + } + + public boolean requestModeSwitch(String portId, PortMode targetMode) { + return requirePort(portId).requestModeSwitch(targetMode); + } + + public void grantModeSwitch(String portId, PortMode targetMode) { + if (!requestModeSwitch(portId, targetMode)) { + throw new DomainRuleViolation( + "port mode switch cannot be granted while jobs remain bound"); + } + } + + public void completeModeSwitch(String portId) { + requirePort(portId).completeModeSwitch(); + } + + public void startTransfer(String stepId, String portId, PortMode direction) { + ConveyorPortState port = requirePort(portId); + if (!port.canAssign(direction)) { + throw new DomainRuleViolation( + "port is not assignable in the requested direction"); + } + port.bindJob(stepId); + } + + public void completeTransfer(String stepId, String portId) { + requirePort(portId).unbindJob(stepId); + } + + @Override + public boolean canAssign(String portId, PortMode direction) { + return requirePort(portId).canAssign(direction); + } + + public boolean canAssignOutbound(String portId) { + return canAssign(portId, PortMode.OUTBOUND); + } + + public boolean isSwitchLocked(String portId) { + return requirePort(portId).isSwitchLocked(); + } + + private ConveyorPortState requirePort(String portId) { + if (portId == null || portId.trim().isEmpty()) { + throw new IllegalArgumentException("portId must not be blank"); + } + ConveyorPortState port = ports.get(portId.trim()); + if (port == null) { + throw new DomainRuleViolation("conveyor port is not registered"); + } + return port; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/AvailableChargers.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/AvailableChargers.java new file mode 100644 index 0000000..ce262c7 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/AvailableChargers.java @@ -0,0 +1,49 @@ +package com.ruoyi.wcs.domain.equipment; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public final class AvailableChargers { + private final List chargers; + + public AvailableChargers(List chargers) { + if (chargers == null) { + throw new IllegalArgumentException("chargers must not be null"); + } + List copy = new ArrayList(chargers); + Collections.sort(copy, new Comparator() { + @Override + public int compare(Charger left, Charger right) { + int distance = Integer.compare(left.distance, right.distance); + return distance != 0 ? distance + : left.chargerId.compareTo(right.chargerId); + } + }); + this.chargers = Collections.unmodifiableList(copy); + } + + public boolean isEmpty() { return chargers.isEmpty(); } + public Charger nearest() { return chargers.isEmpty() ? null : chargers.get(0); } + public List getChargers() { return chargers; } + + public static final class Charger { + private final String chargerId; + private final int distance; + + public Charger(String chargerId, int distance) { + if (chargerId == null || chargerId.trim().isEmpty()) { + throw new IllegalArgumentException("chargerId must not be blank"); + } + if (distance < 0) { + throw new IllegalArgumentException("distance must not be negative"); + } + this.chargerId = chargerId.trim(); + this.distance = distance; + } + + public String getChargerId() { return chargerId; } + public int getDistance() { return distance; } + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ChargingPolicyDecision.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ChargingPolicyDecision.java new file mode 100644 index 0000000..8d10cb4 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ChargingPolicyDecision.java @@ -0,0 +1,29 @@ +package com.ruoyi.wcs.domain.equipment; + +public final class ChargingPolicyDecision { + public enum Type { + FORCED_CHARGE, + OPPORTUNISTIC_CHARGE, + CONTINUE_WORK, + NO_CHARGER, + NOT_ELIGIBLE + } + + private final Type type; + private final String chargerId; + private final String reasonCode; + + public ChargingPolicyDecision(Type type, String chargerId, + String reasonCode) { + this.type = java.util.Objects.requireNonNull(type, "type"); + this.chargerId = chargerId; + if (reasonCode == null || reasonCode.trim().isEmpty()) { + throw new IllegalArgumentException("reasonCode must not be blank"); + } + this.reasonCode = reasonCode.trim(); + } + + public Type getType() { return type; } + public String getChargerId() { return chargerId; } + public String getReasonCode() { return reasonCode; } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ConveyorPortState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ConveyorPortState.java new file mode 100644 index 0000000..4d1e981 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/ConveyorPortState.java @@ -0,0 +1,73 @@ +package com.ruoyi.wcs.domain.equipment; + +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class ConveyorPortState { + private final String portId; + private final Set boundJobIds = new LinkedHashSet(); + private PortMode mode; + private PortMode pendingMode; + private boolean switchLocked; + + public ConveyorPortState(String portId, PortMode mode) { + this.portId = requireText(portId, "portId"); + this.mode = java.util.Objects.requireNonNull(mode, "mode"); + } + + public void bindJob(String jobId) { + if (switchLocked) { + throw new DomainRuleViolation( + "new jobs are blocked while the port mode is switching"); + } + boundJobIds.add(requireText(jobId, "jobId")); + } + + public void unbindJob(String jobId) { + boundJobIds.remove(requireText(jobId, "jobId")); + } + + public boolean requestModeSwitch(PortMode targetMode) { + PortMode target = java.util.Objects.requireNonNull(targetMode, "targetMode"); + if (target == mode) { + return true; + } + if (!boundJobIds.isEmpty() || switchLocked) { + return false; + } + pendingMode = target; + switchLocked = true; + return true; + } + + public void completeModeSwitch() { + if (!switchLocked || pendingMode == null) { + throw new DomainRuleViolation("no port mode switch is pending"); + } + mode = pendingMode; + pendingMode = null; + switchLocked = false; + } + + public boolean canAssign(PortMode direction) { + return !switchLocked && mode == direction; + } + + public String getPortId() { return portId; } + public PortMode getMode() { return mode; } + public PortMode getPendingMode() { return pendingMode; } + public boolean isSwitchLocked() { return switchLocked; } + public Set getBoundJobIds() { + return Collections.unmodifiableSet(new LinkedHashSet(boundJobIds)); + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PendingWorkload.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PendingWorkload.java new file mode 100644 index 0000000..bd7461a --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PendingWorkload.java @@ -0,0 +1,30 @@ +package com.ruoyi.wcs.domain.equipment; + +import java.time.Duration; +import java.util.Objects; + +public final class PendingWorkload { + private final int urgentReadyCount; + private final int ordinaryReadyCount; + private final Duration oldestWait; + + public PendingWorkload(int urgentReadyCount, int ordinaryReadyCount, + Duration oldestWait) { + if (urgentReadyCount < 0 || ordinaryReadyCount < 0) { + throw new IllegalArgumentException("workload counts must not be negative"); + } + this.urgentReadyCount = urgentReadyCount; + this.ordinaryReadyCount = ordinaryReadyCount; + this.oldestWait = Objects.requireNonNull(oldestWait, "oldestWait"); + if (oldestWait.isNegative()) { + throw new IllegalArgumentException("oldestWait must not be negative"); + } + } + + public int getUrgentReadyCount() { return urgentReadyCount; } + public int getOrdinaryReadyCount() { return ordinaryReadyCount; } + public Duration getOldestWait() { return oldestWait; } + public boolean hasReadyWork() { + return urgentReadyCount > 0 || ordinaryReadyCount > 0; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PortMode.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PortMode.java new file mode 100644 index 0000000..3cb8407 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/PortMode.java @@ -0,0 +1,6 @@ +package com.ruoyi.wcs.domain.equipment; + +public enum PortMode { + INBOUND, + OUTBOUND +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ChargingPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ChargingPolicy.java new file mode 100644 index 0000000..a4e8981 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/ChargingPolicy.java @@ -0,0 +1,12 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.equipment.AvailableChargers; +import com.ruoyi.wcs.domain.equipment.ChargingPolicyDecision; +import com.ruoyi.wcs.domain.equipment.PendingWorkload; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; + +public interface ChargingPolicy { + ChargingPolicyDecision decide(VehicleSnapshot vehicle, + PendingWorkload workload, + AvailableChargers chargers); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/PortSelectionPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/PortSelectionPolicy.java new file mode 100644 index 0000000..96efc79 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/PortSelectionPolicy.java @@ -0,0 +1,7 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.equipment.PortMode; + +public interface PortSelectionPolicy { + boolean canAssign(String portId, PortMode direction); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/ConveyorAndChargingCoordinatorTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/ConveyorAndChargingCoordinatorTest.java new file mode 100644 index 0000000..b23d835 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/ConveyorAndChargingCoordinatorTest.java @@ -0,0 +1,90 @@ +package com.ruoyi.wcs.application.device; + +import com.ruoyi.wcs.domain.equipment.AvailableChargers; +import com.ruoyi.wcs.domain.equipment.ChargingPolicyDecision; +import com.ruoyi.wcs.domain.equipment.ConveyorPortState; +import com.ruoyi.wcs.domain.equipment.DeviceCapability; +import com.ruoyi.wcs.domain.equipment.DeviceDescriptor; +import com.ruoyi.wcs.domain.equipment.DeviceType; +import com.ruoyi.wcs.domain.equipment.PendingWorkload; +import com.ruoyi.wcs.domain.equipment.PortMode; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConveyorAndChargingCoordinatorTest { + + @Test + void locksPortAgainstNewOutboundAssignmentDuringInboundSwitchPermission() { + ConveyorCoordinator coordinator = new ConveyorCoordinator(); + coordinator.register(new ConveyorPortState("PORT-1", PortMode.OUTBOUND)); + + coordinator.grantModeSwitch("PORT-1", PortMode.INBOUND); + + assertFalse(coordinator.canAssignOutbound("PORT-1")); + assertTrue(coordinator.isSwitchLocked("PORT-1")); + } + + @Test + void refusesModeSwitchWhileJobsRemainBoundToThePort() { + ConveyorCoordinator coordinator = new ConveyorCoordinator(); + ConveyorPortState port = new ConveyorPortState("PORT-1", PortMode.OUTBOUND); + port.bindJob("JOB-1"); + coordinator.register(port); + + assertFalse(coordinator.requestModeSwitch("PORT-1", PortMode.INBOUND)); + assertTrue(coordinator.canAssignOutbound("PORT-1")); + } + + @Test + void forcedChargeWinsOverOrdinaryReadyTask() { + ChargingCoordinator charging = new ChargingCoordinator(20, 40); + + ChargingPolicyDecision decision = charging.decide(vehicle(15, + VehicleSnapshot.OccupationState.IDLE), + new PendingWorkload(0, 3, Duration.ofMinutes(2)), chargers()); + + assertEquals(ChargingPolicyDecision.Type.FORCED_CHARGE, + decision.getType()); + assertEquals("CHARGER-NEAR", decision.getChargerId()); + } + + @Test + void batteryThresholdNeverInterruptsAnActivePhysicalTask() { + ChargingCoordinator charging = new ChargingCoordinator(20, 40); + + ChargingPolicyDecision decision = charging.decide(vehicle(10, + VehicleSnapshot.OccupationState.BUSY), + new PendingWorkload(0, 0, Duration.ZERO), chargers()); + + assertEquals(ChargingPolicyDecision.Type.NOT_ELIGIBLE, + decision.getType()); + } + + private static AvailableChargers chargers() { + return new AvailableChargers(Arrays.asList( + new AvailableChargers.Charger("CHARGER-FAR", 8), + new AvailableChargers.Charger("CHARGER-NEAR", 2))); + } + + private static VehicleSnapshot vehicle( + int battery, VehicleSnapshot.OccupationState occupation) { + DeviceDescriptor descriptor = new DeviceDescriptor("V1", + DeviceType.FOUR_WAY_SHUTTLE, "F1", + Collections.singleton(DeviceCapability.CHARGING)); + return new VehicleSnapshot(descriptor, + VehicleSnapshot.ConnectionState.ONLINE, + VehicleSnapshot.OperatingMode.AUTO, + VehicleSnapshot.HealthState.NORMAL, occupation, + "A", null, battery, occupation == VehicleSnapshot.OccupationState.BUSY + ? "CMD-1" : null, "F1", Instant.EPOCH); + } +}