feat(wcs): add workflow and task step model
This commit is contained in:
177
ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java
Normal file
177
ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java
Normal file
@@ -0,0 +1,177 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class TaskStep {
|
||||
private final String stepId;
|
||||
private final String stepKey;
|
||||
private final TaskStepType type;
|
||||
private final boolean required;
|
||||
|
||||
private TaskStepState state = TaskStepState.WAITING;
|
||||
private String reasonCode;
|
||||
private String vehicleId;
|
||||
private List<String> reservationIds = Collections.emptyList();
|
||||
private long stateVersion;
|
||||
|
||||
TaskStep(String stepId, WorkflowStepDefinition definition) {
|
||||
this.stepId = requireText(stepId, "stepId");
|
||||
this.stepKey = definition.getStepKey();
|
||||
this.type = definition.getType();
|
||||
this.required = definition.isRequired();
|
||||
if (!definition.isEnabled()) {
|
||||
skip();
|
||||
}
|
||||
}
|
||||
|
||||
public void markReady() {
|
||||
requireState(TaskStepState.WAITING);
|
||||
transitionTo(TaskStepState.READY);
|
||||
}
|
||||
|
||||
public void block(String reasonCode) {
|
||||
requireState(TaskStepState.WAITING, TaskStepState.READY);
|
||||
this.reasonCode = requireText(reasonCode, "reasonCode");
|
||||
transitionTo(TaskStepState.BLOCKED);
|
||||
}
|
||||
|
||||
public void unblock() {
|
||||
requireState(TaskStepState.BLOCKED);
|
||||
this.reasonCode = null;
|
||||
transitionTo(TaskStepState.READY);
|
||||
}
|
||||
|
||||
public void assign(String vehicleId) {
|
||||
assign(vehicleId, Collections.<String>emptyList());
|
||||
}
|
||||
|
||||
public void assign(String vehicleId, List<String> reservationIds) {
|
||||
requireState(TaskStepState.READY);
|
||||
this.vehicleId = requireText(vehicleId, "vehicleId");
|
||||
if (reservationIds == null) {
|
||||
throw new IllegalArgumentException("reservationIds must not be null");
|
||||
}
|
||||
this.reservationIds = Collections.unmodifiableList(
|
||||
new ArrayList<String>(reservationIds));
|
||||
transitionTo(TaskStepState.ASSIGNED);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
requireState(TaskStepState.ASSIGNED);
|
||||
transitionTo(TaskStepState.RUNNING);
|
||||
}
|
||||
|
||||
public void suspend(String reason) {
|
||||
requireState(TaskStepState.ASSIGNED, TaskStepState.RUNNING);
|
||||
this.reasonCode = requireText(reason, "reason");
|
||||
transitionTo(TaskStepState.SUSPENDED);
|
||||
}
|
||||
|
||||
public void resumeAssigned() {
|
||||
requireState(TaskStepState.SUSPENDED);
|
||||
this.reasonCode = null;
|
||||
transitionTo(TaskStepState.ASSIGNED);
|
||||
}
|
||||
|
||||
public void resumeRunning() {
|
||||
requireState(TaskStepState.SUSPENDED);
|
||||
this.reasonCode = null;
|
||||
transitionTo(TaskStepState.RUNNING);
|
||||
}
|
||||
|
||||
public void succeed() {
|
||||
requireState(TaskStepState.RUNNING);
|
||||
transitionTo(TaskStepState.SUCCEEDED);
|
||||
}
|
||||
|
||||
public void skip() {
|
||||
requireState(TaskStepState.WAITING);
|
||||
if (required) {
|
||||
throw new DomainRuleViolation("required step cannot be skipped");
|
||||
}
|
||||
transitionTo(TaskStepState.SKIPPED);
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
requireNonTerminal();
|
||||
transitionTo(TaskStepState.CANCELLED);
|
||||
}
|
||||
|
||||
public void fail(String reason) {
|
||||
requireNonTerminal();
|
||||
this.reasonCode = requireText(reason, "reason");
|
||||
transitionTo(TaskStepState.FAILED);
|
||||
}
|
||||
|
||||
public String getStepId() {
|
||||
return stepId;
|
||||
}
|
||||
|
||||
public String getStepKey() {
|
||||
return stepKey;
|
||||
}
|
||||
|
||||
public TaskStepType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isRequired() {
|
||||
return required;
|
||||
}
|
||||
|
||||
public TaskStepState getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public String getReasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
|
||||
public String getVehicleId() {
|
||||
return vehicleId;
|
||||
}
|
||||
|
||||
public List<String> getReservationIds() {
|
||||
return reservationIds;
|
||||
}
|
||||
|
||||
public long getStateVersion() {
|
||||
return stateVersion;
|
||||
}
|
||||
|
||||
boolean isWaiting() {
|
||||
return state == TaskStepState.WAITING;
|
||||
}
|
||||
|
||||
private void transitionTo(TaskStepState next) {
|
||||
state = next;
|
||||
stateVersion++;
|
||||
}
|
||||
|
||||
private void requireNonTerminal() {
|
||||
if (state == TaskStepState.SUCCEEDED || state == TaskStepState.SKIPPED
|
||||
|| state == TaskStepState.CANCELLED || state == TaskStepState.FAILED) {
|
||||
throw new DomainRuleViolation("terminal step cannot transition from " + state);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireState(TaskStepState... allowed) {
|
||||
for (TaskStepState candidate : allowed) {
|
||||
if (state == candidate) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new DomainRuleViolation("illegal task step transition from " + state);
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
public enum TaskStepState {
|
||||
WAITING,
|
||||
READY,
|
||||
BLOCKED,
|
||||
ASSIGNED,
|
||||
RUNNING,
|
||||
SUSPENDED,
|
||||
SUCCEEDED,
|
||||
SKIPPED,
|
||||
CANCELLED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
public enum TaskStepType {
|
||||
MOVE,
|
||||
LOAD,
|
||||
UNLOAD,
|
||||
LIFT_TRANSFER,
|
||||
CONVEYOR_TRANSFER,
|
||||
INVENTORY_CHECK,
|
||||
CHARGE,
|
||||
YIELD
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class WorkflowDefinition {
|
||||
private final TransportOrderType orderType;
|
||||
private final List<WorkflowStepDefinition> steps;
|
||||
|
||||
public WorkflowDefinition(TransportOrderType orderType,
|
||||
List<WorkflowStepDefinition> steps) {
|
||||
this.orderType = Objects.requireNonNull(orderType, "orderType");
|
||||
if (steps == null || steps.isEmpty()) {
|
||||
throw new IllegalArgumentException("steps must not be empty");
|
||||
}
|
||||
this.steps = Collections.unmodifiableList(
|
||||
new ArrayList<WorkflowStepDefinition>(steps));
|
||||
}
|
||||
|
||||
public TransportOrderType getOrderType() {
|
||||
return orderType;
|
||||
}
|
||||
|
||||
public List<WorkflowStepDefinition> getSteps() {
|
||||
return steps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WorkflowInstance {
|
||||
private final String workflowId;
|
||||
private final String orderId;
|
||||
private final Map<String, TaskStep> steps;
|
||||
private final Map<String, List<String>> mandatoryDependencies;
|
||||
|
||||
private WorkflowInstance(String workflowId, String orderId,
|
||||
WorkflowDefinition definition) {
|
||||
this.workflowId = requireText(workflowId, "workflowId");
|
||||
this.orderId = requireText(orderId, "orderId");
|
||||
this.steps = new LinkedHashMap<String, TaskStep>();
|
||||
this.mandatoryDependencies = new LinkedHashMap<String, List<String>>();
|
||||
|
||||
for (WorkflowStepDefinition stepDefinition : definition.getSteps()) {
|
||||
String stepKey = stepDefinition.getStepKey();
|
||||
if (steps.containsKey(stepKey)) {
|
||||
throw new IllegalArgumentException("duplicate workflow step " + stepKey);
|
||||
}
|
||||
steps.put(stepKey, new TaskStep(workflowId + ":" + stepKey, stepDefinition));
|
||||
mandatoryDependencies.put(stepKey, stepDefinition.getMandatoryDependencies());
|
||||
}
|
||||
validateDependencies();
|
||||
}
|
||||
|
||||
public static WorkflowInstance create(String workflowId, String orderId,
|
||||
WorkflowDefinition definition) {
|
||||
if (definition == null) {
|
||||
throw new IllegalArgumentException("definition must not be null");
|
||||
}
|
||||
return new WorkflowInstance(workflowId, orderId, definition);
|
||||
}
|
||||
|
||||
public List<TaskStep> readySteps() {
|
||||
List<TaskStep> result = new ArrayList<TaskStep>();
|
||||
for (Map.Entry<String, TaskStep> entry : steps.entrySet()) {
|
||||
TaskStep step = entry.getValue();
|
||||
if (!step.isWaiting()) {
|
||||
continue;
|
||||
}
|
||||
DependencyState dependencyState = dependencyState(entry.getKey());
|
||||
if (dependencyState == DependencyState.FAILED) {
|
||||
step.fail("MANDATORY_DEPENDENCY_FAILED");
|
||||
} else if (dependencyState == DependencyState.SUCCEEDED) {
|
||||
step.markReady();
|
||||
result.add(step);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
public void onStepSucceeded(String stepKey) {
|
||||
TaskStep step = step(stepKey);
|
||||
if (step.getState() != TaskStepState.SUCCEEDED) {
|
||||
throw new DomainRuleViolation("step has not succeeded: " + stepKey);
|
||||
}
|
||||
}
|
||||
|
||||
public TaskStep step(String stepKey) {
|
||||
TaskStep step = steps.get(stepKey);
|
||||
if (step == null) {
|
||||
throw new IllegalArgumentException("unknown workflow step " + stepKey);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
public String getWorkflowId() {
|
||||
return workflowId;
|
||||
}
|
||||
|
||||
public String getOrderId() {
|
||||
return orderId;
|
||||
}
|
||||
|
||||
public List<TaskStep> getSteps() {
|
||||
return Collections.unmodifiableList(new ArrayList<TaskStep>(steps.values()));
|
||||
}
|
||||
|
||||
private DependencyState dependencyState(String stepKey) {
|
||||
boolean allSucceeded = true;
|
||||
for (String dependencyKey : mandatoryDependencies.get(stepKey)) {
|
||||
TaskStepState dependencyState = steps.get(dependencyKey).getState();
|
||||
if (dependencyState == TaskStepState.FAILED
|
||||
|| dependencyState == TaskStepState.CANCELLED
|
||||
|| dependencyState == TaskStepState.SKIPPED) {
|
||||
return DependencyState.FAILED;
|
||||
}
|
||||
if (dependencyState != TaskStepState.SUCCEEDED) {
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
return allSucceeded ? DependencyState.SUCCEEDED : DependencyState.WAITING;
|
||||
}
|
||||
|
||||
private void validateDependencies() {
|
||||
for (Map.Entry<String, List<String>> entry : mandatoryDependencies.entrySet()) {
|
||||
for (String dependency : entry.getValue()) {
|
||||
if (!steps.containsKey(dependency)) {
|
||||
throw new IllegalArgumentException("unknown dependency " + dependency);
|
||||
}
|
||||
if (entry.getKey().equals(dependency)) {
|
||||
throw new IllegalArgumentException("step cannot depend on itself");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private enum DependencyState {
|
||||
WAITING,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class WorkflowStepDefinition {
|
||||
private final String stepKey;
|
||||
private final TaskStepType type;
|
||||
private final boolean required;
|
||||
private final boolean enabled;
|
||||
private final List<String> mandatoryDependencies;
|
||||
|
||||
public WorkflowStepDefinition(String stepKey, TaskStepType type,
|
||||
boolean required, boolean enabled,
|
||||
List<String> mandatoryDependencies) {
|
||||
this.stepKey = requireText(stepKey, "stepKey");
|
||||
this.type = Objects.requireNonNull(type, "type");
|
||||
this.required = required;
|
||||
if (required && !enabled) {
|
||||
throw new IllegalArgumentException("required step cannot be disabled");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.mandatoryDependencies = Collections.unmodifiableList(
|
||||
new ArrayList<String>(Objects.requireNonNull(
|
||||
mandatoryDependencies, "mandatoryDependencies")));
|
||||
}
|
||||
|
||||
public String getStepKey() {
|
||||
return stepKey;
|
||||
}
|
||||
|
||||
public TaskStepType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isRequired() {
|
||||
return required;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public List<String> getMandatoryDependencies() {
|
||||
return mandatoryDependencies;
|
||||
}
|
||||
|
||||
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,8 @@
|
||||
package com.ruoyi.wcs.port.out;
|
||||
|
||||
import com.ruoyi.wcs.domain.task.TransportOrderType;
|
||||
import com.ruoyi.wcs.domain.task.WorkflowDefinition;
|
||||
|
||||
public interface WorkflowDefinitionProvider {
|
||||
WorkflowDefinition definitionFor(TransportOrderType orderType);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ruoyi.wcs.domain.task;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class WorkflowInstanceTest {
|
||||
|
||||
@Test
|
||||
void exposesOnlyStepsWhoseMandatoryDependenciesSucceeded() {
|
||||
WorkflowInstance workflow = workflow();
|
||||
|
||||
assertEquals(Collections.singletonList("MOVE"), keys(workflow.readySteps()));
|
||||
workflow.step("MOVE").assign("VEH-1", Collections.singletonList("RES-1"));
|
||||
workflow.step("MOVE").start();
|
||||
workflow.step("MOVE").succeed();
|
||||
workflow.onStepSucceeded("MOVE");
|
||||
|
||||
assertEquals(Collections.singletonList("DROP"), keys(workflow.readySteps()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedMandatoryDependencyFailsItsSuccessor() {
|
||||
WorkflowInstance workflow = workflow();
|
||||
TaskStep move = workflow.readySteps().get(0);
|
||||
move.fail("PLC rejected command before movement");
|
||||
|
||||
assertEquals(Collections.emptyList(), workflow.readySteps());
|
||||
assertEquals(TaskStepState.FAILED, workflow.step("DROP").getState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludedOptionalBranchIsSkippedAndDoesNotBlockRequiredWork() {
|
||||
WorkflowDefinition definition = new WorkflowDefinition(
|
||||
TransportOrderType.INBOUND,
|
||||
Arrays.asList(
|
||||
new WorkflowStepDefinition("MOVE", TaskStepType.MOVE, true,
|
||||
true, Collections.<String>emptyList()),
|
||||
new WorkflowStepDefinition("AUDIT", TaskStepType.INVENTORY_CHECK,
|
||||
false, false, Collections.singletonList("MOVE"))));
|
||||
|
||||
WorkflowInstance workflow = WorkflowInstance.create("WF-2", "ORDER-2", definition);
|
||||
|
||||
assertEquals(TaskStepState.SKIPPED, workflow.step("AUDIT").getState());
|
||||
assertEquals(Collections.singletonList("MOVE"), keys(workflow.readySteps()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalStepRejectsFurtherTransitions() {
|
||||
TaskStep step = workflow().readySteps().get(0);
|
||||
step.cancel();
|
||||
|
||||
assertThrows(DomainRuleViolation.class, step::markReady);
|
||||
}
|
||||
|
||||
private WorkflowInstance workflow() {
|
||||
WorkflowDefinition definition = new WorkflowDefinition(
|
||||
TransportOrderType.INBOUND,
|
||||
Arrays.asList(
|
||||
new WorkflowStepDefinition("MOVE", TaskStepType.MOVE, true,
|
||||
true, Collections.<String>emptyList()),
|
||||
new WorkflowStepDefinition("DROP", TaskStepType.UNLOAD, true,
|
||||
true, Collections.singletonList("MOVE"))));
|
||||
return WorkflowInstance.create("WF-1", "ORDER-1", definition);
|
||||
}
|
||||
|
||||
private List<String> keys(List<TaskStep> steps) {
|
||||
return steps.stream().map(TaskStep::getStepKey).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user