From 0152fb1a8190eb9a4f59ea2695684f079df5e00b Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Tue, 11 Aug 2026 10:06:10 +0800 Subject: [PATCH] feat(wcs): add device command and outbox lifecycle --- .../domain/execution/CommandOutboxEntry.java | 63 ++++++ .../execution/CommandResolutionEvidence.java | 57 +++++ .../wcs/domain/execution/DeviceCommand.java | 206 ++++++++++++++++++ .../domain/execution/DeviceCommandState.java | 11 + .../domain/execution/DeviceCommandType.java | 8 + .../domain/execution/OutboxDeliveryState.java | 7 + .../execution/WmsNotificationOutboxEntry.java | 54 +++++ .../wcs/port/out/CommandOutboxRepository.java | 12 + .../wcs/port/out/DeviceCommandRepository.java | 11 + .../out/WmsNotificationOutboxRepository.java | 12 + .../domain/execution/DeviceCommandTest.java | 119 ++++++++++ 11 files changed, 560 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandOutboxEntry.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandResolutionEvidence.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommand.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandState.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandType.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/OutboxDeliveryState.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/WmsNotificationOutboxEntry.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/CommandOutboxRepository.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DeviceCommandRepository.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationOutboxRepository.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/execution/DeviceCommandTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandOutboxEntry.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandOutboxEntry.java new file mode 100644 index 0000000..37310cc --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandOutboxEntry.java @@ -0,0 +1,63 @@ +package com.ruoyi.wcs.domain.execution; + +import java.time.Instant; +import java.util.Objects; + +public final class CommandOutboxEntry { + private final String commandId; + private final String commandKey; + private final String payload; + private OutboxDeliveryState state; + private int attemptCount; + private Instant nextAttemptAt; + private String lastError; + + private CommandOutboxEntry(String commandId, String commandKey, + String payload, Instant nextAttemptAt) { + this.commandId = requireText(commandId, "commandId"); + this.commandKey = requireText(commandKey, "commandKey"); + this.payload = requireText(payload, "payload"); + this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt"); + this.state = OutboxDeliveryState.PENDING; + } + + public static CommandOutboxEntry pending(String commandId, String commandKey, + String payload, Instant nextAttemptAt) { + return new CommandOutboxEntry(commandId, commandKey, payload, nextAttemptAt); + } + + public String getCommandId() { + return commandId; + } + + public String getCommandKey() { + return commandKey; + } + + public String getPayload() { + return payload; + } + + public OutboxDeliveryState getState() { + return state; + } + + public int getAttemptCount() { + return attemptCount; + } + + public Instant getNextAttemptAt() { + return nextAttemptAt; + } + + public String getLastError() { + return lastError; + } + + 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/execution/CommandResolutionEvidence.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandResolutionEvidence.java new file mode 100644 index 0000000..f1ebd76 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommandResolutionEvidence.java @@ -0,0 +1,57 @@ +package com.ruoyi.wcs.domain.execution; + +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; + +public final class CommandResolutionEvidence { + public enum Type { + PHYSICAL_COMPLETION, + CONTROLLED_ABORT + } + + private final String commandKey; + private final String evidenceId; + private final Type type; + private final String operatorId; + + private CommandResolutionEvidence(String commandKey, String evidenceId, + Type type, String operatorId) { + this.commandKey = requireText(commandKey, "commandKey"); + this.evidenceId = requireText(evidenceId, "evidenceId"); + this.type = java.util.Objects.requireNonNull(type, "type"); + this.operatorId = operatorId; + } + + public static CommandResolutionEvidence physicalCompletion( + String commandKey, String evidenceId) { + return new CommandResolutionEvidence(commandKey, evidenceId, + Type.PHYSICAL_COMPLETION, null); + } + + public static CommandResolutionEvidence controlledAbort( + String commandKey, String evidenceId, String operatorId) { + return new CommandResolutionEvidence(commandKey, evidenceId, + Type.CONTROLLED_ABORT, requireText(operatorId, "operatorId")); + } + + void requireMatches(String expectedCommandKey, Type expectedType) { + if (!commandKey.equals(expectedCommandKey) || type != expectedType) { + throw new DomainRuleViolation( + "resolution evidence does not match the device command"); + } + } + + public String getEvidenceId() { + return evidenceId; + } + + public String getOperatorId() { + return operatorId; + } + + 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/execution/DeviceCommand.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommand.java new file mode 100644 index 0000000..466fc2f --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommand.java @@ -0,0 +1,206 @@ +package com.ruoyi.wcs.domain.execution; + +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; + +import java.time.Instant; +import java.util.Objects; + +public final class DeviceCommand { + private final String commandId; + private final String commandKey; + private final String deviceId; + private final DeviceCommandType type; + private final String payload; + private final int attempt; + private final Instant createdAt; + + private DeviceCommandState state; + private String sendReceiptId; + private String acknowledgementEvidenceId; + private String completionEvidenceId; + private String statusReason; + private Instant updatedAt; + private long stateVersion; + + private DeviceCommand(String commandId, String commandKey, String deviceId, + DeviceCommandType type, String payload, int attempt, + Instant createdAt) { + this.commandId = requireText(commandId, "commandId"); + this.commandKey = requireText(commandKey, "commandKey"); + this.deviceId = requireText(deviceId, "deviceId"); + this.type = Objects.requireNonNull(type, "type"); + this.payload = requireText(payload, "payload"); + if (attempt <= 0) { + throw new IllegalArgumentException("attempt must be positive"); + } + this.attempt = attempt; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + this.state = DeviceCommandState.PENDING; + } + + public static DeviceCommand pending(String commandId, String commandKey, + String deviceId, DeviceCommandType type, + String payload, int attempt, + Instant createdAt) { + return new DeviceCommand(commandId, commandKey, deviceId, type, + payload, attempt, createdAt); + } + + public void markSent(String sendReceiptId, Instant at) { + requireState(DeviceCommandState.PENDING); + Instant transitionAt = requireTransitionTime(at); + this.sendReceiptId = requireText(sendReceiptId, "sendReceiptId"); + transition(DeviceCommandState.SENT, transitionAt); + } + + public void acknowledge(String evidenceId, Instant at) { + requireState(DeviceCommandState.SENT); + Instant transitionAt = requireTransitionTime(at); + this.acknowledgementEvidenceId = requireText(evidenceId, "evidenceId"); + transition(DeviceCommandState.ACKNOWLEDGED, transitionAt); + } + + public void succeed(String evidenceId, Instant at) { + requireState(DeviceCommandState.SENT, DeviceCommandState.ACKNOWLEDGED); + Instant transitionAt = requireTransitionTime(at); + this.completionEvidenceId = requireText(evidenceId, "evidenceId"); + this.statusReason = null; + transition(DeviceCommandState.SUCCEEDED, transitionAt); + } + + public void succeedAfterRecovery(CommandResolutionEvidence evidence, Instant at) { + requireState(DeviceCommandState.UNKNOWN); + Objects.requireNonNull(evidence, "evidence") + .requireMatches(commandKey, + CommandResolutionEvidence.Type.PHYSICAL_COMPLETION); + Instant transitionAt = requireTransitionTime(at); + this.completionEvidenceId = evidence.getEvidenceId(); + this.statusReason = null; + transition(DeviceCommandState.SUCCEEDED, transitionAt); + } + + public void reject(String reason, Instant at) { + requireState(DeviceCommandState.SENT); + Instant transitionAt = requireTransitionTime(at); + this.statusReason = requireText(reason, "reason"); + transition(DeviceCommandState.REJECTED, transitionAt); + } + + public void failBeforeSend(String reason, Instant at) { + requireState(DeviceCommandState.PENDING); + Instant transitionAt = requireTransitionTime(at); + this.statusReason = requireText(reason, "reason"); + transition(DeviceCommandState.FAILED, transitionAt); + } + + public void failAfterRecovery(CommandResolutionEvidence evidence, + String reason, Instant at) { + requireState(DeviceCommandState.UNKNOWN); + Objects.requireNonNull(evidence, "evidence") + .requireMatches(commandKey, + CommandResolutionEvidence.Type.CONTROLLED_ABORT); + Instant transitionAt = requireTransitionTime(at); + this.statusReason = requireText(reason, "reason"); + transition(DeviceCommandState.FAILED, transitionAt); + } + + public void markUnknown(String reason, Instant at) { + requireState(DeviceCommandState.SENT, DeviceCommandState.ACKNOWLEDGED); + Instant transitionAt = requireTransitionTime(at); + this.statusReason = requireText(reason, "reason"); + transition(DeviceCommandState.UNKNOWN, transitionAt); + } + + public boolean canSendMotionCommandAfterIt() { + return state == DeviceCommandState.SUCCEEDED + || state == DeviceCommandState.REJECTED + || state == DeviceCommandState.FAILED; + } + + private void transition(DeviceCommandState next, Instant at) { + this.state = next; + this.updatedAt = at; + this.stateVersion++; + } + + private Instant requireTransitionTime(Instant at) { + Instant candidate = Objects.requireNonNull(at, "at"); + if (candidate.isBefore(updatedAt)) { + throw new DomainRuleViolation("device command transition time regressed"); + } + return candidate; + } + + private void requireState(DeviceCommandState... allowed) { + for (DeviceCommandState candidate : allowed) { + if (state == candidate) { + return; + } + } + throw new DomainRuleViolation("illegal device command transition from " + state); + } + + public String getCommandId() { + return commandId; + } + + public String getCommandKey() { + return commandKey; + } + + public String getDeviceId() { + return deviceId; + } + + public DeviceCommandType getType() { + return type; + } + + public String getPayload() { + return payload; + } + + public int getAttempt() { + return attempt; + } + + public DeviceCommandState getState() { + return state; + } + + public String getSendReceiptId() { + return sendReceiptId; + } + + public String getAcknowledgementEvidenceId() { + return acknowledgementEvidenceId; + } + + public String getCompletionEvidenceId() { + return completionEvidenceId; + } + + public String getStatusReason() { + return statusReason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getStateVersion() { + return stateVersion; + } + + 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/execution/DeviceCommandState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandState.java new file mode 100644 index 0000000..9661abe --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandState.java @@ -0,0 +1,11 @@ +package com.ruoyi.wcs.domain.execution; + +public enum DeviceCommandState { + PENDING, + SENT, + ACKNOWLEDGED, + SUCCEEDED, + REJECTED, + FAILED, + UNKNOWN +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandType.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandType.java new file mode 100644 index 0000000..6970138 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceCommandType.java @@ -0,0 +1,8 @@ +package com.ruoyi.wcs.domain.execution; + +public enum DeviceCommandType { + MOVE_ROUTE, + LIFT_TRANSFER, + CONVEYOR_TRANSFER, + CHARGE +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/OutboxDeliveryState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/OutboxDeliveryState.java new file mode 100644 index 0000000..493ce39 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/OutboxDeliveryState.java @@ -0,0 +1,7 @@ +package com.ruoyi.wcs.domain.execution; + +public enum OutboxDeliveryState { + PENDING, + DELIVERED, + DEAD +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/WmsNotificationOutboxEntry.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/WmsNotificationOutboxEntry.java new file mode 100644 index 0000000..3923f40 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/WmsNotificationOutboxEntry.java @@ -0,0 +1,54 @@ +package com.ruoyi.wcs.domain.execution; + +import com.ruoyi.wcs.port.out.WmsNotification; + +import java.time.Instant; +import java.util.Objects; + +public final class WmsNotificationOutboxEntry { + private final String eventId; + private final WmsNotification notification; + private final Instant nextAttemptAt; + private final OutboxDeliveryState state; + + private WmsNotificationOutboxEntry(String eventId, + WmsNotification notification, + Instant nextAttemptAt) { + this.eventId = requireText(eventId, "eventId"); + this.notification = Objects.requireNonNull(notification, "notification"); + if (!this.eventId.equals(notification.getEventId())) { + throw new IllegalArgumentException( + "eventId must match the notification idempotency key"); + } + this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt"); + this.state = OutboxDeliveryState.PENDING; + } + + public static WmsNotificationOutboxEntry pending( + String eventId, WmsNotification notification, Instant nextAttemptAt) { + return new WmsNotificationOutboxEntry(eventId, notification, nextAttemptAt); + } + + public String getEventId() { + return eventId; + } + + public WmsNotification getNotification() { + return notification; + } + + public Instant getNextAttemptAt() { + return nextAttemptAt; + } + + public OutboxDeliveryState getState() { + return state; + } + + 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/port/out/CommandOutboxRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/CommandOutboxRepository.java new file mode 100644 index 0000000..d6d521e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/CommandOutboxRepository.java @@ -0,0 +1,12 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.execution.CommandOutboxEntry; + +import java.time.Instant; +import java.util.List; + +public interface CommandOutboxRepository { + void saveIfAbsent(CommandOutboxEntry entry); + + List findDue(int limit, Instant now); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DeviceCommandRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DeviceCommandRepository.java new file mode 100644 index 0000000..52ada11 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DeviceCommandRepository.java @@ -0,0 +1,11 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.execution.DeviceCommand; + +public interface DeviceCommandRepository { + DeviceCommand findByCommandKey(String commandKey); + + DeviceCommand findByCommandId(String commandId); + + void save(DeviceCommand command); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationOutboxRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationOutboxRepository.java new file mode 100644 index 0000000..d277326 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationOutboxRepository.java @@ -0,0 +1,12 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry; + +import java.time.Instant; +import java.util.List; + +public interface WmsNotificationOutboxRepository { + void saveIfAbsent(WmsNotificationOutboxEntry entry); + + List findDue(int limit, Instant now); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/execution/DeviceCommandTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/execution/DeviceCommandTest.java new file mode 100644 index 0000000..e34420c --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/execution/DeviceCommandTest.java @@ -0,0 +1,119 @@ +package com.ruoyi.wcs.domain.execution; + +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; +import com.ruoyi.wcs.port.out.WmsNotification; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DeviceCommandTest { + + @Test + void sentCommandBecomesUnknownInsteadOfRetryableFailure() { + DeviceCommand command = pendingCommand(); + + command.markSent("SEND-1", at(1)); + command.markUnknown("ACK_TIMEOUT", at(6)); + + assertEquals(DeviceCommandState.UNKNOWN, command.getState()); + assertEquals("ACK_TIMEOUT", command.getStatusReason()); + assertFalse(command.canSendMotionCommandAfterIt()); + } + + @Test + void unknownCommandRequiresMatchingPhysicalEvidenceToSucceed() { + DeviceCommand command = pendingCommand(); + command.markSent("SEND-1", at(1)); + command.markUnknown("ACK_TIMEOUT", at(2)); + + assertThrows(DomainRuleViolation.class, + () -> command.succeed("FORGED-DONE", at(3))); + assertThrows(DomainRuleViolation.class, + () -> command.succeedAfterRecovery( + CommandResolutionEvidence.physicalCompletion( + "OTHER-COMMAND", "DONE-1"), at(3))); + + command.succeedAfterRecovery( + CommandResolutionEvidence.physicalCompletion( + "CMD-KEY-1", "DONE-1"), at(3)); + + assertEquals(DeviceCommandState.SUCCEEDED, command.getState()); + assertEquals("DONE-1", command.getCompletionEvidenceId()); + } + + @Test + void staleTransitionTimeCannotPartiallyMutateTheCommand() { + DeviceCommand command = pendingCommand(); + command.markSent("SEND-1", at(5)); + + assertThrows(DomainRuleViolation.class, + () -> command.acknowledge("STALE-ACK", at(4))); + + assertEquals(DeviceCommandState.SENT, command.getState()); + assertNull(command.getAcknowledgementEvidenceId()); + assertEquals(at(5), command.getUpdatedAt()); + } + + @Test + void terminalCommandCannotBeAcknowledgedAgain() { + DeviceCommand command = pendingCommand(); + command.markSent("SEND-1", at(1)); + command.acknowledge("ACK-1", at(2)); + command.succeed("DONE-1", at(3)); + + assertThrows(DomainRuleViolation.class, + () -> command.acknowledge("ACK-2", at(4))); + } + + @Test + void onlyPendingCommandCanFailBeforeSend() { + DeviceCommand command = pendingCommand(); + command.markSent("SEND-1", at(1)); + + assertThrows(DomainRuleViolation.class, + () -> command.failBeforeSend("SOCKET_CLOSED", at(2))); + } + + @Test + void duplicateCommandKeyAndAttemptStayStableAcrossOutboxCreation() { + DeviceCommand command = pendingCommand(); + CommandOutboxEntry outbox = CommandOutboxEntry.pending( + command.getCommandId(), command.getCommandKey(), + command.getPayload(), at(5)); + + assertEquals("CMD-KEY-1", outbox.getCommandKey()); + assertEquals("CMD-1", outbox.getCommandId()); + assertEquals(at(5), outbox.getNextAttemptAt()); + } + + @Test + void wmsOutboxUsesTheBusinessEventAsItsIdempotencyKey() { + WmsNotification notification = new WmsNotification( + "EVENT-1", "REQ-1", "ORDER-1", at(0), + "OrderCompleted", "OK", null, + Collections.emptyMap()); + + WmsNotificationOutboxEntry entry = WmsNotificationOutboxEntry.pending( + "EVENT-1", notification, at(7)); + + assertEquals("EVENT-1", entry.getEventId()); + assertEquals("EVENT-1", entry.getNotification().getEventId()); + assertEquals(at(7), entry.getNextAttemptAt()); + } + + private static DeviceCommand pendingCommand() { + return DeviceCommand.pending("CMD-1", "CMD-KEY-1", "V1", + DeviceCommandType.MOVE_ROUTE, "{\"route\":[\"A\",\"B\"]}", + 1, at(0)); + } + + private static Instant at(long seconds) { + return Instant.EPOCH.plusSeconds(seconds); + } +}