feat(wcs): coordinate lift joint resources

This commit is contained in:
zgw
2026-08-11 10:46:13 +08:00
parent 0d09eb93da
commit 6030deb227
8 changed files with 579 additions and 5 deletions

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package com.ruoyi.wcs.domain.equipment;
public enum LiftDecisionType {
READY,
BLOCKED,
ADVANCED,
COMPLETED,
RECOVERY_REQUIRED
}

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public final class DeviceSnapshot {
@@ -20,6 +22,7 @@ public final class DeviceSnapshot {
private final String currentNodeId;
private final List<String> remainingRouteNodes;
private final boolean commandCompleted;
private final Map<String, String> facts;
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
String taskNumber, long deviceSequence,
@@ -28,6 +31,19 @@ public final class DeviceSnapshot {
long fencingToken, String currentNodeId,
List<String> remainingRouteNodes,
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.deviceId = requireText(deviceId, "deviceId");
this.commandKey = requireText(commandKey, "commandKey");
@@ -44,6 +60,7 @@ public final class DeviceSnapshot {
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
this.remainingRouteNodes = immutable(remainingRouteNodes);
this.commandCompleted = commandCompleted;
this.facts = immutableFacts(facts);
}
public String getSnapshotId() { return snapshotId; }
@@ -59,6 +76,19 @@ public final class DeviceSnapshot {
public String getCurrentNodeId() { return currentNodeId; }
public List<String> getRemainingRouteNodes() { return remainingRouteNodes; }
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) {
if (source == null || source.isEmpty()) {

View File

@@ -62,11 +62,16 @@ public final class ResourceLedger {
if (!existing.matches(request)) {
throw new DomainRuleViolation("reservation id reused with different request");
}
return existing.getState() == ReservationState.GRANTED
|| existing.getState() == ReservationState.OCCUPIED
? ReservationDecision.granted(existing)
: ReservationDecision.rejected(existing.getReservationId(),
existing.getResourceKeys(), existing.getState().name());
if (existing.getState() == ReservationState.REJECTED
&& "CAPACITY_CONFLICT".equals(existing.getTerminalReason())) {
reservations.remove(request.getReservationId());
} else {
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);

View File

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