feat(wcs): add device command and outbox lifecycle
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.wcs.domain.execution;
|
||||
|
||||
public enum DeviceCommandState {
|
||||
PENDING,
|
||||
SENT,
|
||||
ACKNOWLEDGED,
|
||||
SUCCEEDED,
|
||||
REJECTED,
|
||||
FAILED,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.wcs.domain.execution;
|
||||
|
||||
public enum DeviceCommandType {
|
||||
MOVE_ROUTE,
|
||||
LIFT_TRANSFER,
|
||||
CONVEYOR_TRANSFER,
|
||||
CHARGE
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.wcs.domain.execution;
|
||||
|
||||
public enum OutboxDeliveryState {
|
||||
PENDING,
|
||||
DELIVERED,
|
||||
DEAD
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user