feat(wcs): add transport order state machine
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ruoyi.wcs.domain.task;
|
||||||
|
|
||||||
|
public enum TransportOrderState {
|
||||||
|
ACCEPTED,
|
||||||
|
PLANNED,
|
||||||
|
EXECUTING,
|
||||||
|
SUSPENDED,
|
||||||
|
CANCEL_REQUESTED,
|
||||||
|
REJECTED,
|
||||||
|
COMPLETED,
|
||||||
|
CANCELLED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ruoyi.wcs.domain.task;
|
||||||
|
|
||||||
|
public enum TransportOrderType {
|
||||||
|
INBOUND,
|
||||||
|
OUTBOUND,
|
||||||
|
RELOCATION,
|
||||||
|
INVENTORY,
|
||||||
|
CHARGING
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user