feat(wcs): add idempotent transport order use cases
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.wcs.port.out;
|
||||
|
||||
public interface WmsNotificationGateway {
|
||||
WmsNotificationReceipt notify(WmsNotification notification);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user