feat(wcs): add transport order state machine

This commit is contained in:
zgw
2026-08-10 15:08:57 +08:00
parent 74979e3226
commit ba245255f9
4 changed files with 325 additions and 0 deletions

View File

@@ -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<DomainEvent> uncommittedEvents = new ArrayList<DomainEvent>();
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<DomainEvent> getUncommittedEvents() {
return Collections.unmodifiableList(new ArrayList<DomainEvent>(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;
}
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.wcs.domain.task;
public enum TransportOrderState {
ACCEPTED,
PLANNED,
EXECUTING,
SUSPENDED,
CANCEL_REQUESTED,
REJECTED,
COMPLETED,
CANCELLED,
FAILED
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.wcs.domain.task;
public enum TransportOrderType {
INBOUND,
OUTBOUND,
RELOCATION,
INVENTORY,
CHARGING
}

View File

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