From ba245255f99d1779736d4dfe67d5bad8161acbcf Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 15:08:57 +0800 Subject: [PATCH] feat(wcs): add transport order state machine --- .../ruoyi/wcs/domain/task/TransportOrder.java | 220 ++++++++++++++++++ .../wcs/domain/task/TransportOrderState.java | 13 ++ .../wcs/domain/task/TransportOrderType.java | 9 + .../wcs/domain/task/TransportOrderTest.java | 83 +++++++ 4 files changed, 325 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrder.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderState.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderType.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/TransportOrderTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrder.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrder.java new file mode 100644 index 0000000..8833161 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrder.java @@ -0,0 +1,220 @@ +package com.ruoyi.wcs.domain.task; + +import com.ruoyi.wcs.domain.shared.DomainEvent; +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class TransportOrder { + private final String orderId; + private final String externalRequestId; + private final TransportOrderType type; + private final String loadId; + private final String sourceCode; + private final String destinationCode; + private final int priority; + private final Instant createdAt; + private final List uncommittedEvents = new ArrayList(); + + private TransportOrderState state; + private String workflowId; + private String resultCode; + private String stateReason; + private long stateVersion; + private long eventSequence; + private Instant updatedAt; + + private TransportOrder(String orderId, String externalRequestId, + TransportOrderType type, String loadId, + String sourceCode, String destinationCode, + int priority, Instant createdAt) { + this.orderId = requireText(orderId, "orderId"); + this.externalRequestId = requireText(externalRequestId, "externalRequestId"); + this.type = Objects.requireNonNull(type, "type"); + this.loadId = loadId; + this.sourceCode = requireText(sourceCode, "sourceCode"); + this.destinationCode = requireText(destinationCode, "destinationCode"); + this.priority = priority; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + this.state = TransportOrderState.ACCEPTED; + addEvent("OrderAccepted", createdAt); + } + + public static TransportOrder accept(String orderId, String externalRequestId, + TransportOrderType type, String loadId, + String sourceCode, String destinationCode, + int priority, Instant createdAt) { + return new TransportOrder(orderId, externalRequestId, type, loadId, + sourceCode, destinationCode, priority, createdAt); + } + + public void plan(String workflowId) { + requireState(TransportOrderState.ACCEPTED); + this.workflowId = requireText(workflowId, "workflowId"); + transitionTo(TransportOrderState.PLANNED, "OrderPlanned"); + } + + public void reject(String code, String reason) { + requireState(TransportOrderState.ACCEPTED); + this.resultCode = requireText(code, "code"); + this.stateReason = requireText(reason, "reason"); + transitionTo(TransportOrderState.REJECTED, "OrderRejected"); + } + + public void start() { + requireState(TransportOrderState.PLANNED); + transitionTo(TransportOrderState.EXECUTING, "OrderStarted"); + } + + public void suspend(String reason) { + requireState(TransportOrderState.ACCEPTED, TransportOrderState.PLANNED, + TransportOrderState.EXECUTING); + this.stateReason = requireText(reason, "reason"); + transitionTo(TransportOrderState.SUSPENDED, "OrderSuspended"); + } + + public void resumeToPlanned() { + requireState(TransportOrderState.SUSPENDED); + this.stateReason = null; + transitionTo(TransportOrderState.PLANNED, "OrderResumedToPlanned"); + } + + public void resumeToExecuting() { + requireState(TransportOrderState.SUSPENDED); + this.stateReason = null; + transitionTo(TransportOrderState.EXECUTING, "OrderResumedToExecuting"); + } + + public void requestCancel() { + requireState(TransportOrderState.EXECUTING, TransportOrderState.SUSPENDED); + transitionTo(TransportOrderState.CANCEL_REQUESTED, "OrderCancelRequested"); + } + + public void cancel() { + requireState(TransportOrderState.ACCEPTED, TransportOrderState.PLANNED, + TransportOrderState.CANCEL_REQUESTED); + transitionTo(TransportOrderState.CANCELLED, "OrderCancelled"); + } + + public void complete() { + requireState(TransportOrderState.EXECUTING); + transitionTo(TransportOrderState.COMPLETED, "OrderCompleted"); + } + + public void fail(String reason) { + if (isTerminal()) { + throw new DomainRuleViolation("terminal order cannot transition from " + state); + } + this.stateReason = requireText(reason, "reason"); + transitionTo(TransportOrderState.FAILED, "OrderFailed"); + } + + public String getOrderId() { + return orderId; + } + + public String getExternalRequestId() { + return externalRequestId; + } + + public TransportOrderType getType() { + return type; + } + + public String getLoadId() { + return loadId; + } + + public String getSourceCode() { + return sourceCode; + } + + public String getDestinationCode() { + return destinationCode; + } + + public int getPriority() { + return priority; + } + + public TransportOrderState getState() { + return state; + } + + public String getWorkflowId() { + return workflowId; + } + + public String getResultCode() { + return resultCode; + } + + public String getStateReason() { + return stateReason; + } + + public long getStateVersion() { + return stateVersion; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public List getUncommittedEvents() { + return Collections.unmodifiableList(new ArrayList(uncommittedEvents)); + } + + public void clearUncommittedEvents() { + uncommittedEvents.clear(); + } + + private void transitionTo(TransportOrderState next, String eventType) { + this.state = next; + this.stateVersion++; + this.updatedAt = Instant.now(); + addEvent(eventType, updatedAt); + } + + private void addEvent(String eventType, Instant occurredAt) { + eventSequence++; + uncommittedEvents.add(new DomainEvent( + orderId + ":" + eventSequence, + orderId, + eventSequence, + occurredAt, + eventType)); + } + + private void requireState(TransportOrderState... allowed) { + for (TransportOrderState candidate : allowed) { + if (state == candidate) { + return; + } + } + throw new DomainRuleViolation("illegal order transition from " + state); + } + + private boolean isTerminal() { + return state == TransportOrderState.REJECTED + || state == TransportOrderState.COMPLETED + || state == TransportOrderState.CANCELLED + || state == TransportOrderState.FAILED; + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderState.java new file mode 100644 index 0000000..6a2353e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderState.java @@ -0,0 +1,13 @@ +package com.ruoyi.wcs.domain.task; + +public enum TransportOrderState { + ACCEPTED, + PLANNED, + EXECUTING, + SUSPENDED, + CANCEL_REQUESTED, + REJECTED, + COMPLETED, + CANCELLED, + FAILED +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderType.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderType.java new file mode 100644 index 0000000..7869f6a --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderType.java @@ -0,0 +1,9 @@ +package com.ruoyi.wcs.domain.task; + +public enum TransportOrderType { + INBOUND, + OUTBOUND, + RELOCATION, + INVENTORY, + CHARGING +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/TransportOrderTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/TransportOrderTest.java new file mode 100644 index 0000000..8ba2dd7 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/TransportOrderTest.java @@ -0,0 +1,83 @@ +package com.ruoyi.wcs.domain.task; + +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TransportOrderTest { + + @Test + void completesOnlyThroughPlannedAndExecuting() { + TransportOrder order = order(); + + order.plan("WF-1"); + order.start(); + order.complete(); + + assertEquals(TransportOrderState.COMPLETED, order.getState()); + assertEquals(4, order.getUncommittedEvents().size()); + assertEquals("OrderCompleted", order.getUncommittedEvents().get(3).getType()); + } + + @Test + void cannotCompleteBeforeExecutionStarts() { + TransportOrder order = order(); + order.plan("WF-1"); + + assertThrows(DomainRuleViolation.class, order::complete); + } + + @Test + void terminalOrderCannotResume() { + TransportOrder order = order(); + order.plan("WF-1"); + order.cancel(); + + assertThrows(DomainRuleViolation.class, order::resumeToPlanned); + } + + @Test + void successfulTransitionsIncrementVersionAndEventSequenceOnce() { + TransportOrder order = order(); + order.plan("WF-1"); + order.start(); + order.suspend("PLC snapshot stale"); + order.resumeToExecuting(); + + assertEquals(4L, order.getStateVersion()); + assertEquals(5L, order.getUncommittedEvents().get(4).getSequence()); + + assertThrows(DomainRuleViolation.class, order::resumeToPlanned); + assertEquals(4L, order.getStateVersion()); + assertEquals(5, order.getUncommittedEvents().size()); + } + + @Test + void runningCancellationRequiresTheExplicitConvergenceState() { + TransportOrder order = order(); + order.plan("WF-1"); + order.start(); + + assertThrows(DomainRuleViolation.class, order::cancel); + order.requestCancel(); + order.cancel(); + + assertEquals(TransportOrderState.CANCELLED, order.getState()); + } + + private TransportOrder order() { + return TransportOrder.accept( + "ORDER-1", + "REQ-1", + TransportOrderType.INBOUND, + "LOAD-1", + "PORT-1", + "SPACE-1", + 50, + Instant.parse("2026-08-04T00:00:00Z")); + } +}