feat(wcs): coordinate lift joint resources
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
package com.ruoyi.wcs.application.device;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftDecision;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftDecisionType;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftTransfer;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftTransferState;
|
||||||
|
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ReservationState;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||||
|
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
|
||||||
|
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
public final class LiftCoordinator {
|
||||||
|
private final ResourceLedger ledger;
|
||||||
|
private final DeviceCommandRepository commandRepository;
|
||||||
|
private final CommandOutboxRepository outboxRepository;
|
||||||
|
private final IdGenerator idGenerator;
|
||||||
|
private final long topologyVersion;
|
||||||
|
private final Supplier<Instant> clock;
|
||||||
|
|
||||||
|
public LiftCoordinator(ResourceLedger ledger,
|
||||||
|
DeviceCommandRepository commandRepository,
|
||||||
|
CommandOutboxRepository outboxRepository,
|
||||||
|
IdGenerator idGenerator, long topologyVersion,
|
||||||
|
Supplier<Instant> clock) {
|
||||||
|
this.ledger = Objects.requireNonNull(ledger, "ledger");
|
||||||
|
this.commandRepository = Objects.requireNonNull(commandRepository,
|
||||||
|
"commandRepository");
|
||||||
|
this.outboxRepository = Objects.requireNonNull(outboxRepository,
|
||||||
|
"outboxRepository");
|
||||||
|
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
|
||||||
|
if (topologyVersion <= 0L) {
|
||||||
|
throw new IllegalArgumentException("topologyVersion must be positive");
|
||||||
|
}
|
||||||
|
this.topologyVersion = topologyVersion;
|
||||||
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LiftDecision prepare(String stepId, String vehicleId, String liftId,
|
||||||
|
String sourcePort, String targetPort) {
|
||||||
|
String step = requireSafe(stepId, "stepId");
|
||||||
|
String vehicle = requireSafe(vehicleId, "vehicleId");
|
||||||
|
String lift = requireSafe(liftId, "liftId");
|
||||||
|
String source = requireSafe(sourcePort, "sourcePort");
|
||||||
|
String target = requireSafe(targetPort, "targetPort");
|
||||||
|
String reservationId = "LIFT-RES:" + step;
|
||||||
|
ReservationRequest request = new ReservationRequest(reservationId,
|
||||||
|
"LIFT-ORDER:" + step, step, vehicle, Arrays.asList(
|
||||||
|
"LIFT_PORT:" + source, "LIFT:" + lift,
|
||||||
|
"LIFT_PORT:" + target));
|
||||||
|
ReservationDecision reservation = ledger.reserve(request);
|
||||||
|
if (!reservation.isGranted()) {
|
||||||
|
return new LiftDecision(LiftDecisionType.BLOCKED,
|
||||||
|
reservation.getReasonCode(), null);
|
||||||
|
}
|
||||||
|
if (ledger.reservation(reservationId).getState()
|
||||||
|
== ReservationState.GRANTED) {
|
||||||
|
ledger.occupy(reservationId, reservation.getFencingToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
String commandKey = "LIFT:" + step;
|
||||||
|
LiftTransfer transfer = LiftTransfer.prepared(step, vehicle, lift,
|
||||||
|
source, target, commandKey, reservationId,
|
||||||
|
reservation.getFencingToken());
|
||||||
|
DeviceCommand existing = commandRepository.findByCommandKey(commandKey);
|
||||||
|
if (existing == null) {
|
||||||
|
Instant now = Objects.requireNonNull(clock.get(), "clock returned null");
|
||||||
|
String payload = "stepId=" + step + "|vehicleId=" + vehicle
|
||||||
|
+ "|sourcePort=" + source + "|targetPort=" + target;
|
||||||
|
DeviceCommand command = DeviceCommand.pending(
|
||||||
|
requireSafe(idGenerator.nextId(), "generated commandId"),
|
||||||
|
commandKey, lift, DeviceCommandType.LIFT_TRANSFER,
|
||||||
|
payload, 1, now);
|
||||||
|
commandRepository.save(command);
|
||||||
|
outboxRepository.saveIfAbsent(CommandOutboxEntry.pending(
|
||||||
|
command.getCommandId(), commandKey, payload, now,
|
||||||
|
topologyVersion, reservation.getFencingToken()));
|
||||||
|
} else if (!existing.getDeviceId().equals(lift)) {
|
||||||
|
throw new DomainRuleViolation(
|
||||||
|
"lift command key reused for another lift");
|
||||||
|
}
|
||||||
|
return new LiftDecision(LiftDecisionType.READY,
|
||||||
|
"JOINT_RESOURCES_RESERVED", transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LiftDecision onVehicleLeftTarget(LiftTransfer transfer,
|
||||||
|
DeviceSnapshot snapshot) {
|
||||||
|
Objects.requireNonNull(transfer, "transfer");
|
||||||
|
Objects.requireNonNull(snapshot, "snapshot");
|
||||||
|
String mismatch = mismatchReason(transfer, snapshot);
|
||||||
|
if (mismatch != null) {
|
||||||
|
if (ledger.reservation(transfer.getReservationId()).getState()
|
||||||
|
== ReservationState.OCCUPIED) {
|
||||||
|
ledger.markRecoveryUnresolved(transfer.getReservationId());
|
||||||
|
}
|
||||||
|
return new LiftDecision(LiftDecisionType.RECOVERY_REQUIRED,
|
||||||
|
mismatch, transfer);
|
||||||
|
}
|
||||||
|
transfer.completeAfterVehicleLeft(snapshot.getSnapshotId());
|
||||||
|
com.ruoyi.wcs.domain.traffic.ResourceReservation held =
|
||||||
|
ledger.reservation(transfer.getReservationId());
|
||||||
|
if (held.isCommandUnknown() || held.isRecoveryUnresolved()) {
|
||||||
|
ledger.clearProtectionAfterRecovery(transfer.getReservationId(),
|
||||||
|
snapshot.getSnapshotId());
|
||||||
|
}
|
||||||
|
ledger.release(transfer.getReservationId(), snapshot.getSnapshotId(),
|
||||||
|
transfer.getFencingToken());
|
||||||
|
return new LiftDecision(LiftDecisionType.COMPLETED,
|
||||||
|
"VEHICLE_LEFT_TARGET_PORT", transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mismatchReason(LiftTransfer transfer, DeviceSnapshot snapshot) {
|
||||||
|
if (transfer.getState() != LiftTransferState.TARGET_EXIT_COMMITTED) {
|
||||||
|
return "TARGET_EXIT_NOT_COMMITTED";
|
||||||
|
}
|
||||||
|
if (!snapshot.isConnected()) {
|
||||||
|
return "COMMUNICATION_LOST";
|
||||||
|
}
|
||||||
|
if (!transfer.getVehicleId().equals(snapshot.getDeviceId())) {
|
||||||
|
return "VEHICLE_MISMATCH";
|
||||||
|
}
|
||||||
|
if (!transfer.getCommandKey().equals(snapshot.getCommandKey())) {
|
||||||
|
return "COMMAND_KEY_MISMATCH";
|
||||||
|
}
|
||||||
|
if (!transfer.getStepId().equals(snapshot.getTaskNumber())) {
|
||||||
|
return "TASK_NUMBER_MISMATCH";
|
||||||
|
}
|
||||||
|
if (snapshot.getTopologyVersion() != topologyVersion) {
|
||||||
|
return "TOPOLOGY_VERSION_MISMATCH";
|
||||||
|
}
|
||||||
|
if (snapshot.getFencingToken() != transfer.getFencingToken()) {
|
||||||
|
return "FENCING_TOKEN_MISMATCH";
|
||||||
|
}
|
||||||
|
if (!transfer.getTargetPort().equals(snapshot.getCurrentNodeId())
|
||||||
|
|| !snapshot.isCommandCompleted()) {
|
||||||
|
return "VEHICLE_NOT_CLEAR_OF_TARGET";
|
||||||
|
}
|
||||||
|
Map<String, String> facts = snapshot.getFacts();
|
||||||
|
if (!transfer.getLiftId().equals(facts.get("liftId"))) {
|
||||||
|
return "LIFT_ID_MISMATCH";
|
||||||
|
}
|
||||||
|
if (!transfer.getTargetPort().equals(facts.get("liftFloor"))) {
|
||||||
|
return "LIFT_FLOOR_MISMATCH";
|
||||||
|
}
|
||||||
|
if (!"true".equals(facts.get("liftDoorOpen"))) {
|
||||||
|
return "LIFT_DOOR_NOT_PROVEN_OPEN";
|
||||||
|
}
|
||||||
|
if (!"true".equals(facts.get("targetPortClear"))) {
|
||||||
|
return "TARGET_PORT_NOT_PROVEN_CLEAR";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireSafe(String value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
}
|
||||||
|
String text = value.trim();
|
||||||
|
if (text.indexOf('|') >= 0 || text.indexOf('=') >= 0) {
|
||||||
|
throw new IllegalArgumentException(field + " contains reserved delimiter");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ruoyi.wcs.domain.equipment;
|
||||||
|
|
||||||
|
public final class LiftDecision {
|
||||||
|
private final LiftDecisionType type;
|
||||||
|
private final String reasonCode;
|
||||||
|
private final LiftTransfer transfer;
|
||||||
|
|
||||||
|
public LiftDecision(LiftDecisionType type, String reasonCode,
|
||||||
|
LiftTransfer transfer) {
|
||||||
|
this.type = java.util.Objects.requireNonNull(type, "type");
|
||||||
|
this.reasonCode = requireText(reasonCode, "reasonCode");
|
||||||
|
this.transfer = transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LiftDecisionType getType() { return type; }
|
||||||
|
public String getReasonCode() { return reasonCode; }
|
||||||
|
public LiftTransfer getTransfer() { return transfer; }
|
||||||
|
|
||||||
|
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,9 @@
|
|||||||
|
package com.ruoyi.wcs.domain.equipment;
|
||||||
|
|
||||||
|
public enum LiftDecisionType {
|
||||||
|
READY,
|
||||||
|
BLOCKED,
|
||||||
|
ADVANCED,
|
||||||
|
COMPLETED,
|
||||||
|
RECOVERY_REQUIRED
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.ruoyi.wcs.domain.equipment;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||||
|
|
||||||
|
public final class LiftTransfer {
|
||||||
|
private final String stepId;
|
||||||
|
private final String vehicleId;
|
||||||
|
private final String liftId;
|
||||||
|
private final String sourcePort;
|
||||||
|
private final String targetPort;
|
||||||
|
private final String commandKey;
|
||||||
|
private final String reservationId;
|
||||||
|
private final long fencingToken;
|
||||||
|
private LiftTransferState state;
|
||||||
|
private String lastEvidenceId;
|
||||||
|
|
||||||
|
private LiftTransfer(String stepId, String vehicleId, String liftId,
|
||||||
|
String sourcePort, String targetPort, String commandKey,
|
||||||
|
String reservationId, long fencingToken) {
|
||||||
|
this.stepId = requireText(stepId, "stepId");
|
||||||
|
this.vehicleId = requireText(vehicleId, "vehicleId");
|
||||||
|
this.liftId = requireText(liftId, "liftId");
|
||||||
|
this.sourcePort = requireText(sourcePort, "sourcePort");
|
||||||
|
this.targetPort = requireText(targetPort, "targetPort");
|
||||||
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
|
this.reservationId = requireText(reservationId, "reservationId");
|
||||||
|
if (fencingToken <= 0L) {
|
||||||
|
throw new IllegalArgumentException("fencingToken must be positive");
|
||||||
|
}
|
||||||
|
this.fencingToken = fencingToken;
|
||||||
|
this.state = LiftTransferState.SOURCE_ENTRY_COMMITTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LiftTransfer prepared(String stepId, String vehicleId,
|
||||||
|
String liftId, String sourcePort,
|
||||||
|
String targetPort, String commandKey,
|
||||||
|
String reservationId,
|
||||||
|
long fencingToken) {
|
||||||
|
return new LiftTransfer(stepId, vehicleId, liftId, sourcePort,
|
||||||
|
targetPort, commandKey, reservationId, fencingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void vehicleEnteredLift(String evidenceId) {
|
||||||
|
transition(LiftTransferState.SOURCE_ENTRY_COMMITTED,
|
||||||
|
LiftTransferState.VEHICLE_IN_LIFT, evidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void liftStartedMoving(String evidenceId) {
|
||||||
|
transition(LiftTransferState.VEHICLE_IN_LIFT,
|
||||||
|
LiftTransferState.LIFT_MOVING, evidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void arrivedAtTarget(String evidenceId) {
|
||||||
|
transition(LiftTransferState.LIFT_MOVING,
|
||||||
|
LiftTransferState.AT_TARGET, evidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void commitTargetExit(String evidenceId) {
|
||||||
|
transition(LiftTransferState.AT_TARGET,
|
||||||
|
LiftTransferState.TARGET_EXIT_COMMITTED, evidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void completeAfterVehicleLeft(String evidenceId) {
|
||||||
|
transition(LiftTransferState.TARGET_EXIT_COMMITTED,
|
||||||
|
LiftTransferState.VEHICLE_LEFT_LIFT, evidenceId);
|
||||||
|
state = LiftTransferState.COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void transition(LiftTransferState required, LiftTransferState next,
|
||||||
|
String evidenceId) {
|
||||||
|
if (state != required) {
|
||||||
|
throw new DomainRuleViolation("lift transfer state must be " + required
|
||||||
|
+ " but was " + state);
|
||||||
|
}
|
||||||
|
lastEvidenceId = requireText(evidenceId, "evidenceId");
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStepId() { return stepId; }
|
||||||
|
public String getVehicleId() { return vehicleId; }
|
||||||
|
public String getLiftId() { return liftId; }
|
||||||
|
public String getSourcePort() { return sourcePort; }
|
||||||
|
public String getTargetPort() { return targetPort; }
|
||||||
|
public String getCommandKey() { return commandKey; }
|
||||||
|
public String getReservationId() { return reservationId; }
|
||||||
|
public long getFencingToken() { return fencingToken; }
|
||||||
|
public LiftTransferState getState() { return state; }
|
||||||
|
public String getLastEvidenceId() { return lastEvidenceId; }
|
||||||
|
|
||||||
|
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,12 @@
|
|||||||
|
package com.ruoyi.wcs.domain.equipment;
|
||||||
|
|
||||||
|
public enum LiftTransferState {
|
||||||
|
WAITING_SOURCE,
|
||||||
|
SOURCE_ENTRY_COMMITTED,
|
||||||
|
VEHICLE_IN_LIFT,
|
||||||
|
LIFT_MOVING,
|
||||||
|
AT_TARGET,
|
||||||
|
TARGET_EXIT_COMMITTED,
|
||||||
|
VEHICLE_LEFT_LIFT,
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import java.time.Instant;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class DeviceSnapshot {
|
public final class DeviceSnapshot {
|
||||||
@@ -20,6 +22,7 @@ public final class DeviceSnapshot {
|
|||||||
private final String currentNodeId;
|
private final String currentNodeId;
|
||||||
private final List<String> remainingRouteNodes;
|
private final List<String> remainingRouteNodes;
|
||||||
private final boolean commandCompleted;
|
private final boolean commandCompleted;
|
||||||
|
private final Map<String, String> facts;
|
||||||
|
|
||||||
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
||||||
String taskNumber, long deviceSequence,
|
String taskNumber, long deviceSequence,
|
||||||
@@ -28,6 +31,19 @@ public final class DeviceSnapshot {
|
|||||||
long fencingToken, String currentNodeId,
|
long fencingToken, String currentNodeId,
|
||||||
List<String> remainingRouteNodes,
|
List<String> remainingRouteNodes,
|
||||||
boolean commandCompleted) {
|
boolean commandCompleted) {
|
||||||
|
this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence,
|
||||||
|
observedAt, connected, deviceBootId, topologyVersion,
|
||||||
|
fencingToken, currentNodeId, remainingRouteNodes,
|
||||||
|
commandCompleted, Collections.<String, String>emptyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
||||||
|
String taskNumber, long deviceSequence,
|
||||||
|
Instant observedAt, boolean connected,
|
||||||
|
String deviceBootId, long topologyVersion,
|
||||||
|
long fencingToken, String currentNodeId,
|
||||||
|
List<String> remainingRouteNodes,
|
||||||
|
boolean commandCompleted, Map<String, String> facts) {
|
||||||
this.snapshotId = requireText(snapshotId, "snapshotId");
|
this.snapshotId = requireText(snapshotId, "snapshotId");
|
||||||
this.deviceId = requireText(deviceId, "deviceId");
|
this.deviceId = requireText(deviceId, "deviceId");
|
||||||
this.commandKey = requireText(commandKey, "commandKey");
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
@@ -44,6 +60,7 @@ public final class DeviceSnapshot {
|
|||||||
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
|
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
|
||||||
this.remainingRouteNodes = immutable(remainingRouteNodes);
|
this.remainingRouteNodes = immutable(remainingRouteNodes);
|
||||||
this.commandCompleted = commandCompleted;
|
this.commandCompleted = commandCompleted;
|
||||||
|
this.facts = immutableFacts(facts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSnapshotId() { return snapshotId; }
|
public String getSnapshotId() { return snapshotId; }
|
||||||
@@ -59,6 +76,19 @@ public final class DeviceSnapshot {
|
|||||||
public String getCurrentNodeId() { return currentNodeId; }
|
public String getCurrentNodeId() { return currentNodeId; }
|
||||||
public List<String> getRemainingRouteNodes() { return remainingRouteNodes; }
|
public List<String> getRemainingRouteNodes() { return remainingRouteNodes; }
|
||||||
public boolean isCommandCompleted() { return commandCompleted; }
|
public boolean isCommandCompleted() { return commandCompleted; }
|
||||||
|
public Map<String, String> getFacts() { return facts; }
|
||||||
|
|
||||||
|
private static Map<String, String> immutableFacts(Map<String, String> source) {
|
||||||
|
if (source == null) {
|
||||||
|
throw new IllegalArgumentException("facts must not be null");
|
||||||
|
}
|
||||||
|
Map<String, String> copy = new LinkedHashMap<String, String>();
|
||||||
|
for (Map.Entry<String, String> entry : source.entrySet()) {
|
||||||
|
copy.put(requireText(entry.getKey(), "fact key"),
|
||||||
|
requireText(entry.getValue(), "fact value"));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableMap(copy);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<String> immutable(List<String> source) {
|
private static List<String> immutable(List<String> source) {
|
||||||
if (source == null || source.isEmpty()) {
|
if (source == null || source.isEmpty()) {
|
||||||
|
|||||||
@@ -62,11 +62,16 @@ public final class ResourceLedger {
|
|||||||
if (!existing.matches(request)) {
|
if (!existing.matches(request)) {
|
||||||
throw new DomainRuleViolation("reservation id reused with different request");
|
throw new DomainRuleViolation("reservation id reused with different request");
|
||||||
}
|
}
|
||||||
return existing.getState() == ReservationState.GRANTED
|
if (existing.getState() == ReservationState.REJECTED
|
||||||
|| existing.getState() == ReservationState.OCCUPIED
|
&& "CAPACITY_CONFLICT".equals(existing.getTerminalReason())) {
|
||||||
? ReservationDecision.granted(existing)
|
reservations.remove(request.getReservationId());
|
||||||
: ReservationDecision.rejected(existing.getReservationId(),
|
} else {
|
||||||
existing.getResourceKeys(), existing.getState().name());
|
return existing.getState() == ReservationState.GRANTED
|
||||||
|
|| existing.getState() == ReservationState.OCCUPIED
|
||||||
|
? ReservationDecision.granted(existing)
|
||||||
|
: ReservationDecision.rejected(existing.getReservationId(),
|
||||||
|
existing.getResourceKeys(), existing.getState().name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ResourceReservation reservation = new ResourceReservation(request);
|
ResourceReservation reservation = new ResourceReservation(request);
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.ruoyi.wcs.application.device;
|
||||||
|
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftDecision;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftDecisionType;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftTransfer;
|
||||||
|
import com.ruoyi.wcs.domain.equipment.LiftTransferState;
|
||||||
|
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||||
|
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
|
||||||
|
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
|
||||||
|
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||||
|
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
|
||||||
|
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class LiftCoordinatorTest {
|
||||||
|
private ResourceLedger ledger;
|
||||||
|
private FakeCommandRepository commands;
|
||||||
|
private FakeOutboxRepository outbox;
|
||||||
|
private LiftCoordinator coordinator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Map<String, Integer> capacities = new LinkedHashMap<String, Integer>();
|
||||||
|
capacities.put("LIFT_PORT:P1", 1);
|
||||||
|
capacities.put("LIFT:L1", 1);
|
||||||
|
capacities.put("LIFT_PORT:P2", 1);
|
||||||
|
ledger = new ResourceLedger(capacities);
|
||||||
|
commands = new FakeCommandRepository();
|
||||||
|
outbox = new FakeOutboxRepository();
|
||||||
|
coordinator = new LiftCoordinator(ledger, commands, outbox,
|
||||||
|
new IdGenerator() {
|
||||||
|
@Override
|
||||||
|
public String nextId() {
|
||||||
|
return "LIFT-CMD-1";
|
||||||
|
}
|
||||||
|
}, 7L, new java.util.function.Supplier<Instant>() {
|
||||||
|
@Override
|
||||||
|
public Instant get() {
|
||||||
|
return Instant.EPOCH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotEnterLiftWhenTargetExitCannotBeReserved() {
|
||||||
|
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
|
||||||
|
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
|
||||||
|
Collections.singletonList("LIFT_PORT:P2")));
|
||||||
|
ledger.occupy("OTHER-RES", occupied.getFencingToken());
|
||||||
|
|
||||||
|
LiftDecision decision = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2");
|
||||||
|
|
||||||
|
assertEquals(LiftDecisionType.BLOCKED, decision.getType());
|
||||||
|
assertEquals(0, commands.values.size());
|
||||||
|
assertEquals(0, outbox.values.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blockedPreparationCanRetryAfterTheTargetPortBecomesAvailable() {
|
||||||
|
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
|
||||||
|
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
|
||||||
|
Collections.singletonList("LIFT_PORT:P2")));
|
||||||
|
ledger.occupy("OTHER-RES", occupied.getFencingToken());
|
||||||
|
LiftDecision blocked = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2");
|
||||||
|
ledger.release("OTHER-RES", "OTHER-LEFT",
|
||||||
|
occupied.getFencingToken());
|
||||||
|
|
||||||
|
LiftDecision retried = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2");
|
||||||
|
|
||||||
|
assertEquals(LiftDecisionType.BLOCKED, blocked.getType());
|
||||||
|
assertEquals(LiftDecisionType.READY, retried.getType());
|
||||||
|
assertEquals(1, commands.values.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releasesLiftOnlyAfterVehicleLeavesTargetPort() {
|
||||||
|
LiftDecision prepared = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2");
|
||||||
|
LiftTransfer transfer = prepared.getTransfer();
|
||||||
|
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||||
|
transfer.liftStartedMoving("LIFT-MOVING");
|
||||||
|
transfer.arrivedAtTarget("AT-TARGET");
|
||||||
|
transfer.commitTargetExit("TARGET-EXIT");
|
||||||
|
|
||||||
|
LiftDecision completed = coordinator.onVehicleLeftTarget(
|
||||||
|
transfer, matchingFacts());
|
||||||
|
|
||||||
|
assertEquals(LiftDecisionType.COMPLETED, completed.getType());
|
||||||
|
assertEquals(LiftTransferState.COMPLETED, transfer.getState());
|
||||||
|
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
|
||||||
|
assertEquals(1, ledger.availableCapacity("LIFT_PORT:P2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mismatchingDoorOrFloorFactsKeepAllJointResourcesIsolated() {
|
||||||
|
LiftTransfer transfer = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
|
||||||
|
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||||
|
transfer.liftStartedMoving("LIFT-MOVING");
|
||||||
|
transfer.arrivedAtTarget("AT-TARGET");
|
||||||
|
transfer.commitTargetExit("TARGET-EXIT");
|
||||||
|
Map<String, String> facts = matchingFactMap();
|
||||||
|
facts.put("liftDoorOpen", "false");
|
||||||
|
|
||||||
|
LiftDecision decision = coordinator.onVehicleLeftTarget(
|
||||||
|
transfer, snapshot(facts));
|
||||||
|
|
||||||
|
assertEquals(LiftDecisionType.RECOVERY_REQUIRED, decision.getType());
|
||||||
|
assertEquals(0, ledger.availableCapacity("LIFT:L1"));
|
||||||
|
assertEquals(0, ledger.availableCapacity("LIFT_PORT:P2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laterMatchingCombinedFactsCanResolveTheIsolationAndRelease() {
|
||||||
|
LiftTransfer transfer = coordinator.prepare(
|
||||||
|
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
|
||||||
|
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||||
|
transfer.liftStartedMoving("LIFT-MOVING");
|
||||||
|
transfer.arrivedAtTarget("AT-TARGET");
|
||||||
|
transfer.commitTargetExit("TARGET-EXIT");
|
||||||
|
Map<String, String> mismatch = matchingFactMap();
|
||||||
|
mismatch.put("liftFloor", "P1");
|
||||||
|
coordinator.onVehicleLeftTarget(transfer, snapshot(mismatch));
|
||||||
|
|
||||||
|
LiftDecision recovered = coordinator.onVehicleLeftTarget(
|
||||||
|
transfer, matchingFacts());
|
||||||
|
|
||||||
|
assertEquals(LiftDecisionType.COMPLETED, recovered.getType());
|
||||||
|
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeviceSnapshot matchingFacts() {
|
||||||
|
return snapshot(matchingFactMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> matchingFactMap() {
|
||||||
|
Map<String, String> facts = new LinkedHashMap<String, String>();
|
||||||
|
facts.put("liftId", "L1");
|
||||||
|
facts.put("liftFloor", "P2");
|
||||||
|
facts.put("liftDoorOpen", "true");
|
||||||
|
facts.put("targetPortClear", "true");
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeviceSnapshot snapshot(Map<String, String> facts) {
|
||||||
|
return new DeviceSnapshot("LIFT-SNAPSHOT-1", "V1",
|
||||||
|
"LIFT:STEP-1", "STEP-1", 10L, Instant.EPOCH.plusSeconds(10),
|
||||||
|
true, "BOOT-1", 7L, 1L, "P2",
|
||||||
|
Collections.singletonList("P2"), true, facts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeCommandRepository
|
||||||
|
implements DeviceCommandRepository {
|
||||||
|
private final Map<String, DeviceCommand> values =
|
||||||
|
new LinkedHashMap<String, DeviceCommand>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DeviceCommand findByCommandKey(String commandKey) {
|
||||||
|
for (DeviceCommand command : values.values()) {
|
||||||
|
if (command.getCommandKey().equals(commandKey)) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DeviceCommand findByCommandId(String commandId) {
|
||||||
|
return values.get(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(DeviceCommand command) {
|
||||||
|
values.put(command.getCommandId(), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeOutboxRepository
|
||||||
|
implements CommandOutboxRepository {
|
||||||
|
private final Map<String, CommandOutboxEntry> values =
|
||||||
|
new LinkedHashMap<String, CommandOutboxEntry>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveIfAbsent(CommandOutboxEntry entry) {
|
||||||
|
if (!values.containsKey(entry.getCommandId())) {
|
||||||
|
values.put(entry.getCommandId(), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(CommandOutboxEntry entry) {
|
||||||
|
values.put(entry.getCommandId(), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CommandOutboxEntry> claimDue(int limit, Instant now) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user