feat(wcs): add workflow and task step model

This commit is contained in:
zgw
2026-08-10 15:12:05 +08:00
parent ba245255f9
commit fd83225c75
8 changed files with 502 additions and 0 deletions

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

View File

@@ -0,0 +1,14 @@
package com.ruoyi.wcs.domain.task;
public enum TaskStepState {
WAITING,
READY,
BLOCKED,
ASSIGNED,
RUNNING,
SUSPENDED,
SUCCEEDED,
SKIPPED,
CANCELLED,
FAILED
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.wcs.domain.task;
public enum TaskStepType {
MOVE,
LOAD,
UNLOAD,
LIFT_TRANSFER,
CONVEYOR_TRANSFER,
INVENTORY_CHECK,
CHARGE,
YIELD
}

View File

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

View File

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

View File

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

View File

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

View File

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