Bladeren bron

新增子节点配置

ye-zhaojia 4 dagen geleden
bovenliggende
commit
1cbcb03aee

+ 7 - 0
src/main/java/com/qqflow/engine/domain/flow/assembler/ApprovalTaskAssembler.java

@@ -9,11 +9,18 @@ public class ApprovalTaskAssembler {
     public ApprovalTask buildNew(Long instanceId, String nodeId, String nodeName,
                                   String nodeType, Long assigneeId,
                                   String assigneeType, Integer taskStatus) {
+        return buildNew(instanceId, nodeId, nodeName, nodeType, null, assigneeId, assigneeType, taskStatus);
+    }
+
+    public ApprovalTask buildNew(Long instanceId, String nodeId, String nodeName,
+                                  String nodeType, String subNodeId, Long assigneeId,
+                                  String assigneeType, Integer taskStatus) {
         ApprovalTask po = new ApprovalTask();
         po.setInstanceId(instanceId);
         po.setNodeId(nodeId);
         po.setNodeName(nodeName);
         po.setNodeType(nodeType);
+        po.setSubNodeId(subNodeId);
         po.setAssigneeId(assigneeId);
         po.setAssigneeType(assigneeType);
         po.setTaskStatus(taskStatus);

+ 7 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/ApprovalTaskDTO.java

@@ -25,6 +25,12 @@ public class ApprovalTaskDTO {
     @Schema(description = "节点类型")
     private String nodeType;
 
+    @Schema(description = "子节点ID")
+    private String subNodeId;
+
+    @Schema(description = "子节点名称")
+    private String subNodeName;
+
     @Schema(description = "处理人ID")
     private Long assigneeId;
 
@@ -98,6 +104,7 @@ public class ApprovalTaskDTO {
         dto.setNodeId(po.getNodeId());
         dto.setNodeName(po.getNodeName());
         dto.setNodeType(po.getNodeType());
+        dto.setSubNodeId(po.getSubNodeId());
         dto.setAssigneeId(po.getAssigneeId());
         dto.setAssigneeType(po.getAssigneeType());
         dto.setTaskStatus(po.getTaskStatus());

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/NodeProgressDTO.java

@@ -26,4 +26,7 @@ public class NodeProgressDTO {
 
     @Schema(description = "当前登录用户是否是需要处理的人")
     private Boolean isMyTurn;
+
+    @Schema(description = "子节点进度列表")
+    private List<SubNodeProgressDTO> subNodes;
 }

+ 29 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/SubNodeProgressDTO.java

@@ -0,0 +1,29 @@
+package com.qqflow.engine.domain.flow.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 父审批节点下的子节点进度
+ */
+@Data
+@Schema(description = "子节点进度DTO")
+public class SubNodeProgressDTO {
+
+    @Schema(description = "子节点ID")
+    private String subNodeId;
+
+    @Schema(description = "子节点名称")
+    private String subNodeName;
+
+    @Schema(description = "状态: completed-已完成 current-当前 pending-未开始")
+    private String status;
+
+    @Schema(description = "该子节点下的任务列表")
+    private List<ApprovalTaskDTO> tasks;
+
+    @Schema(description = "当前登录用户是否是需要处理的人")
+    private Boolean isMyTurn;
+}

+ 19 - 0
src/main/java/com/qqflow/engine/domain/flow/model/SubNodeConfig.java

@@ -0,0 +1,19 @@
+package com.qqflow.engine.domain.flow.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+/**
+ * 审批节点下的子节点配置
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class SubNodeConfig {
+
+    private String id;
+    private String name;
+    private String assigneeValue;
+    private String approveMode;
+    private Integer timeoutHours;
+    private String timeoutAction;
+}

+ 4 - 0
src/main/java/com/qqflow/engine/domain/flow/po/ApprovalTask.java

@@ -36,6 +36,10 @@ public class ApprovalTask {
     @Schema(description = "节点类型")
     private String nodeType;
 
+    @TableField("sub_node_id")
+    @Schema(description = "子节点ID")
+    private String subNodeId;
+
     @TableField("assignee_id")
     @Schema(description = "处理人ID")
     private Long assigneeId;

+ 353 - 24
src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java

@@ -1,6 +1,7 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.domain.flow.assembler.ApprovalTaskAssembler;
 import com.qqflow.engine.domain.flow.enums.ApprovalAction;
@@ -16,6 +17,7 @@ import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
 import com.qqflow.engine.domain.flow.model.FlowEdge;
 import com.qqflow.engine.domain.flow.model.FlowModel;
 import com.qqflow.engine.domain.flow.model.FlowNode;
+import com.qqflow.engine.domain.flow.model.SubNodeConfig;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import com.qqflow.engine.domain.flow.po.ProcessDefinition;
 import com.qqflow.engine.domain.flow.po.ProcessInstance;
@@ -198,6 +200,96 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         this.executeTransition(instance, currentTask, action, null);
     }
 
+    // ==================== 子节点(子审批步骤)辅助方法 ====================
+
+    private boolean isSubNodesEnabled(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return false;
+        }
+        Object enabled = props.get("subNodesEnabled");
+        if (enabled instanceof Boolean) {
+            return (Boolean) enabled;
+        }
+        return "true".equalsIgnoreCase(String.valueOf(enabled));
+    }
+
+    private boolean isSequentialMode(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return false;
+        }
+        Object mode = props.get("subNodeMode");
+        if (mode == null) {
+            return false;
+        }
+        return "sequential".equalsIgnoreCase(mode.toString());
+    }
+
+    private List<SubNodeConfig> extractSubNodes(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return Collections.emptyList();
+        }
+        Object subNodesObj = props.get("subNodes");
+        if (subNodesObj == null) {
+            return Collections.emptyList();
+        }
+        try {
+            List<SubNodeConfig> list = OBJECT_MAPPER.convertValue(subNodesObj, new TypeReference<List<SubNodeConfig>>() {});
+            return list != null ? list : Collections.emptyList();
+        } catch (Exception e) {
+            return Collections.emptyList();
+        }
+    }
+
+    private SubNodeConfig findSubNodeById(FlowNode node, String subNodeId) {
+        return this.extractSubNodes(node).stream()
+                .filter(s -> s.getId() != null && s.getId().equals(subNodeId))
+                .findFirst()
+                .orElse(null);
+    }
+
+    private int findSubNodeIndex(FlowNode node, String subNodeId) {
+        List<SubNodeConfig> subNodes = this.extractSubNodes(node);
+        for (int i = 0; i < subNodes.size(); i++) {
+            if (subNodes.get(i).getId() != null && subNodes.get(i).getId().equals(subNodeId)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private List<Long> calculateSubNodeAssignees(SubNodeConfig subNode, FlowNode parentNode, ProcessInstance instance) {
+        String assigneeValue = subNode.getAssigneeValue();
+        if (assigneeValue == null || assigneeValue.isBlank()) {
+            // 子节点未配置审批人时,回退到父节点配置
+            return this.calculateAssignees(parentNode, instance);
+        }
+        return this.doCalculateAssignees(ASSIGNEE_TYPE_ROLE, assigneeValue, instance);
+    }
+
+    private String getSubNodeApproveMode(SubNodeConfig subNode) {
+        String mode = subNode.getApproveMode();
+        return mode != null ? mode : "or";
+    }
+
+    private boolean hasExistingTaskForSubNode(Long instanceId, String nodeId, String subNodeId) {
+        if (instanceId == null || nodeId == null || subNodeId == null) {
+            return false;
+        }
+        long count = this.approvalTaskMapper.selectCount(
+                new LambdaQueryWrapper<ApprovalTask>()
+                        .eq(ApprovalTask::getInstanceId, instanceId)
+                        .eq(ApprovalTask::getNodeId, nodeId)
+                        .eq(ApprovalTask::getSubNodeId, subNodeId)
+                        .in(ApprovalTask::getTaskStatus,
+                                TaskStatus.PENDING.getCode(),
+                                TaskStatus.HANDLED.getCode(),
+                                TaskStatus.SKIPPED.getCode()));
+        return count > 0;
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void executeTransition(ProcessInstance instance, ApprovalTask currentTask, ApprovalAction action, String comment) {
@@ -209,7 +301,13 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     public void executeTransition(ProcessInstance instance, ApprovalTask currentTask, ApprovalAction action, String comment, String targetNodeId) {
         switch (action) {
             case APPROVE -> this.handleApprove(instance, currentTask, comment, targetNodeId);
-            case REJECT -> this.handleReject(instance, currentTask, comment);
+            case REJECT -> {
+                if (currentTask != null && currentTask.getSubNodeId() != null && !currentTask.getSubNodeId().isBlank()) {
+                    this.handleSubNodeReject(instance, currentTask, comment);
+                } else {
+                    this.handleReject(instance, currentTask, comment);
+                }
+            }
             case RETURN -> this.handleReturn(instance, currentTask, comment, targetNodeId);
             default -> throw new BusinessException("不支持的操作类型");
         }
@@ -322,33 +420,42 @@ public class FlowEngineServiceImpl implements FlowEngineService {
                 .filter(n -> n.getId().equals(lockedTask.getNodeId()))
                 .findFirst()
                 .orElse(null);
-        String approveMode;
-        if (currentNode == null) {
-            approveMode = "or";
-        } else {
-            approveMode = this.getApproveMode(currentNode);
-        }
 
-        // 会签模式:检查同节点是否还有未处理的任务
-        if ("and".equals(approveMode)) {
-            long pendingCount = this.approvalTaskMapper.selectCount(
-                    new LambdaQueryWrapper<ApprovalTask>()
-                            .eq(ApprovalTask::getInstanceId, instance.getId())
-                            .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
-                            .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
-            if (pendingCount > 0) {
-                // 还有未处理的任务,不推进流程
+        // 子节点处理
+        if (currentNode != null && this.isSubNodesEnabled(currentNode)) {
+            boolean parentCompleted = this.handleSubNodeApprove(instance, currentNode, lockedTask, model, comment);
+            if (!parentCompleted) {
                 return;
             }
         } else {
-            // 或签模式:将同节点其他 PENDING 任务标记为 SKIPPED
-            this.approvalTaskMapper.update(null,
-                    new LambdaUpdateWrapper<ApprovalTask>()
-                            .eq(ApprovalTask::getInstanceId, instance.getId())
-                            .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
-                            .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
-                            .ne(ApprovalTask::getId, lockedTask.getId())
-                            .set(ApprovalTask::getTaskStatus, TaskStatus.SKIPPED.getCode()));
+            String approveMode;
+            if (currentNode == null) {
+                approveMode = "or";
+            } else {
+                approveMode = this.getApproveMode(currentNode);
+            }
+
+            // 会签模式:检查同节点是否还有未处理的任务
+            if ("and".equals(approveMode)) {
+                long pendingCount = this.approvalTaskMapper.selectCount(
+                        new LambdaQueryWrapper<ApprovalTask>()
+                                .eq(ApprovalTask::getInstanceId, instance.getId())
+                                .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
+                                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
+                if (pendingCount > 0) {
+                    // 还有未处理的任务,不推进流程
+                    return;
+                }
+            } else {
+                // 或签模式:将同节点其他 PENDING 任务标记为 SKIPPED
+                this.approvalTaskMapper.update(null,
+                        new LambdaUpdateWrapper<ApprovalTask>()
+                                .eq(ApprovalTask::getInstanceId, instance.getId())
+                                .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
+                                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                                .ne(ApprovalTask::getId, lockedTask.getId())
+                                .set(ApprovalTask::getTaskStatus, TaskStatus.SKIPPED.getCode()));
+            }
         }
 
         // 指定了目标节点 → 跳过条件判断,直接走该分支
@@ -585,6 +692,12 @@ public class FlowEngineServiceImpl implements FlowEngineService {
                 }
                 continue;
             }
+            // 审批节点:若开启子节点,则按子节点创建任务,不创建父节点任务
+            if (NodeType.APPROVAL.getCode().equals(node.getType()) && this.isSubNodesEnabled(node)) {
+                this.createSubNodeTasks(instance, node, model, visited, depth, allowRecreate);
+                continue;
+            }
+
             // 确定处理人类型
             Map<String, Object> nodeProps = node.getProperties();
             String assigneeType = ASSIGNEE_TYPE_ROLE;
@@ -772,4 +885,220 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         }
         return "remind";
     }
+
+    // ==================== 子节点任务创建与推进 ====================
+
+    private void createSubNodeTasks(ProcessInstance instance, FlowNode node, FlowModel model,
+                                    Set<String> visited, int depth, boolean allowRecreate) {
+        List<SubNodeConfig> subNodes = this.extractSubNodes(node);
+        if (subNodes.isEmpty()) {
+            return;
+        }
+        boolean sequential = this.isSequentialMode(node);
+        if (sequential) {
+            SubNodeConfig first = subNodes.get(0);
+            if (!allowRecreate && this.hasExistingTaskForSubNode(instance.getId(), node.getId(), first.getId())) {
+                return;
+            }
+            this.createSubNodeTask(instance, node, first, model);
+        } else {
+            for (SubNodeConfig subNode : subNodes) {
+                if (!allowRecreate && this.hasExistingTaskForSubNode(instance.getId(), node.getId(), subNode.getId())) {
+                    continue;
+                }
+                this.createSubNodeTask(instance, node, subNode, model);
+            }
+        }
+    }
+
+    private void createSubNodeTask(ProcessInstance instance, FlowNode parentNode, SubNodeConfig subNode, FlowModel model) {
+        List<Long> assignees = this.calculateSubNodeAssignees(subNode, parentNode, instance);
+        String taskAssigneeType = ASSIGNEE_TYPE_ROLE;
+        if (assignees.isEmpty()) {
+            assignees = Collections.singletonList(instance.getApplicantId());
+            taskAssigneeType = ASSIGNEE_TYPE_USER;
+        }
+        LocalDateTime timeoutTime = this.subNodeTimeoutTime(subNode);
+        String timeoutAction = this.subNodeTimeoutAction(subNode);
+        for (Long assigneeId : assignees) {
+            ApprovalTask task = this.approvalTaskAssembler.buildNew(
+                    instance.getId(), parentNode.getId(), parentNode.getName(),
+                    parentNode.getType(), subNode.getId(), assigneeId, taskAssigneeType, TaskStatus.PENDING.getCode()
+            );
+            task.setTimeoutTime(timeoutTime);
+            task.setTimeoutAction(timeoutAction);
+            this.approvalTaskMapper.insert(task);
+        }
+    }
+
+    /**
+     * 处理子节点审批通过。
+     * 返回 true 表示父节点已可继续推进主流程;false 表示仍需等待其他子节点。
+     */
+    private boolean handleSubNodeApprove(ProcessInstance instance, FlowNode currentNode, ApprovalTask lockedTask,
+                                         FlowModel model, String comment) {
+        List<SubNodeConfig> subNodes = this.extractSubNodes(currentNode);
+        if (subNodes.isEmpty()) {
+            return true;
+        }
+        String subNodeId = lockedTask.getSubNodeId();
+        SubNodeConfig currentSubNode = subNodeId != null ? this.findSubNodeById(currentNode, subNodeId) : null;
+        boolean sequential = this.isSequentialMode(currentNode);
+
+        if (currentSubNode != null) {
+            String subMode = this.getSubNodeApproveMode(currentSubNode);
+            if ("and".equals(subMode)) {
+                long pendingCount = this.approvalTaskMapper.selectCount(
+                        new LambdaQueryWrapper<ApprovalTask>()
+                                .eq(ApprovalTask::getInstanceId, instance.getId())
+                                .eq(ApprovalTask::getNodeId, currentNode.getId())
+                                .eq(ApprovalTask::getSubNodeId, subNodeId)
+                                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
+                if (pendingCount > 0) {
+                    return false;
+                }
+            } else {
+                // 或签:将同子节点其他 PENDING 任务标记为 SKIPPED
+                this.approvalTaskMapper.update(null,
+                        new LambdaUpdateWrapper<ApprovalTask>()
+                                .eq(ApprovalTask::getInstanceId, instance.getId())
+                                .eq(ApprovalTask::getNodeId, currentNode.getId())
+                                .eq(ApprovalTask::getSubNodeId, subNodeId)
+                                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                                .ne(ApprovalTask::getId, lockedTask.getId())
+                                .set(ApprovalTask::getTaskStatus, TaskStatus.SKIPPED.getCode()));
+            }
+        }
+
+        if (sequential) {
+            int idx = this.findSubNodeIndex(currentNode, subNodeId);
+            if (idx < 0 || idx >= subNodes.size()) {
+                return false;
+            }
+            if (idx == subNodes.size() - 1) {
+                return true;
+            }
+            SubNodeConfig nextSubNode = subNodes.get(idx + 1);
+            if (!this.hasPendingSubNodeTask(instance.getId(), currentNode.getId(), nextSubNode.getId())) {
+                this.createSubNodeTask(instance, currentNode, nextSubNode, model);
+            }
+            return false;
+        } else {
+            return this.hasAllSubNodesCompleted(instance, currentNode);
+        }
+    }
+
+    /**
+     * 处理子节点审批驳回。
+     * 顺序模式下驳回到上一个子节点(第一个子节点驳回则驳回整个父节点);
+     * 并行模式下仅重置当前子节点任务为待重新审批。
+     */
+    private void handleSubNodeReject(ProcessInstance instance, ApprovalTask currentTask, String comment) {
+        this.lockInstance(instance.getId());
+        ApprovalTask lockedTask = this.approvalTaskMapper.selectByIdForUpdate(currentTask.getId());
+        if (lockedTask == null || !TaskStatus.PENDING.getCode().equals(lockedTask.getTaskStatus())) {
+            throw new BusinessException("任务已处理");
+        }
+        this.completeCurrentTask(lockedTask, ApprovalResult.REJECT.getCode(), comment);
+
+        FlowModel model = this.getModelByInstance(instance);
+        FlowNode currentNode = safeNodes(model).stream()
+                .filter(n -> n.getId().equals(lockedTask.getNodeId()))
+                .findFirst()
+                .orElse(null);
+        if (currentNode == null) {
+            this.rejectInstance(instance);
+            return;
+        }
+
+        List<SubNodeConfig> subNodes = this.extractSubNodes(currentNode);
+        String subNodeId = lockedTask.getSubNodeId();
+        int idx = this.findSubNodeIndex(currentNode, subNodeId);
+        boolean sequential = this.isSequentialMode(currentNode);
+
+        if (sequential) {
+            if (idx <= 0) {
+                // 第一个子节点驳回,驳回整个父节点
+                this.approvalTaskHelper.cancelPendingTasks(instance.getId(), LocalDateTime.now());
+                this.rejectInstance(instance);
+                return;
+            }
+            // 取消当前及之后子节点的待处理任务
+            List<String> subNodeIdsToCancel = subNodes.subList(idx, subNodes.size()).stream()
+                    .map(SubNodeConfig::getId)
+                    .collect(Collectors.toList());
+            if (!subNodeIdsToCancel.isEmpty()) {
+                this.approvalTaskMapper.update(null,
+                        new LambdaUpdateWrapper<ApprovalTask>()
+                                .eq(ApprovalTask::getInstanceId, instance.getId())
+                                .eq(ApprovalTask::getNodeId, currentNode.getId())
+                                .in(ApprovalTask::getSubNodeId, subNodeIdsToCancel)
+                                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                                .set(ApprovalTask::getTaskStatus, TaskStatus.SKIPPED.getCode()));
+            }
+            // 创建上一个子节点的新任务
+            SubNodeConfig prevSubNode = subNodes.get(idx - 1);
+            if (!this.hasPendingSubNodeTask(instance.getId(), currentNode.getId(), prevSubNode.getId())) {
+                this.createSubNodeTask(instance, currentNode, prevSubNode, model);
+            }
+        } else {
+            // 并行模式:重新创建当前子节点任务,旧任务保留为驳回记录
+            SubNodeConfig subNode = this.findSubNodeById(currentNode, subNodeId);
+            if (subNode != null) {
+                this.createSubNodeTask(instance, currentNode, subNode, model);
+            }
+        }
+    }
+
+    private boolean hasAllSubNodesCompleted(ProcessInstance instance, FlowNode currentNode) {
+        List<SubNodeConfig> subNodes = this.extractSubNodes(currentNode);
+        for (SubNodeConfig subNode : subNodes) {
+            long pendingCount = this.approvalTaskMapper.selectCount(
+                    new LambdaQueryWrapper<ApprovalTask>()
+                            .eq(ApprovalTask::getInstanceId, instance.getId())
+                            .eq(ApprovalTask::getNodeId, currentNode.getId())
+                            .eq(ApprovalTask::getSubNodeId, subNode.getId())
+                            .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
+            long passedCount = this.approvalTaskMapper.selectCount(
+                    new LambdaQueryWrapper<ApprovalTask>()
+                            .eq(ApprovalTask::getInstanceId, instance.getId())
+                            .eq(ApprovalTask::getNodeId, currentNode.getId())
+                            .eq(ApprovalTask::getSubNodeId, subNode.getId())
+                            .eq(ApprovalTask::getTaskStatus, TaskStatus.HANDLED.getCode())
+                            .eq(ApprovalTask::getApprovalResult, ApprovalResult.PASS.getCode()));
+            if (pendingCount > 0 || passedCount == 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private boolean hasPendingSubNodeTask(Long instanceId, String nodeId, String subNodeId) {
+        if (instanceId == null || nodeId == null || subNodeId == null) {
+            return false;
+        }
+        long count = this.approvalTaskMapper.selectCount(
+                new LambdaQueryWrapper<ApprovalTask>()
+                        .eq(ApprovalTask::getInstanceId, instanceId)
+                        .eq(ApprovalTask::getNodeId, nodeId)
+                        .eq(ApprovalTask::getSubNodeId, subNodeId)
+                        .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
+        return count > 0;
+    }
+
+    private LocalDateTime subNodeTimeoutTime(SubNodeConfig subNode) {
+        Integer hours = subNode.getTimeoutHours();
+        if (hours == null || hours <= 0) {
+            return null;
+        }
+        return LocalDateTime.now().plusHours(hours);
+    }
+
+    private String subNodeTimeoutAction(SubNodeConfig subNode) {
+        String action = subNode.getTimeoutAction();
+        if ("pass".equals(action) || "reject".equals(action)) {
+            return action;
+        }
+        return "remind";
+    }
 }

+ 88 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java

@@ -17,6 +17,7 @@ import com.qqflow.engine.domain.flow.dto.NodeProgressDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessInstanceDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessProgressDTO;
 import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
+import com.qqflow.engine.domain.flow.dto.SubNodeProgressDTO;
 import com.qqflow.engine.domain.flow.enums.ApprovalResult;
 import com.qqflow.engine.domain.flow.enums.DefinitionStatus;
 import com.qqflow.engine.domain.flow.enums.NodeType;
@@ -30,6 +31,7 @@ import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
 import com.qqflow.engine.domain.flow.model.FlowEdge;
 import com.qqflow.engine.domain.flow.model.FlowModel;
 import com.qqflow.engine.domain.flow.model.FlowNode;
+import com.qqflow.engine.domain.flow.model.SubNodeConfig;
 import com.qqflow.engine.domain.flow.po.ApprovalRecord;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import com.qqflow.engine.domain.flow.po.Attachment;
@@ -232,6 +234,61 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
             List<ApprovalTask> nodeTasks = tasks.stream()
                     .filter(t -> t.getNodeId().equals(node.getId()))
                     .collect(Collectors.toList());
+
+            // 子节点进度
+            List<SubNodeConfig> subNodes = this.extractSubNodes(node);
+            if (!subNodes.isEmpty()) {
+                List<SubNodeProgressDTO> subNodeProgressList = new ArrayList<>();
+                boolean allSubCompleted = true;
+                boolean anySubCurrent = false;
+                for (SubNodeConfig subNode : subNodes) {
+                    List<ApprovalTask> subNodeTasks = nodeTasks.stream()
+                            .filter(t -> subNode.getId().equals(t.getSubNodeId()))
+                            .collect(Collectors.toList());
+                    List<ApprovalTaskDTO> subNodeTaskDtos = subNodeTasks.stream().map(ApprovalTaskDTO::of).collect(Collectors.toList());
+                    this.approvalTaskDTOAssembler.fillAssigneeName(subNodeTaskDtos);
+                    SubNodeProgressDTO subDto = new SubNodeProgressDTO();
+                    subDto.setSubNodeId(subNode.getId());
+                    subDto.setSubNodeName(subNode.getName());
+                    boolean subHasHandled = subNodeTasks.stream().anyMatch(t -> TaskStatus.HANDLED.getCode().equals(t.getTaskStatus()));
+                    boolean subHasPending = subNodeTasks.stream().anyMatch(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus()));
+                    boolean subIsCompleted = subHasHandled && !subHasPending
+                            && subNodeTasks.stream().noneMatch(t -> ApprovalResult.REJECT.getCode().equals(t.getApprovalResult()));
+                    if (subIsCompleted) {
+                        subDto.setStatus("completed");
+                    } else if (subHasPending) {
+                        subDto.setStatus("current");
+                        anySubCurrent = true;
+                        allSubCompleted = false;
+                    } else {
+                        subDto.setStatus("pending");
+                        allSubCompleted = false;
+                    }
+                    boolean subIsMyTurn = subNodeTasks.stream()
+                            .anyMatch(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus())
+                                    && this.canOperateTask(t, currentUserId, getCurrentUserType()));
+                    subDto.setIsMyTurn(subIsMyTurn);
+                    subDto.setTasks(subNodeTaskDtos);
+                    subNodeProgressList.add(subDto);
+                }
+                dto.setSubNodes(subNodeProgressList);
+
+                if (allSubCompleted) {
+                    dto.setStatus("completed");
+                } else if (anySubCurrent) {
+                    dto.setStatus("current");
+                    remainingCount++;
+                } else {
+                    dto.setStatus("pending");
+                    remainingCount++;
+                }
+                boolean isMyTurn = subNodeProgressList.stream().anyMatch(SubNodeProgressDTO::getIsMyTurn);
+                dto.setIsMyTurn(isMyTurn);
+                dto.setTasks(Collections.emptyList());
+                nodeProgressList.add(dto);
+                continue;
+            }
+
             List<ApprovalTaskDTO> nodeTaskDtos = nodeTasks.stream().map(ApprovalTaskDTO::of).collect(Collectors.toList());
             this.approvalTaskDTOAssembler.fillAssigneeName(nodeTaskDtos);
             dto.setTasks(nodeTaskDtos);
@@ -820,4 +877,35 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
 
         return sorted;
     }
+
+    // ==================== 子节点进度辅助方法 ====================
+
+    private boolean isSubNodesEnabled(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return false;
+        }
+        Object enabled = props.get("subNodesEnabled");
+        if (enabled instanceof Boolean) {
+            return (Boolean) enabled;
+        }
+        return "true".equalsIgnoreCase(String.valueOf(enabled));
+    }
+
+    private List<SubNodeConfig> extractSubNodes(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return Collections.emptyList();
+        }
+        Object subNodesObj = props.get("subNodes");
+        if (subNodesObj == null) {
+            return Collections.emptyList();
+        }
+        try {
+            List<SubNodeConfig> list = this.objectMapper.convertValue(subNodesObj, new TypeReference<List<SubNodeConfig>>() {});
+            return list != null ? list : Collections.emptyList();
+        } catch (Exception e) {
+            return Collections.emptyList();
+        }
+    }
 }

+ 1 - 0
src/main/resources/mapper/flow/ApprovalTaskMapper.xml

@@ -8,6 +8,7 @@
         <result column="node_id" property="nodeId"/>
         <result column="node_name" property="nodeName"/>
         <result column="node_type" property="nodeType"/>
+        <result column="sub_node_id" property="subNodeId"/>
         <result column="assignee_id" property="assigneeId"/>
         <result column="assignee_type" property="assigneeType"/>
         <result column="task_status" property="taskStatus"/>

+ 4 - 0
src/main/resources/schema-dev.sql

@@ -102,6 +102,7 @@ CREATE TABLE IF NOT EXISTS bpm_approval_task (
     assignee_id BIGINT,
     assignee_type VARCHAR(20),
     task_status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已转办 3已跳过 4已回退',
+    sub_node_id VARCHAR(50) COMMENT '子节点ID(父审批节点下的子节点标识)',
     approval_result VARCHAR(20),
     version INT DEFAULT 0,
     approval_comment TEXT,
@@ -159,6 +160,9 @@ CREATE TABLE IF NOT EXISTS bpm_approval_record (
     KEY idx_record_operator (operator_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+-- 兼容已存在的数据库:补充审批任务子节点字段
+ALTER TABLE bpm_approval_task ADD COLUMN IF NOT EXISTS sub_node_id VARCHAR(50) COMMENT '子节点ID(父审批节点下的子节点标识)';
+
 -- 兼容已存在的数据库:补充企业微信字段
 ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
 ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';

+ 4 - 0
src/main/resources/schema-mysql.sql

@@ -129,6 +129,7 @@ CREATE TABLE IF NOT EXISTS bpm_approval_task (
     assignee_id BIGINT COMMENT '任务处理人ID',
     assignee_type VARCHAR(20) COMMENT '处理人类型:USER-指定用户 ROLE-角色 SELF-发起人 LEADER-部门主管',
     task_status TINYINT DEFAULT 0 COMMENT '任务状态:0待处理 1已处理 2已转办 3已跳过 4已回退',
+    sub_node_id VARCHAR(50) COMMENT '子节点ID(父审批节点下的子节点标识)',
     approval_result VARCHAR(20) COMMENT '审批结果:PASS/REJECT/RETURN/TRANSFER',
     version INT DEFAULT 0 COMMENT '乐观锁版本号',
     approval_comment TEXT COMMENT '审批意见',
@@ -190,6 +191,9 @@ CREATE TABLE IF NOT EXISTS bpm_attachment (
     KEY idx_attachment_record_id (record_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程附件表';
 
+-- 兼容已存在的 MySQL 数据库:补充审批任务子节点字段
+ALTER TABLE bpm_approval_task ADD COLUMN IF NOT EXISTS sub_node_id VARCHAR(50) COMMENT '子节点ID(父审批节点下的子节点标识)';
+
 -- 兼容已存在的 MySQL 数据库:补充角色企业微信字段
 ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
 ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';

+ 1 - 0
src/main/resources/schema-test.sql

@@ -97,6 +97,7 @@ CREATE TABLE IF NOT EXISTS bpm_approval_task (
     assignee_id BIGINT,
     assignee_type VARCHAR(20),
     task_status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已转办 3已跳过 4已回退',
+    sub_node_id VARCHAR(50) COMMENT '子节点ID(父审批节点下的子节点标识)',
     approval_result VARCHAR(20),
     version INT DEFAULT 0,
     approval_comment TEXT,

+ 246 - 0
src/test/java/com/qqflow/engine/domain/flow/service/FlowSubNodeServiceTest.java

@@ -0,0 +1,246 @@
+package com.qqflow.engine.domain.flow.service;
+
+import com.qqflow.engine.config.security.LoginUser;
+import com.qqflow.engine.domain.flow.dto.ApproveTaskDTO;
+import com.qqflow.engine.domain.flow.dto.ProcessProgressDTO;
+import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
+import com.qqflow.engine.domain.flow.enums.DefinitionStatus;
+import com.qqflow.engine.domain.flow.enums.TaskStatus;
+import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
+import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import com.qqflow.engine.domain.flow.po.ProcessDefinition;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@ActiveProfiles("dev")
+@TestPropertySource(properties = {
+        "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
+        "spring.datasource.driver-class-name=org.h2.Driver",
+        "spring.datasource.username=sa",
+        "spring.datasource.password=",
+        "spring.sql.init.mode=always",
+        "spring.sql.init.schema-locations=classpath:schema-dev.sql",
+        "spring.sql.init.data-locations=classpath:data-dev.sql",
+        "jwt.secret=qqflow-test-secret-key-2024-min-32chars"
+})
+class FlowSubNodeServiceTest {
+
+    @Autowired
+    private FlowEngineService flowEngineService;
+
+    @Autowired
+    private ProcessDefinitionService processDefinitionService;
+
+    @Autowired
+    private ProcessInstanceService processInstanceService;
+
+    @Autowired
+    private ApprovalTaskService approvalTaskService;
+
+    @Autowired
+    private ApprovalTaskMapper approvalTaskMapper;
+
+    @BeforeEach
+    void setup() {
+        LoginUser loginUser = new LoginUser();
+        loginUser.setUserId(1L);
+        loginUser.setUsername("admin");
+        loginUser.setRoles(Collections.singletonList("super_admin"));
+        UsernamePasswordAuthenticationToken authentication =
+                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+    }
+
+    /**
+     * 顺序子节点:启动后只有第一个子节点产生任务;逐个审批后流程结束。
+     */
+    @Test
+    void testSequentialSubNodes() {
+        Long definitionId = createDefinitionWithSubNodes("sequential");
+        Long instanceId = startProcess(definitionId);
+
+        List<ApprovalTask> tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        // 只有第一个子节点任务
+        assertEquals(1, tasks.size(), "顺序模式启动后应只有第一个子节点任务");
+        assertEquals("sub1", tasks.get(0).getSubNodeId());
+        assertEquals(TaskStatus.PENDING.getCode(), tasks.get(0).getTaskStatus());
+
+        // 通过第一个子节点,应创建第二个子节点任务
+        approve(tasks.get(0).getId());
+        tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        List<ApprovalTask> pendingTasks = tasks.stream()
+                .filter(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus()))
+                .collect(Collectors.toList());
+        assertEquals(1, pendingTasks.size(), "应创建第二个子节点任务");
+        assertEquals("sub2", pendingTasks.get(0).getSubNodeId());
+
+        // 通过第二个子节点,流程应结束
+        approve(pendingTasks.get(0).getId());
+        ProcessProgressDTO progress = processInstanceService.getProgress(instanceId);
+        assertEquals(5, progress.getInstance().getStatus(), "流程应已通过完成");
+        assertEquals(0, progress.getRemainingNodeCount(), "剩余节点数应为 0");
+    }
+
+    /**
+     * 并行子节点:启动后所有子节点都产生任务;全部通过后流程结束。
+     */
+    @Test
+    void testParallelSubNodes() {
+        Long definitionId = createDefinitionWithSubNodes("parallel");
+        Long instanceId = startProcess(definitionId);
+
+        List<ApprovalTask> tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        // 所有子节点都应有 PENDING 任务
+        assertEquals(2, tasks.size(), "并行模式启动后应同时创建所有子节点任务");
+        assertTrue(tasks.stream().allMatch(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus())));
+
+        // 通过第一个子节点,流程不应结束
+        approve(tasks.get(0).getId());
+        ProcessProgressDTO progress = processInstanceService.getProgress(instanceId);
+        assertEquals(1, progress.getInstance().getStatus(), "流程仍应运行中");
+        assertEquals(1, progress.getRemainingNodeCount(), "仍剩余父节点");
+        assertEquals(1, progress.getNodes().get(0).getSubNodes().stream()
+                .filter(s -> "completed".equals(s.getStatus())).count(), "一个子节点应已完成");
+
+        // 通过第二个子节点,流程结束
+        approve(tasks.get(1).getId());
+        progress = processInstanceService.getProgress(instanceId);
+        assertEquals(5, progress.getInstance().getStatus(), "流程应已通过完成");
+        assertEquals(0, progress.getRemainingNodeCount(), "剩余节点数应为 0");
+    }
+
+    /**
+     * 顺序子节点驳回:第二个子节点驳回后,应回退到第一个子节点重新审批。
+     */
+    @Test
+    void testSequentialSubNodeRejectRollback() {
+        Long definitionId = createDefinitionWithSubNodes("sequential");
+        Long instanceId = startProcess(definitionId);
+
+        // 通过 sub1
+        List<ApprovalTask> tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        approve(tasks.get(0).getId());
+
+        // 驳回 sub2,应回退到 sub1
+        tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        ApprovalTask sub2Task = tasks.stream().filter(t -> "sub2".equals(t.getSubNodeId())).findFirst().orElseThrow();
+        reject(sub2Task.getId(), "复审不通过,退回初审");
+
+        tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        // sub2 旧任务为 HANDLED REJECT,sub1 新任务为 PENDING
+        assertTrue(tasks.stream().anyMatch(t -> "sub2".equals(t.getSubNodeId())
+                && TaskStatus.HANDLED.getCode().equals(t.getTaskStatus())
+                && "REJECT".equals(t.getApprovalResult())), "sub2 应保留驳回记录");
+        List<ApprovalTask> pendingSub1 = tasks.stream()
+                .filter(t -> "sub1".equals(t.getSubNodeId())
+                        && TaskStatus.PENDING.getCode().equals(t.getTaskStatus()))
+                .collect(Collectors.toList());
+        assertEquals(1, pendingSub1.size(), "应重新创建 sub1 任务");
+
+        // 重新通过 sub1,再次创建 sub2
+        approve(pendingSub1.get(0).getId());
+        tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        List<ApprovalTask> pendingSub2 = tasks.stream()
+                .filter(t -> "sub2".equals(t.getSubNodeId())
+                        && TaskStatus.PENDING.getCode().equals(t.getTaskStatus()))
+                .collect(Collectors.toList());
+        assertEquals(1, pendingSub2.size(), "应重新创建 sub2 任务");
+
+        // 通过 sub2,流程结束
+        approve(pendingSub2.get(0).getId());
+        ProcessProgressDTO progress = processInstanceService.getProgress(instanceId);
+        assertEquals(5, progress.getInstance().getStatus(), "流程应已通过完成");
+    }
+
+    /**
+     * 并行子节点驳回:驳回一个子节点,只影响该子节点,其他子节点继续,重新审批后流程结束。
+     */
+    @Test
+    void testParallelSubNodeRejectAndReapprove() {
+        Long definitionId = createDefinitionWithSubNodes("parallel");
+        Long instanceId = startProcess(definitionId);
+
+        List<ApprovalTask> tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        ApprovalTask sub1 = tasks.stream().filter(t -> "sub1".equals(t.getSubNodeId())).findFirst().orElseThrow();
+        ApprovalTask sub2 = tasks.stream().filter(t -> "sub2".equals(t.getSubNodeId())).findFirst().orElseThrow();
+
+        // 通过 sub1
+        approve(sub1.getId());
+
+        // 驳回 sub2,只影响 sub2
+        reject(sub2.getId(), "初审需修改");
+        tasks = approvalTaskMapper.selectByInstanceId(instanceId);
+        assertTrue(tasks.stream().anyMatch(t -> "sub2".equals(t.getSubNodeId())
+                && TaskStatus.HANDLED.getCode().equals(t.getTaskStatus())
+                && "REJECT".equals(t.getApprovalResult())), "sub2 应保留驳回记录");
+        List<ApprovalTask> pendingSub2 = tasks.stream()
+                .filter(t -> "sub2".equals(t.getSubNodeId())
+                        && TaskStatus.PENDING.getCode().equals(t.getTaskStatus()))
+                .collect(Collectors.toList());
+        assertEquals(1, pendingSub2.size(), "应重新创建 sub2 任务");
+
+        // sub1 仍保持 HANDLED PASS
+        assertTrue(tasks.stream().anyMatch(t -> "sub1".equals(t.getSubNodeId())
+                && TaskStatus.HANDLED.getCode().equals(t.getTaskStatus())
+                && "PASS".equals(t.getApprovalResult())), "sub1 应保持通过记录");
+
+        // 重新通过 sub2,流程结束
+        approve(pendingSub2.get(0).getId());
+        ProcessProgressDTO progress = processInstanceService.getProgress(instanceId);
+        assertEquals(5, progress.getInstance().getStatus(), "流程应已通过完成");
+    }
+
+    private Long createDefinitionWithSubNodes(String subNodeMode) {
+        String subNodes = "[{\"id\":\"sub1\",\"name\":\"初审\",\"assigneeValue\":\"super_admin\",\"approveMode\":\"or\"}"
+                + ",{\"id\":\"sub2\",\"name\":\"复审\",\"assigneeValue\":\"super_admin\",\"approveMode\":\"or\"}]";
+        String modelJson = "{\"nodes\":[" +
+                "{\"id\":\"start\",\"type\":\"start\"}" +
+                ", {\"id\":\"approval\",\"type\":\"approval\",\"name\":\"审批\"," +
+                "\"properties\":{\"subNodesEnabled\":true,\"subNodeMode\":\"" + subNodeMode + "\",\"subNodes\":" + subNodes + "}}" +
+                ", {\"id\":\"end\",\"type\":\"end\"}]," +
+                "\"edges\":[" +
+                "{\"sourceNodeId\":\"start\",\"targetNodeId\":\"approval\"}" +
+                ", {\"sourceNodeId\":\"approval\",\"targetNodeId\":\"end\"}]}";
+        ProcessDefinition po = new ProcessDefinition();
+        po.setProcessCode("SUB_NODE_" + subNodeMode.toUpperCase() + "_" + System.currentTimeMillis());
+        po.setProcessName("子节点测试流程");
+        po.setModelJson(modelJson);
+        po.setStatus(DefinitionStatus.ENABLED.getCode());
+        return processDefinitionService.saveDefinition(po);
+    }
+
+    private Long startProcess(Long definitionId) {
+        StartProcessDTO dto = new StartProcessDTO();
+        dto.setProcessDefinitionId(definitionId);
+        dto.setTitle("子节点测试实例");
+        dto.setFormData("{}");
+        return processInstanceService.startProcess(dto);
+    }
+
+    private void approve(Long taskId) {
+        ApproveTaskDTO dto = new ApproveTaskDTO();
+        dto.setTaskId(taskId);
+        dto.setComment("同意");
+        approvalTaskService.approve(dto);
+    }
+
+    private void reject(Long taskId, String comment) {
+        ApproveTaskDTO dto = new ApproveTaskDTO();
+        dto.setTaskId(taskId);
+        dto.setComment(comment);
+        approvalTaskService.reject(dto);
+    }
+}