From 6030deb2271fd1b37afd8bad346d64d22a8aa078 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Tue, 11 Aug 2026 10:46:13 +0800 Subject: [PATCH] feat(wcs): coordinate lift joint resources --- .../application/device/LiftCoordinator.java | 181 +++++++++++++++ .../wcs/domain/equipment/LiftDecision.java | 25 ++ .../domain/equipment/LiftDecisionType.java | 9 + .../wcs/domain/equipment/LiftTransfer.java | 96 ++++++++ .../domain/equipment/LiftTransferState.java | 12 + .../wcs/domain/execution/DeviceSnapshot.java | 30 +++ .../wcs/domain/traffic/ResourceLedger.java | 15 +- .../device/LiftCoordinatorTest.java | 216 ++++++++++++++++++ 8 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/LiftCoordinator.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecision.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecisionType.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransfer.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransferState.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/LiftCoordinatorTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/LiftCoordinator.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/LiftCoordinator.java new file mode 100644 index 0000000..35e7e3e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/LiftCoordinator.java @@ -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 clock; + + public LiftCoordinator(ResourceLedger ledger, + DeviceCommandRepository commandRepository, + CommandOutboxRepository outboxRepository, + IdGenerator idGenerator, long topologyVersion, + Supplier 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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecision.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecision.java new file mode 100644 index 0000000..e369402 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecision.java @@ -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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecisionType.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecisionType.java new file mode 100644 index 0000000..f5b5f1d --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftDecisionType.java @@ -0,0 +1,9 @@ +package com.ruoyi.wcs.domain.equipment; + +public enum LiftDecisionType { + READY, + BLOCKED, + ADVANCED, + COMPLETED, + RECOVERY_REQUIRED +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransfer.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransfer.java new file mode 100644 index 0000000..75eafb5 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransfer.java @@ -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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransferState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransferState.java new file mode 100644 index 0000000..796b55a --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/equipment/LiftTransferState.java @@ -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 +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java index b359ece..5d4e3c4 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java @@ -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 remainingRouteNodes; private final boolean commandCompleted; + private final Map 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 remainingRouteNodes, boolean commandCompleted) { + this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence, + observedAt, connected, deviceBootId, topologyVersion, + fencingToken, currentNodeId, remainingRouteNodes, + commandCompleted, Collections.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 remainingRouteNodes, + boolean commandCompleted, Map 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 getRemainingRouteNodes() { return remainingRouteNodes; } public boolean isCommandCompleted() { return commandCompleted; } + public Map getFacts() { return facts; } + + private static Map immutableFacts(Map source) { + if (source == null) { + throw new IllegalArgumentException("facts must not be null"); + } + Map copy = new LinkedHashMap(); + for (Map.Entry entry : source.entrySet()) { + copy.put(requireText(entry.getKey(), "fact key"), + requireText(entry.getValue(), "fact value")); + } + return Collections.unmodifiableMap(copy); + } private static List immutable(List source) { if (source == null || source.isEmpty()) { diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java index 052a229..0f9a5d7 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java @@ -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); diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/LiftCoordinatorTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/LiftCoordinatorTest.java new file mode 100644 index 0000000..80d468e --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/device/LiftCoordinatorTest.java @@ -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 capacities = new LinkedHashMap(); + 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() { + @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 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 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 matchingFactMap() { + Map facts = new LinkedHashMap(); + facts.put("liftId", "L1"); + facts.put("liftFloor", "P2"); + facts.put("liftDoorOpen", "true"); + facts.put("targetPortClear", "true"); + return facts; + } + + private static DeviceSnapshot snapshot(Map 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 values = + new LinkedHashMap(); + + @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 values = + new LinkedHashMap(); + + @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 claimDue(int limit, Instant now) { + return Collections.emptyList(); + } + } +}