test(ft546): verify standard WCS end to end
This commit is contained in:
@@ -13,7 +13,7 @@ import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class CommandDeliveryRecorder {
|
||||
public class CommandDeliveryRecorder {
|
||||
private final DeviceCommandRepository commandRepository;
|
||||
private final CommandOutboxRepository outboxRepository;
|
||||
private final RecoveryCaseOpener recoveryCaseOpener;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DeviceCommandCompletionHandler {
|
||||
DeviceCommandCompletionHandler NO_OP = command -> { };
|
||||
|
||||
void onSucceeded(DeviceCommand command);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||
import com.ruoyi.wcs.domain.execution.CommittedDeviceExecution;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceExecutionRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -16,9 +18,11 @@ import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class DeviceExecutionService {
|
||||
public class DeviceExecutionService {
|
||||
private final DeviceCommandRepository commandRepository;
|
||||
private final CommandOutboxRepository outboxRepository;
|
||||
private final DeviceExecutionRepository executionRepository;
|
||||
private final DispatchGrantHandler dispatchGrantHandler;
|
||||
private final IdGenerator idGenerator;
|
||||
private final Supplier<Instant> clock;
|
||||
|
||||
@@ -26,10 +30,32 @@ public final class DeviceExecutionService {
|
||||
CommandOutboxRepository outboxRepository,
|
||||
IdGenerator idGenerator,
|
||||
Supplier<Instant> clock) {
|
||||
this(commandRepository, outboxRepository, null,
|
||||
DispatchGrantHandler.NO_OP, idGenerator, clock);
|
||||
}
|
||||
|
||||
public DeviceExecutionService(DeviceCommandRepository commandRepository,
|
||||
CommandOutboxRepository outboxRepository,
|
||||
DeviceExecutionRepository executionRepository,
|
||||
IdGenerator idGenerator,
|
||||
Supplier<Instant> clock) {
|
||||
this(commandRepository, outboxRepository, executionRepository,
|
||||
DispatchGrantHandler.NO_OP, idGenerator, clock);
|
||||
}
|
||||
|
||||
public DeviceExecutionService(DeviceCommandRepository commandRepository,
|
||||
CommandOutboxRepository outboxRepository,
|
||||
DeviceExecutionRepository executionRepository,
|
||||
DispatchGrantHandler dispatchGrantHandler,
|
||||
IdGenerator idGenerator,
|
||||
Supplier<Instant> clock) {
|
||||
this.commandRepository = Objects.requireNonNull(commandRepository,
|
||||
"commandRepository");
|
||||
this.outboxRepository = Objects.requireNonNull(outboxRepository,
|
||||
"outboxRepository");
|
||||
this.executionRepository = executionRepository;
|
||||
this.dispatchGrantHandler = Objects.requireNonNull(dispatchGrantHandler,
|
||||
"dispatchGrantHandler");
|
||||
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
@@ -50,6 +76,8 @@ public final class DeviceExecutionService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
dispatchGrantHandler.onGranted(grant);
|
||||
|
||||
Instant now = Objects.requireNonNull(clock.get(), "clock returned null");
|
||||
DeviceCommand command = DeviceCommand.pending(
|
||||
requireText(idGenerator.nextId(), "generated commandId"),
|
||||
@@ -59,10 +87,25 @@ public final class DeviceExecutionService {
|
||||
command.getCommandId(), command.getCommandKey(), payload, now,
|
||||
grant.getTopologyVersion(), grant.getFencingToken());
|
||||
commandRepository.save(command);
|
||||
if (executionRepository != null) {
|
||||
executionRepository.save(execution(command, grant));
|
||||
}
|
||||
outboxRepository.saveIfAbsent(entry);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static CommittedDeviceExecution execution(
|
||||
DeviceCommand command, GrantedDispatch grant) {
|
||||
return new CommittedDeviceExecution(command.getCommandId(),
|
||||
command.getCommandKey(), command.getDeviceId(),
|
||||
requireText(grant.getCommandParameters().get("taskNo"),
|
||||
"taskNo"),
|
||||
grant.getProtectedRoute().getNodeIds(),
|
||||
grant.getProtectedRoute().getResourceKeys(),
|
||||
grant.getTopologyVersion(), grant.getFencingToken(),
|
||||
grant.getCommandParameters().get("expectedBootId"));
|
||||
}
|
||||
|
||||
private static String encode(GrantedDispatch grant) {
|
||||
StringBuilder route = new StringBuilder();
|
||||
for (String node : grant.getProtectedRoute().getNodeIds()) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DispatchGrantHandler {
|
||||
DispatchGrantHandler NO_OP = grant -> { };
|
||||
|
||||
void onGranted(GrantedDispatch grant);
|
||||
}
|
||||
@@ -20,19 +20,31 @@ import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class FeedbackReconciler
|
||||
public class FeedbackReconciler
|
||||
implements DeviceEventUseCase, DeviceSnapshotUseCase {
|
||||
private final DeviceCommandRepository commandRepository;
|
||||
private final DeviceExecutionRepository executionRepository;
|
||||
private final DeviceEventRepository eventRepository;
|
||||
private final ResourceReservationRepository reservationRepository;
|
||||
private final RecoveryCaseOpener recoveryCaseOpener;
|
||||
private final DeviceCommandCompletionHandler completionHandler;
|
||||
|
||||
public FeedbackReconciler(DeviceCommandRepository commandRepository,
|
||||
DeviceExecutionRepository executionRepository,
|
||||
DeviceEventRepository eventRepository,
|
||||
ResourceReservationRepository reservationRepository,
|
||||
RecoveryCaseOpener recoveryCaseOpener) {
|
||||
this(commandRepository, executionRepository, eventRepository,
|
||||
reservationRepository, recoveryCaseOpener,
|
||||
DeviceCommandCompletionHandler.NO_OP);
|
||||
}
|
||||
|
||||
public FeedbackReconciler(DeviceCommandRepository commandRepository,
|
||||
DeviceExecutionRepository executionRepository,
|
||||
DeviceEventRepository eventRepository,
|
||||
ResourceReservationRepository reservationRepository,
|
||||
RecoveryCaseOpener recoveryCaseOpener,
|
||||
DeviceCommandCompletionHandler completionHandler) {
|
||||
this.commandRepository = java.util.Objects.requireNonNull(
|
||||
commandRepository, "commandRepository");
|
||||
this.executionRepository = java.util.Objects.requireNonNull(
|
||||
@@ -43,6 +55,8 @@ public final class FeedbackReconciler
|
||||
reservationRepository, "reservationRepository");
|
||||
this.recoveryCaseOpener = java.util.Objects.requireNonNull(
|
||||
recoveryCaseOpener, "recoveryCaseOpener");
|
||||
this.completionHandler = java.util.Objects.requireNonNull(
|
||||
completionHandler, "completionHandler");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -70,6 +84,7 @@ public final class FeedbackReconciler
|
||||
}
|
||||
|
||||
DeviceCommand command = requireCommand(execution.getCommandKey());
|
||||
boolean completed = false;
|
||||
try {
|
||||
switch (event.getEventType()) {
|
||||
case ACKNOWLEDGED:
|
||||
@@ -91,6 +106,7 @@ public final class FeedbackReconciler
|
||||
event.getCurrentNodeId(), event.getRemainingRouteNodes(),
|
||||
true, event.getEventId());
|
||||
complete(command, event.getEventId(), event.getReceivedTime());
|
||||
completed = true;
|
||||
break;
|
||||
case REJECTED:
|
||||
requireActive(command, false);
|
||||
@@ -113,6 +129,9 @@ public final class FeedbackReconciler
|
||||
return EventAcceptanceResult.RECOVERY_REQUIRED;
|
||||
}
|
||||
commandRepository.save(command);
|
||||
if (completed) {
|
||||
completionHandler.onSucceeded(command);
|
||||
}
|
||||
return EventAcceptanceResult.APPLIED;
|
||||
}
|
||||
|
||||
@@ -134,8 +153,9 @@ public final class FeedbackReconciler
|
||||
return recovery(snapshot, execution, "TASK_NUMBER_MISMATCH");
|
||||
}
|
||||
if (snapshot.hasDeviceBootSession()
|
||||
&& !execution.getExpectedBootId().equals(
|
||||
snapshot.getDeviceBootId())) {
|
||||
&& (!execution.hasExpectedBootSession()
|
||||
|| !execution.getExpectedBootId().equals(
|
||||
snapshot.getDeviceBootId()))) {
|
||||
return recovery(snapshot, execution, "DEVICE_RESTARTED");
|
||||
}
|
||||
if (execution.getTopologyVersion() != snapshot.getTopologyVersion()) {
|
||||
@@ -174,6 +194,7 @@ public final class FeedbackReconciler
|
||||
if (snapshot.isCommandCompleted()) {
|
||||
complete(command, snapshot.getSnapshotId(), snapshot.getObservedAt());
|
||||
commandRepository.save(command);
|
||||
completionHandler.onSucceeded(command);
|
||||
}
|
||||
return new ReconcileResult(released.isEmpty()
|
||||
? ReconcileStatus.CONSISTENT : ReconcileStatus.ADVANCED,
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public final class WmsNotificationOutboxProjector {
|
||||
public class WmsNotificationOutboxProjector {
|
||||
private final WmsNotificationOutboxRepository repository;
|
||||
|
||||
public WmsNotificationOutboxProjector(
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class RecoveryApplicationService
|
||||
public class RecoveryApplicationService
|
||||
implements RecoveryUseCase, RecoveryCaseOpener {
|
||||
private final RecoveryCaseRepository repository;
|
||||
private final IdGenerator idGenerator;
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class TransportOrderApplicationService implements TransportOrderUseCase {
|
||||
public class TransportOrderApplicationService implements TransportOrderUseCase {
|
||||
private final TransportOrderRepository repository;
|
||||
private final WorkflowDefinitionProvider workflowDefinitions;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.ruoyi.wcs.application.task;
|
||||
|
||||
import com.ruoyi.wcs.application.device.DeviceCommandCompletionHandler;
|
||||
import com.ruoyi.wcs.application.device.DispatchGrantHandler;
|
||||
import com.ruoyi.wcs.application.device.WmsNotificationOutboxProjector;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
import com.ruoyi.wcs.domain.shared.DomainEvent;
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import com.ruoyi.wcs.domain.task.TaskStep;
|
||||
import com.ruoyi.wcs.domain.task.TaskStepState;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrder;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderExecution;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderState;
|
||||
import com.ruoyi.wcs.domain.task.WorkflowInstance;
|
||||
import com.ruoyi.wcs.port.out.DomainEventPublisher;
|
||||
import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository;
|
||||
import com.ruoyi.wcs.port.out.WmsNotification;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class TransportOrderExecutionService
|
||||
implements DispatchGrantHandler, DeviceCommandCompletionHandler {
|
||||
private static final String COMMAND_PREFIX = "DISPATCH:";
|
||||
|
||||
private final TransportOrderExecutionRepository repository;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
private final WmsNotificationOutboxProjector notificationProjector;
|
||||
|
||||
public TransportOrderExecutionService(
|
||||
TransportOrderExecutionRepository repository,
|
||||
DomainEventPublisher eventPublisher,
|
||||
WmsNotificationOutboxProjector notificationProjector) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.eventPublisher = Objects.requireNonNull(eventPublisher,
|
||||
"eventPublisher");
|
||||
this.notificationProjector = Objects.requireNonNull(
|
||||
notificationProjector, "notificationProjector");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void onGranted(GrantedDispatch grant) {
|
||||
Objects.requireNonNull(grant, "grant");
|
||||
TransportOrderExecution execution = requireExecution(
|
||||
grant.getOrderId());
|
||||
TransportOrder order = execution.getOrder();
|
||||
WorkflowInstance workflow = execution.getWorkflow();
|
||||
TaskStep step = workflow.stepById(grant.getStepId());
|
||||
|
||||
if (step.getState() == TaskStepState.RUNNING
|
||||
&& grant.getVehicleId().equals(step.getVehicleId())) {
|
||||
return;
|
||||
}
|
||||
if (step.getState() != TaskStepState.READY) {
|
||||
throw new DomainRuleViolation(
|
||||
"dispatch grant requires a READY task step");
|
||||
}
|
||||
if (order.getState() == TransportOrderState.PLANNED) {
|
||||
order.start();
|
||||
} else if (order.getState() != TransportOrderState.EXECUTING) {
|
||||
throw new DomainRuleViolation(
|
||||
"dispatch grant requires a PLANNED or EXECUTING order");
|
||||
}
|
||||
step.assign(grant.getVehicleId(), grant.getReservationIds());
|
||||
step.start();
|
||||
repository.save(execution);
|
||||
publishAndClear(order, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void onSucceeded(DeviceCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
DispatchReference reference = reference(command.getCommandKey());
|
||||
if (reference == null) {
|
||||
return;
|
||||
}
|
||||
TransportOrderExecution execution = requireExecution(
|
||||
reference.orderId);
|
||||
TransportOrder order = execution.getOrder();
|
||||
WorkflowInstance workflow = execution.getWorkflow();
|
||||
TaskStep step = workflow.stepById(reference.stepId);
|
||||
|
||||
if (step.getState() == TaskStepState.SUCCEEDED
|
||||
&& order.getState() == TransportOrderState.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
if (step.getState() != TaskStepState.RUNNING) {
|
||||
throw new DomainRuleViolation(
|
||||
"command completion requires a RUNNING task step");
|
||||
}
|
||||
step.succeed();
|
||||
workflow.onStepSucceeded(step.getStepKey());
|
||||
if (workflow.allRequiredStepsSucceeded()) {
|
||||
order.complete();
|
||||
}
|
||||
repository.save(execution);
|
||||
publishAndClear(order, true);
|
||||
}
|
||||
|
||||
private TransportOrderExecution requireExecution(String orderId) {
|
||||
TransportOrderExecution execution = repository.load(orderId);
|
||||
if (execution == null) {
|
||||
throw new DomainRuleViolation(
|
||||
"transport order execution not found: " + orderId);
|
||||
}
|
||||
return execution;
|
||||
}
|
||||
|
||||
private void publishAndClear(TransportOrder order,
|
||||
boolean projectTerminalEvents) {
|
||||
List<DomainEvent> events = order.getUncommittedEvents();
|
||||
eventPublisher.publish(events);
|
||||
if (projectTerminalEvents) {
|
||||
for (DomainEvent event : events) {
|
||||
if (isTerminal(event.getType())) {
|
||||
notificationProjector.project(notification(order, event),
|
||||
event.getOccurredAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
order.clearUncommittedEvents();
|
||||
}
|
||||
|
||||
private static WmsNotification notification(TransportOrder order,
|
||||
DomainEvent event) {
|
||||
return new WmsNotification(event.getEventId(),
|
||||
order.getExternalRequestId(), order.getOrderId(),
|
||||
event.getOccurredAt(), event.getType(),
|
||||
order.getResultCode(), order.getStateReason(),
|
||||
Collections.<String, String>emptyMap());
|
||||
}
|
||||
|
||||
private static boolean isTerminal(String eventType) {
|
||||
return "OrderCompleted".equals(eventType)
|
||||
|| "OrderFailed".equals(eventType)
|
||||
|| "OrderCancelled".equals(eventType);
|
||||
}
|
||||
|
||||
private static DispatchReference reference(String commandKey) {
|
||||
if (commandKey == null || !commandKey.startsWith(COMMAND_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
String value = commandKey.substring(COMMAND_PREFIX.length());
|
||||
int separator = value.indexOf(':');
|
||||
if (separator <= 0 || separator == value.length() - 1) {
|
||||
throw new DomainRuleViolation("invalid dispatch command key");
|
||||
}
|
||||
return new DispatchReference(value.substring(0, separator),
|
||||
value.substring(separator + 1));
|
||||
}
|
||||
|
||||
private static final class DispatchReference {
|
||||
private final String orderId;
|
||||
private final String stepId;
|
||||
|
||||
private DispatchReference(String orderId, String stepId) {
|
||||
this.orderId = orderId;
|
||||
this.stepId = stepId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public final class CommittedDeviceExecution {
|
||||
}
|
||||
this.topologyVersion = topologyVersion;
|
||||
this.fencingToken = fencingToken;
|
||||
this.expectedBootId = requireText(expectedBootId, "expectedBootId");
|
||||
this.expectedBootId = optionalText(expectedBootId);
|
||||
this.currentNodeIndex = currentNodeIndex;
|
||||
this.releasedResourceCount = releasedResourceCount;
|
||||
this.lastDeviceSequence = lastDeviceSequence;
|
||||
@@ -134,6 +134,7 @@ public final class CommittedDeviceExecution {
|
||||
public long getTopologyVersion() { return topologyVersion; }
|
||||
public long getFencingToken() { return fencingToken; }
|
||||
public String getExpectedBootId() { return expectedBootId; }
|
||||
public boolean hasExpectedBootSession() { return expectedBootId != null; }
|
||||
public long getLastDeviceSequence() { return lastDeviceSequence; }
|
||||
|
||||
public static final class Progress {
|
||||
@@ -209,4 +210,8 @@ public final class CommittedDeviceExecution {
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String optionalText(String value) {
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class TaskStep {
|
||||
private final String stepId;
|
||||
@@ -28,6 +29,30 @@ public final class TaskStep {
|
||||
}
|
||||
}
|
||||
|
||||
public static TaskStep restore(String stepId, String stepKey,
|
||||
TaskStepType type, boolean required,
|
||||
TaskStepState state, String reasonCode,
|
||||
String vehicleId,
|
||||
List<String> reservationIds,
|
||||
long stateVersion) {
|
||||
if (stateVersion < 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"stateVersion must not be negative");
|
||||
}
|
||||
WorkflowStepDefinition definition = new WorkflowStepDefinition(
|
||||
stepKey, type, required, true,
|
||||
Collections.<String>emptyList());
|
||||
TaskStep step = new TaskStep(stepId, definition);
|
||||
step.state = Objects.requireNonNull(state, "state");
|
||||
step.reasonCode = reasonCode;
|
||||
step.vehicleId = vehicleId;
|
||||
step.reservationIds = Collections.unmodifiableList(
|
||||
new ArrayList<String>(Objects.requireNonNull(
|
||||
reservationIds, "reservationIds")));
|
||||
step.stateVersion = stateVersion;
|
||||
return step;
|
||||
}
|
||||
|
||||
public void markReady() {
|
||||
requireState(TaskStepState.WAITING);
|
||||
transitionTo(TaskStepState.READY);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class TransportOrderExecution {
|
||||
private final TransportOrder order;
|
||||
private final WorkflowInstance workflow;
|
||||
private final long loadedOrderVersion;
|
||||
private final Map<String, Long> loadedStepVersions;
|
||||
|
||||
private TransportOrderExecution(TransportOrder order,
|
||||
WorkflowInstance workflow) {
|
||||
this.order = Objects.requireNonNull(order, "order");
|
||||
this.workflow = Objects.requireNonNull(workflow, "workflow");
|
||||
if (!order.getOrderId().equals(workflow.getOrderId())) {
|
||||
throw new IllegalArgumentException(
|
||||
"workflow belongs to another transport order");
|
||||
}
|
||||
this.loadedOrderVersion = order.getStateVersion();
|
||||
Map<String, Long> versions = new LinkedHashMap<String, Long>();
|
||||
for (TaskStep step : workflow.getSteps()) {
|
||||
versions.put(step.getStepId(), step.getStateVersion());
|
||||
}
|
||||
this.loadedStepVersions = Collections.unmodifiableMap(versions);
|
||||
}
|
||||
|
||||
public static TransportOrderExecution loaded(TransportOrder order,
|
||||
WorkflowInstance workflow) {
|
||||
return new TransportOrderExecution(order, workflow);
|
||||
}
|
||||
|
||||
public TransportOrder getOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
public WorkflowInstance getWorkflow() {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
public long getLoadedOrderVersion() {
|
||||
return loadedOrderVersion;
|
||||
}
|
||||
|
||||
public long getLoadedStepVersion(String stepId) {
|
||||
Long version = loadedStepVersions.get(stepId);
|
||||
if (version == null) {
|
||||
throw new IllegalArgumentException("unknown loaded step " + stepId);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,33 @@ public final class WorkflowInstance {
|
||||
validateDependencies();
|
||||
}
|
||||
|
||||
private WorkflowInstance(String workflowId, String orderId,
|
||||
List<TaskStep> restoredSteps,
|
||||
Map<String, List<String>> dependencies) {
|
||||
this.workflowId = requireText(workflowId, "workflowId");
|
||||
this.orderId = requireText(orderId, "orderId");
|
||||
this.steps = new LinkedHashMap<String, TaskStep>();
|
||||
for (TaskStep step : restoredSteps) {
|
||||
if (steps.put(step.getStepKey(), step) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate workflow step " + step.getStepKey());
|
||||
}
|
||||
}
|
||||
if (steps.isEmpty()) {
|
||||
throw new IllegalArgumentException("steps must not be empty");
|
||||
}
|
||||
this.mandatoryDependencies =
|
||||
new LinkedHashMap<String, List<String>>();
|
||||
for (String stepKey : steps.keySet()) {
|
||||
List<String> values = dependencies.get(stepKey);
|
||||
this.mandatoryDependencies.put(stepKey,
|
||||
values == null ? Collections.<String>emptyList()
|
||||
: Collections.unmodifiableList(
|
||||
new ArrayList<String>(values)));
|
||||
}
|
||||
validateDependencies();
|
||||
}
|
||||
|
||||
public static WorkflowInstance create(String workflowId, String orderId,
|
||||
WorkflowDefinition definition) {
|
||||
if (definition == null) {
|
||||
@@ -40,6 +67,17 @@ public final class WorkflowInstance {
|
||||
return new WorkflowInstance(workflowId, orderId, definition);
|
||||
}
|
||||
|
||||
public static WorkflowInstance restore(String workflowId, String orderId,
|
||||
List<TaskStep> steps,
|
||||
Map<String, List<String>> dependencies) {
|
||||
if (steps == null || dependencies == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"restored workflow data must not be null");
|
||||
}
|
||||
return new WorkflowInstance(workflowId, orderId,
|
||||
new ArrayList<TaskStep>(steps), dependencies);
|
||||
}
|
||||
|
||||
public List<TaskStep> readySteps() {
|
||||
List<TaskStep> result = new ArrayList<TaskStep>();
|
||||
for (Map.Entry<String, TaskStep> entry : steps.entrySet()) {
|
||||
@@ -73,6 +111,26 @@ public final class WorkflowInstance {
|
||||
return step;
|
||||
}
|
||||
|
||||
public TaskStep stepById(String stepId) {
|
||||
String required = requireText(stepId, "stepId");
|
||||
for (TaskStep step : steps.values()) {
|
||||
if (required.equals(step.getStepId())) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("unknown workflow step id " + stepId);
|
||||
}
|
||||
|
||||
public boolean allRequiredStepsSucceeded() {
|
||||
for (TaskStep step : steps.values()) {
|
||||
if (step.isRequired()
|
||||
&& step.getState() != TaskStepState.SUCCEEDED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public String getWorkflowId() {
|
||||
return workflowId;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@ package com.ruoyi.wcs.infrastructure.persistence.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.task.TaskStep;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrder;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderExecution;
|
||||
import com.ruoyi.wcs.domain.task.WorkflowInstance;
|
||||
import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository;
|
||||
import com.ruoyi.wcs.port.out.TransportOrderRepository;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class MyBatisTransportOrderRepository implements TransportOrderRepository {
|
||||
public final class MyBatisTransportOrderRepository
|
||||
implements TransportOrderRepository, TransportOrderExecutionRepository {
|
||||
private final TransportOrderMapper mapper;
|
||||
|
||||
public MyBatisTransportOrderRepository(TransportOrderMapper mapper) {
|
||||
@@ -73,4 +79,66 @@ public final class MyBatisTransportOrderRepository implements TransportOrderRepo
|
||||
"transport order state changed concurrently: " + order.getOrderId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportOrderExecution load(String orderId) {
|
||||
TransportOrderRow orderRow = mapper.selectByOrderId(orderId);
|
||||
if (orderRow == null) {
|
||||
return null;
|
||||
}
|
||||
WorkflowRow workflowRow = mapper.selectWorkflowByOrderId(orderId);
|
||||
if (workflowRow == null) {
|
||||
throw new IllegalStateException(
|
||||
"transport order has no workflow: " + orderId);
|
||||
}
|
||||
if (!orderId.equals(workflowRow.getOrderId())) {
|
||||
throw new IllegalStateException(
|
||||
"workflow belongs to another transport order");
|
||||
}
|
||||
List<TaskStep> steps = new ArrayList<TaskStep>();
|
||||
for (TaskStepRow row : mapper.selectTaskSteps(
|
||||
workflowRow.getWorkflowId())) {
|
||||
steps.add(row.toDomain());
|
||||
}
|
||||
Map<String, List<String>> dependencies =
|
||||
new LinkedHashMap<String, List<String>>();
|
||||
for (TaskDependencyRow row : mapper.selectTaskDependencies(
|
||||
workflowRow.getWorkflowId())) {
|
||||
List<String> values = dependencies.get(row.getStepKey());
|
||||
if (values == null) {
|
||||
values = new ArrayList<String>();
|
||||
dependencies.put(row.getStepKey(), values);
|
||||
}
|
||||
values.add(row.getDependsOnStepKey());
|
||||
}
|
||||
WorkflowInstance workflow = WorkflowInstance.restore(
|
||||
workflowRow.getWorkflowId(), workflowRow.getOrderId(),
|
||||
steps, dependencies);
|
||||
return TransportOrderExecution.loaded(orderRow.toDomain(), workflow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(TransportOrderExecution execution) {
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
TransportOrder order = execution.getOrder();
|
||||
if (order.getStateVersion() != execution.getLoadedOrderVersion()
|
||||
&& mapper.updateState(TransportOrderRow.fromDomain(order),
|
||||
execution.getLoadedOrderVersion()) != 1) {
|
||||
throw new OptimisticLockingFailureException(
|
||||
"transport order state changed concurrently: "
|
||||
+ order.getOrderId());
|
||||
}
|
||||
for (TaskStep step : execution.getWorkflow().getSteps()) {
|
||||
long expectedVersion = execution.getLoadedStepVersion(
|
||||
step.getStepId());
|
||||
if (step.getStateVersion() != expectedVersion
|
||||
&& mapper.updateTaskStep(TaskStepRow.fromDomain(
|
||||
execution.getWorkflow().getWorkflowId(), step),
|
||||
expectedVersion) != 1) {
|
||||
throw new OptimisticLockingFailureException(
|
||||
"task step state changed concurrently: "
|
||||
+ step.getStepId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.ruoyi.wcs.infrastructure.persistence.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.task.TaskStep;
|
||||
import com.ruoyi.wcs.domain.task.TaskStepState;
|
||||
import com.ruoyi.wcs.domain.task.TaskStepType;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
public final class TaskStepRow {
|
||||
private String stepId;
|
||||
@@ -27,6 +31,13 @@ public final class TaskStepRow {
|
||||
return row;
|
||||
}
|
||||
|
||||
public TaskStep toDomain() {
|
||||
return TaskStep.restore(stepId, stepKey,
|
||||
TaskStepType.valueOf(stepType), required,
|
||||
TaskStepState.valueOf(stepState), reasonCode, vehicleId,
|
||||
Collections.<String>emptyList(), stateVersion);
|
||||
}
|
||||
|
||||
public String getStepId() {
|
||||
return stepId;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ruoyi.wcs.infrastructure.persistence.task;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TransportOrderMapper {
|
||||
TransportOrderRow selectByExternalRequestId(
|
||||
@@ -20,4 +22,15 @@ public interface TransportOrderMapper {
|
||||
int insertTaskStep(TaskStepRow row);
|
||||
|
||||
int insertTaskDependency(TaskDependencyRow row);
|
||||
|
||||
WorkflowRow selectWorkflowByOrderId(@Param("orderId") String orderId);
|
||||
|
||||
List<TaskStepRow> selectTaskSteps(
|
||||
@Param("workflowId") String workflowId);
|
||||
|
||||
List<TaskDependencyRow> selectTaskDependencies(
|
||||
@Param("workflowId") String workflowId);
|
||||
|
||||
int updateTaskStep(@Param("row") TaskStepRow row,
|
||||
@Param("expectedVersion") long expectedVersion);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.wcs.port.out;
|
||||
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderExecution;
|
||||
|
||||
public interface TransportOrderExecutionRepository {
|
||||
TransportOrderExecution load(String orderId);
|
||||
|
||||
void save(TransportOrderExecution execution);
|
||||
}
|
||||
@@ -21,6 +21,34 @@
|
||||
<result property="updatedTime" column="updated_time"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="WorkflowRowMap"
|
||||
type="com.ruoyi.wcs.infrastructure.persistence.task.WorkflowRow">
|
||||
<id property="workflowId" column="workflow_id"/>
|
||||
<result property="orderId" column="order_id"/>
|
||||
<result property="workflowState" column="workflow_state"/>
|
||||
<result property="stateVersion" column="state_version"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="TaskStepRowMap"
|
||||
type="com.ruoyi.wcs.infrastructure.persistence.task.TaskStepRow">
|
||||
<id property="stepId" column="step_id"/>
|
||||
<result property="workflowId" column="workflow_id"/>
|
||||
<result property="stepKey" column="step_key"/>
|
||||
<result property="stepType" column="step_type"/>
|
||||
<result property="required" column="required_flag"/>
|
||||
<result property="stepState" column="step_state"/>
|
||||
<result property="reasonCode" column="reason_code"/>
|
||||
<result property="vehicleId" column="vehicle_id"/>
|
||||
<result property="stateVersion" column="state_version"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="TaskDependencyRowMap"
|
||||
type="com.ruoyi.wcs.infrastructure.persistence.task.TaskDependencyRow">
|
||||
<id property="workflowId" column="workflow_id"/>
|
||||
<result property="stepKey" column="step_key"/>
|
||||
<result property="dependsOnStepKey" column="depends_on_step_key"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="OrderColumns">
|
||||
order_id, external_request_id, order_type, load_id, source_code,
|
||||
destination_code, priority, order_state, workflow_id, result_code,
|
||||
@@ -88,4 +116,35 @@
|
||||
#{workflowId}, #{stepKey}, #{dependsOnStepKey}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="selectWorkflowByOrderId" resultMap="WorkflowRowMap">
|
||||
SELECT workflow_id, order_id, workflow_state, state_version
|
||||
FROM dbo.wcs_workflow_instance
|
||||
WHERE order_id = #{orderId}
|
||||
</select>
|
||||
|
||||
<select id="selectTaskSteps" resultMap="TaskStepRowMap">
|
||||
SELECT step_id, workflow_id, step_key, step_type, required_flag,
|
||||
step_state, reason_code, vehicle_id, state_version
|
||||
FROM dbo.wcs_task_step
|
||||
WHERE workflow_id = #{workflowId}
|
||||
ORDER BY step_id
|
||||
</select>
|
||||
|
||||
<select id="selectTaskDependencies" resultMap="TaskDependencyRowMap">
|
||||
SELECT workflow_id, step_key, depends_on_step_key
|
||||
FROM dbo.wcs_task_dependency
|
||||
WHERE workflow_id = #{workflowId}
|
||||
ORDER BY step_key, depends_on_step_key
|
||||
</select>
|
||||
|
||||
<update id="updateTaskStep">
|
||||
UPDATE dbo.wcs_task_step
|
||||
SET step_state = #{row.stepState},
|
||||
reason_code = #{row.reasonCode},
|
||||
vehicle_id = #{row.vehicleId},
|
||||
state_version = #{row.stateVersion}
|
||||
WHERE step_id = #{row.stepId}
|
||||
AND state_version = #{expectedVersion}
|
||||
</update>
|
||||
</mapper>
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
package com.ruoyi.wcs.infrastructure.persistence.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.task.TaskStepType;
|
||||
import com.ruoyi.wcs.domain.task.TaskStepState;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrder;
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderExecution;
|
||||
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.TransportOrderExecutionRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MyBatisTransportOrderRepositoryTest {
|
||||
|
||||
@Test
|
||||
void implementsTheOrderExecutionRepositoryContract() {
|
||||
Object repository = new MyBatisTransportOrderRepository(
|
||||
new RecordingMapper());
|
||||
|
||||
assertTrue(repository instanceof TransportOrderExecutionRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsAndOptimisticallySavesOrderAndWorkflowExecution() {
|
||||
RecordingMapper mapper = new RecordingMapper();
|
||||
TransportOrder order = order();
|
||||
WorkflowInstance workflow = WorkflowInstance.create("WF-1", "ORDER-1",
|
||||
new WorkflowDefinition(TransportOrderType.INBOUND,
|
||||
Collections.singletonList(new WorkflowStepDefinition(
|
||||
"MOVE", TaskStepType.MOVE, true, true,
|
||||
Collections.<String>emptyList()))));
|
||||
order.plan(workflow.getWorkflowId());
|
||||
order.start();
|
||||
workflow.readySteps().get(0).assign("VEH-1");
|
||||
workflow.step("MOVE").start();
|
||||
mapper.selected = TransportOrderRow.fromDomain(order);
|
||||
mapper.selectedWorkflow = WorkflowRow.fromDomain(workflow);
|
||||
mapper.selectedSteps = Collections.singletonList(
|
||||
TaskStepRow.fromDomain(workflow.getWorkflowId(),
|
||||
workflow.step("MOVE")));
|
||||
|
||||
Object candidate = new MyBatisTransportOrderRepository(mapper);
|
||||
assertTrue(candidate instanceof TransportOrderExecutionRepository);
|
||||
TransportOrderExecutionRepository repository =
|
||||
(TransportOrderExecutionRepository) candidate;
|
||||
|
||||
TransportOrderExecution execution = repository.load("ORDER-1");
|
||||
|
||||
assertEquals(TransportOrderState.EXECUTING,
|
||||
execution.getOrder().getState());
|
||||
assertEquals(TaskStepState.RUNNING,
|
||||
execution.getWorkflow().step("MOVE").getState());
|
||||
|
||||
execution.getWorkflow().step("MOVE").succeed();
|
||||
execution.getOrder().complete();
|
||||
repository.save(execution);
|
||||
|
||||
assertEquals(2L, mapper.expectedVersion);
|
||||
assertEquals(3L, mapper.expectedStepVersion);
|
||||
assertEquals(1, mapper.stepUpdates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAStateUpdateWhenThePersistedVersionChanged() {
|
||||
RecordingMapper mapper = new RecordingMapper();
|
||||
@@ -72,6 +126,12 @@ class MyBatisTransportOrderRepositoryTest {
|
||||
private int workflowInserts;
|
||||
private int stepInserts;
|
||||
private int dependencyInserts;
|
||||
private WorkflowRow selectedWorkflow;
|
||||
private List<TaskStepRow> selectedSteps = Collections.emptyList();
|
||||
private List<TaskDependencyRow> selectedDependencies =
|
||||
Collections.emptyList();
|
||||
private long expectedStepVersion = -1L;
|
||||
private int stepUpdates;
|
||||
|
||||
@Override
|
||||
public TransportOrderRow selectByExternalRequestId(String externalRequestId) {
|
||||
@@ -112,5 +172,28 @@ class MyBatisTransportOrderRepositoryTest {
|
||||
dependencyInserts++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorkflowRow selectWorkflowByOrderId(String orderId) {
|
||||
return selectedWorkflow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TaskStepRow> selectTaskSteps(String workflowId) {
|
||||
return selectedSteps;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TaskDependencyRow> selectTaskDependencies(
|
||||
String workflowId) {
|
||||
return selectedDependencies;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTaskStep(TaskStepRow row, long expectedVersion) {
|
||||
expectedStepVersion = expectedVersion;
|
||||
stepUpdates++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,9 @@ class TransportOrderMapperXmlTest {
|
||||
assertTrue(configuration.hasStatement(namespace + "insertWorkflow"));
|
||||
assertTrue(configuration.hasStatement(namespace + "insertTaskStep"));
|
||||
assertTrue(configuration.hasStatement(namespace + "insertTaskDependency"));
|
||||
assertTrue(configuration.hasStatement(namespace + "selectWorkflowByOrderId"));
|
||||
assertTrue(configuration.hasStatement(namespace + "selectTaskSteps"));
|
||||
assertTrue(configuration.hasStatement(namespace + "selectTaskDependencies"));
|
||||
assertTrue(configuration.hasStatement(namespace + "updateTaskStep"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user