From 441a9e064bab19daf29add4c47993bc3ee4049ae Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 2 Nov 2026 20:47:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=AD=90=E6=B5=81=E7=A8=8B=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E5=A4=8D=E7=94=A8=E5=88=9B=E5=BB=BA=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8F=82=E6=95=B0=E4=B8=8E=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E8=AE=BE=E5=AE=9A=20#237?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重置时重新执行子流程创建脚本,按旧实例创建顺序映射请求并校验数量,保证多实例数据组装不错位。自动提交携带 operatorSelectMap,发起人/审批人设定与首次发起一致。抽取 SubProcessSessionFactory 统一结果判定与重置的父流程会话构建,并补充测试与能力文档。 Co-Authored-By: Claude Code --- .../flow-engine-framework/sub-process.md | 8 +- .../flow/exception/FlowStateException.java | 13 + .../impl/FlowSubProcessResetService.java | 40 +-- .../impl/FlowSubProcessResultService.java | 50 +--- .../impl/SubProcessSessionFactory.java | 83 ++++++ .../FlowSubProcessResetServiceTest.java | 282 ++++++++++++++++++ 6 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/SubProcessSessionFactory.java diff --git a/docs/capabilities/flow-engine-framework/sub-process.md b/docs/capabilities/flow-engine-framework/sub-process.md index c0ac4252..c70023f8 100644 --- a/docs/capabilities/flow-engine-framework/sub-process.md +++ b/docs/capabilities/flow-engine-framework/sub-process.md @@ -16,6 +16,7 @@ symbols: - SubProcessResultScript - FlowSubProcessResultService - FlowSubProcessResetService + - SubProcessSessionFactory - FlowSubProcessResetRequest - FlowSubProcessResetEvent - FlowSession.findSubProcessRecords @@ -89,7 +90,9 @@ def run(request){ ### 接口与定位方式 - 请求 `FlowSubProcessResetRequest`:`recordId`(当前操作的待办记录)+ `resetInstanceProcessIds`(选中重建的实例流程id)+ `advice`(重置说明)。 -- **不需要指定子流程节点**:由选中实例的流程id定位其所属聚合组(全部选中实例须同属一个未取代聚合组),重置始终是整个子流程**从头重走一次**——重建实例经由子流程节点配置的创建脚本与自动提交策略重新发起,不跳转、不迁移、不自动跳过任何历史数据。 +- **不需要指定子流程节点**:由选中实例的流程id定位其所属聚合组(全部选中实例须同属一个未取代聚合组),重置始终是整个子流程**从头重走一次**——重建时重新执行子流程节点配置的创建脚本,按旧实例在聚合组中的创建顺序取对应的 `FlowCreateRequest`,再按创建脚本的自动提交策略提交;不跳转、不迁移、不自动跳过任何历史数据。 +- **参数与人员设定由脚本决定**:重置接口不接受表单、发起人、流程标题或人员设定参数,这些均以创建脚本重新执行的输出为准(`FlowCreateRequest.formData` / `operatorId` / `operatorSelectMap` / `workTitle` / `actionId`),与首次发起完全一致;「发起人设定 / 审批人设定」(`INITIATOR_SELECT` / `APPROVER_SELECT`)类节点在重置后仍由脚本指定的人员接收待办。 +- **数量一致性校验**:创建脚本返回的创建请求数量必须与旧聚合组实例数一致,否则抛出 `state.subProcess.resetScriptMismatch` 并拒绝重置,避免按错位请求重建、打乱多实例的创建顺序与数据组装。 - 前置约束:当前记录为待办且操作人匹配;目标聚合组已放行(PASSED)、未被取代、全部实例已结束;当前流程无等待中聚合组;当前记录位于**锁定合并节点**(见下)。 ### 锁定合并节点(复杂下游拓扑支持) @@ -106,7 +109,8 @@ def run(request){ - **聚合组建模**:旧聚合组标记 `superseded`(聚合状态保持不变,历史记录保留有效可查),新建一条聚合组取代其成为当前有效数据。新组由两类实例组成: - **继承实例**(未选中,`Instance.inherited=true`):沿用原实例的流程id与最终状态,不重新执行; - - **重建实例**(选中):基于旧实例的启动记录反推创建请求(沿用原子流程定义、表单数据、流程标题与原发起人),创建全新子流程(新流程id),`Instance.sourceProcessId` 记录其替换的旧实例流程id,供订阅方完成旧 → 新映射。 + - **重建实例**(选中):重新执行子流程创建脚本,按旧实例在聚合组中的创建顺序取对应的 `FlowCreateRequest`(表单数据、发起人、人员设定、流程标题与开始动作均以脚本输出为准,不再基于旧启动记录反推请求),创建全新子流程(新流程id),`Instance.sourceProcessId` 记录其替换的旧实例流程id,供订阅方完成旧 → 新映射。 +- **脚本上下文一致**:重置与结果判定共用 `SubProcessSessionFactory` 以锚点记录重建父流程会话(当前节点为子流程节点、表单/审批意见/动作均取自锚点记录),保证重置时创建脚本拿到的 `request` 与首次触发子流程时一致。 - **锚点不变**:新组沿用旧组的 `parentRecordId`(原触发记录),保证结果判定后主流程恢复位置不变。 - **记录链作废**:触发锚点之后的主流程记录链以撤销语义作废(含执行重置的当前待办,重置说明写入该记录供审计),主流程退回子流程节点重新等待——**原待办从待办列表消失,新聚合组放行后生成全新的下游待办**;作废链路与 `FlowRevokeService` 一致,逐条推送 `FlowRecordRevokeEvent`。 - **结果判定自愈**:重建实例全部结束后,`FlowSubProcessResultService` 按新组判定(结果脚本经上下文可见继承 + 重建实例的完整最终记录集),放行后生成新的下游待办;`complete()` 定位聚合组时跳过已取代组,避免继承实例同属新旧两组时命中旧组。 diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java index 5892fac8..cbe2599c 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java @@ -175,6 +175,19 @@ public static FlowStateException subProcessResetMergeNodeOnly() { "Sub process reset only allowed on the todo record of the locked merge node"); } + /** + * Sub process reset script result does not match the original group instance count + * + * @param expected original group instance count + * @param actual create request count returned by the creation script + * @return exception + */ + public static FlowStateException subProcessResetScriptMismatch(int expected, int actual) { + return new FlowStateException("state.subProcess.resetScriptMismatch", + String.format("Sub process reset creation script returned %d create requests, " + + "but the original group has %d instances", actual, expected)); + } + /** * Workflow script not found when executing a node or action * diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java index e5d085f8..d913015d 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java @@ -20,6 +20,7 @@ import com.codingapi.flow.service.FlowRecordService; import com.codingapi.flow.service.FlowService; import com.codingapi.flow.service.WorkflowService; +import com.codingapi.flow.session.FlowSession; import com.codingapi.flow.session.IRepositoryHolder; import com.codingapi.flow.strategy.node.SubProcessStrategy; import com.codingapi.flow.workflow.Workflow; @@ -112,15 +113,27 @@ public void reset() { throw FlowStateException.subProcessResetMergeNodeOnly(); } + // 重建与首次发起共用同一套创建脚本与数据组装过程:重新执行子流程创建脚本, + // 按旧实例在聚合组中的创建顺序取对应请求,保证参数、发起人、发起人设定的人员 + // 与首次发起一致,且不会打乱多实例的创建顺序 + FlowSession parentSession = SubProcessSessionFactory.createParentSession( + currentGroup, repositoryHolder, workflowService); + List scriptRequests = strategy.getSubProcessScript().execute(parentSession); + List oldInstances = currentGroup.getInstances(); + if (scriptRequests.size() != oldInstances.size()) { + throw FlowStateException.subProcessResetScriptMismatch(oldInstances.size(), scriptRequests.size()); + } + // 先创建重建实例(不触发自动提交),落库新聚合组后再统一提交, // 避免重建实例在新组落库前结束导致结果判定找不到聚合组 boolean submit = strategy.isSubmit(); FlowService flowService = repositoryHolder.createFlowService(); List newInstances = new ArrayList<>(); List submitRequests = new ArrayList<>(); - for (SubProcessRecord.Instance instance : currentGroup.getInstances()) { + for (int index = 0; index < oldInstances.size(); index++) { + SubProcessRecord.Instance instance = oldInstances.get(index); if (request.getResetInstanceProcessIds().contains(instance.getProcessId())) { - FlowCreateRequest createRequest = buildRebuildRequest(instance, anchorRecord); + FlowCreateRequest createRequest = scriptRequests.get(index); long newRecordId = flowService.create(createRequest); FlowRecord newStartRecord = repositoryHolder.getRecordById(newRecordId); newInstances.add(SubProcessRecord.Instance.rebuiltFrom(newStartRecord, instance.getProcessId())); @@ -236,29 +249,6 @@ private FlowRecord findLockedMergeRecord(String processId, long anchorId) { .orElse(null); } - /** - * 依据旧实例的启动记录反推重建请求:沿用原子流程定义、表单数据、流程标题与原发起人, - * 锚定到原触发记录。 - * - * @param oldInstance 被重置的旧实例 - * @param anchorRecord 父流程中子流程节点的触发记录 - * @return 重建子流程的创建请求 - */ - private FlowCreateRequest buildRebuildRequest(SubProcessRecord.Instance oldInstance, FlowRecord anchorRecord) { - FlowRecord oldStartRecord = repositoryHolder.getRecordById(oldInstance.getStartRecordId()); - if (oldStartRecord == null) { - throw FlowNotFoundException.record(oldInstance.getStartRecordId()); - } - FlowCreateRequest request = new FlowCreateRequest(); - request.setWorkCode(oldStartRecord.getWorkCode()); - request.setOperatorId(oldStartRecord.getCreateOperatorId()); - request.setActionId(oldStartRecord.getActionId()); - request.setFormData(oldStartRecord.getFormData()); - request.setWorkTitle(oldInstance.getWorkTitle()); - request.setParentRecordId(anchorRecord.getId()); - return request; - } - /** * 判断指定记录当前是否可以执行子流程重置(供详情数据计算重置标识)。 * diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java index 7d6fce00..b96fa8db 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResultService.java @@ -1,31 +1,21 @@ package com.codingapi.flow.service.impl; import com.codingapi.flow.action.BaseAction; -import com.codingapi.flow.action.IFlowAction; -import com.codingapi.flow.action.actions.PassAction; -import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache; import com.codingapi.flow.domain.SubProcessContext; import com.codingapi.flow.domain.SubProcessRecord; import com.codingapi.flow.error.ErrorThrow; import com.codingapi.flow.event.FlowRecordDoneEvent; import com.codingapi.flow.event.FlowRecordTodoEvent; import com.codingapi.flow.event.IFlowEvent; -import com.codingapi.flow.exception.FlowNotFoundException; import com.codingapi.flow.exception.FlowValidationException; -import com.codingapi.flow.form.FormData; import com.codingapi.flow.node.BaseFlowNode; import com.codingapi.flow.node.IFlowNode; import com.codingapi.flow.node.nodes.SubProcessNode; -import com.codingapi.flow.operator.IFlowOperator; import com.codingapi.flow.record.FlowRecord; import com.codingapi.flow.repository.SubProcessRepository; -import com.codingapi.flow.service.WorkflowService; -import com.codingapi.flow.session.FlowAdvice; import com.codingapi.flow.session.FlowSession; import com.codingapi.flow.session.IRepositoryHolder; import com.codingapi.flow.strategy.node.SubProcessStrategy; -import com.codingapi.flow.workflow.Workflow; -import com.codingapi.flow.workflow.runtime.WorkflowRuntime; import com.codingapi.springboot.framework.event.EventPusher; import java.util.ArrayList; @@ -39,13 +29,11 @@ public class FlowSubProcessResultService { private final FlowRecord childFinalRecord; private final IRepositoryHolder repositoryHolder; private final SubProcessRepository subProcessRepository; - private final WorkflowService workflowService; public FlowSubProcessResultService(FlowRecord childFinalRecord, IRepositoryHolder repositoryHolder) { this.childFinalRecord = childFinalRecord; this.repositoryHolder = repositoryHolder; this.subProcessRepository = repositoryHolder.getSubProcessRepository(); - this.workflowService = repositoryHolder.getWorkflowService(); } public void complete() { @@ -90,42 +78,8 @@ public void complete() { } private FlowSession createParentSession(SubProcessRecord subProcessRecord) { - FlowRecord parentRecord = repositoryHolder.getRecordById(subProcessRecord.getParentRecordId()); - if (parentRecord == null) { - throw FlowNotFoundException.record(subProcessRecord.getParentRecordId()); - } - WorkflowRuntime workflowRuntime = workflowService.getWorkflowRuntime(subProcessRecord.getParentWorkRuntimeId()); - if (workflowRuntime == null) { - throw FlowNotFoundException.workflow(subProcessRecord.getParentWorkRuntimeId() + " not found"); - } - FlowRuntimeScriptLocalCache.getInstance().set(workflowRuntime.getScripts()); - Workflow workflow = workflowRuntime.toWorkflow(); - IFlowNode sourceNode = workflow.getFlowNode(parentRecord.getNodeId()); - IFlowAction action = sourceNode.actionManager().getActionById(parentRecord.getActionId()); - // 前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226): - // 恢复下游以来源节点的通过动作兜底,避免空动作会话导致 NPE 使主流程永久停滞 - if (action == null && parentRecord.isAutoDone()) { - action = sourceNode.actionManager().getAction(PassAction.class); - } - IFlowOperator currentOperator = repositoryHolder.getOperatorById(parentRecord.getCurrentOperatorId()); - IFlowOperator createdOperator = repositoryHolder.getOperatorById(parentRecord.getCreateOperatorId()); - IFlowOperator submitOperator = repositoryHolder.getOperatorById(parentRecord.getSubmitOperatorId()); - FormData formData = new FormData(workflow.getForm()); - formData.reset(parentRecord.getFormData()); - FlowAdvice advice = parentRecord.toAdvice(workflow); - IFlowNode subProcessNode = workflow.getFlowNode(subProcessRecord.getNodeId()); - FlowSession session = new FlowSession(repositoryHolder, - currentOperator, - createdOperator, - submitOperator, - workflow, - subProcessNode, - action, - formData, - parentRecord, - List.of(), - parentRecord.getWorkRuntimeId(), - advice); + FlowSession session = SubProcessSessionFactory.createParentSession( + subProcessRecord, repositoryHolder, repositoryHolder.getWorkflowService()); session.setSubProcessContext(new SubProcessContext(subProcessRecord, childFinalRecord)); return session; } diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/SubProcessSessionFactory.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/SubProcessSessionFactory.java new file mode 100644 index 00000000..d2befe42 --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/SubProcessSessionFactory.java @@ -0,0 +1,83 @@ +package com.codingapi.flow.service.impl; + +import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.PassAction; +import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache; +import com.codingapi.flow.domain.SubProcessRecord; +import com.codingapi.flow.exception.FlowNotFoundException; +import com.codingapi.flow.form.FormData; +import com.codingapi.flow.node.IFlowNode; +import com.codingapi.flow.operator.IFlowOperator; +import com.codingapi.flow.record.FlowRecord; +import com.codingapi.flow.service.WorkflowService; +import com.codingapi.flow.session.FlowAdvice; +import com.codingapi.flow.session.FlowSession; +import com.codingapi.flow.session.IRepositoryHolder; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.runtime.WorkflowRuntime; + +import java.util.List; + +/** + * 子流程父流程会话工厂。 + * + *

