diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java new file mode 100644 index 0000000..5ca3c6e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java @@ -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 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.emptyList()); + } + + public void assign(String vehicleId, List 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(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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepState.java new file mode 100644 index 0000000..d65ea19 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepState.java @@ -0,0 +1,14 @@ +package com.ruoyi.wcs.domain.task; + +public enum TaskStepState { + WAITING, + READY, + BLOCKED, + ASSIGNED, + RUNNING, + SUSPENDED, + SUCCEEDED, + SKIPPED, + CANCELLED, + FAILED +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepType.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepType.java new file mode 100644 index 0000000..2f23350 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStepType.java @@ -0,0 +1,12 @@ +package com.ruoyi.wcs.domain.task; + +public enum TaskStepType { + MOVE, + LOAD, + UNLOAD, + LIFT_TRANSFER, + CONVEYOR_TRANSFER, + INVENTORY_CHECK, + CHARGE, + YIELD +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowDefinition.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowDefinition.java new file mode 100644 index 0000000..df319ee --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowDefinition.java @@ -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 steps; + + public WorkflowDefinition(TransportOrderType orderType, + List 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(steps)); + } + + public TransportOrderType getOrderType() { + return orderType; + } + + public List getSteps() { + return steps; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java new file mode 100644 index 0000000..45f029e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java @@ -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 steps; + private final Map> mandatoryDependencies; + + private WorkflowInstance(String workflowId, String orderId, + WorkflowDefinition definition) { + this.workflowId = requireText(workflowId, "workflowId"); + this.orderId = requireText(orderId, "orderId"); + this.steps = new LinkedHashMap(); + this.mandatoryDependencies = new LinkedHashMap>(); + + 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 readySteps() { + List result = new ArrayList(); + for (Map.Entry 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 getSteps() { + return Collections.unmodifiableList(new ArrayList(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> 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 + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowStepDefinition.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowStepDefinition.java new file mode 100644 index 0000000..687e26d --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowStepDefinition.java @@ -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 mandatoryDependencies; + + public WorkflowStepDefinition(String stepKey, TaskStepType type, + boolean required, boolean enabled, + List 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(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 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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WorkflowDefinitionProvider.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WorkflowDefinitionProvider.java new file mode 100644 index 0000000..65dbce9 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/WorkflowDefinitionProvider.java @@ -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); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/WorkflowInstanceTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/WorkflowInstanceTest.java new file mode 100644 index 0000000..e484896 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/task/WorkflowInstanceTest.java @@ -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.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.emptyList()), + new WorkflowStepDefinition("DROP", TaskStepType.UNLOAD, true, + true, Collections.singletonList("MOVE")))); + return WorkflowInstance.create("WF-1", "ORDER-1", definition); + } + + private List keys(List steps) { + return steps.stream().map(TaskStep::getStepKey).collect(Collectors.toList()); + } +}