feat(wcs): add device command and outbox lifecycle

This commit is contained in:
zgw
2026-08-11 10:06:10 +08:00
parent 74f2de59cd
commit 0152fb1a81
11 changed files with 560 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
package com.ruoyi.wcs.domain.execution;
public enum DeviceCommandState {
PENDING,
SENT,
ACKNOWLEDGED,
SUCCEEDED,
REJECTED,
FAILED,
UNKNOWN
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.wcs.domain.execution;
public enum DeviceCommandType {
MOVE_ROUTE,
LIFT_TRANSFER,
CONVEYOR_TRANSFER,
CHARGE
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.wcs.domain.execution;
public enum OutboxDeliveryState {
PENDING,
DELIVERED,
DEAD
}

View File

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

View File

@@ -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<CommandOutboxEntry> findDue(int limit, Instant now);
}

View File

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

View File

@@ -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<WmsNotificationOutboxEntry> findDue(int limit, Instant now);
}

View File

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