feat(wcs): coordinate conveyor ports and charging
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, ConveyorPortState> ports =
|
||||
new LinkedHashMap<String, ConveyorPortState>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Charger> chargers;
|
||||
|
||||
public AvailableChargers(List<Charger> chargers) {
|
||||
if (chargers == null) {
|
||||
throw new IllegalArgumentException("chargers must not be null");
|
||||
}
|
||||
List<Charger> copy = new ArrayList<Charger>(chargers);
|
||||
Collections.sort(copy, new Comparator<Charger>() {
|
||||
@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<Charger> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<String> boundJobIds = new LinkedHashSet<String>();
|
||||
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<String> getBoundJobIds() {
|
||||
return Collections.unmodifiableSet(new LinkedHashSet<String>(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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.wcs.domain.equipment;
|
||||
|
||||
public enum PortMode {
|
||||
INBOUND,
|
||||
OUTBOUND
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user