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..31a0a0f1 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit a5c83b236542bd5cb4dda72488a467fe346bc60b +Subproject commit 31a0a0f1443b031adeed1ad39d5381aaf55b0487