From 492076f677d303f2970f156e2dbef2d89897fd10 Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Mon, 24 Aug 2026 17:54:49 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=AD=90=E6=B5=81=E7=A8=8B?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=87=8D=E7=BD=AE=E8=83=BD=E5=8A=9B=EF=BC=88?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BC=80=E5=85=B3=E9=A9=B1=E5=8A=A8=E7=9A=84?=
=?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=8E=A5=E5=8F=A3=20+=20=E9=94=81=E5=AE=9A?=
=?UTF-8?q?=E5=90=88=E5=B9=B6=E8=8A=82=E7=82=B9=EF=BC=89=20issue=20#219?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 子流程节点策略新增 resettable 能力开关(默认关闭,兼容历史配置)
- 新增独立接口 /api/cmd/record/subProcess/reset:按选中实例流程id定位已完成的聚合组,
旧组标记已取代保留审计,新组由继承实例与重建实例组成,子流程从头重走一次
- 锁定合并节点规则:重置仅允许在锚点之后首个业务记录节点(条件分支取实际命中分支、
并行/包容分支锁定第一个分支)的待办上发起;详情数据新增 resetSubProcess 标识
- 新增 FlowSubProcessResetEvent 重置事件(新旧聚合组快照与实例映射),
重置时清除流程循环触发标记避免抄送等节点重走被误判为循环
- 数据结构纯增量兼容:聚合组 superseded、实例 inherited/sourceProcessId、
实体可空列,存量数据按默认值读取
- 前端升级至 0.2.5:审批模块接入重置接口与上下文能力、详情标识类型
282 个后端测试全部通过,前端构建与测试通过。
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
docs/Integration/event-integration.md | 18 +-
docs/Integration/rest-api.md | 16 +
.../flow-engine-framework/sub-process.md | 52 +
.../flow/context/LoopTriggerTraceContext.java | 12 +
.../flow/domain/SubProcessRecord.java | 172 +++-
.../flow/event/FlowSubProcessResetEvent.java | 47 +
.../flow/exception/FlowStateException.java | 30 +
.../exception/FlowValidationException.java | 11 +
.../request/FlowSubProcessResetRequest.java | 58 ++
.../flow/pojo/response/FlowContent.java | 7 +
.../flow/pojo/response/ProcessNode.java | 22 +-
.../codingapi/flow/service/FlowService.java | 16 +
.../flow/service/impl/FlowDetailService.java | 6 +-
.../impl/FlowSubProcessResetService.java | 322 ++++++
.../impl/FlowSubProcessResultService.java | 2 +
.../codingapi/flow/session/FlowSession.java | 5 +-
.../strategy/node/SubProcessStrategy.java | 27 +-
.../FlowSubProcessResetServiceTest.java | 964 ++++++++++++++++++
.../api/controller/FlowRecordController.java | 14 +
.../convert/SubProcessRecordConvertor.java | 4 +-
.../infra/entity/SubProcessRecordEntity.java | 6 +
.../SubProcessRecordConvertorTest.java | 59 ++
flow-frontend | 2 +-
23 files changed, 1859 insertions(+), 13 deletions(-)
create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowSubProcessResetEvent.java
create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/pojo/request/FlowSubProcessResetRequest.java
create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java
create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java
diff --git a/docs/Integration/event-integration.md b/docs/Integration/event-integration.md
index 5d16dc22..f866fc3c 100644
--- a/docs/Integration/event-integration.md
+++ b/docs/Integration/event-integration.md
@@ -27,9 +27,9 @@ public interface IAsyncEvent extends IEvent {} // 异步事件标记 ←
同步/异步由 `DomainEventContext.push` 的 `instanceof` 判定:`IAsyncEvent` → 异步(线程池执行);`ISyncEvent`/裸 `IEvent` → 同步(发布线程内联执行)。
-### 1.2 七种事件
+### 1.2 八种事件
-所有事件均携带 `FlowRecord`(或当前记录)与 `boolean mock` 标记(Mock 模式为 `true`)。
+记录类事件均携带 `FlowRecord`(或当前记录)与 `boolean mock` 标记(Mock 模式为 `true`)。
| 事件类 | 触发时机 | 构造参数 | 访问器 |
|---|---|---|---|
@@ -40,6 +40,19 @@ public interface IAsyncEvent extends IEvent {} // 异步事件标记 ←
| `FlowRecordRevokeEvent` | 撤销(被撤销的后置记录) | `FlowRecord currentRecord, boolean mock` | `getCurrentRecord()` / `isMock()` |
| `FlowRecordUrgeEvent` | 催办(每条待办) | `FlowRecord, IFlowOperator urgeOperator, boolean mock` | `getFlowRecord()` / `getUrgeOperator()` / `isMock()` |
| `FlowRecordDeleteEvent` | 删除未流转实例(开始节点) | `FlowRecord, boolean mock` | `getFlowRecord()` / `isMock()` |
+| `FlowSubProcessResetEvent` | 子流程数据重置(issue #219) | `SubProcessRecord oldRecord, SubProcessRecord newRecord, long resetRecordId, IFlowOperator resetOperator, boolean mock` | `getOldRecord()` / `getNewRecord()` / `getResetRecordId()` / `getResetOperator()` / `isMock()` |
+
+**子流程重置事件订阅须知**:
+
+- 事件提醒业务方子流程数据已被重置:`oldRecord` 为被取代的旧聚合组快照(含全部旧实例流程id),
+ `newRecord` 为重置后的新聚合组——继承实例沿用原流程id,重建实例为新流程id,
+ 重建实例的 `getSourceProcessId()` 记录其替换的旧实例流程id,据此完成旧 → 新流程id映射;
+- 事件经异步线程池分发、到达顺序不保证(重置同时伴随被作废记录的 `RevokeEvent`
+ 与重建实例的 `StartEvent`/`TodoEvent`),订阅方应按「同一 `parentRecordId` + 节点维度下
+ **最大聚合组 id 为活跃组**」做幂等覆盖式收敛,禁止增量累加;
+- 事件的 `oldRecord` id 与订阅方当前活跃组 id 不一致时,说明存在丢失的重置事件,
+ 应通过节点记录查询(`/api/cmd/record/processNodes`)全量对账;
+- 旧实例记录不会被删除(仅聚合组标记已取代),订阅方收到事件后应自行将旧流程id的数据标记失效。
范例(`FlowRecordUrgeEvent.java`,唯一带三个字段的事件):
@@ -107,6 +120,7 @@ EventPusher.push(new FlowRecordStartEvent(flowRecord, session.isMock()));
| `FlowUrgeService.urge()` | 每条待办 `UrgeEvent` |
| `FlowRevokeService.revoke()` | 当前记录恢复待办 `TodoEvent` + 被撤销后置记录 `RevokeEvent` |
| `FlowDeleteService.delete()` | `DeleteEvent` |
+| `FlowSubProcessResetService.reset()` | 被作废记录链逐条 `RevokeEvent` + 子流程重置 `SubProcessResetEvent`;重建实例经 `FlowCreateService` 推送各自的 `StartEvent` + `TodoEvent` |
> 事件在 `repositoryHolder.saveRecords(...)` **落库之后**推送,保证订阅方看到的记录已持久化。
diff --git a/docs/Integration/rest-api.md b/docs/Integration/rest-api.md
index 55f3b157..9b0dd47f 100644
--- a/docs/Integration/rest-api.md
+++ b/docs/Integration/rest-api.md
@@ -27,6 +27,7 @@ operatorId 请求参数 → 存在 → 使用该 id 作为当前操作人
| POST | `/api/cmd/record/processNodes` | `FlowProcessNodeRequest` | `MultiResponse` | 流程流转节点预览 |
| POST | `/api/cmd/record/create` | `FlowCreateRequest` | `SingleResponse` | 发起流程,返回首条记录 id |
| POST | `/api/cmd/record/action` | `FlowActionRequest` | `SingleResponse` | 执行动作(通过/拒绝/加签/委派/退回/转办/自定义/保存) |
+| POST | `/api/cmd/record/subProcess/reset` | `FlowSubProcessResetRequest` | `Response` | 子流程数据重置(独立接口,需子流程节点开启重置能力) |
| POST | `/api/cmd/record/urge` | `IdRequest` | `Response` | 催办 |
| POST | `/api/cmd/record/revoke` | `IdRequest` | `Response` | 撤销 |
| POST | `/api/cmd/record/delete` | `IdRequest` | `Response` | 删除(仅未流转实例,开始节点) |
@@ -139,6 +140,20 @@ public interface FlowRecordQueryService {
- `forwardOperatorIds`:加签/委派/转办的目标操作人。
- `backNodeId`:退回动作的目标节点(仅 Start/Approval/Handle 可退回)。
+### `FlowSubProcessResetRequest`(子流程数据重置,独立接口)
+
+```json
+{
+ "recordId": 456,
+ "resetInstanceProcessIds": ["选中重建的子流程实例流程id"],
+ "advice": "重置说明"
+}
+```
+
+- 重置不属于审批动作,不经过 `/action` 接口;仅当子流程节点开启 `resettable` 能力且当前待办位于其下游时可调用。
+- 无需指定子流程节点:由选中实例流程id定位其所属聚合组,选中实例须同属一个已放行且未取代的聚合组。
+- 流程详情 `FlowContent.resetSubProcess` 标识字段表明当前记录是否具备该能力,前端据此提供交互。
+
### `ActionResponse`
```json
@@ -165,6 +180,7 @@ form(FlowForm 元数据) / fieldPermissions(字段权限)
todos(当前节点待办 Body 列表) / actions / actionList
mergeable / createOperator / currentOperator / flowState / recordState
histories(审批历史) / nodes(NodeOption 可操作节点) / revoke / urge
+resetSubProcess(子流程重置能力标识:当前待办位于开启重置能力、已汇聚完成的子流程下游时为 true)
```
### `FlowRecordContent`(列表响应,字段摘要)
diff --git a/docs/capabilities/flow-engine-framework/sub-process.md b/docs/capabilities/flow-engine-framework/sub-process.md
index 9024a07f..01b76ad8 100644
--- a/docs/capabilities/flow-engine-framework/sub-process.md
+++ b/docs/capabilities/flow-engine-framework/sub-process.md
@@ -15,6 +15,9 @@ symbols:
- SubProcessScript
- SubProcessResultScript
- FlowSubProcessResultService
+ - FlowSubProcessResetService
+ - FlowSubProcessResetRequest
+ - FlowSubProcessResetEvent
- FlowSession.findSubProcessRecords
- ProcessNode.SubProcessBody
---
@@ -75,8 +78,57 @@ def run(request){
- 拼接的祖先流程节点统一返回 `ProcessNode.parentProcessRecord=true`,当前子流程节点为 `false`,供展示层区分记录来源。
- 开关开启时主流程历史包含审批人和审批意见,因此应由流程设计者按数据可见性要求显式配置。
+## 子流程数据重置(issue #219)
+
+子流程汇聚放行后、下游审批发现数据有误时,可对**已完成的子流程聚合记录**执行退回重走。重置是**独立接口**(不属于常规审批动作),能力完全由业务配置控制:
+
+### 能力开关(子流程节点配置)
+
+`SubProcessStrategy` 新增 `resettable` 布尔配置,**默认关闭**。仅当子流程节点开启该能力时,其汇聚完成后的下游待办记录才具备重置能力,`/api/cmd/record/subProcess/reset` 接口才允许调用;未开启时接口直接拒绝。历史流程定义无该字段时按关闭处理(`fromMap` 兼容)。
+
+### 接口与定位方式
+
+- 请求 `FlowSubProcessResetRequest`:`recordId`(当前操作的待办记录)+ `resetInstanceProcessIds`(选中重建的实例流程id)+ `advice`(重置说明)。
+- **不需要指定子流程节点**:由选中实例的流程id定位其所属聚合组(全部选中实例须同属一个未取代聚合组),重置始终是整个子流程**从头重走一次**——重建实例经由子流程节点配置的创建脚本与自动提交策略重新发起,不跳转、不迁移、不自动跳过任何历史数据。
+- 前置约束:当前记录为待办且操作人匹配;目标聚合组已放行(PASSED)、未被取代、全部实例已结束;当前流程无等待中聚合组;当前记录位于**锁定合并节点**(见下)。
+
+### 锁定合并节点(复杂下游拓扑支持)
+
+合并节点 = 子流程放行恢复后,锚点记录之后**首个产生业务记录的节点**。恢复遍历以锚点为来源记录,合并点首代记录均满足 `fromId == 锚点id`,据此从真实执行数据判定(抄送记录不参与判定):
+
+- 串联 / 条件分支 / 触发节点 / 抄送直通:合并节点唯一——条件分支取**实际命中分支**的首个节点,触发与抄送节点不阻断、不产生合并点;
+- 并行 / 包容分支扇出:多个分支首节点记录并存时,按记录产生顺序**锁定第一个分支的节点**为合并节点,兄弟分支不可重置;
+- **重置仅允许在锁定合并节点的待办记录上发起**,合并节点之后的更深层节点不可重置(重置语义为退回子流程重走,入口固定在汇聚处)。
+
+重置执行时同步清除该流程实例的循环触发标记(`LoopTriggerTraceContext.clearByProcess`),保证重走再次经过下游节点(如抄送)时不被被动式环检测误判为循环。
+
+### 重置语义
+
+- **聚合组建模**:旧聚合组标记 `superseded`(聚合状态保持不变,历史记录保留有效可查),新建一条聚合组取代其成为当前有效数据。新组由两类实例组成:
+ - **继承实例**(未选中,`Instance.inherited=true`):沿用原实例的流程id与最终状态,不重新执行;
+ - **重建实例**(选中):基于旧实例的启动记录反推创建请求(沿用原子流程定义、表单数据、流程标题与原发起人),创建全新子流程(新流程id),`Instance.sourceProcessId` 记录其替换的旧实例流程id,供订阅方完成旧 → 新映射。
+- **锚点不变**:新组沿用旧组的 `parentRecordId`(原触发记录),保证结果判定后主流程恢复位置不变。
+- **记录链作废**:触发锚点之后的主流程记录链以撤销语义作废(含执行重置的当前待办,重置说明写入该记录供审计),主流程退回子流程节点重新等待——**原待办从待办列表消失,新聚合组放行后生成全新的下游待办**;作废链路与 `FlowRevokeService` 一致,逐条推送 `FlowRecordRevokeEvent`。
+- **结果判定自愈**:重建实例全部结束后,`FlowSubProcessResultService` 按新组判定(结果脚本经上下文可见继承 + 重建实例的完整最终记录集),放行后生成新的下游待办;`complete()` 定位聚合组时跳过已取代组,避免继承实例同属新旧两组时命中旧组。
+- **脚本视野**:`FlowSession.findSubProcessRecords` 过滤已取代组,重置后脚本仅见当前有效组。
+- **事件通知**:推送 `FlowSubProcessResetEvent`(旧组快照 + 新组快照 + 重置记录id + 重置操作人 + mock),提醒业务订阅方子流程数据已被重置;订阅方收敛规则见 `docs/Integration/event-integration.md`。
+
+### 可见性(详情标识字段)
+
+重置不是节点动作,不出现在动作列表中。流程详情 `FlowContent` 新增 `resetSubProcess` 布尔标识:当前待办位于「开启重置能力、已汇聚完成」的子流程下游时为 `true`。前端解析到该标识即可自行提供重置交互(呈现方式由使用方决定,不限定于操作列);节点视图中新旧聚合组同时呈现,`ProcessNode.SubProcessBody.superseded` 与 `SubProcessInstanceBody.inherited/sourceProcessId` 供前端区分。
+
+### 数据结构兼容性
+
+- `SubProcessRecord` 新增 `superseded` 标记、`Instance` 新增 `inherited`/`sourceProcessId` 字段,均为纯增量:持久化实例 JSON 由 fastjson2 按字段读写,旧数据缺失字段按 `false`/`null` 处理;实体新增可空列 `superseded`,存量行为 `null` 时按未取代处理;
+- `SubProcessStrategy.resettable`、`FlowContent.resetSubProcess` 均为新增字段,旧数据/旧客户端不受影响;
+- 重置不删除任何历史记录,旧聚合组与旧实例记录在其原流程内保持有效可查。
+
## 运行约束
- 主流程等待期间不允许撤销子流程触发记录。
- 未自动提交的子流程草稿在主流程等待期间不允许删除。
- 结果失败的异常策略仅支持跳转节点,不支持返回兜底操作人。
+- 子流程重置仅在节点开启 `resettable` 能力时可用,默认关闭。
+- 主流程等待期间(存在等待中聚合组)不允许执行子流程重置。
+- 结果未放行(ERROR)的聚合组不允许重置。
+- 重置仅允许在锁定合并节点的待办上发起;并行/包容分支的兄弟分支与合并节点之后的更深层节点不可重置。
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/context/LoopTriggerTraceContext.java b/flow-engine-framework/src/main/java/com/codingapi/flow/context/LoopTriggerTraceContext.java
index 706c5273..11446801 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/context/LoopTriggerTraceContext.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/context/LoopTriggerTraceContext.java
@@ -75,6 +75,18 @@ public void clearExpired() {
this.traces.entrySet().removeIf(entry -> now - entry.getValue() > DEFAULT_WINDOW_MILLIS);
}
+ /**
+ * 清除指定流程实例的全部触发标记。
+ * 子流程重置等"退回重走"场景会作废既有记录链并重新触发下游节点,
+ * 旧执行留下的标记不应把合法的重走误判为循环。
+ *
+ * @param processId 流程实例标识
+ */
+ public void clearByProcess(String processId) {
+ String prefix = processId + ":";
+ this.traces.keySet().removeIf(key -> key.startsWith(prefix));
+ }
+
/**
* 清空全部标记(测试隔离用)。
*/
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/domain/SubProcessRecord.java b/flow-engine-framework/src/main/java/com/codingapi/flow/domain/SubProcessRecord.java
index 409769ab..980dafd8 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/domain/SubProcessRecord.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/domain/SubProcessRecord.java
@@ -1,7 +1,6 @@
package com.codingapi.flow.domain;
import com.codingapi.flow.record.FlowRecord;
-import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -17,7 +16,6 @@
* 并恢复主流程;全部实例结束但结果未通过时置为 {@link State#ERROR} 并跳转异常节点。
*/
@Getter
-@AllArgsConstructor
public class SubProcessRecord {
/**
@@ -76,6 +74,74 @@ public class SubProcessRecord {
*/
private long finishTime;
+ /**
+ * 是否已被重置取代。
+ * 子流程数据重置时旧聚合组被标记为已取代(状态保持不变以保留审计语义),
+ * 新聚合组取代其成为当前有效数据;已取代的组不再参与结果判定、脚本查询与重置。
+ */
+ private boolean superseded;
+
+ /**
+ * 全字段构造(持久化转换使用)。
+ *
+ * @param id 记录id
+ * @param groupId 本次子流程执行的分组id
+ * @param parentProcessId 父流程(主流程)的流程id
+ * @param parentRecordId 父流程中子流程节点的执行记录id
+ * @param parentWorkRuntimeId 父流程(主流程)的运行实例id
+ * @param nodeId 父流程中子流程节点的节点id
+ * @param totalCount 本次创建的子流程实例总数
+ * @param instances 本次创建的全部子流程实例
+ * @param state 本次子流程执行的聚合状态
+ * @param createTime 创建时间(毫秒时间戳)
+ * @param finishTime 结束时间(毫秒时间戳),未结束时为 0
+ */
+ public SubProcessRecord(long id,
+ String groupId,
+ String parentProcessId,
+ long parentRecordId,
+ long parentWorkRuntimeId,
+ String nodeId,
+ int totalCount,
+ List instances,
+ State state,
+ long createTime,
+ long finishTime) {
+ this(id, groupId, parentProcessId, parentRecordId, parentWorkRuntimeId, nodeId,
+ totalCount, instances, state, createTime, finishTime, false);
+ }
+
+ /**
+ * 全字段构造(含已取代标记)。
+ *
+ * @param superseded 是否已被重置取代
+ */
+ public SubProcessRecord(long id,
+ String groupId,
+ String parentProcessId,
+ long parentRecordId,
+ long parentWorkRuntimeId,
+ String nodeId,
+ int totalCount,
+ List instances,
+ State state,
+ long createTime,
+ long finishTime,
+ boolean superseded) {
+ this.id = id;
+ this.groupId = groupId;
+ this.parentProcessId = parentProcessId;
+ this.parentRecordId = parentRecordId;
+ this.parentWorkRuntimeId = parentWorkRuntimeId;
+ this.nodeId = nodeId;
+ this.totalCount = totalCount;
+ this.instances = instances;
+ this.state = state;
+ this.createTime = createTime;
+ this.finishTime = finishTime;
+ this.superseded = superseded;
+ }
+
/**
* 创建子流程执行记录,初始状态为等待子流程结果。
*
@@ -101,6 +167,31 @@ public SubProcessRecord(String groupId,
0);
}
+ /**
+ * 标记本次执行已被重置取代。
+ * 聚合状态保持不变(保留审计语义),仅置位已取代标记。
+ */
+ public void supersede() {
+ this.superseded = true;
+ }
+
+ /**
+ * 是否已被重置取代。
+ */
+ public boolean isSuperseded() {
+ return superseded;
+ }
+
+ /**
+ * 生成本记录的不可变快照(供事件携带,避免异步消费时被后续状态变更污染)。
+ *
+ * @return 字段相同的副本,实例列表不可变
+ */
+ public SubProcessRecord snapshot() {
+ return new SubProcessRecord(id, groupId, parentProcessId, parentRecordId, parentWorkRuntimeId,
+ nodeId, totalCount, List.copyOf(instances), state, createTime, finishTime, superseded);
+ }
+
/**
* 判断本次执行是否包含指定子流程。
*
@@ -206,7 +297,6 @@ public enum State {
* 子流程实例。
*/
@Getter
- @AllArgsConstructor
@NoArgsConstructor
public static class Instance {
@@ -240,6 +330,61 @@ public static class Instance {
*/
private long finishTime;
+ /**
+ * 是否为继承实例:子流程数据重置时未被选中重置的实例直接沿用原结果,
+ * 不再重新执行,仅在新聚合组中继承其最终状态。
+ */
+ private boolean inherited;
+
+ /**
+ * 重建实例替换的旧实例流程id:重置时重建的实例记录其取代的旧实例,
+ * 供订阅方完成旧 → 新流程id映射;继承实例与常规实例为 null。
+ */
+ private String sourceProcessId;
+
+ /**
+ * 全字段构造(持久化转换与测试使用)。
+ *
+ * @param startRecordId 子流程开始节点(发起)的执行记录id
+ * @param processId 子流程的流程id
+ * @param workTitle 子流程的流程名称
+ * @param finishRecordId 子流程最终执行记录id,未结束时为 0
+ * @param state 子流程实例运行状态
+ * @param finishTime 结束时间(毫秒时间戳),未结束时为 0
+ */
+ public Instance(long startRecordId,
+ String processId,
+ String workTitle,
+ long finishRecordId,
+ InstanceState state,
+ long finishTime) {
+ this(startRecordId, processId, workTitle, finishRecordId, state, finishTime, false, null);
+ }
+
+ /**
+ * 全字段构造(含重置标记)。
+ *
+ * @param inherited 是否为继承实例
+ * @param sourceProcessId 重建实例替换的旧实例流程id(继承实例与常规实例为 null)
+ */
+ public Instance(long startRecordId,
+ String processId,
+ String workTitle,
+ long finishRecordId,
+ InstanceState state,
+ long finishTime,
+ boolean inherited,
+ String sourceProcessId) {
+ this.startRecordId = startRecordId;
+ this.processId = processId;
+ this.workTitle = workTitle;
+ this.finishRecordId = finishRecordId;
+ this.state = state;
+ this.finishTime = finishTime;
+ this.inherited = inherited;
+ this.sourceProcessId = sourceProcessId;
+ }
+
/**
* 创建子流程实例,初始状态为运行中。
*
@@ -250,6 +395,27 @@ public Instance(FlowRecord startRecord) {
0, InstanceState.RUNNING, 0);
}
+ /**
+ * 创建重置重建的子流程实例,初始状态为运行中。
+ *
+ * @param startRecord 重建子流程开始节点(发起)的执行记录
+ * @param sourceProcessId 被替换的旧实例流程id
+ */
+ public static Instance rebuiltFrom(FlowRecord startRecord, String sourceProcessId) {
+ return new Instance(startRecord.getId(), startRecord.getProcessId(), startRecord.getWorkTitle(),
+ 0, InstanceState.RUNNING, 0, false, sourceProcessId);
+ }
+
+ /**
+ * 创建重置继承的子流程实例:完整沿用原实例的最终状态,不再重新执行。
+ *
+ * @param source 被取代聚合组中的原实例
+ */
+ public static Instance inheritFrom(Instance source) {
+ return new Instance(source.getStartRecordId(), source.getProcessId(), source.getWorkTitle(),
+ source.getFinishRecordId(), source.getState(), source.getFinishTime(), true, null);
+ }
+
/**
* 完成子流程实例:依据最终记录的主流程状态标记为已完成或已终止。
*
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowSubProcessResetEvent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowSubProcessResetEvent.java
new file mode 100644
index 00000000..1b496667
--- /dev/null
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowSubProcessResetEvent.java
@@ -0,0 +1,47 @@
+package com.codingapi.flow.event;
+
+import com.codingapi.flow.domain.SubProcessRecord;
+import com.codingapi.flow.operator.IFlowOperator;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 子流程重置事件
+ * 主流程节点上执行子流程数据重置时推送,提醒业务订阅方子流程数据已被重置:
+ * 被取代旧组中的选中实例已重建为全新子流程(新流程id),未选中实例以继承方式沿用原结果。
+ *
+ * 订阅方收敛规则:事件经异步线程池分发、到达顺序不保证,应按
+ * 「同一父记录 + 节点维度下最大聚合组 id 为活跃组」做幂等覆盖;
+ * 事件的 {@link #oldRecord} id 与订阅方当前活跃组 id 不一致时,说明存在丢失的重置事件,
+ * 应通过节点记录查询全量对账。
+ */
+@Getter
+@AllArgsConstructor
+public class FlowSubProcessResetEvent implements IFlowEvent {
+
+ /**
+ * 被重置取代的旧聚合组快照(含全部旧实例的流程id,推送前已做不可变快照)
+ */
+ private final SubProcessRecord oldRecord;
+
+ /**
+ * 重置后的新聚合组快照(继承实例沿用原流程id,重建实例为新流程id,
+ * 重建实例的 sourceProcessId 记录其替换的旧实例流程id)
+ */
+ private final SubProcessRecord newRecord;
+
+ /**
+ * 执行重置动作的主流程记录id
+ */
+ private final long resetRecordId;
+
+ /**
+ * 重置操作人
+ */
+ private final IFlowOperator resetOperator;
+
+ /**
+ * 是否模拟环境
+ */
+ private final boolean mock;
+}
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 e4f52a41..378025e9 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
@@ -144,4 +144,34 @@ public static FlowStateException nodeNotStartNode() {
public static FlowStateException recordAlreadyRunning() {
return new FlowStateException("state.record.alreadyRunning", "Flow is already running, delete not allowed");
}
+
+ /**
+ * Sub process group not support reset (no passed group / group not finished / already superseded)
+ *
+ * @return exception
+ */
+ public static FlowStateException subProcessNotSupportReset() {
+ return new FlowStateException("state.subProcess.notSupportReset",
+ "Sub process group not support reset, only a passed group with all instances finished can be reset");
+ }
+
+ /**
+ * Sub process reset not allowed while any group is waiting
+ *
+ * @return exception
+ */
+ public static FlowStateException subProcessResetWaiting() {
+ return new FlowStateException("state.subProcess.resetWaiting",
+ "Sub process reset not allowed while any sub process group is waiting");
+ }
+
+ /**
+ * Sub process reset only allowed on the locked merge node record
+ *
+ * @return exception
+ */
+ public static FlowStateException subProcessResetMergeNodeOnly() {
+ return new FlowStateException("state.subProcess.resetMergeNodeOnly",
+ "Sub process reset only allowed on the todo record of the locked merge node");
+ }
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowValidationException.java b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowValidationException.java
index 49fa8f7e..b1d103e9 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowValidationException.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowValidationException.java
@@ -32,6 +32,17 @@ public static FlowValidationException nodeRequired(String fieldName) {
String.format("Required field %s cannot be empty", fieldName));
}
+ /**
+ * Reset selected instance not found in current sub process group
+ *
+ * @param processId selected instance process id
+ * @return exception
+ */
+ public static FlowValidationException resetInstanceNotFound(String processId) {
+ return new FlowValidationException("validation.resetInstance.notFound",
+ String.format("Reset selected instance %s not found in current sub process group", processId));
+ }
+
/**
* Field is read-only
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/request/FlowSubProcessResetRequest.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/request/FlowSubProcessResetRequest.java
new file mode 100644
index 00000000..11274557
--- /dev/null
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/request/FlowSubProcessResetRequest.java
@@ -0,0 +1,58 @@
+package com.codingapi.flow.pojo.request;
+
+import com.codingapi.flow.exception.FlowValidationException;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 子流程数据重置请求(独立接口,不属于常规审批动作)。
+ *
+ * 重置以「已完成的子流程聚合记录」为目标:由选中的子流程实例流程id定位其所属聚合组,
+ * 将该组标记为已取代并重建选中实例,主流程退回子流程节点重新等待。
+ * 请求不需要指定子流程节点,节点信息由选中实例所属的聚合记录推导,
+ * 重置始终是子流程从头重走一次,不存在跳转到子流程内部某节点的语义。
+ */
+@Data
+@NoArgsConstructor
+public class FlowSubProcessResetRequest {
+
+ /**
+ * 主流程当前操作记录id(执行重置的待办记录)
+ */
+ private long recordId;
+
+ /**
+ * 操作者
+ */
+ private long operatorId;
+
+ /**
+ * 选中重建的子流程实例流程id列表(须同属一个已完成聚合组,且非空)
+ */
+ private List resetInstanceProcessIds;
+
+ /**
+ * 重置说明(记录在被作废的当前记录上,供审计追溯)
+ */
+ private String advice;
+
+ public FlowSubProcessResetRequest(long recordId, long operatorId, List resetInstanceProcessIds) {
+ this.recordId = recordId;
+ this.operatorId = operatorId;
+ this.resetInstanceProcessIds = resetInstanceProcessIds;
+ }
+
+ public void verify() {
+ if (recordId <= 0) {
+ throw FlowValidationException.required("recordId");
+ }
+ if (operatorId <= 0) {
+ throw FlowValidationException.required("operatorId");
+ }
+ if (resetInstanceProcessIds == null || resetInstanceProcessIds.isEmpty()) {
+ throw FlowValidationException.required("resetInstanceProcessIds");
+ }
+ }
+}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
index 8870f476..62309c4d 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
@@ -176,6 +176,13 @@ public class FlowContent {
*/
private boolean urge;
+ /**
+ * 支持子流程数据重置(独立能力标识,非审批动作)。
+ * 当前待办记录位于已开启重置能力、且已汇聚完成的子流程下游时为 true;
+ * 前端可据此标识自行决定交互呈现方式,调用 {@code /api/cmd/record/subProcess/reset} 接口。
+ */
+ private boolean resetSubProcess;
+
/**
* 设置操作动作按钮
*
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/ProcessNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/ProcessNode.java
index 677652aa..599dc541 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/ProcessNode.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/ProcessNode.java
@@ -365,6 +365,11 @@ public static class SubProcessBody {
*/
private List instances;
+ /**
+ * 是否已被重置取代(历史聚合组,仅保留审计用途)
+ */
+ private boolean superseded;
+
private static SubProcessBody create(SubProcessRecord record, Function workTitleLoader) {
List instances = record.getInstances().stream()
.map(instance -> SubProcessInstanceBody.create(instance, workTitleLoader))
@@ -381,7 +386,8 @@ private static SubProcessBody create(SubProcessRecord record, Function workTitleLoader) {
String workTitle = instance.getWorkTitle();
@@ -436,7 +452,9 @@ private static SubProcessInstanceBody create(SubProcessRecord.Instance instance,
workTitle,
instance.getFinishRecordId(),
instance.getState(),
- instance.getFinishTime()
+ instance.getFinishTime(),
+ instance.isInherited(),
+ instance.getSourceProcessId()
);
}
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowService.java
index 789d67d3..8a48d60d 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowService.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowService.java
@@ -114,4 +114,20 @@ public void urge(FlowUrgeRequest request) {
FlowUrgeService flowUrgeService = new FlowUrgeService(request, this.repositoryHolder);
flowUrgeService.urge();
}
+
+ /**
+ * 子流程数据重置(独立接口,不属于常规审批动作)。
+ *
+ * 将已完成的子流程聚合记录中的选中实例从头重建为新子流程,主流程退回子流程节点重新等待。
+ * 仅当子流程节点配置了重置能力({@code SubProcessStrategy.resettable})时允许执行。
+ *
+ * @param request 重置请求
+ */
+ public void resetSubProcess(FlowSubProcessResetRequest request) {
+ FlowOperatorLocalThreadCache.getInstance().clear();
+ FlowRuntimeScriptLocalCache.getInstance().clear();
+ FlowSubProcessResetService flowSubProcessResetService =
+ new FlowSubProcessResetService(request, this.repositoryHolder);
+ flowSubProcessResetService.reset();
+ }
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDetailService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDetailService.java
index f17f09f5..6d8a3552 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDetailService.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowDetailService.java
@@ -85,8 +85,12 @@ public FlowContent detail() {
FlowSession flowSession = flowRecord.createFlowSession(repositoryHolder, workflow, currentOperator,
createOperator, submitOperator, formData, flowAdvice);
- return new FlowContentFactory(flowSession, workflow, flowRecord, currentOperator,
+ FlowContent flowContent = new FlowContentFactory(flowSession, workflow, flowRecord, currentOperator,
repositoryHolder.getFlowRecordService()).create();
+ // 子流程重置为独立能力(非审批动作):仅当当前待办位于已开启重置能力、
+ // 且已汇聚完成的子流程下游时携带标识,由前端自行决定交互呈现
+ flowContent.setResetSubProcess(FlowSubProcessResetService.canReset(flowRecord, repositoryHolder));
+ return flowContent;
}
}
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
new file mode 100644
index 00000000..a65e0e6a
--- /dev/null
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/impl/FlowSubProcessResetService.java
@@ -0,0 +1,322 @@
+package com.codingapi.flow.service.impl;
+
+import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache;
+import com.codingapi.flow.context.LoopTriggerTraceContext;
+import com.codingapi.flow.domain.SubProcessRecord;
+import com.codingapi.flow.event.FlowRecordRevokeEvent;
+import com.codingapi.flow.event.FlowSubProcessResetEvent;
+import com.codingapi.flow.exception.FlowNotFoundException;
+import com.codingapi.flow.exception.FlowStateException;
+import com.codingapi.flow.exception.FlowValidationException;
+import com.codingapi.flow.generator.FlowIDGeneratorGatewayContext;
+import com.codingapi.flow.mock.MockRepositoryHolder;
+import com.codingapi.flow.node.IFlowNode;
+import com.codingapi.flow.operator.IFlowOperator;
+import com.codingapi.flow.pojo.request.FlowActionRequest;
+import com.codingapi.flow.pojo.request.FlowCreateRequest;
+import com.codingapi.flow.pojo.request.FlowSubProcessResetRequest;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.repository.SubProcessRepository;
+import com.codingapi.flow.service.FlowRecordService;
+import com.codingapi.flow.service.FlowService;
+import com.codingapi.flow.service.WorkflowService;
+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;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 子流程数据重置服务(独立接口,不属于常规审批动作)。
+ *
+ *
重置以「已完成的子流程聚合记录」为目标:由选中的子流程实例流程id定位其所属聚合组,
+ * 旧聚合组标记为已取代(保留审计),新聚合组由继承实例(未选中,沿用原结果)与
+ * 重建实例(选中,基于旧实例数据从头重新发起的全新子流程)组成;当前记录作废,
+ * 主流程退回子流程节点重新等待,新聚合组完成后经既有结果判定恢复主流程。
+ *
+ * 可重置性由子流程节点的 {@link SubProcessStrategy#isResettable()} 配置控制,默认关闭;
+ * 同时要求当前操作记录位于该子流程触发锚点的真实下游记录链上(条件/并行分支中
+ * 不在该子流程之后的节点不可重置)。重建沿用子流程节点配置的创建脚本提交策略,
+ * 不迁移、不跳过任何历史数据,历史聚合组与旧实例记录保持有效可查。
+ */
+public class FlowSubProcessResetService {
+
+ private final FlowSubProcessResetRequest request;
+ private final IRepositoryHolder repositoryHolder;
+ private final SubProcessRepository subProcessRepository;
+ private final FlowRecordService flowRecordService;
+ private final WorkflowService workflowService;
+
+ public FlowSubProcessResetService(FlowSubProcessResetRequest request, IRepositoryHolder repositoryHolder) {
+ this.request = request;
+ this.repositoryHolder = repositoryHolder;
+ this.subProcessRepository = repositoryHolder.getSubProcessRepository();
+ this.flowRecordService = repositoryHolder.getFlowRecordService();
+ this.workflowService = repositoryHolder.getWorkflowService();
+ }
+
+ /**
+ * 执行子流程数据重置。
+ */
+ public void reset() {
+ request.verify();
+
+ FlowRecord currentRecord = flowRecordService.getFlowRecord(request.getRecordId());
+ if (currentRecord == null) {
+ throw FlowNotFoundException.record(request.getRecordId());
+ }
+ if (!currentRecord.isTodo()) {
+ throw FlowStateException.recordAlreadyDone();
+ }
+ if (currentRecord.getCurrentOperatorId() != request.getOperatorId()) {
+ throw FlowStateException.operatorNotMatch();
+ }
+
+ WorkflowRuntime workflowRuntime = workflowService.getWorkflowRuntime(currentRecord.getWorkRuntimeId());
+ if (workflowRuntime == null) {
+ throw FlowNotFoundException.workflow(currentRecord.getWorkRuntimeId() + " not found");
+ }
+ FlowRuntimeScriptLocalCache.getInstance().set(workflowRuntime.getScripts());
+ Workflow workflow = workflowRuntime.toWorkflow();
+
+ SubProcessRecord currentGroup = loadTargetGroup(currentRecord.getProcessId(),
+ request.getResetInstanceProcessIds());
+ verifyResettableGroup(currentGroup);
+
+ // 子流程节点配置开关:未开启重置能力的子流程不允许重置
+ IFlowNode subProcessNode = workflow.getFlowNode(currentGroup.getNodeId());
+ SubProcessStrategy strategy = subProcessNode.strategyManager().getStrategy(SubProcessStrategy.class);
+ if (strategy == null || !strategy.isResettable()) {
+ throw FlowStateException.subProcessNotSupportReset();
+ }
+
+ if (subProcessRepository.findByParentProcessId(currentRecord.getProcessId()).stream()
+ .anyMatch(SubProcessRecord::isWaiting)) {
+ throw FlowStateException.subProcessResetWaiting();
+ }
+
+ FlowRecord anchorRecord = repositoryHolder.getRecordById(currentGroup.getParentRecordId());
+ if (anchorRecord == null) {
+ throw FlowNotFoundException.record(currentGroup.getParentRecordId());
+ }
+ // 锁定合并节点守卫:重置仅允许在锁定合并节点(锚点之后首个产生业务记录的节点)的待办上发起。
+ // 并行/包容分支扇出时多分支共享锚点,仅按记录产生顺序锁定的第一个分支节点可重置,
+ // 兄弟分支与合并节点之后的更深层节点均不允许
+ FlowRecord lockedMergeRecord = findLockedMergeRecord(currentRecord.getProcessId(), anchorRecord.getId());
+ if (lockedMergeRecord == null
+ || !currentRecord.getNodeId().equals(lockedMergeRecord.getNodeId())) {
+ throw FlowStateException.subProcessResetMergeNodeOnly();
+ }
+
+ // 先创建重建实例(不触发自动提交),落库新聚合组后再统一提交,
+ // 避免重建实例在新组落库前结束导致结果判定找不到聚合组
+ boolean submit = strategy.isSubmit();
+ FlowService flowService = repositoryHolder.createFlowService();
+ List newInstances = new ArrayList<>();
+ List submitRequests = new ArrayList<>();
+ for (SubProcessRecord.Instance instance : currentGroup.getInstances()) {
+ if (request.getResetInstanceProcessIds().contains(instance.getProcessId())) {
+ FlowCreateRequest createRequest = buildRebuildRequest(instance, anchorRecord);
+ long newRecordId = flowService.create(createRequest);
+ FlowRecord newStartRecord = repositoryHolder.getRecordById(newRecordId);
+ newInstances.add(SubProcessRecord.Instance.rebuiltFrom(newStartRecord, instance.getProcessId()));
+ if (submit) {
+ submitRequests.add(createRequest.toActionRequest(newRecordId));
+ }
+ } else {
+ newInstances.add(SubProcessRecord.Instance.inheritFrom(instance));
+ }
+ }
+
+ currentGroup.supersede();
+ subProcessRepository.save(currentGroup);
+ SubProcessRecord newGroup = new SubProcessRecord(
+ FlowIDGeneratorGatewayContext.getInstance().generateProcessId(),
+ anchorRecord,
+ currentGroup.getNodeId(),
+ newInstances);
+ subProcessRepository.save(newGroup);
+
+ for (FlowActionRequest submitRequest : submitRequests) {
+ flowService.action(submitRequest);
+ }
+
+ // 作废触发锚点之后的记录链(复用撤销语义):重置后主流程退回子流程节点等待,
+ // 锚点之后的旧记录不再代表有效路径,且其 fromId + 节点与恢复后新建记录相同,
+ // 不作废会被同节点记录查询误判为多人审批
+ List invalidatedRecords = new ArrayList<>();
+ List afterRecords = repositoryHolder
+ .findAfterRecords(currentRecord.getProcessId(), anchorRecord.getId());
+ for (FlowRecord afterRecord : afterRecords) {
+ if (afterRecord.getId() == currentRecord.getId()) {
+ // 重置说明记录在操作记录上,供审计追溯
+ afterRecord.setAdvice(request.getAdvice());
+ }
+ afterRecord.revoke();
+ invalidatedRecords.add(afterRecord);
+ }
+ repositoryHolder.saveRecords(invalidatedRecords);
+ // 重置属于有意的退回重走:清除该流程的循环触发标记,
+ // 避免重走再次触发下游节点(如抄送)时被被动式环检测误判为循环
+ LoopTriggerTraceContext.getInstance().clearByProcess(currentRecord.getProcessId());
+
+ boolean mock = repositoryHolder instanceof MockRepositoryHolder;
+ IFlowOperator resetOperator = repositoryHolder.getOperatorById(request.getOperatorId());
+ for (FlowRecord invalidatedRecord : invalidatedRecords) {
+ EventPusher.push(new FlowRecordRevokeEvent(invalidatedRecord, mock));
+ }
+ EventPusher.push(new FlowSubProcessResetEvent(
+ currentGroup.snapshot(),
+ newGroup.snapshot(),
+ currentRecord.getId(),
+ resetOperator,
+ mock));
+ }
+
+ /**
+ * 由选中的子流程实例流程id定位其所属的聚合组(全部选中实例须同属一个未取代的聚合组)。
+ *
+ * @param processId 主流程的流程id
+ * @param selectedProcessIds 选中重建的实例流程id
+ * @return 聚合组;实例不存在时抛出参数异常
+ */
+ private SubProcessRecord loadTargetGroup(String processId, List selectedProcessIds) {
+ List groups = subProcessRepository.findByParentProcessId(processId);
+ SubProcessRecord targetGroup = null;
+ for (String selectedProcessId : selectedProcessIds) {
+ SubProcessRecord owner = groups.stream()
+ .filter(group -> !group.isSuperseded())
+ .filter(group -> group.containsChildProcess(selectedProcessId))
+ .findFirst()
+ .orElse(null);
+ if (owner == null) {
+ throw FlowValidationException.resetInstanceNotFound(selectedProcessId);
+ }
+ if (targetGroup == null) {
+ targetGroup = owner;
+ } else if (targetGroup.getId() != owner.getId()) {
+ throw FlowValidationException.resetInstanceNotFound(selectedProcessId);
+ }
+ }
+ return targetGroup;
+ }
+
+ /**
+ * 校验聚合组可重置:未被取代、已放行且全部实例已结束。
+ */
+ private void verifyResettableGroup(SubProcessRecord group) {
+ if (group == null || group.isSuperseded() || !group.isPassed() || !group.isAllFinished()) {
+ throw FlowStateException.subProcessNotSupportReset();
+ }
+ }
+
+ /**
+ * 查找锁定的合并节点记录:锚点记录之后的首代业务记录中,按记录产生顺序取第一条。
+ *
+ * 恢复后的记录遍历以锚点为来源记录,合并点首代记录均满足 {@code fromId == 锚点id}:
+ * 串联/条件分支/触发与抄送直通时唯一;并行/包容分支扇出时为多个分支首节点记录,
+ * 按记录id(即遍历产生顺序)取第一个分支节点作为锁定合并节点。
+ *
+ * @param processId 主流程的流程id
+ * @param anchorId 锚点记录id
+ * @return 锁定合并节点记录;不存在时返回 null
+ */
+ private FlowRecord findLockedMergeRecord(String processId, long anchorId) {
+ return repositoryHolder.findAfterRecords(processId, anchorId).stream()
+ .filter(record -> record.getFromId() == anchorId)
+ .filter(record -> !record.isNotify())
+ .min(Comparator.comparingLong(FlowRecord::getId))
+ .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;
+ }
+
+ /**
+ * 判断指定记录当前是否可以执行子流程重置(供详情数据计算重置标识)。
+ *
+ * 满足全部条件时返回 true:记录为待办、所属流程存在未取代且已放行、全部实例已结束的
+ * 聚合组,该子流程节点配置了重置能力,且记录位于锁定合并节点(锚点之后首个产生业务
+ * 记录的节点,并行/包容分支扇出时按记录产生顺序取第一个分支节点)。
+ *
+ * @param record 当前记录
+ * @param repositoryHolder 仓储持有者
+ * @return 可重置返回 true
+ */
+ public static boolean canReset(FlowRecord record, IRepositoryHolder repositoryHolder) {
+ if (record == null || record.getId() <= 0 || !record.isTodo()) {
+ return false;
+ }
+ SubProcessRepository subProcessRepository = repositoryHolder.getSubProcessRepository();
+ List groups = subProcessRepository.findByParentProcessId(record.getProcessId());
+ if (groups.stream().anyMatch(SubProcessRecord::isWaiting)) {
+ return false;
+ }
+ WorkflowService workflowService = repositoryHolder.getWorkflowService();
+ for (SubProcessRecord group : groups) {
+ if (group.isSuperseded() || !group.isPassed() || !group.isAllFinished()) {
+ continue;
+ }
+ WorkflowRuntime workflowRuntime = workflowService.getWorkflowRuntime(group.getParentWorkRuntimeId());
+ if (workflowRuntime == null) {
+ continue;
+ }
+ Workflow workflow = workflowRuntime.toWorkflow();
+ IFlowNode subProcessNode = workflow.getFlowNode(group.getNodeId());
+ if (subProcessNode == null) {
+ continue;
+ }
+ SubProcessStrategy strategy = subProcessNode.strategyManager().getStrategy(SubProcessStrategy.class);
+ if (strategy == null || !strategy.isResettable()) {
+ continue;
+ }
+ FlowRecord anchorRecord = repositoryHolder.getRecordById(group.getParentRecordId());
+ if (anchorRecord == null) {
+ continue;
+ }
+ FlowRecord lockedMergeRecord = findLockedMergeRecord(record.getProcessId(),
+ anchorRecord.getId(), repositoryHolder);
+ if (lockedMergeRecord != null && record.getNodeId().equals(lockedMergeRecord.getNodeId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 查找锁定的合并节点记录(静态版,供 {@link #canReset} 使用),
+ * 语义与 {@link #findLockedMergeRecord(String, long)} 一致。
+ */
+ private static FlowRecord findLockedMergeRecord(String processId, long anchorId,
+ IRepositoryHolder repositoryHolder) {
+ return repositoryHolder.findAfterRecords(processId, anchorId).stream()
+ .filter(record -> record.getFromId() == anchorId)
+ .filter(record -> !record.isNotify())
+ .min(Comparator.comparingLong(FlowRecord::getId))
+ .orElse(null);
+ }
+}
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 798e5067..4e8340b6 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
@@ -53,6 +53,8 @@ public void complete() {
}
SubProcessRecord subProcessRecord = subProcessRepository
.findByParentRecordId(childFinalRecord.getParentId()).stream()
+ // 已取代的聚合组不参与结果判定,避免继承实例同时归属新旧两组时命中旧组导致主流程停滞
+ .filter(record -> !record.isSuperseded())
.filter(record -> record.containsChildProcess(childFinalRecord.getProcessId()))
.findFirst()
.orElse(null);
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/session/FlowSession.java b/flow-engine-framework/src/main/java/com/codingapi/flow/session/FlowSession.java
index 66a6cc1b..73cd1efc 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/session/FlowSession.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/session/FlowSession.java
@@ -442,8 +442,11 @@ public List findSubProcessRecords(String subProcessNodeId) {
&& subProcessContext.getSubProcessRecord().getNodeId().equals(subProcessNodeId)) {
records = List.of(subProcessContext.getSubProcessRecord());
} else {
+ // 已取代的聚合组不参与脚本查询,重置后脚本仅可见当前有效组(含继承实例的最终记录)
records = repositoryHolder.getSubProcessRepository()
- .findByParentProcessIdAndNodeId(currentRecord.getProcessId(), subProcessNodeId);
+ .findByParentProcessIdAndNodeId(currentRecord.getProcessId(), subProcessNodeId).stream()
+ .filter(record -> !record.isSuperseded())
+ .toList();
}
List ids = records.stream()
.flatMap(record -> record.findFinishedRecordIds().stream())
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/strategy/node/SubProcessStrategy.java b/flow-engine-framework/src/main/java/com/codingapi/flow/strategy/node/SubProcessStrategy.java
index 2042eed7..ae1cbd48 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/strategy/node/SubProcessStrategy.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/strategy/node/SubProcessStrategy.java
@@ -47,22 +47,38 @@ public class SubProcessStrategy extends BaseStrategy {
*/
private boolean showParentProcessRecords;
+ /**
+ * 是否允许在子流程汇聚完成后,由下游节点对该子流程执行数据重置(退回重走)。
+ * 默认关闭。开启后,主流程走到该子流程下游的待办记录上时,详情数据会携带
+ * 重置标识,业务方可调用独立的子流程重置接口;未开启则接口拒绝执行。
+ */
+ private boolean resettable;
+
public SubProcessStrategy(String subProcessScript, boolean submit) {
- this(subProcessScript, submit, SubProcessResultScript.defaultScript().getScript(), false);
+ this(subProcessScript, submit, SubProcessResultScript.defaultScript().getScript(), false, false);
}
public SubProcessStrategy(String subProcessScript, boolean submit, String resultScript) {
- this(subProcessScript, submit, resultScript, false);
+ this(subProcessScript, submit, resultScript, false, false);
}
public SubProcessStrategy(String subProcessScript,
boolean submit,
String resultScript,
boolean showParentProcessRecords) {
+ this(subProcessScript, submit, resultScript, showParentProcessRecords, false);
+ }
+
+ public SubProcessStrategy(String subProcessScript,
+ boolean submit,
+ String resultScript,
+ boolean showParentProcessRecords,
+ boolean resettable) {
this.submit = submit;
this.subProcessScript = new SubProcessScript(subProcessScript);
this.resultScript = new SubProcessResultScript(resultScript);
this.showParentProcessRecords = showParentProcessRecords;
+ this.resettable = resettable;
}
@Override
@@ -72,6 +88,7 @@ public void copy(INodeStrategy target) {
this.subProcessScript = strategy.subProcessScript;
this.resultScript = strategy.resultScript;
this.showParentProcessRecords = strategy.showParentProcessRecords;
+ this.resettable = strategy.resettable;
}
public static SubProcessStrategy defaultStrategy() {
@@ -80,6 +97,7 @@ public static SubProcessStrategy defaultStrategy() {
processStrategy.resultScript = SubProcessResultScript.defaultScript();
processStrategy.submit = true;
processStrategy.showParentProcessRecords = false;
+ processStrategy.resettable = false;
return processStrategy;
}
@@ -90,6 +108,7 @@ public Map toMap() {
map.put("submit", submit);
map.put("resultScript", resultScript.getScript());
map.put("showParentProcessRecords", showParentProcessRecords);
+ map.put("resettable", resettable);
return map;
}
@@ -105,6 +124,10 @@ public static SubProcessStrategy fromMap(Map map) {
Object showParentProcessRecords = map.get("showParentProcessRecords");
processStrategy.showParentProcessRecords = showParentProcessRecords != null
&& Boolean.parseBoolean(showParentProcessRecords.toString());
+ // 历史配置无该字段时默认关闭,保证旧版本流程定义加载后行为不变
+ Object resettable = map.get("resettable");
+ processStrategy.resettable = resettable != null
+ && Boolean.parseBoolean(resettable.toString());
return processStrategy;
}
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
new file mode 100644
index 00000000..c4d9d296
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSubProcessResetServiceTest.java
@@ -0,0 +1,964 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.builder.FormFieldPermissionsBuilder;
+import com.codingapi.flow.builder.NodeStrategyBuilder;
+import com.codingapi.flow.domain.SubProcessRecord;
+import com.codingapi.flow.event.FlowSubProcessResetEvent;
+import com.codingapi.flow.exception.FlowStateException;
+import com.codingapi.flow.exception.FlowValidationException;
+import com.codingapi.flow.factory.MyFlowServiceFactory;
+import com.codingapi.flow.form.DataType;
+import com.codingapi.flow.form.FlowForm;
+import com.codingapi.flow.form.FlowFormBuilder;
+import com.codingapi.flow.form.permission.PermissionType;
+import com.codingapi.flow.node.IFlowNode;
+import com.codingapi.flow.action.IFlowAction;
+import com.codingapi.flow.node.nodes.ApprovalNode;
+import com.codingapi.flow.node.nodes.ConditionBranchNode;
+import com.codingapi.flow.node.nodes.ConditionElseBranchNode;
+import com.codingapi.flow.node.nodes.ConditionNode;
+import com.codingapi.flow.node.nodes.EndNode;
+import com.codingapi.flow.node.nodes.NotifyNode;
+import com.codingapi.flow.node.nodes.ParallelBranchNode;
+import com.codingapi.flow.node.nodes.ParallelNode;
+import com.codingapi.flow.node.nodes.StartNode;
+import com.codingapi.flow.node.nodes.SubProcessNode;
+import com.codingapi.flow.pojo.body.FlowAdviceBody;
+import com.codingapi.flow.pojo.request.FlowActionRequest;
+import com.codingapi.flow.pojo.request.FlowCreateRequest;
+import com.codingapi.flow.pojo.request.FlowDetailRequest;
+import com.codingapi.flow.pojo.request.FlowProcessNodeRequest;
+import com.codingapi.flow.pojo.request.FlowSubProcessResetRequest;
+import com.codingapi.flow.pojo.response.FlowContent;
+import com.codingapi.flow.pojo.response.ProcessNode;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.script.node.SubProcessResultScript;
+import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
+import com.codingapi.flow.strategy.node.OperatorLoadStrategy;
+import com.codingapi.flow.strategy.node.SubProcessStrategy;
+import com.codingapi.flow.user.User;
+import com.codingapi.flow.workflow.Workflow;
+import com.codingapi.flow.workflow.WorkflowBuilder;
+import com.codingapi.springboot.framework.event.DomainEvent;
+import com.codingapi.springboot.framework.event.IEvent;
+import com.codingapi.springboot.framework.event.SpringEventInitializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationEvent;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 子流程数据重置场景测试(issue #219)。
+ *
+ * 重置为独立接口(非审批动作):主流程 开始 -> 子流程节点(2 实例) -> 最终审批 -> 结束,
+ * 子流程节点开启 {@code resettable} 能力后,最终审批待办上调用
+ * {@code FlowService.resetSubProcess} 对选中实例退回重走。
+ */
+class FlowSubProcessResetServiceTest {
+
+ private static final String FORM_CODE = "sub-process-reset-form";
+ private static final String PARENT_CODE = "sub-process-reset-parent";
+ private static final String CHILD_CODE = "sub-process-reset-child";
+
+ private MyFlowServiceFactory factory;
+ private User initiator;
+ private User childOperator;
+ private User finalOperator;
+ private FlowForm form;
+ private StartNode childStart;
+ private ApprovalNode childApproval;
+ private StartNode parentStart;
+ private SubProcessNode subProcessNode;
+ private ApprovalNode finalApproval;
+
+ private final List capturedEvents = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ factory = new MyFlowServiceFactory();
+ initiator = saveUser(1, "发起人");
+ childOperator = saveUser(2, "子流程审批人");
+ finalOperator = saveUser(3, "主流程最终审批人");
+ form = FlowFormBuilder.builder()
+ .name("子流程重置测试表单")
+ .code(FORM_CODE)
+ .addField("业务内容", "content", DataType.STRING)
+ .build();
+ saveChildWorkflow();
+ }
+
+ @AfterEach
+ void resetEventContext() throws Exception {
+ Class> clazz = Class.forName("com.codingapi.springboot.framework.event.DomainEventContext");
+ java.lang.reflect.Method getInstance = clazz.getDeclaredMethod("getInstance");
+ getInstance.setAccessible(true);
+ Object instance = getInstance.invoke(null);
+ Field field = clazz.getDeclaredField("context");
+ field.setAccessible(true);
+ field.set(instance, null);
+ }
+
+ /**
+ * 测试目标:验证在最终审批节点重置选中的 1 个子流程实例后,主流程重新等待,
+ * 重建实例完成后主流程恢复并可正常走完。
+ * 前置条件:子流程节点开启重置能力,2 个实例全部放行,主流程停在最终审批待办。
+ * 执行步骤:对最终审批待办调用重置接口(选中第 1 个实例)→ 完成重建子流程 → 审批最终节点。
+ * 期望断言:旧聚合组被取代;新组含 1 个继承实例与 1 个重建实例(新 processId、
+ * sourceProcessId 指向旧实例);最终审批记录作废;重建实例完成后主流程仅恢复一次,流程正常结束。
+ */
+ @Test
+ void shouldResetSelectedInstanceAndResumeMainFlowAfterRebuiltChildFinishes() {
+ buildParentWorkflow(true);
+ long parentStartRecordId = createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ assertEquals(2, childTodos.size());
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ String resetProcessId = passedGroup.getInstances().get(0).getProcessId();
+ String keptProcessId = passedGroup.getInstances().get(1).getProcessId();
+
+ resetSubProcess(finalTodo, List.of(resetProcessId), finalOperator);
+
+ List groups = factory.subProcessRepository
+ .findByParentProcessIdAndNodeId(parentProcessId(finalTodo), subProcessNode.getId());
+ assertAll("重置后的聚合组状态",
+ () -> assertEquals(2, groups.size(), "重置应保留旧组并新增一组"),
+ () -> assertTrue(groups.get(0).isSuperseded(), "旧聚合组应标记为已取代"),
+ () -> assertEquals(SubProcessRecord.State.PASSED, groups.get(0).getState(),
+ "旧聚合组状态保持放行以保留审计语义"),
+ () -> assertFalse(groups.get(1).isSuperseded()),
+ () -> assertEquals(SubProcessRecord.State.WAITING, groups.get(1).getState()),
+ () -> assertEquals(2, groups.get(1).getTotalCount()));
+ SubProcessRecord newGroup = groups.get(1);
+ SubProcessRecord.Instance inherited = newGroup.getInstances().stream()
+ .filter(SubProcessRecord.Instance::isInherited)
+ .findFirst().orElseThrow();
+ SubProcessRecord.Instance rebuilt = newGroup.getInstances().stream()
+ .filter(instance -> !instance.isInherited())
+ .findFirst().orElseThrow();
+ assertAll("新聚合组实例构成",
+ () -> assertEquals(keptProcessId, inherited.getProcessId(), "继承实例应沿用原流程id"),
+ () -> assertTrue(inherited.isFinished(), "继承实例沿用原最终状态"),
+ () -> assertNull(inherited.getSourceProcessId()),
+ () -> assertEquals(resetProcessId, rebuilt.getSourceProcessId(),
+ "重建实例应记录其替换的旧实例流程id"),
+ () -> assertNotEquals(resetProcessId, rebuilt.getProcessId(), "重建实例应为全新流程"),
+ () -> assertEquals(SubProcessRecord.InstanceState.RUNNING, rebuilt.getState()));
+
+ FlowRecord finalRecordAfterReset = factory.flowRecordRepository.get(finalTodo.getId());
+ assertAll("重置后旧记录链作废",
+ () -> assertTrue(finalRecordAfterReset.isRevoked(), "旧记录链应作废"),
+ () -> assertFalse(finalRecordAfterReset.isTodo(), "重置后旧待办应被清理"),
+ () -> assertEquals(parentStartRecordId, newGroup.getParentRecordId(),
+ "新组应沿用原触发记录作为锚点"));
+ assertTrue(todos(finalOperator, finalApproval).isEmpty(), "重置后主流程应重新等待子流程");
+ assertEquals(1, todos(childOperator, childApproval).size(), "重建实例应自动提交并产生子流程待办");
+
+ FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0);
+ assertEquals(rebuilt.getProcessId(), rebuiltTodo.getProcessId());
+ approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData());
+
+ List resumedTodos = todos(finalOperator, finalApproval);
+ assertEquals(1, resumedTodos.size(), "重建实例完成后主流程只能恢复一次");
+ FlowRecord newFinalTodo = resumedTodos.get(0);
+ assertNotEquals(finalTodo.getId(), newFinalTodo.getId(), "恢复后应生成新的最终审批记录");
+ assertEquals(parentStartRecordId, newFinalTodo.getFromId(), "新记录仍以触发记录为来源");
+
+ approve(newFinalTodo, passAction(finalApproval), finalOperator, newFinalTodo.getFormData());
+ assertTrue(factory.flowRecordRepository.get(newFinalTodo.getId()).isFinish(),
+ "重置重走后流程应正常结束");
+ }
+
+ /**
+ * 测试目标:验证选中全部实例时等价整组重建,流程仍可走完。
+ * 前置条件:同主场景,主流程停在最终审批待办。
+ * 执行步骤:重置全部 2 个实例,依次完成两个重建子流程,再审批最终节点。
+ * 期望断言:新组无继承实例、全部为重建实例;全部完成后主流程恢复一次并正常结束。
+ */
+ @Test
+ void shouldResetAllInstancesWhenAllSelected() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ List allProcessIds = passedGroup.getInstances().stream()
+ .map(SubProcessRecord.Instance::getProcessId)
+ .toList();
+
+ resetSubProcess(finalTodo, allProcessIds, finalOperator);
+
+ SubProcessRecord newGroup = factory.subProcessRepository
+ .findByParentProcessIdAndNodeId(parentProcessId(finalTodo), subProcessNode.getId()).get(1);
+ assertAll("整组重建",
+ () -> assertEquals(2, newGroup.getInstances().size()),
+ () -> assertTrue(newGroup.getInstances().stream().noneMatch(SubProcessRecord.Instance::isInherited)),
+ () -> assertTrue(newGroup.getInstances().stream()
+ .allMatch(instance -> allProcessIds.contains(instance.getSourceProcessId()))));
+
+ assertEquals(2, todos(childOperator, childApproval).size());
+ for (FlowRecord todo : todos(childOperator, childApproval)) {
+ approve(todo, passAction(childApproval), childOperator, todo.getFormData());
+ }
+ List resumedTodos = todos(finalOperator, finalApproval);
+ assertEquals(1, resumedTodos.size());
+ approve(resumedTodos.get(0), passAction(finalApproval), finalOperator, resumedTodos.get(0).getFormData());
+ assertTrue(factory.flowRecordRepository.get(resumedTodos.get(0).getId()).isFinish());
+ }
+
+ /**
+ * 测试目标:验证首次重置重走完成后允许再次重置(重复重置可收敛)。
+ * 前置条件:主流程经一次重置重走后再次停在最终审批待办。
+ * 执行步骤:第二次重置选中另一实例,完成后审批最终节点。
+ * 期望断言:第二次重置基于最新聚合组,历史共产生 3 个聚合组且仅最新组有效;流程正常结束。
+ */
+ @Test
+ void shouldSupportResetAgainAfterFirstResetCompletes() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord firstGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ String firstResetProcessId = firstGroup.getInstances().get(0).getProcessId();
+ String secondResetProcessId = firstGroup.getInstances().get(1).getProcessId();
+ resetSubProcess(finalTodo, List.of(firstResetProcessId), finalOperator);
+ FlowRecord firstRebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(firstRebuiltTodo, passAction(childApproval), childOperator, firstRebuiltTodo.getFormData());
+ FlowRecord secondFinalTodo = todos(finalOperator, finalApproval).get(0);
+
+ resetSubProcess(secondFinalTodo, List.of(secondResetProcessId), finalOperator);
+
+ List groups = factory.subProcessRepository
+ .findByParentProcessIdAndNodeId(parentProcessId(secondFinalTodo), subProcessNode.getId());
+ assertAll("重复重置后的聚合组链",
+ () -> assertEquals(3, groups.size()),
+ () -> assertTrue(groups.get(0).isSuperseded()),
+ () -> assertTrue(groups.get(1).isSuperseded()),
+ () -> assertFalse(groups.get(2).isSuperseded()),
+ () -> assertEquals(SubProcessRecord.State.WAITING, groups.get(2).getState()));
+ SubProcessRecord thirdGroup = groups.get(2);
+ assertEquals(secondResetProcessId,
+ thirdGroup.getInstances().stream()
+ .filter(instance -> !instance.isInherited())
+ .findFirst().orElseThrow().getSourceProcessId(),
+ "第二次重置应基于最新聚合组");
+
+ FlowRecord secondRebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(secondRebuiltTodo, passAction(childApproval), childOperator, secondRebuiltTodo.getFormData());
+ FlowRecord thirdFinalTodo = todos(finalOperator, finalApproval).get(0);
+ approve(thirdFinalTodo, passAction(finalApproval), finalOperator, thirdFinalTodo.getFormData());
+ assertTrue(factory.flowRecordRepository.get(thirdFinalTodo.getId()).isFinish());
+ }
+
+ /**
+ * 测试目标:验证重置推送的事件携带新旧聚合组与映射信息。
+ * 前置条件:主流程停在最终审批待办,事件捕获上下文已注入。
+ * 执行步骤:调用重置接口。
+ * 期望断言:恰好推送 1 条重置事件;旧组已取代、新组等待;重置记录与操作人正确;
+ * 新组重建实例的 sourceProcessId 与被重置旧实例对位。
+ */
+ @Test
+ void shouldPushResetEventWithOldAndNewGroupMapping() throws Exception {
+ initEventCapture();
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ String resetProcessId = passedGroup.getInstances().get(0).getProcessId();
+ int eventIndex = capturedEvents.size();
+
+ resetSubProcess(finalTodo, List.of(resetProcessId), finalOperator);
+
+ List resetEvents = capturedEvents.subList(eventIndex, capturedEvents.size())
+ .stream()
+ .filter(FlowSubProcessResetEvent.class::isInstance)
+ .map(FlowSubProcessResetEvent.class::cast)
+ .toList();
+ assertEquals(1, resetEvents.size(), "重置应恰好推送一条重置事件");
+ FlowSubProcessResetEvent event = resetEvents.get(0);
+ assertAll("重置事件载荷",
+ () -> assertTrue(event.getOldRecord().isSuperseded()),
+ () -> assertEquals(passedGroup.getId(), event.getOldRecord().getId()),
+ () -> assertEquals(SubProcessRecord.State.WAITING, event.getNewRecord().getState()),
+ () -> assertEquals(finalTodo.getId(), event.getResetRecordId()),
+ () -> assertEquals(finalOperator.getUserId(), event.getResetOperator().getUserId()),
+ () -> assertFalse(event.isMock()),
+ () -> assertEquals(resetProcessId, event.getNewRecord().getInstances().stream()
+ .filter(instance -> !instance.isInherited())
+ .findFirst().orElseThrow().getSourceProcessId(),
+ "事件应可完成旧 -> 新实例映射"));
+ }
+
+ /**
+ * 测试目标:验证重置后脚本查询只见当前有效组,旧组数据不重复计入。
+ * 前置条件:主场景重置完成,重建实例尚未结束。
+ * 执行步骤:完成重建实例后观察结果判定。
+ * 期望断言:结果脚本按新组(继承 + 重建共 2 条最终记录)判定通过,主流程恢复,
+ * 证明 superseded 组未进入脚本视野。
+ */
+ @Test
+ void shouldOnlyExposeCurrentGroupToScriptsAfterReset() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ resetSubProcess(finalTodo,
+ List.of(passedGroup.getInstances().get(0).getProcessId()), finalOperator);
+
+ FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData());
+
+ assertEquals(1, todos(finalOperator, finalApproval).size(),
+ "默认结果脚本按当前组(继承 + 重建)判定通过,旧组不应重复计入");
+ }
+
+ /**
+ * 测试目标:验证详情数据在可重置时携带标识字段,不可重置时不携带。
+ * 前置条件:子流程节点开启重置能力,主流程停在最终审批待办;另建未开启能力的流程。
+ * 执行步骤:分别查询两个待办的流程详情。
+ * 期望断言:开启能力且子流程已汇聚完成时详情携带 resetSubProcess=true;
+ * 未开启能力时为 false。
+ */
+ @Test
+ void shouldExposeResetFlagInDetailOnlyWhenResettable() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ FlowContent content = factory.flowService.detail(
+ new FlowDetailRequest(finalTodo.getId(), finalOperator.getUserId()));
+ assertTrue(content.isResetSubProcess(),
+ "开启重置能力且子流程已汇聚完成时,详情应携带重置标识");
+ }
+
+ /**
+ * 测试目标:验证子流程未汇聚完成(主流程等待中)时详情不携带重置标识。
+ * 前置条件:子流程节点开启重置能力,子流程实例尚未全部完成。
+ * 执行步骤:子流程进行中时查询主流程触发记录详情。
+ * 期望断言:等待中不存在可重置聚合组,标识为 false。
+ */
+ @Test
+ void shouldNotExposeResetFlagWhileSubProcessWaiting() {
+ buildParentWorkflow(true);
+ long parentStartRecordId = createAndSubmitParent();
+ assertEquals(2, todos(childOperator, childApproval).size());
+
+ FlowContent content = factory.flowService.detail(
+ new FlowDetailRequest(parentStartRecordId, initiator.getUserId()));
+ assertFalse(content.isResetSubProcess(), "子流程等待中不应携带重置标识");
+ }
+
+ /**
+ * 测试目标:验证重置后节点视图保留旧组历史并展示新组等待状态。
+ * 前置条件:主场景重置完成。
+ * 执行步骤:以旧最终审批记录查询节点视图。
+ * 期望断言:子流程节点出现两条聚合记录——旧组已取代、新组等待中;
+ * 新组含继承实例标记与重建实例映射。
+ */
+ @Test
+ void shouldShowSupersededGroupAndWaitingGroupInProcessNodeView() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ String resetProcessId = passedGroup.getInstances().get(0).getProcessId();
+ resetSubProcess(finalTodo, List.of(resetProcessId), finalOperator);
+
+ List nodes = factory.flowService.processNodes(new FlowProcessNodeRequest(
+ finalTodo.getId(), finalOperator.getUserId(), Map.of("content", "parent")));
+ List subProcessViews = nodes.stream()
+ .filter(node -> subProcessNode.getId().equals(node.getNodeId()))
+ .toList();
+ assertEquals(2, subProcessViews.size(), "新旧两个聚合组都应在节点视图中呈现");
+ ProcessNode supersededView = subProcessViews.stream()
+ .filter(node -> node.getSubProcess() != null && node.getSubProcess().isSuperseded())
+ .findFirst().orElseThrow();
+ ProcessNode waitingView = subProcessViews.stream()
+ .filter(node -> node.getSubProcess() != null && !node.getSubProcess().isSuperseded())
+ .findFirst().orElseThrow();
+ assertAll("节点视图聚合组展示",
+ () -> assertEquals("PASSED", supersededView.getSubProcess().getState().name()),
+ () -> assertEquals("WAITING", waitingView.getSubProcess().getState().name()),
+ () -> assertEquals(ProcessNode.ApproveState.PROCESSING, waitingView.getApproveState()),
+ () -> assertEquals(2, waitingView.getSubProcess().getTotalCount()),
+ () -> assertEquals(1, waitingView.getSubProcess().getFinishedCount(),
+ "继承实例已结束应计入完成数"),
+ () -> assertTrue(waitingView.getSubProcess().getInstances().stream()
+ .anyMatch(ProcessNode.SubProcessInstanceBody::isInherited)),
+ () -> assertEquals(resetProcessId, waitingView.getSubProcess().getInstances().stream()
+ .filter(instance -> !instance.isInherited())
+ .findFirst().orElseThrow().getSourceProcessId()));
+ }
+
+ /**
+ * 测试目标:验证子流程节点未开启重置能力时拒绝重置(默认关闭,业务可控)。
+ * 前置条件:子流程节点未开启重置能力,主流程停在最终审批待办。
+ * 执行步骤:调用重置接口。
+ * 期望断言:抛出状态异常,不产生新聚合组。
+ */
+ @Test
+ void shouldRejectResetWhenResettableDisabled() {
+ buildParentWorkflow(false);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ int groupCount = factory.subProcessRepository.findByParentProcessId(parentProcessId(finalTodo)).size();
+
+ List processIds = List.of(passedGroup.getInstances().get(0).getProcessId());
+ assertThrows(FlowStateException.class,
+ () -> resetSubProcess(finalTodo, processIds, finalOperator));
+ assertEquals(groupCount, factory.subProcessRepository.findByParentProcessId(parentProcessId(finalTodo)).size());
+
+ FlowContent content = factory.flowService.detail(
+ new FlowDetailRequest(finalTodo.getId(), finalOperator.getUserId()));
+ assertFalse(content.isResetSubProcess(), "未开启重置能力时详情不应携带标识");
+ }
+
+ /**
+ * 测试目标:验证选中实例不属于任何有效聚合组时拒绝重置。
+ * 前置条件:主流程停在最终审批待办。
+ * 执行步骤:以不存在的实例流程id调用重置。
+ * 期望断言:抛出参数校验异常。
+ */
+ @Test
+ void shouldRejectResetWhenSelectedInstanceNotInGroup() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ assertThrows(FlowValidationException.class,
+ () -> resetSubProcess(finalTodo, List.of("not-exist-process"), finalOperator));
+ }
+
+ /**
+ * 测试目标:验证存在等待中的聚合组时禁止重置。
+ * 前置条件:主流程停在最终审批待办;通过仓储直接补录一条等待中的聚合组模拟并行等待。
+ * 执行步骤:调用重置接口。
+ * 期望断言:抛出状态异常。
+ */
+ @Test
+ void shouldRejectResetWhenAnyGroupIsWaiting() {
+ buildParentWorkflow(true);
+ createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ factory.subProcessRepository.save(new SubProcessRecord(
+ "waiting-group", finalTodo, "other-sub-node", new ArrayList<>()));
+
+ List processIds = List.of(passedGroup.getInstances().get(0).getProcessId());
+ assertThrows(FlowStateException.class,
+ () -> resetSubProcess(finalTodo, processIds, finalOperator));
+ assertFalse(factory.subProcessRepository
+ .findByParentProcessIdAndNodeId(parentProcessId(finalTodo), subProcessNode.getId())
+ .get(0).isSuperseded(), "拒绝重置时旧组不能被取代");
+ }
+
+ /**
+ * 测试目标:验证操作人不匹配或记录非待办时拒绝重置。
+ * 前置条件:主流程停在最终审批待办。
+ * 执行步骤:分别用非当前操作人、已办结记录调用重置。
+ * 期望断言:均抛出状态异常。
+ */
+ @Test
+ void shouldRejectResetWhenOperatorMismatchOrRecordNotTodo() {
+ buildParentWorkflow(true);
+ long parentStartRecordId = createAndSubmitParent();
+ List childTodos = todos(childOperator, childApproval);
+ approve(childTodos.get(0), passAction(childApproval), childOperator, childTodos.get(0).getFormData());
+ approve(childTodos.get(1), passAction(childApproval), childOperator, childTodos.get(1).getFormData());
+ FlowRecord finalTodo = todos(finalOperator, finalApproval).get(0);
+
+ SubProcessRecord passedGroup = factory.subProcessRepository
+ .findByParentProcessId(parentProcessId(finalTodo)).get(0);
+ List processIds = List.of(passedGroup.getInstances().get(0).getProcessId());
+
+ assertThrows(FlowStateException.class,
+ () -> resetSubProcess(finalTodo, processIds, childOperator),
+ "非当前操作人不能重置");
+ assertThrows(FlowStateException.class,
+ () -> factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ parentStartRecordId, initiator.getUserId(), processIds)),
+ "已办结记录不能重置");
+ }
+
+ /**
+ * 测试目标:验证子流程后接并行分支时,合并节点锁定为第一个分支节点,
+ * 仅锁定节点的待办可重置,兄弟分支被拒绝;重置作废全部分支记录链,重走后可正常走完。
+ * 前置条件:主流程 开始 -> 子流程(可重置,单实例) -> 并行[分支1:审批 / 分支2:审批] -> 结束,
+ * 子流程放行后两个分支待办同时存在。
+ * 执行步骤:分支2待办尝试重置(拒绝)→ 分支1待办重置 → 完成重建实例 → 两个分支重新生成并依次审批。
+ * 期望断言:兄弟分支重置被拒绝且详情标识为 false;锁定节点重置成功,兄弟分支记录一并作废;
+ * 重建实例完成后两分支重新生成,流程正常结束。
+ */
+ @Test
+ void shouldLockMergeNodeOnFirstBranchWhenParallelFollowsSubProcess() {
+ StartNode start = writableStart("并行开始");
+ SubProcessNode subProcess = resettableSubProcess(singleChildScript());
+ User x1Operator = saveUser(5, "分支1审批人");
+ User x2Operator = saveUser(6, "分支2审批人");
+ ApprovalNode x1 = approvalNode("分支1审批", x1Operator);
+ ApprovalNode x2 = approvalNode("分支2审批", x2Operator);
+ ParallelBranchNode branch1 = ParallelBranchNode.builder()
+ .name("分支1").order(1).blocks(x1).build();
+ ParallelBranchNode branch2 = ParallelBranchNode.builder()
+ .name("分支2").order(2).blocks(x2).build();
+ ParallelNode parallel = ParallelNode.builder()
+ .name("并行控制").blocks(branch1, branch2).build();
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-并行下游")
+ .code(PARENT_CODE + "-parallel")
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(start)
+ .addNode(subProcess)
+ .addNode(parallel)
+ .addNode(EndNode.builder().name("并行结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+
+ submitWorkflow(PARENT_CODE + "-parallel", start, Map.of("content", "parallel"));
+ FlowRecord childTodo = todos(childOperator, childApproval).get(0);
+ approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData());
+
+ FlowRecord x1Todo = todos(x1Operator, x1).get(0);
+ FlowRecord x2Todo = todos(x2Operator, x2).get(0);
+ SubProcessRecord group = factory.subProcessRepository
+ .findByParentProcessId(x1Todo.getProcessId()).get(0);
+ List processIds = List.of(group.getInstances().get(0).getProcessId());
+
+ assertThrows(FlowStateException.class,
+ () -> factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ x2Todo.getId(), x2Operator.getUserId(), processIds)),
+ "兄弟分支不是锁定合并节点,不能重置");
+ assertFalse(factory.flowService.detail(
+ new FlowDetailRequest(x2Todo.getId(), x2Operator.getUserId())).isResetSubProcess(),
+ "兄弟分支待办详情不应携带重置标识");
+ assertTrue(factory.flowService.detail(
+ new FlowDetailRequest(x1Todo.getId(), x1Operator.getUserId())).isResetSubProcess(),
+ "锁定合并节点待办详情应携带重置标识");
+
+ factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ x1Todo.getId(), x1Operator.getUserId(), processIds));
+ assertAll("锁定节点重置后的记录链",
+ () -> assertTrue(factory.flowRecordRepository.get(x1Todo.getId()).isRevoked()),
+ () -> assertTrue(factory.flowRecordRepository.get(x2Todo.getId()).isRevoked(),
+ "重置应作废锚点之后全部分支记录链"),
+ () -> assertTrue(todos(x1Operator, x1).isEmpty()),
+ () -> assertTrue(todos(x2Operator, x2).isEmpty(), "主流程重新等待子流程"));
+
+ FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData());
+ FlowRecord x1Todo2 = todos(x1Operator, x1).get(0);
+ FlowRecord x2Todo2 = todos(x2Operator, x2).get(0);
+ approve(x1Todo2, passAction(x1), x1Operator, x1Todo2.getFormData());
+ approve(x2Todo2, passAction(x2), x2Operator, x2Todo2.getFormData());
+ assertTrue(factory.flowRecordRepository.get(x2Todo2.getId()).isFinish(),
+ "重走后并行分支汇聚流程应正常结束");
+ }
+
+ /**
+ * 测试目标:验证子流程后接条件分支时,实际命中分支的首个节点即为锁定合并节点,可重置。
+ * 前置条件:主流程 开始 -> 子流程(可重置,单实例) -> 条件[主分支:审批 / ELSE:审批] -> 结束,
+ * 以不满足主分支条件的数据发起,命中 ELSE 分支。
+ * 执行步骤:完成子流程 → ELSE 分支待办上重置 → 完成重建实例 → 再次审批 ELSE 分支。
+ * 期望断言:主分支不产生待办;ELSE 分支可重置且详情标识为 true;重走后流程正常结束。
+ */
+ @Test
+ void shouldAllowResetFromActuallyTakenConditionBranch() {
+ StartNode start = writableStart("条件开始");
+ SubProcessNode subProcess = resettableSubProcess(singleChildScript());
+ User primaryOperator = saveUser(7, "主分支审批人");
+ User elseOperator = saveUser(8, "ELSE分支审批人");
+ ApprovalNode primaryApproval = approvalNode("主分支审批", primaryOperator);
+ ApprovalNode elseApproval = approvalNode("ELSE分支审批", elseOperator);
+ ConditionBranchNode primaryBranch = ConditionBranchNode.builder()
+ .name("条件主分支").order(1)
+ .conditionScript(FlowGroovyScriptFactory.createConditionScript(
+ "def run(request){return request.getFormData('content') == 'PRIMARY'}").getKey())
+ .blocks(primaryApproval).build();
+ ConditionElseBranchNode elseBranch = ConditionElseBranchNode.builder()
+ .name("条件ELSE分支").blocks(elseApproval).build();
+ ConditionNode condition = ConditionNode.builder()
+ .name("条件").blocks(primaryBranch, elseBranch).build();
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-条件下游")
+ .code(PARENT_CODE + "-condition")
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(start)
+ .addNode(subProcess)
+ .addNode(condition)
+ .addNode(EndNode.builder().name("条件结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+
+ submitWorkflow(PARENT_CODE + "-condition", start, Map.of("content", "OTHER"));
+ FlowRecord childTodo = todos(childOperator, childApproval).get(0);
+ approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData());
+
+ assertTrue(todos(primaryOperator, primaryApproval).isEmpty(), "未命中分支不应产生待办");
+ FlowRecord elseTodo = todos(elseOperator, elseApproval).get(0);
+ assertTrue(factory.flowService.detail(
+ new FlowDetailRequest(elseTodo.getId(), elseOperator.getUserId())).isResetSubProcess(),
+ "实际命中分支的首个节点应携带重置标识");
+
+ SubProcessRecord group = factory.subProcessRepository
+ .findByParentProcessId(elseTodo.getProcessId()).get(0);
+ factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ elseTodo.getId(), elseOperator.getUserId(),
+ List.of(group.getInstances().get(0).getProcessId())));
+ assertTrue(factory.flowRecordRepository.get(elseTodo.getId()).isRevoked());
+
+ FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData());
+ FlowRecord elseTodo2 = todos(elseOperator, elseApproval).get(0);
+ approve(elseTodo2, passAction(elseApproval), elseOperator, elseTodo2.getFormData());
+ assertTrue(factory.flowRecordRepository.get(elseTodo2.getId()).isFinish(),
+ "重走后条件分支流程应正常结束");
+ }
+
+ /**
+ * 测试目标:验证重置仅允许在锁定合并节点发起,合并节点之后的更深层节点不可重置。
+ * 前置条件:主流程 开始 -> 子流程(可重置,单实例) -> 第一审批 -> 第二审批 -> 结束,
+ * 流程已推进到第二审批待办。
+ * 执行步骤:在第二审批待办上调用重置。
+ * 期望断言:抛出状态异常,详情标识为 false。
+ */
+ @Test
+ void shouldRejectResetFromNodeDeeperThanLockedMergeNode() {
+ StartNode start = writableStart("深层开始");
+ SubProcessNode subProcess = resettableSubProcess(singleChildScript());
+ User firstOperator = saveUser(9, "第一审批人");
+ User secondOperator = saveUser(10, "第二审批人");
+ ApprovalNode firstApproval = approvalNode("第一审批", firstOperator);
+ ApprovalNode secondApproval = approvalNode("第二审批", secondOperator);
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-深层节点")
+ .code(PARENT_CODE + "-deep")
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(start)
+ .addNode(subProcess)
+ .addNode(firstApproval)
+ .addNode(secondApproval)
+ .addNode(EndNode.builder().name("深层结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+
+ submitWorkflow(PARENT_CODE + "-deep", start, Map.of("content", "deep"));
+ FlowRecord childTodo = todos(childOperator, childApproval).get(0);
+ approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData());
+
+ FlowRecord firstTodo = todos(firstOperator, firstApproval).get(0);
+ assertTrue(factory.flowService.detail(
+ new FlowDetailRequest(firstTodo.getId(), firstOperator.getUserId())).isResetSubProcess(),
+ "锁定合并节点(第一审批)应携带重置标识");
+ approve(firstTodo, passAction(firstApproval), firstOperator, firstTodo.getFormData());
+
+ FlowRecord secondTodo = todos(secondOperator, secondApproval).get(0);
+ SubProcessRecord group = factory.subProcessRepository
+ .findByParentProcessId(secondTodo.getProcessId()).stream()
+ .filter(record -> !record.isSuperseded())
+ .findFirst().orElseThrow();
+ assertThrows(FlowStateException.class,
+ () -> factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ secondTodo.getId(), secondOperator.getUserId(),
+ List.of(group.getInstances().get(0).getProcessId()))),
+ "合并节点之后的更深层节点不能重置");
+ assertFalse(factory.flowService.detail(
+ new FlowDetailRequest(secondTodo.getId(), secondOperator.getUserId())).isResetSubProcess());
+ }
+
+ /**
+ * 测试目标:验证子流程与合并节点之间存在抄送节点时,抄送记录不影响锁定合并节点判定。
+ * 前置条件:主流程 开始 -> 子流程(可重置,单实例) -> 抄送 -> 审批 -> 结束。
+ * 执行步骤:完成子流程 → 审批待办上重置 → 完成重建实例 → 审批。
+ * 期望断言:抄送记录产生但不参与锁定判定,审批节点可重置;重走后流程正常结束。
+ */
+ @Test
+ void shouldAllowResetWhenNotifyNodeBeforeMergeNode() {
+ StartNode start = writableStart("抄送开始");
+ SubProcessNode subProcess = resettableSubProcess(singleChildScript());
+ User notifyOperator = saveUser(11, "抄送接收人");
+ NotifyNode notify = NotifyNode.builder()
+ .name("抄送")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(readonlyPermission())
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript(
+ "def run(request){return [" + notifyOperator.getUserId() + "]}").getKey()))
+ .build())
+ .build();
+ ApprovalNode approval = approvalNode("抄送后审批", finalOperator);
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-抄送下游")
+ .code(PARENT_CODE + "-notify")
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(start)
+ .addNode(subProcess)
+ .addNode(notify)
+ .addNode(approval)
+ .addNode(EndNode.builder().name("抄送结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+
+ submitWorkflow(PARENT_CODE + "-notify", start, Map.of("content", "notify"));
+ FlowRecord childTodo = todos(childOperator, childApproval).get(0);
+ approve(childTodo, passAction(childApproval), childOperator, childTodo.getFormData());
+
+ FlowRecord approvalTodo = todos(finalOperator, approval).get(0);
+ assertTrue(factory.flowService.detail(
+ new FlowDetailRequest(approvalTodo.getId(), finalOperator.getUserId())).isResetSubProcess(),
+ "抄送节点直通后,锁定合并节点为审批节点");
+
+ SubProcessRecord group = factory.subProcessRepository
+ .findByParentProcessId(approvalTodo.getProcessId()).get(0);
+ factory.flowService.resetSubProcess(new FlowSubProcessResetRequest(
+ approvalTodo.getId(), finalOperator.getUserId(),
+ List.of(group.getInstances().get(0).getProcessId())));
+ assertTrue(factory.flowRecordRepository.get(approvalTodo.getId()).isRevoked());
+
+ FlowRecord rebuiltTodo = todos(childOperator, childApproval).get(0);
+ approve(rebuiltTodo, passAction(childApproval), childOperator, rebuiltTodo.getFormData());
+ FlowRecord approvalTodo2 = todos(finalOperator, approval).get(0);
+ approve(approvalTodo2, passAction(approval), finalOperator, approvalTodo2.getFormData());
+ assertTrue(factory.flowRecordRepository.get(approvalTodo2.getId()).isFinish(),
+ "重走后流程应正常结束");
+ }
+
+ // ==================== 流程构建与操作辅助 ====================
+
+ private SubProcessNode resettableSubProcess(String script) {
+ return SubProcessNode.builder()
+ .name("可重置子流程")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(new SubProcessStrategy(
+ FlowGroovyScriptFactory.createSubProcessScript(script).getKey(),
+ true,
+ SubProcessResultScript.defaultScript().getScript(),
+ false,
+ true))
+ .build())
+ .build();
+ }
+
+ private String singleChildScript() {
+ return """
+ def run(request){
+ return request.toCreateRequest('%s', %d, '%s', [content:'only-child'])
+ }
+ """.formatted(CHILD_CODE, initiator.getUserId(), passAction(childStart).id());
+ }
+
+ private long submitWorkflow(String code, StartNode start, Map data) {
+ FlowCreateRequest request = new FlowCreateRequest();
+ request.setWorkCode(code);
+ request.setOperatorId(initiator.getUserId());
+ request.setActionId(passAction(start).id());
+ request.setFormData(data);
+ long recordId = factory.flowService.create(request);
+ approve(factory.flowRecordRepository.get(recordId), passAction(start), initiator, data);
+ return recordId;
+ }
+
+ private void buildParentWorkflow(boolean resettable) {
+ parentStart = writableStart("主流程开始");
+ subProcessNode = SubProcessNode.builder()
+ .name("批量子流程")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(new SubProcessStrategy(
+ FlowGroovyScriptFactory.createSubProcessScript(createScript()).getKey(),
+ true,
+ SubProcessResultScript.defaultScript().getScript(),
+ false,
+ resettable))
+ .build())
+ .build();
+ finalApproval = approvalNode("最终审批", finalOperator);
+ Workflow parentWorkflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-主流程")
+ .code(PARENT_CODE)
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(parentStart)
+ .addNode(subProcessNode)
+ .addNode(finalApproval)
+ .addNode(EndNode.builder().name("主流程结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(parentWorkflow);
+ }
+
+ private String createScript() {
+ return """
+ def run(request){
+ return [
+ request.toCreateRequest('%s', %d, '%s', [content:'child-1']),
+ request.toCreateRequest('%s', %d, '%s', [content:'child-2'])
+ ]
+ }
+ """.formatted(CHILD_CODE, initiator.getUserId(), passAction(childStart).id(),
+ CHILD_CODE, initiator.getUserId(), passAction(childStart).id());
+ }
+
+ private void saveChildWorkflow() {
+ childStart = writableStart("子流程开始");
+ childApproval = approvalNode("子流程审批", childOperator);
+ Workflow childWorkflow = WorkflowBuilder.builder()
+ .title("子流程重置测试-子流程")
+ .code(CHILD_CODE)
+ .createdOperator(initiator)
+ .form(form)
+ .addNode(childStart)
+ .addNode(childApproval)
+ .addNode(EndNode.builder().name("子流程结束").build())
+ .build();
+ factory.workflowService.saveWorkflow(childWorkflow);
+ }
+
+ private long createAndSubmitParent() {
+ Map data = Map.of("content", "parent");
+ FlowCreateRequest request = new FlowCreateRequest();
+ request.setWorkCode(PARENT_CODE);
+ request.setOperatorId(initiator.getUserId());
+ request.setActionId(passAction(parentStart).id());
+ request.setFormData(data);
+ long recordId = factory.flowService.create(request);
+ approve(factory.flowRecordRepository.get(recordId), passAction(parentStart), initiator, data);
+ return recordId;
+ }
+
+ private void resetSubProcess(FlowRecord record, List processIds, User operator) {
+ factory.flowService.resetSubProcess(
+ new FlowSubProcessResetRequest(record.getId(), operator.getUserId(), processIds));
+ }
+
+ private void approve(FlowRecord record, IFlowAction action, User operator, Map data) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setRecordId(record.getId());
+ request.setFormData(data);
+ request.setAdvice(new FlowAdviceBody(action.id(), "同意", operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private List todos(User operator, IFlowNode node) {
+ return factory.flowRecordRepository.findTodoByOperator(operator.getUserId()).stream()
+ .filter(record -> record.getNodeId().equals(node.getId()))
+ .toList();
+ }
+
+ private IFlowAction passAction(IFlowNode node) {
+ return node.actionManager().getActions().stream()
+ .filter(action -> "PASS".equals(action.type()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private String parentProcessId(FlowRecord record) {
+ return record.getProcessId();
+ }
+
+ private StartNode writableStart(String name) {
+ return StartNode.builder()
+ .name(name)
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission(FORM_CODE, "content", PermissionType.WRITE)
+ .build()))
+ .build())
+ .build();
+ }
+
+ private ApprovalNode approvalNode(String name, User operator) {
+ String script = "def run(request){return [" + operator.getUserId() + "]}";
+ return ApprovalNode.builder()
+ .name(name)
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(readonlyPermission())
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript(script).getKey()))
+ .build())
+ .build();
+ }
+
+ private FormFieldPermissionStrategy readonlyPermission() {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission(FORM_CODE, "content", PermissionType.READ)
+ .build());
+ }
+
+ private User saveUser(long id, String name) {
+ User user = new User(id, name);
+ factory.userGateway.save(user);
+ return user;
+ }
+
+ private void initEventCapture() throws Exception {
+ ApplicationContext mockContext = Mockito.mock(ApplicationContext.class);
+ Mockito.doAnswer(invocation -> {
+ DomainEvent domainEvent = invocation.getArgument(0);
+ capturedEvents.add(domainEvent.getEvent());
+ return null;
+ }).when(mockContext).publishEvent(Mockito.any(ApplicationEvent.class));
+ new SpringEventInitializer(mockContext).afterPropertiesSet();
+ }
+}
diff --git a/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/FlowRecordController.java b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/FlowRecordController.java
index 3244bb5e..3b36fe1c 100644
--- a/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/FlowRecordController.java
+++ b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/FlowRecordController.java
@@ -112,4 +112,18 @@ public SingleResponse action(@RequestBody FlowActionRequest requ
return SingleResponse.of(response);
}
+ /**
+ * 子流程数据重置(独立接口,不属于常规审批动作)。
+ * 仅当子流程节点开启重置能力且当前记录位于其下游时可调用,
+ * 详情数据中的 {@code resetSubProcess} 标识字段表明当前记录是否具备该能力。
+ */
+ @PostMapping("/subProcess/reset")
+ public Response resetSubProcess(@RequestBody FlowSubProcessResetRequest request) {
+ FlowService flowService = this.loadFlowService();
+ long operatorId = loadCurrentOperatorId();
+ request.setOperatorId(operatorId);
+ flowService.resetSubProcess(request);
+ return Response.buildSuccess();
+ }
+
}
diff --git a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertor.java b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertor.java
index 6aba4a91..69dd018f 100644
--- a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertor.java
+++ b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertor.java
@@ -40,7 +40,8 @@ public static SubProcessRecord convert(SubProcessRecordEntity entity) {
new ArrayList<>(instances),
SubProcessRecord.State.valueOf(entity.getState()),
entity.getCreateTime(),
- entity.getFinishTime());
+ entity.getFinishTime(),
+ Boolean.TRUE.equals(entity.getSuperseded()));
}
/**
@@ -76,6 +77,7 @@ public static SubProcessRecordEntity convert(SubProcessRecord record, SubProcess
entity.setState(record.getState().name());
entity.setCreateTime(record.getCreateTime());
entity.setFinishTime(record.getFinishTime());
+ entity.setSuperseded(record.isSuperseded());
return entity;
}
}
diff --git a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/entity/SubProcessRecordEntity.java b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/entity/SubProcessRecordEntity.java
index b9de6b28..a7ea8ebe 100644
--- a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/entity/SubProcessRecordEntity.java
+++ b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/entity/SubProcessRecordEntity.java
@@ -94,6 +94,12 @@ public class SubProcessRecordEntity {
@Column(nullable = false)
private Long finishTime;
+ /**
+ * 是否已被重置取代;存量数据为 null 时按未取代处理
+ */
+ @Column
+ private Boolean superseded;
+
/**
* 乐观锁版本号(由 JPA {@link Version} 自动维护)
*/
diff --git a/flow-engine-starter-infra/src/test/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertorTest.java b/flow-engine-starter-infra/src/test/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertorTest.java
index 9d09ce15..69c23409 100644
--- a/flow-engine-starter-infra/src/test/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertorTest.java
+++ b/flow-engine-starter-infra/src/test/java/com/codingapi/flow/infra/convert/SubProcessRecordConvertorTest.java
@@ -6,7 +6,9 @@
import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -56,4 +58,61 @@ void shouldRoundTripAndKeepManagedEntityVersion() {
assertEquals(SubProcessRecord.InstanceState.RUNNING, record.getInstances().get(1).getState());
assertTrue(converted.getInstances().contains("采购审批子流程"), "新记录应在实例 JSON 中保存流程名称");
}
+
+ /**
+ * 测试目标:验证重置相关字段(已取代标记、继承实例、重建映射)的转换与存量兼容性。
+ * 前置条件:持久化对象携带已取代标记与含重置字段的实例;另有一条不含新字段的存量数据。
+ * 执行步骤:转换为领域对象后回写持久化对象。
+ * 期望断言:新字段完整往返;存量数据(无新字段)按未取代、非继承读取。
+ */
+ @Test
+ void shouldRoundTripResetFieldsAndKeepLegacyCompatibility() {
+ SubProcessRecordEntity entity = new SubProcessRecordEntity();
+ entity.setId(11L);
+ entity.setGroupId("group-reset");
+ entity.setParentProcessId("parent-process");
+ entity.setParentRecordId(20L);
+ entity.setParentWorkRuntimeId(30L);
+ entity.setNodeId("sub-node");
+ entity.setTotalCount(2);
+ entity.setInstances("""
+ [{"startRecordId":1,"processId":"child-1","finishRecordId":11,"state":"FINISHED","finishTime":100,"inherited":true},
+ {"startRecordId":3,"processId":"child-3","finishRecordId":0,"state":"RUNNING","finishTime":0,"sourceProcessId":"child-2"}]
+ """);
+ entity.setState(SubProcessRecord.State.WAITING.name());
+ entity.setCreateTime(90L);
+ entity.setFinishTime(0L);
+ entity.setSuperseded(true);
+
+ SubProcessRecord record = SubProcessRecordConvertor.convert(entity);
+ assertAll("重置字段读取",
+ () -> assertTrue(record.isSuperseded()),
+ () -> assertTrue(record.getInstances().get(0).isInherited()),
+ () -> assertEquals("child-2", record.getInstances().get(1).getSourceProcessId()));
+
+ SubProcessRecordEntity converted = SubProcessRecordConvertor.convert(record, entity);
+ assertEquals(Boolean.TRUE, converted.getSuperseded());
+ assertTrue(converted.getInstances().contains("sourceProcessId"), "实例 JSON 应保存重建映射字段");
+
+ SubProcessRecordEntity legacy = new SubProcessRecordEntity();
+ legacy.setId(12L);
+ legacy.setGroupId("group-legacy");
+ legacy.setParentProcessId("parent-process");
+ legacy.setParentRecordId(20L);
+ legacy.setParentWorkRuntimeId(30L);
+ legacy.setNodeId("sub-node");
+ legacy.setTotalCount(1);
+ legacy.setInstances("""
+ [{"startRecordId":1,"processId":"child-1","finishRecordId":11,"state":"FINISHED","finishTime":100}]
+ """);
+ legacy.setState(SubProcessRecord.State.PASSED.name());
+ legacy.setCreateTime(90L);
+ legacy.setFinishTime(100L);
+
+ SubProcessRecord legacyRecord = SubProcessRecordConvertor.convert(legacy);
+ assertAll("存量数据兼容",
+ () -> assertFalse(legacyRecord.isSuperseded(), "无已取代标记的存量数据按未取代处理"),
+ () -> assertFalse(legacyRecord.getInstances().get(0).isInherited()),
+ () -> assertNull(legacyRecord.getInstances().get(0).getSourceProcessId()));
+ }
}
diff --git a/flow-frontend b/flow-frontend
index a5c83b23..7a2906ec 160000
--- a/flow-frontend
+++ b/flow-frontend
@@ -1 +1 @@
-Subproject commit a5c83b236542bd5cb4dda72488a467fe346bc60b
+Subproject commit 7a2906ec6e7c238a7e6cf60cc88976cf1136a128
From 3921c095d852b4160665de54d9bc3cdadf0e735f Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Mon, 24 Aug 2026 19:49:54 +0800
Subject: [PATCH 2/2] Update flow-frontend to latest main
---
flow-frontend | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flow-frontend b/flow-frontend
index 7a2906ec..31a0a0f1 160000
--- a/flow-frontend
+++ b/flow-frontend
@@ -1 +1 @@
-Subproject commit 7a2906ec6e7c238a7e6cf60cc88976cf1136a128
+Subproject commit 31a0a0f1443b031adeed1ad39d5381aaf55b0487