以子流程聚合记录的锚点记录为来源重建父流程会话,供子流程结果判定脚本与 + * 子流程重置重建脚本复用,保证两处脚本执行时的父流程上下文、表单数据与动作信息一致。

+ */ +public class SubProcessSessionFactory { + + private SubProcessSessionFactory() { + } + + /** + * 以子流程聚合记录的锚点记录重建父流程会话。 + * + *

当前记录为锚点记录、当前节点为子流程节点,表单数据与审批意见均来自锚点记录, + * 因而与首次触发子流程时的脚本上下文保持一致。

+ * + * @param subProcessRecord 子流程聚合记录 + * @param repositoryHolder 仓储持有者 + * @param workflowService 流程服务 + * @return 父流程会话 + */ + public static FlowSession createParentSession(SubProcessRecord subProcessRecord, + IRepositoryHolder repositoryHolder, + WorkflowService workflowService) { + FlowRecord parentRecord = repositoryHolder.getRecordById(subProcessRecord.getParentRecordId()); + if (parentRecord == null) { + throw FlowNotFoundException.record(subProcessRecord.getParentRecordId()); + } + WorkflowRuntime workflowRuntime = workflowService.getWorkflowRuntime(subProcessRecord.getParentWorkRuntimeId()); + if (workflowRuntime == null) { + throw FlowNotFoundException.workflow(subProcessRecord.getParentWorkRuntimeId() + " not found"); + } + FlowRuntimeScriptLocalCache.getInstance().set(workflowRuntime.getScripts()); + Workflow workflow = workflowRuntime.toWorkflow(); + IFlowNode sourceNode = workflow.getFlowNode(parentRecord.getNodeId()); + IFlowAction action = sourceNode.actionManager().getActionById(parentRecord.getActionId()); + // 前驱记录可能为相同人员自动通过的留痕记录(无审批动作,actionId=null,issue #226): + // 恢复下游以来源节点的通过动作兜底,避免空动作会话导致 NPE 使主流程永久停滞 + if (action == null && parentRecord.isAutoDone()) { + action = sourceNode.actionManager().getAction(PassAction.class); + } + IFlowOperator currentOperator = repositoryHolder.getOperatorById(parentRecord.getCurrentOperatorId()); + IFlowOperator createdOperator = repositoryHolder.getOperatorById(parentRecord.getCreateOperatorId()); + IFlowOperator submitOperator = repositoryHolder.getOperatorById(parentRecord.getSubmitOperatorId()); + FormData formData = new FormData(workflow.getForm()); + formData.reset(parentRecord.getFormData()); + FlowAdvice advice = parentRecord.toAdvice(workflow); + IFlowNode subProcessNode = workflow.getFlowNode(subProcessRecord.getNodeId()); + return new FlowSession(repositoryHolder, + currentOperator, + createdOperator, + submitOperator, + workflow, + subProcessNode, + action, + formData, + parentRecord, + List.of(), + parentRecord.getWorkRuntimeId(), + advice); + } +} \ No newline at end of file diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java index 7ac9eab8..2687d8a9 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java @@ -848,6 +848,256 @@ def run(request){ "同步完成的新聚合组应放行"); } + /** + * 测试目标:验证 issue #237 场景——主流程在子流程节点前有审批节点, + * 子流程节点发起 3 个子流程,全部通过后由下游审批对其中 1 条发起退回重填。 + * 前置条件:主流程 开始 -> B(审批) -> C(子流程,3 实例,可重置) -> D(审批) -> 结束。 + * 执行步骤:B 审批通过 → 3 个子流程全部通过 → D 待办上重置选中 1 条 → + * 完成重建子流程 → 审批 D。 + * 期望断言:旧聚合组被取代(被重置的实例不再有效);新组含 2 个继承实例与 1 个重建实例; + * 主流程退回子流程节点等待(D 待办作废);重建完成后 D 重新生成待办,流程正常结束。 + * 重建请求由创建脚本重新生成并按旧实例创建顺序取用,重置选中的是第 2 条数据, + * 因此重建实例应拿到脚本输出的第 2 份参数,证明多实例创建顺序没有被重置打乱。 + */ + @Test + void shouldResetOneOfThreeInstancesAfterIntermediateApproval() { + User beforeOperator = saveUser(20, "B节点审批人"); + User afterOperator = saveUser(21, "D节点审批人"); + StartNode start = writableStart("237开始"); + ApprovalNode beforeApproval = approvalNode("237-B审批", beforeOperator); + SubProcessNode subProcess = SubProcessNode.builder() + .name("237-C子流程") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(new SubProcessStrategy( + FlowGroovyScriptFactory.createSubProcessScript(threeChildScript()).getKey(), + true, + SubProcessResultScript.defaultScript().getScript(), + false, + true)) + .build()) + .build(); + ApprovalNode afterApproval = approvalNode("237-D审批", afterOperator); + Workflow workflow = WorkflowBuilder.builder() + .title("issue237 子流程退回重填") + .code(PARENT_CODE + "-237") + .createdOperator(initiator) + .form(form) + .addNode(start) + .addNode(beforeApproval) + .addNode(subProcess) + .addNode(afterApproval) + .addNode(EndNode.builder().name("237结束").build()) + .build(); + factory.workflowService.saveWorkflow(workflow); + + submitWorkflow(PARENT_CODE + "-237", start, Map.of("content", "issue237")); + FlowRecord beforeTodo = todos(beforeOperator, beforeApproval).get(0); + approve(beforeTodo, passAction(beforeApproval), beforeOperator, beforeTodo.getFormData()); + + List childTodos = todos(childOperator, childApproval); + assertEquals(3, childTodos.size(), "子流程节点应发起 3 个子流程"); + for (FlowRecord childTodo : childTodos) { + approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData()); + } + + FlowRecord afterTodo = todos(afterOperator, afterApproval).get(0); + SubProcessRecord passedGroup = factory.subProcessRepository + .findByParentProcessId(afterTodo.getProcessId()).get(0); + assertAll("3 个子流程全部通过后进入 D 节点", + () -> assertEquals(3, passedGroup.getTotalCount()), + () -> assertEquals(SubProcessRecord.State.PASSED, passedGroup.getState())); + String resetProcessId = passedGroup.getInstances().get(1).getProcessId(); + FlowRecord oldResetStartRecord = factory.flowRecordRepository + .get(passedGroup.getInstances().get(1).getStartRecordId()); + + resetSubProcess(afterTodo, List.of(resetProcessId), afterOperator); + + List groups = factory.subProcessRepository + .findByParentProcessIdAndNodeId(afterTodo.getProcessId(), subProcess.getId()); + assertEquals(2, groups.size(), "重置应保留旧组并新增一组"); + SubProcessRecord newGroup = groups.get(1); + List rebuiltInstances = newGroup.getInstances().stream() + .filter(instance -> !instance.isInherited()) + .toList(); + assertAll("issue237 重置后的聚合组", + () -> assertTrue(groups.get(0).isSuperseded(), "旧聚合组应标记为已取代"), + () -> assertEquals(SubProcessRecord.State.WAITING, newGroup.getState()), + () -> assertEquals(3, newGroup.getTotalCount()), + () -> assertEquals(2, newGroup.getInstances().stream() + .filter(SubProcessRecord.Instance::isInherited).count(), + "未选中的 2 条数据应继承原结果"), + () -> assertEquals(1, rebuiltInstances.size(), "选中的 1 条数据应重建"), + () -> assertEquals(resetProcessId, rebuiltInstances.get(0).getSourceProcessId(), + "重建实例应记录被替换的旧实例流程id")); + assertAll("主流程退回子流程节点等待", + () -> assertTrue(factory.flowRecordRepository.get(afterTodo.getId()).isRevoked()), + () -> assertTrue(todos(afterOperator, afterApproval).isEmpty())); + assertEquals(1, todos(childOperator, childApproval).size(), "仅重建实例需要重新审批"); + + FlowRecord rebuiltStartRecord = factory.flowRecordRepository + .get(rebuiltInstances.get(0).getStartRecordId()); + assertAll("重建按旧实例创建顺序复用创建脚本输出", + () -> assertEquals(oldResetStartRecord.getFormData(), rebuiltStartRecord.getFormData()), + () -> assertEquals(oldResetStartRecord.getCreateOperatorId(), + rebuiltStartRecord.getCreateOperatorId()), + () -> assertEquals("child-2", rebuiltStartRecord.getFormData().get("content"), + "重置第 2 条数据应取脚本输出的第 2 份请求,不能打乱创建顺序")); + + FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0); + approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData()); + + List resumedTodos = todos(afterOperator, afterApproval); + assertEquals(1, resumedTodos.size(), "3 条子流程数据全部完成后 D 节点重新生成待办"); + approve(resumedTodos.get(0), passAction(afterApproval), afterOperator, resumedTodos.get(0).getFormData()); + assertTrue(factory.flowRecordRepository.get(resumedTodos.get(0).getId()).isFinish(), + "退回重填完成后流程应正常结束"); + } + + /** + * 测试目标:验证重置与首次发起使用同一套创建脚本——子流程审批节点配置为发起人设定 + * (INITIATOR_SELECT)时,脚本设置的 operatorSelectMap 在重建实例上同样生效。 + * 前置条件:子流程 开始 -> 审批(INITIATOR_SELECT) -> 结束;创建脚本为子流程审批节点 + * 设置 operatorSelectMap=[childOperator];主流程 开始 -> 子流程(可重置) -> 审批 -> 结束。 + * 执行步骤:发起主流程并完成子流程 → 主流程下游待办上重置该子流程 → 观察重建实例。 + * 期望断言:重建实例自动提交成功,不产生子流程开始待办;新流程的人员分配已按脚本 + * 写入,子流程审批待办直接生成给脚本指定的人员;完成后续流程正常结束。 + */ + @Test + void shouldReuseCreationScriptOperatorSelectWhenRebuildingInitiatorSelectChild() { + StartNode selectChildStart = writableStart("发起人设定子流程开始"); + ApprovalNode initiatorSelectApproval = ApprovalNode.builder() + .name("发起人设定审批") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(readonlyPermission()) + .addStrategy(OperatorLoadStrategy.initiatorSelectStrategy()) + .build()) + .build(); + Workflow childWorkflow = WorkflowBuilder.builder() + .title("issue237-发起人设定子流程") + .code(CHILD_CODE + "-initiator-select") + .createdOperator(initiator) + .form(form) + .addNode(selectChildStart) + .addNode(initiatorSelectApproval) + .addNode(EndNode.builder().name("发起人设定子流程结束").build()) + .build(); + factory.workflowService.saveWorkflow(childWorkflow); + + String createScript = """ + def run(request){ + def createRequest = request.toCreateRequest('%s', %d, '%s', [content:'initiator-select-child']) + createRequest.operatorSelectMap = ['%s': [%dL]] + return createRequest + } + """.formatted(CHILD_CODE + "-initiator-select", initiator.getUserId(), + passAction(selectChildStart).id(), initiatorSelectApproval.getId(), + childOperator.getUserId()); + + StartNode parentStart = writableStart("发起人设定主流程开始"); + SubProcessNode subProcess = resettableSubProcess(createScript); + ApprovalNode parentApproval = approvalNode("发起人设定主流程审批", finalOperator); + Workflow parentWorkflow = WorkflowBuilder.builder() + .title("issue237-发起人设定主流程") + .code(PARENT_CODE + "-initiator-select") + .createdOperator(initiator) + .form(form) + .addNode(parentStart) + .addNode(subProcess) + .addNode(parentApproval) + .addNode(EndNode.builder().name("发起人设定主流程结束").build()) + .build(); + factory.workflowService.saveWorkflow(parentWorkflow); + + submitWorkflow(PARENT_CODE + "-initiator-select", parentStart, + Map.of("content", "initiator-select")); + + assertTrue(todos(initiator, selectChildStart).isEmpty(), + "首次发起应自动提交成功,子流程开始节点不应留下待办"); + List firstChildTodos = todos(childOperator, initiatorSelectApproval); + assertEquals(1, firstChildTodos.size(), "首次发起应按脚本设置生成子流程审批待办"); + approve(firstChildTodos.get(0), passAction(initiatorSelectApproval), childOperator, + firstChildTodos.get(0).getFormData()); + + FlowRecord parentTodo = todos(finalOperator, parentApproval).get(0); + SubProcessRecord passedGroup = factory.subProcessRepository + .findByParentProcessId(parentTodo.getProcessId()).get(0); + String resetProcessId = passedGroup.getInstances().get(0).getProcessId(); + + resetSubProcess(parentTodo, List.of(resetProcessId), finalOperator); + + assertTrue(todos(initiator, selectChildStart).isEmpty(), + "重建实例也应自动提交,不能停在子流程开始节点"); + List rebuiltChildTodos = todos(childOperator, initiatorSelectApproval); + assertEquals(1, rebuiltChildTodos.size(), + "重建实例应按脚本设置的 operatorSelectMap 生成子流程审批待办"); + FlowRecord rebuiltChildTodo = rebuiltChildTodos.get(0); + assertEquals(List.of(childOperator.getUserId()), + factory.flowOperatorAssignmentRepository.findOperatorIds( + rebuiltChildTodo.getProcessId(), initiatorSelectApproval.getId()), + "新子流程应保存脚本设置的人员分配"); + + approve(rebuiltChildTodo, passAction(initiatorSelectApproval), childOperator, + rebuiltChildTodo.getFormData()); + FlowRecord resumedParentTodo = todos(finalOperator, parentApproval).get(0); + approve(resumedParentTodo, passAction(parentApproval), finalOperator, + resumedParentTodo.getFormData()); + assertTrue(factory.flowRecordRepository.get(resumedParentTodo.getId()).isFinish(), + "发起人设定子流程重走后主流程应正常结束"); + } + + /** + * 测试目标:验证重置时创建脚本输出与原子流程实例数不一致时拒绝重置, + * 避免按错位的请求重建、打乱原有多实例的数据组装。 + * 前置条件:子流程初始创建 2 个实例;重置前修改锚点记录表单数据,使创建脚本在 + * 重置时只返回 1 个创建请求。 + * 执行步骤:在主流程下游待办上发起重置。 + * 期望断言:抛出状态异常;旧聚合组未被取代,未产生新聚合组,当前待办仍有效。 + */ + @Test + void shouldRejectResetWhenCreationScriptOutputCountMismatchesOriginalGroup() { + StartNode start = writableStart("数量校验开始"); + SubProcessNode subProcess = resettableSubProcess(countChangingScript()); + ApprovalNode finalApprovalNode = approvalNode("数量校验审批", finalOperator); + Workflow workflow = WorkflowBuilder.builder() + .title("issue237-脚本数量校验") + .code(PARENT_CODE + "-count-check") + .createdOperator(initiator) + .form(form) + .addNode(start) + .addNode(subProcess) + .addNode(finalApprovalNode) + .addNode(EndNode.builder().name("数量校验结束").build()) + .build(); + factory.workflowService.saveWorkflow(workflow); + + long parentStartRecordId = submitWorkflow(PARENT_CODE + "-count-check", start, + Map.of("content", "normal")); + List childTodos = todos(childOperator, childApproval); + assertEquals(2, childTodos.size()); + for (FlowRecord childTodo : childTodos) { + approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData()); + } + FlowRecord finalTodo = todos(finalOperator, finalApprovalNode).get(0); + + SubProcessRecord passedGroup = factory.subProcessRepository + .findByParentProcessId(finalTodo.getProcessId()).get(0); + FlowRecord anchorRecord = factory.flowRecordRepository.get(passedGroup.getParentRecordId()); + assertEquals(parentStartRecordId, anchorRecord.getId()); + anchorRecord.setFormData(Map.of("content", "changed")); + factory.flowRecordRepository.save(anchorRecord); + + String resetProcessId = passedGroup.getInstances().get(0).getProcessId(); + assertThrows(FlowStateException.class, + () -> resetSubProcess(finalTodo, List.of(resetProcessId), finalOperator)); + + List groups = factory.subProcessRepository + .findByParentProcessIdAndNodeId(finalTodo.getProcessId(), subProcess.getId()); + assertAll("脚本数量不一致时拒绝重置", + () -> assertEquals(1, groups.size()), + () -> assertFalse(groups.get(0).isSuperseded()), + () -> assertTrue(factory.flowRecordRepository.get(finalTodo.getId()).isTodo())); + } + // ==================== 流程构建与操作辅助 ==================== private SubProcessNode resettableSubProcess(String script) { @@ -922,6 +1172,38 @@ def run(request){ CHILD_CODE, initiator.getUserId(), passAction(childStart).id()); } + private String threeChildScript() { + return """ + def run(request){ + return [ + request.toCreateRequest('%s', %d, '%s', [content:'child-1']), + request.toCreateRequest('%s', %d, '%s', [content:'child-2']), + request.toCreateRequest('%s', %d, '%s', [content:'child-3']) + ] + } + """.formatted(CHILD_CODE, initiator.getUserId(), passAction(childStart).id(), + CHILD_CODE, initiator.getUserId(), passAction(childStart).id(), + CHILD_CODE, initiator.getUserId(), passAction(childStart).id()); + } + + /** + * 首次发起返回 2 个创建请求;表单内容变为 changed 后只返回 1 个, + * 用于模拟父流程数据变化导致重置脚本输出数量与旧组实例数不一致。 + */ + private String countChangingScript() { + return """ + def run(request){ + def first = request.toCreateRequest('%s', %d, '%s', [content:'child-1']) + if (request.getFormData('content') == 'changed') { + return first + } + def second = request.toCreateRequest('%s', %d, '%s', [content:'child-2']) + return [first, second] + } + """.formatted(CHILD_CODE, initiator.getUserId(), passAction(childStart).id(), + CHILD_CODE, initiator.getUserId(), passAction(childStart).id()); + } + private void saveChildWorkflow() { childStart = writableStart("子流程开始"); childApproval = approvalNode("子流程审批", childOperator); From 98c46a7e10bb123df2e7a0a49c6b146eddec7da1 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 2 Nov 2026 20:52:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20flow-frontend?= =?UTF-8?q?=20=E5=AD=90=E6=A8=A1=E5=9D=97=E8=87=B3=200.2.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 @coding-flow/* 各包同步升级至 0.2.14 - 对齐后端版本升级 Co-Authored-By: Claude Code --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index ac7a7c50..e25af67a 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit ac7a7c5011d47a94fc12b2a7ce74aa05c90d439a +Subproject commit e25af67ab3c54378d719eeeb2a6bc803c5a164d2