test(ft546): verify standard WCS end to end

This commit is contained in:
zgw
2026-08-12 08:27:40 +08:00
parent ceba465675
commit f66585f717
31 changed files with 2560 additions and 16 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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()) {

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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());
}
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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"));
}
}