feat(wcs): coordinate conveyor ports and charging

This commit is contained in:
zgw
2026-08-11 10:51:23 +08:00
parent 6030deb227
commit ef3cac9e3e
10 changed files with 438 additions and 0 deletions

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.wcs.domain.equipment;
public enum PortMode {
INBOUND,
OUTBOUND
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}