diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/CreateTransportOrderCommand.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/CreateTransportOrderCommand.java new file mode 100644 index 0000000..999523a --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/CreateTransportOrderCommand.java @@ -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 attributes; + + public CreateTransportOrderCommand(String externalRequestId, + TransportOrderType orderType, + String loadId, String sourceCode, + String destinationCode, int priority, + Instant deadline, + Map 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( + 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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java new file mode 100644 index 0000000..2d3fdf0 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java @@ -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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderResult.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderResult.java new file mode 100644 index 0000000..fd0a8db --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderResult.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/TransportOrderUseCase.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/TransportOrderUseCase.java new file mode 100644 index 0000000..fe28ccd --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/in/TransportOrderUseCase.java @@ -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); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DomainEventPublisher.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DomainEventPublisher.java new file mode 100644 index 0000000..eea7b46 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DomainEventPublisher.java @@ -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 domainEvents); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderRepository.java new file mode 100644 index 0000000..ba80693 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderRepository.java @@ -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); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotification.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotification.java new file mode 100644 index 0000000..e2b2d52 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotification.java @@ -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 context; + + public WmsNotification(String eventId, String externalRequestId, + String orderId, Instant occurredAt, + String eventType, String resultCode, + String resultMessage, Map 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( + 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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationGateway.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationGateway.java new file mode 100644 index 0000000..0a4582c --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationGateway.java @@ -0,0 +1,5 @@ +package com.ruoyi.wcs.port.out; + +public interface WmsNotificationGateway { + WmsNotificationReceipt notify(WmsNotification notification); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationReceipt.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationReceipt.java new file mode 100644 index 0000000..0f15e2c --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WmsNotificationReceipt.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/task/TransportOrderApplicationServiceTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/task/TransportOrderApplicationServiceTest.java new file mode 100644 index 0000000..26a4e01 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/task/TransportOrderApplicationServiceTest.java @@ -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.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.emptyList()))); + } + } + + private static final class RecordingPublisher implements DomainEventPublisher { + private final List events = new ArrayList(); + + @Override + public void publish(List domainEvents) { + events.addAll(domainEvents); + } + } + + private static final class InMemoryRepository implements TransportOrderRepository { + private final Map orders = + new LinkedHashMap(); + private final Map workflows = + new LinkedHashMap(); + + @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(); + } + } +}