feat(wcs): add idempotent transport order use cases

This commit is contained in:
zgw
2026-08-10 15:15:29 +08:00
parent fd83225c75
commit f4ae3df382
10 changed files with 565 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
package com.ruoyi.wcs.application.task;
import com.ruoyi.wcs.domain.task.TransportOrderType;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public final class CreateTransportOrderCommand {
private final String externalRequestId;
private final TransportOrderType orderType;
private final String loadId;
private final String sourceCode;
private final String destinationCode;
private final int priority;
private final Instant deadline;
private final Map<String, String> attributes;
public CreateTransportOrderCommand(String externalRequestId,
TransportOrderType orderType,
String loadId, String sourceCode,
String destinationCode, int priority,
Instant deadline,
Map<String, String> attributes) {
this.externalRequestId = requireText(externalRequestId, "externalRequestId");
this.orderType = Objects.requireNonNull(orderType, "orderType");
this.loadId = loadId;
this.sourceCode = requireText(sourceCode, "sourceCode");
this.destinationCode = requireText(destinationCode, "destinationCode");
this.priority = priority;
this.deadline = deadline;
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<String, String>(
Objects.requireNonNull(attributes, "attributes")));
}
public String getExternalRequestId() {
return externalRequestId;
}
public TransportOrderType getOrderType() {
return orderType;
}
public String getLoadId() {
return loadId;
}
public String getSourceCode() {
return sourceCode;
}
public String getDestinationCode() {
return destinationCode;
}
public int getPriority() {
return priority;
}
public Instant getDeadline() {
return deadline;
}
public Map<String, String> getAttributes() {
return attributes;
}
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,130 @@
package com.ruoyi.wcs.application.task;
import com.ruoyi.wcs.domain.shared.DomainEvent;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.domain.task.TransportOrder;
import com.ruoyi.wcs.domain.task.TransportOrderState;
import com.ruoyi.wcs.domain.task.WorkflowDefinition;
import com.ruoyi.wcs.domain.task.WorkflowInstance;
import com.ruoyi.wcs.port.in.TransportOrderUseCase;
import com.ruoyi.wcs.port.out.DomainEventPublisher;
import com.ruoyi.wcs.port.out.TransportOrderRepository;
import com.ruoyi.wcs.port.out.WorkflowDefinitionProvider;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
public final class TransportOrderApplicationService implements TransportOrderUseCase {
private final TransportOrderRepository repository;
private final WorkflowDefinitionProvider workflowDefinitions;
private final DomainEventPublisher eventPublisher;
private final IdGenerator idGenerator;
private final WcsClock clock;
public TransportOrderApplicationService(TransportOrderRepository repository,
WorkflowDefinitionProvider workflowDefinitions,
DomainEventPublisher eventPublisher,
IdGenerator idGenerator,
WcsClock clock) {
this.repository = Objects.requireNonNull(repository, "repository");
this.workflowDefinitions = Objects.requireNonNull(
workflowDefinitions, "workflowDefinitions");
this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher");
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
@Transactional
public TransportOrderResult create(CreateTransportOrderCommand command) {
Objects.requireNonNull(command, "command");
TransportOrder existing = repository.findByExternalRequestId(
command.getExternalRequestId());
if (existing != null) {
return TransportOrderResult.from(existing);
}
TransportOrder order = TransportOrder.accept(
idGenerator.nextId(),
command.getExternalRequestId(),
command.getOrderType(),
command.getLoadId(),
command.getSourceCode(),
command.getDestinationCode(),
command.getPriority(),
clock.now());
WorkflowDefinition definition = workflowDefinitions.definitionFor(order.getType());
WorkflowInstance workflow = WorkflowInstance.create(
idGenerator.nextId(), order.getOrderId(), definition);
order.plan(workflow.getWorkflowId());
repository.save(order, workflow);
publishAndClear(order);
return TransportOrderResult.from(order);
}
@Override
@Transactional
public TransportOrderResult cancel(String externalRequestId) {
TransportOrder order = requireByExternalRequestId(externalRequestId);
order.cancel();
repository.save(order);
publishAndClear(order);
return TransportOrderResult.from(order);
}
@Override
@Transactional
public TransportOrderResult suspend(String orderId, String reason) {
TransportOrder order = requireByOrderId(orderId);
order.suspend(reason);
repository.save(order);
publishAndClear(order);
return TransportOrderResult.from(order);
}
@Override
@Transactional
public TransportOrderResult resume(String orderId) {
TransportOrder order = requireByOrderId(orderId);
if (order.getWorkflowId() == null) {
throw new IllegalStateException("accepted order has no workflow to resume");
}
order.resumeToPlanned();
repository.save(order);
publishAndClear(order);
return TransportOrderResult.from(order);
}
private void publishAndClear(TransportOrder order) {
List<DomainEvent> events = order.getUncommittedEvents();
eventPublisher.publish(events);
order.clearUncommittedEvents();
}
private TransportOrder requireByExternalRequestId(String externalRequestId) {
TransportOrder order = repository.findByExternalRequestId(
requireText(externalRequestId, "externalRequestId"));
if (order == null) {
throw new IllegalArgumentException("transport order not found");
}
return order;
}
private TransportOrder requireByOrderId(String orderId) {
TransportOrder order = repository.findByOrderId(requireText(orderId, "orderId"));
if (order == null) {
throw new IllegalArgumentException("transport order not found");
}
return order;
}
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,57 @@
package com.ruoyi.wcs.application.task;
import com.ruoyi.wcs.domain.task.TransportOrder;
import com.ruoyi.wcs.domain.task.TransportOrderState;
import com.ruoyi.wcs.domain.task.TransportOrderType;
public final class TransportOrderResult {
private final String orderId;
private final String externalRequestId;
private final TransportOrderType orderType;
private final String sourceCode;
private final String destinationCode;
private final TransportOrderState state;
private final String workflowId;
private TransportOrderResult(TransportOrder order) {
this.orderId = order.getOrderId();
this.externalRequestId = order.getExternalRequestId();
this.orderType = order.getType();
this.sourceCode = order.getSourceCode();
this.destinationCode = order.getDestinationCode();
this.state = order.getState();
this.workflowId = order.getWorkflowId();
}
public static TransportOrderResult from(TransportOrder order) {
return new TransportOrderResult(order);
}
public String getOrderId() {
return orderId;
}
public String getExternalRequestId() {
return externalRequestId;
}
public TransportOrderType getOrderType() {
return orderType;
}
public String getSourceCode() {
return sourceCode;
}
public String getDestinationCode() {
return destinationCode;
}
public TransportOrderState getState() {
return state;
}
public String getWorkflowId() {
return workflowId;
}
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.wcs.port.in;
import com.ruoyi.wcs.application.task.CreateTransportOrderCommand;
import com.ruoyi.wcs.application.task.TransportOrderResult;
public interface TransportOrderUseCase {
TransportOrderResult create(CreateTransportOrderCommand command);
TransportOrderResult cancel(String externalRequestId);
TransportOrderResult suspend(String orderId, String reason);
TransportOrderResult resume(String orderId);
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.wcs.port.out;
import com.ruoyi.wcs.domain.shared.DomainEvent;
import java.util.List;
public interface DomainEventPublisher {
void publish(List<DomainEvent> domainEvents);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.wcs.port.out;
import com.ruoyi.wcs.domain.task.TransportOrder;
import com.ruoyi.wcs.domain.task.WorkflowInstance;
public interface TransportOrderRepository {
TransportOrder findByExternalRequestId(String externalRequestId);
TransportOrder findByOrderId(String orderId);
void save(TransportOrder order, WorkflowInstance workflow);
void save(TransportOrder order);
}

View File

@@ -0,0 +1,72 @@
package com.ruoyi.wcs.port.out;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public final class WmsNotification {
private final String eventId;
private final String externalRequestId;
private final String orderId;
private final Instant occurredAt;
private final String eventType;
private final String resultCode;
private final String resultMessage;
private final Map<String, String> context;
public WmsNotification(String eventId, String externalRequestId,
String orderId, Instant occurredAt,
String eventType, String resultCode,
String resultMessage, Map<String, String> context) {
this.eventId = requireText(eventId, "eventId");
this.externalRequestId = requireText(externalRequestId, "externalRequestId");
this.orderId = requireText(orderId, "orderId");
this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt");
this.eventType = requireText(eventType, "eventType");
this.resultCode = resultCode;
this.resultMessage = resultMessage;
this.context = Collections.unmodifiableMap(new LinkedHashMap<String, String>(
Objects.requireNonNull(context, "context")));
}
public String getEventId() {
return eventId;
}
public String getExternalRequestId() {
return externalRequestId;
}
public String getOrderId() {
return orderId;
}
public Instant getOccurredAt() {
return occurredAt;
}
public String getEventType() {
return eventType;
}
public String getResultCode() {
return resultCode;
}
public String getResultMessage() {
return resultMessage;
}
public Map<String, String> getContext() {
return context;
}
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,5 @@
package com.ruoyi.wcs.port.out;
public interface WmsNotificationGateway {
WmsNotificationReceipt notify(WmsNotification notification);
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.wcs.port.out;
import java.util.Objects;
public final class WmsNotificationReceipt {
public enum Status {
DELIVERED,
RETRYABLE_FAILURE,
PERMANENT_FAILURE
}
private final Status status;
private final String detail;
public WmsNotificationReceipt(Status status, String detail) {
this.status = Objects.requireNonNull(status, "status");
this.detail = detail;
}
public Status getStatus() {
return status;
}
public String getDetail() {
return detail;
}
}

View File

@@ -0,0 +1,161 @@
package com.ruoyi.wcs.application.task;
import com.ruoyi.wcs.domain.shared.DomainEvent;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.domain.task.TaskStepType;
import com.ruoyi.wcs.domain.task.TransportOrder;
import com.ruoyi.wcs.domain.task.TransportOrderState;
import com.ruoyi.wcs.domain.task.TransportOrderType;
import com.ruoyi.wcs.domain.task.WorkflowDefinition;
import com.ruoyi.wcs.domain.task.WorkflowInstance;
import com.ruoyi.wcs.domain.task.WorkflowStepDefinition;
import com.ruoyi.wcs.port.out.DomainEventPublisher;
import com.ruoyi.wcs.port.out.TransportOrderRepository;
import com.ruoyi.wcs.port.out.WorkflowDefinitionProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TransportOrderApplicationServiceTest {
private InMemoryRepository repository;
private RecordingPublisher publisher;
private TransportOrderApplicationService service;
@BeforeEach
void setUp() {
repository = new InMemoryRepository();
publisher = new RecordingPublisher();
service = new TransportOrderApplicationService(
repository,
new SingleStepDefinitions(),
publisher,
new SequenceIdGenerator(),
new FixedClock());
}
@Test
void returnsTheExistingOrderForTheSameExternalRequest() {
TransportOrderResult first = service.create(command("REQ-1"));
TransportOrderResult second = service.create(command("REQ-1"));
assertEquals(first.getOrderId(), second.getOrderId());
assertEquals(1, repository.count());
assertEquals(2, publisher.events.size());
}
@Test
void createsAPlannedOrderAndItsWorkflowAtomically() {
TransportOrderResult result = service.create(command("REQ-2"));
assertEquals(TransportOrderState.PLANNED, result.getState());
assertEquals("PORT-1", result.getSourceCode());
assertEquals("SPACE-9", result.getDestinationCode());
assertEquals(1, repository.workflows.size());
assertEquals("OrderAccepted", publisher.events.get(0).getType());
assertEquals("OrderPlanned", publisher.events.get(1).getType());
}
@Test
void cancelBeforeExecutionProducesOneCancelledTransition() {
service.create(command("REQ-3"));
TransportOrderResult cancelled = service.cancel("REQ-3");
assertEquals(TransportOrderState.CANCELLED, cancelled.getState());
assertEquals("OrderCancelled", publisher.events.get(2).getType());
}
private CreateTransportOrderCommand command(String requestId) {
return new CreateTransportOrderCommand(
requestId,
TransportOrderType.INBOUND,
"LOAD-1",
"PORT-1",
"SPACE-9",
50,
null,
Collections.<String, String>emptyMap());
}
private static final class FixedClock implements WcsClock {
@Override
public Instant now() {
return Instant.parse("2026-08-04T00:00:00Z");
}
}
private static final class SequenceIdGenerator implements IdGenerator {
private int next = 1;
@Override
public String nextId() {
return "ID-" + next++;
}
}
private static final class SingleStepDefinitions implements WorkflowDefinitionProvider {
@Override
public WorkflowDefinition definitionFor(TransportOrderType orderType) {
return new WorkflowDefinition(orderType, Collections.singletonList(
new WorkflowStepDefinition("MOVE", TaskStepType.MOVE,
true, true, Collections.<String>emptyList())));
}
}
private static final class RecordingPublisher implements DomainEventPublisher {
private final List<DomainEvent> events = new ArrayList<DomainEvent>();
@Override
public void publish(List<DomainEvent> domainEvents) {
events.addAll(domainEvents);
}
}
private static final class InMemoryRepository implements TransportOrderRepository {
private final Map<String, TransportOrder> orders =
new LinkedHashMap<String, TransportOrder>();
private final Map<String, WorkflowInstance> workflows =
new LinkedHashMap<String, WorkflowInstance>();
@Override
public TransportOrder findByExternalRequestId(String externalRequestId) {
return orders.get(externalRequestId);
}
@Override
public TransportOrder findByOrderId(String orderId) {
for (TransportOrder order : orders.values()) {
if (order.getOrderId().equals(orderId)) {
return order;
}
}
return null;
}
@Override
public void save(TransportOrder order, WorkflowInstance workflow) {
orders.put(order.getExternalRequestId(), order);
if (workflow != null) {
workflows.put(workflow.getWorkflowId(), workflow);
}
}
@Override
public void save(TransportOrder order) {
orders.put(order.getExternalRequestId(), order);
}
int count() {
return orders.size();
}
}
}