diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java index 475e3d7..349b11b 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java @@ -1,6 +1,6 @@ package com.yunhai.flowcenter.controller.api; -import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java index 880e828..4ecdbdb 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java @@ -81,12 +81,13 @@ public class FlowUserController { return queryService.detail(schId, instance.getId(), user.getId()); } - /** 同意(opinion 可选)——一切审批入口的收敛点 */ + /** 同意(opinion 可选;多出口节点必须带 edgeName=所选出口名,单出口可省)——一切审批入口的收敛点 */ @PostMapping("/task/{id}/approve") public void approve(BoaUser user, @PathVariable("id") Long id, @RequestBody(required = false) Map body) { String opinion = body == null ? null : body.get("opinion"); - taskService.handle(requireSchId(user), id, true, opinion, user.getId()); + String edgeName = body == null ? null : body.get("edgeName"); + taskService.handle(requireSchId(user), id, true, opinion, user.getId(), edgeName); } /** 拒绝(opinion 必填) */ @@ -97,6 +98,26 @@ public class FlowUserController { taskService.handle(requireSchId(user), id, false, opinion, user.getId()); } + /** 退回(targetNodeId 可选=缺省回最近已办节点;opinion 可选) */ + @PostMapping("/task/{id}/returnBack") + public void returnBack(BoaUser user, @PathVariable("id") Long id, + @RequestBody(required = false) Map body) { + String targetNodeId = body == null ? null : body.get("targetNodeId"); + String opinion = body == null ? null : body.get("opinion"); + taskService.returnBack(requireSchId(user), id, targetNodeId, opinion, user.getId()); + } + + /** 移交(toUserId 必填;reason 可选) */ + @PostMapping("/task/{id}/transfer") + public void transfer(BoaUser user, @PathVariable("id") Long id, + @RequestBody Map body) { + Integer toUserId = body.get("toUserId") == null ? null + : Integer.valueOf(String.valueOf(body.get("toUserId"))); + Object reason = body.get("reason"); + taskService.transfer(requireSchId(user), id, toUserId, + reason == null ? null : String.valueOf(reason), user.getId()); + } + /** 撤回(提交人;仅尚无任务被办理时) */ @PostMapping("/instance/{id}/withdraw") public void withdraw(BoaUser user, @PathVariable("id") Long id) { diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java index da54648..49cf532 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java @@ -3,7 +3,7 @@ package com.yunhai.flowcenter.engine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import lombok.RequiredArgsConstructor; @@ -61,4 +61,26 @@ public class AssigneeResolver { return ""; } } + + /** + * 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反): + * 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。 + * UserBase 不可达与"人不存在"分别报错,便于排查。 + */ + public String requireUserName(Integer userId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象"); + } + BoaUser user; + try { + user = userClient.getUserById(String.valueOf(userId)); + } catch (Exception e) { + log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage()); + throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId); + } + if (user == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId); + } + return user.getName() == null ? "" : user.getName(); + } } diff --git a/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java b/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java new file mode 100644 index 0000000..29fe2af --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/BranchScope.java @@ -0,0 +1,57 @@ +package com.yunhai.flowcenter.engine; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; + +/** + * 分支归属判定:节点属于哪个并行分支(branchKey=PARALLEL_SPLIT 出边目标节点 id), + * 主线节点与汇聚点之后属 null。运行时用于推导分支上下文({@link FlowEngine}), + * 发布期用于拒绝跨分支连线({@link DefValidator})——两处必须同口径,故提取为静态工具。 + */ +public final class BranchScope { + + private BranchScope() { + } + + /** + * 沿入边反向 BFS 走到最近的上游 PARALLEL_SPLIT,返回该 split 指向本链的 outEdge.to(即分支键); + * 遇 START/PARALLEL_JOIN 边界即停(join 之后属汇聚链,分支键已消耗,返回 null)。 + */ + public static String keyOf(FlowDefModel model, String nodeId) { + if (model == null || nodeId == null) { + return null; + } + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + queue.add(nodeId); + visited.add(nodeId); + while (!queue.isEmpty()) { + String cur = queue.poll(); + for (FlowDefModel.Edge e : model.inEdges(cur)) { + FlowDefModel.Node from = model.node(e.from); + if (from == null) { + continue; + } + if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) { + return e.to; + } + if (FlowDefModel.TYPE_START.equals(from.type) + || FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type) + || !visited.add(e.from)) { + continue; + } + queue.add(e.from); + } + } + return null; + } + + /** 两节点是否同属一个分支上下文(含"都属主线"=都属于 null) */ + public static boolean sameScope(FlowDefModel model, String a, String b) { + String ka = keyOf(model, a); + String kb = keyOf(model, b); + return ka == null ? kb == null : ka.equals(kb); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index 9e3bf54..80cc260 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -87,12 +87,19 @@ public final class DefValidator { for (FlowDefModel.Node n : model.nodes) { int out = model.outEdges(n.id).size(); int in = model.inEdges(n.id).size(); - if (FlowDefModel.TYPE_START.equals(n.type) || FlowDefModel.TYPE_APPROVAL.equals(n.type) + if (FlowDefModel.TYPE_START.equals(n.type) || FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) { if (out != 1) { problems.add(nodeLabel(n) + " 出边必须恰为 1 条(当前 " + out + ")"); } } + if (FlowDefModel.TYPE_APPROVAL.equals(n.type)) { + if (out < 1) { + problems.add(nodeLabel(n) + " 出边至少 1 条"); + } else if (out > 1) { + validateOutlets(model, n, problems); + } + } if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type) && out < 1) { problems.add(nodeLabel(n) + " 出边至少 1 条"); } @@ -134,6 +141,29 @@ public final class DefValidator { } } + /** + * 多出口节点校验(办理人按出口名选路):每个出口必须命名且同节点内唯一(名即办理按钮文案); + * 出口目标必须与本节点同属一个分支上下文——跨分支连线依赖运行时的跨分支移动,一期未实现, + * 在发布期明确拒绝而非运行时静默走错边。 + */ + private static void validateOutlets(FlowDefModel model, FlowDefModel.Node n, List problems) { + Set names = new HashSet<>(); + for (FlowDefModel.Edge e : model.outEdges(n.id)) { + if (e.name == null || e.name.trim().isEmpty()) { + problems.add(nodeLabel(n) + " 有 " + model.outEdges(n.id).size() + + " 条出边,每个出口必须命名(名称即办理按钮文案)"); + continue; + } + if (!names.add(e.name.trim())) { + problems.add(nodeLabel(n) + " 出口名称重复:" + e.name + "(同一节点的出口名必须唯一)"); + } + if (!BranchScope.sameScope(model, n.id, e.to)) { + problems.add(nodeLabel(n) + " 的出口「" + e.name + "」指向另一并行分支内的节点(" + + e.to + "):暂不支持跨分支连线,可改用并行分支编排"); + } + } + } + private static void validateApproval(FlowDefModel.Node n, Set enabledDeptKeys, List problems) { FlowDefModel.Assignee a = n.assignee; if (a == null || a.type == null) { diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java index 83a14e3..294ecf7 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java @@ -2,6 +2,7 @@ package com.yunhai.flowcenter.engine; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; @@ -27,9 +28,15 @@ public class FlowAdvanceService { private final FlwTaskRepository taskRepository; private final FlowEngine flowEngine; - /** @return 是否实际推进了 */ + /** + * @param nodeId 待放行节点 + * @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点 + * 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查, + * 事件传参只是快路径,落库的值才是权威) + * @return 是否实际推进了 + */ @Transactional(propagation = Propagation.REQUIRES_NEW) - public boolean tryAdvance(Long instanceId, String nodeId) { + public boolean tryAdvance(Long instanceId, String nodeId, String edgeName) { FlwInstance instance = instanceRepository.findById(instanceId).orElse(null); if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { @@ -55,7 +62,17 @@ public class FlowAdvanceService { instanceId, instance.getDefId()); return false; } - flowEngine.continueAdvance(instance, def, nodeId); + String chosen = edgeName; + if (chosen == null) { + chosen = taskRepository.findByInstanceIdAndStatus(instanceId, + FlwEnums.TaskStatus.APPROVED.getCode()).stream() + .filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null) + .max(java.util.Comparator.comparing( + FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))) + .map(FlwTask::getEdgeName) + .orElse(null); + } + flowEngine.continueAdvance(instance, def, nodeId, chosen); instanceRepository.save(instance); return true; } diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java index 55b32c1..8d10a79 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -6,6 +6,7 @@ import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import java.util.List; +import java.util.Map; /** * 流程定义 JSON 模型(概览 §5.1 规范,一期 5 节点)。 @@ -38,6 +39,11 @@ public class FlowDefModel { public List nodes; public List edges; public Settings settings; + /** + * 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。 + * 缺失时前端自行做自动布局,故可选、可空。 + */ + public Map layout; @JsonIgnoreProperties(ignoreUnknown = true) public static class Node { @@ -65,7 +71,20 @@ public class FlowDefModel { public static class Edge { public String from; public String to; + /** + * 出边名(按钮文案)。单出口可空;多出口节点必填且同节点内唯一, + * 办理人在办理页选它决定走哪条边(见 {@link #edgeByName})。 + */ public String name; + /** 出口条件(二期条件自动路由预留;一期只校验透传,引擎不消费) */ + public String condition; + } + + /** 画布坐标(前端设计器专用) */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Layout { + public Integer x; + public Integer y; } @JsonIgnoreProperties(ignoreUnknown = true) @@ -135,4 +154,17 @@ public class FlowDefModel { } return in; } + + /** 按出边名取边(多出口节点路由用);未命中返回 null */ + public Edge edgeByName(String fromNodeId, String name) { + if (name == null) { + return null; + } + for (Edge e : outEdges(fromNodeId)) { + if (name.equals(e.name)) { + return e; + } + } + return null; + } } diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java index eeb615a..a7719cb 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java @@ -14,13 +14,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; -import java.util.ArrayDeque; -import java.util.Deque; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; /** * 流程引擎推进核心(概览 §5.2 激活规则):由调用方事务驱动—— @@ -50,19 +46,30 @@ public class FlowEngine { } /** - * 某节点全部同意后的放行推进:移出活跃节点集并激活唯一出边目标。 - * 分支上下文由 {@link #branchKeyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。 + * 某节点全部同意后的放行推进:移出活跃节点集并激活出边目标。 + * 单出边走唯一出边(旧定义逐字节兼容);多出边按办理人选的出口名(edgeName,即办理按钮文案) + * 选路——未命中时记录错误并保持节点在途,绝不静默走第一条边。 + * 分支上下文由 {@link BranchScope#keyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。 */ - public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId) { + public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId, String edgeName) { FlowDefModel model = parseModel(def); FlowDefModel.Node node = model.nodeById(fromNodeId); List outs = model.outEdges(fromNodeId); if (outs.isEmpty()) { return; } + FlowDefModel.Edge chosen = outs.get(0); + if (outs.size() > 1) { + chosen = model.edgeByName(fromNodeId, edgeName); + if (chosen == null) { + log.error("[flow-center] 多出口节点未命中出口,推进中止(节点保持在途):instance={} node={} edgeName={}", + instance.getId(), fromNodeId, edgeName); + return; + } + } removeCurrentNode(instance, fromNodeId); - String branchKey = branchKeyOf(model, fromNodeId); - String next = outs.get(0).to; + String branchKey = BranchScope.keyOf(model, fromNodeId); + String next = chosen.to; FlowDefModel.Node nextNode = model.nodeById(next); if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(nextNode.type)) { // 会签合并守门:标记本分支到达、全部到达才放行(直通无分支键时 JOIN 直通放行) @@ -73,34 +80,15 @@ public class FlowEngine { } /** - * 回溯 fromNodeId 所在分支的 branchKey(= PARALLEL_SPLIT 出边目标节点 id): - * 沿入边反向走到 split,split→N 边的 N 即分支键;遇 START/PARALLEL_JOIN 边界即停 - * (join 之后属汇聚链,branchKey 已消耗,返回 null)。 + * 退回目标激活(FlowTaskService.returnBack 调用):从 currentNodes 摘除来源节点, + * 重新激活目标审批节点建新任务。沿用统一激活入口,split 幂等化后重入并行段不撞分支唯一约束。 */ - private String branchKeyOf(FlowDefModel model, String fromNodeId) { - Set visited = new HashSet<>(); - Deque queue = new ArrayDeque<>(); - queue.add(fromNodeId); - visited.add(fromNodeId); - while (!queue.isEmpty()) { - String cur = queue.poll(); - for (FlowDefModel.Edge e : model.inEdges(cur)) { - FlowDefModel.Node from = model.node(e.from); - if (from == null) { - continue; - } - if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) { - return e.to; - } - if (FlowDefModel.TYPE_START.equals(from.type) - || FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type) - || !visited.add(e.from)) { - continue; - } - queue.add(e.from); - } - } - return null; + public void returnTo(FlwInstance instance, FlwDef def, String fromNodeId, String targetNodeId) { + FlowDefModel model = parseModel(def); + removeCurrentNode(instance, fromNodeId); + FlowDefModel.Node target = model.nodeById(targetNodeId); + String branchKey = BranchScope.keyOf(model, targetNodeId); + advance(instance, model, target.id, branchKey); } /** 统一激活入口:含 JOIN 的到达标记与放行判断 */ @@ -148,10 +136,17 @@ public class FlowEngine { break; case FlowDefModel.TYPE_PARALLEL_SPLIT: { for (FlowDefModel.Edge edge : model.outEdges(node.id)) { - FlwBranch branch = new FlwBranch(); - branch.setInstanceId(instance.getId()); - branch.setBranchKey(edge.to); - branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode()); + // 幂等 upsert:退回重入并行段时分支行已存在(uk instance+branchKey),重置进行中即可 + FlwBranch branch = branchRepository + .findByInstanceIdAndBranchKey(instance.getId(), edge.to).orElse(null); + if (branch == null) { + branch = new FlwBranch(); + branch.setInstanceId(instance.getId()); + branch.setBranchKey(edge.to); + branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode()); + } else { + branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode()); + } branchRepository.save(branch); advance(instance, model, edge.to, edge.to); } diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java index 8898ec5..0692b69 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java @@ -28,6 +28,10 @@ public class FlowEvent { REJECTED("flow.rejected"), /** 提交人撤回,instance=40 */ WITHDRAWN("flow.withdrawn"), + /** 办理人退回上游节点(办件仍在途,instance 仍=10) */ + RETURNED("flow.returned"), + /** 办理人移交办理权给他人(任务换人,办件仍在途) */ + TRANSFERRED("flow.transferred"), /** 催办(仅 UserMsg 通道,非回调事件枚举) */ URGE("flow.urge"); diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java index 6efb90f..334de02 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java @@ -30,9 +30,10 @@ public class FlowNodeAdvanceListener { if (nodeId == null) { return; } + String edgeName = (String) event.getData().get("edgeName"); for (int attempt = 1; attempt <= 3; attempt++) { try { - advanceService.tryAdvance(event.getInstanceId(), nodeId); + advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName); return; } catch (ObjectOptimisticLockingFailureException e) { log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}", diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java index 6012684..b1ff952 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java @@ -47,7 +47,7 @@ public class FlwTask extends BaseEntity { @Column(name = "assignee_name", length = 128) private String assigneeName; - /** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 */ + /** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 / 4 已退回 / 5 已移交 */ @Column(name = "status", nullable = false) private Integer status = FlwEnums.TaskStatus.PENDING.getCode(); @@ -55,6 +55,13 @@ public class FlwTask extends BaseEntity { @Column(name = "opinion", length = 500) private String opinion; + /** + * 多出口节点办理时选中的出口名(对应 def 边 name);单出口节点恒为 null。 + * 节点放行推进时按它决定走哪条边。 + */ + @Column(name = "edge_name", length = 64) + private String edgeName; + @Column(name = "arrive_time", nullable = false) private LocalDateTime arriveTime; diff --git a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java index 1f52ef7..a3167ed 100644 --- a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java +++ b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java @@ -23,9 +23,13 @@ public final class FlwEnums { } } - /** 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标) */ + /** + * 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标) + * / 4 已退回(办理人把流程退回上游节点)/ 5 已移交(办理人把办理权转给他人)。 + * 4/5 都算"已被办理":既进我的已办,也阻止提交人撤回。 + */ public enum TaskStatus { - PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3); + PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3), RETURNED(4), TRANSFERRED(5); private final int code; @@ -36,6 +40,12 @@ public final class FlwEnums { public int getCode() { return code; } + + /** "已被办理"状态集(本人已办列表、撤回守卫共用口径),勿在各处硬编码字面量 */ + public static java.util.List handledCodes() { + return java.util.Arrays.asList(APPROVED.getCode(), REJECTED.getCode(), + RETURNED.getCode(), TRANSFERRED.getCode()); + } } /** 并行分支状态:0 进行中 / 1 已到达汇聚点 / 2 已失效 */ diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java index 0a93751..61c4e1b 100644 --- a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java @@ -76,6 +76,15 @@ public final class FlowModels { /** 当前用户可否办理(存在本人的待办任务) */ private boolean myPendingTask; private List myPendingTaskIds; + /** + * 本人待办所在节点的出口列表(多出口办理按钮,[{name,toNodeId,toNodeName}]); + * 单出口/无待办为空数组——前端据此决定是否显示去向选择器。 + */ + private List> outlets; + /** 可退回目标(主线已办审批节点去重,排除当前节点)[{nodeId,nodeName}] */ + private List> returnTargets; + /** 本人待办可否移交(恒=有本人待办,展示用) */ + private boolean canTransfer; } /** 流程示意图出参(用户面 /diagram 与管理面 /admin/def/diagram 共用,方案 §3.1) */ @@ -91,9 +100,11 @@ public final class FlowModels { private String mode; private List nodes; private List edges; + /** 画布坐标(nodeId→{x,y},设计器保存时透传;空=查看器自行自动布局) */ + private Map> layout; } - /** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析) */ + /** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析;state 仅 instance 口径有值) */ @Data public static class NodeVo { private String id; @@ -104,12 +115,18 @@ public final class FlowModels { private String assigneeType; private String countersign; private Integer deadlineHours; + /** + * 运行时状态(仅 instanceId 口径):done 已办过 / active 当前在办 / pending 未到 / + * rejected 被拒绝 / 其他为 null。flowGuid 口径恒 null。 + */ + private String state; } - /** 示意图连边 */ + /** 示意图连边(name=出口文案,多出口节点显示为连线标签) */ @Data public static class EdgeVo { private String from; private String to; + private String name; } } diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java index b301ca3..6c421aa 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java @@ -1,9 +1,12 @@ package com.yunhai.flowcenter.notify; import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.PlatformService; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.openapi.msg.YschMsgOpenApi; +import com.yunhai.platform.openapi.msg.model.SendMsgRequest; +import com.yunhai.platform.openapi.msg.model.TodoCountRequest; import com.yunhai.platform.utils.http.IDHttp; -import lombok.Data; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -11,30 +14,56 @@ import org.springframework.stereotype.Service; import java.util.List; /** - * UserMsg 站内信客户端(概览 §七 契约,M1 手工三件套路线的直达版): - * 经共享 IDHttp(yh-openapi 装配的出站自动签名 RestTemplate,X-App-Key/X-Timestamp/X-Signature - * = SignUtil.signRequest 请求级 canonical,与 svc-hall FlowCenterClient 同一套通道)直调 - * UserMsg /oapi/msg/**。前提:UserMsg 侧 trusted-apps 白名单收本应用 appKey(本地占位 100019); - * SDK(yh-usermsg-spring-boot-starter)落地后整体切换为 SDK 调用,契约不变。 - * 发送方不阻断主业务:失败只记日志(对齐 PartyLearn 容错语义),由调用方在 AFTER_COMMIT 监听器里容错。 + * UserMsg 站内信客户端(概览 §七 契约):主路径走 SDK 类型化门面 {@link YschMsgOpenApi} + * (服务目录 key={@code usermsg}:yh.call.targets > yh.open.services > 枚举兜底 + * base-host + /usermsg)——签名/信封/错误映射由 SDK 统一承载,本应用零专属地址配置 + * (《子应用配置优化方案》§4.2)。目录未显式配置时回落 legacy + * {@code flow-center.usermsg.base-url}(ad-hoc,迁移期兼容)。

+ * 发送方不阻断主业务:失败只记日志(对齐 PartyLearn 容错语义),由调用方在 AFTER_COMMIT + * 监听器里容错。{@code flow-center.usermsg.enabled=false} 时全部方法静默返回(UserMsg 未部署 + * 的部署形态;生产当前即此态,避免打 localhost 的空转告警)。 */ @Slf4j @Service -@RequiredArgsConstructor public class UserMsgClient { + /** 服务目录命名目标(UserMsg 已入 PlatformService 枚举:key=usermsg、ctx=/usermsg) */ + private static final String DIRECTORY_KEY = PlatformService.USERMSG.key(); + + private final YschOpenCall openCall; private final IDHttp http; + private final boolean enabled; + private final String legacyBaseUrl; + private final boolean directoryResolved; - @Value("${flow-center.usermsg.enabled:false}") - private boolean enabled; - - @Value("${flow-center.usermsg.base-url:http://localhost:8080}") - private String baseUrl; + public UserMsgClient(YschOpenCall openCall, + IDHttp http, + @Value("${flow-center.usermsg.enabled:false}") boolean enabled, + @Value("${flow-center.usermsg.base-url:}") String baseUrl) { + this.openCall = openCall; + this.http = http; + this.enabled = enabled; + this.legacyBaseUrl = baseUrl == null ? "" : trimTrailing(baseUrl.trim()); + this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY); + if (directoryResolved) { + YschMsgOpenApi.setCall(openCall); + } + if (enabled && !directoryResolved && legacyBaseUrl.isEmpty()) { + // enabled=true 表示确实要发;目录与 legacy 键都缺 → 构造期 fail-fast + throw new IllegalStateException("UserMsg 调用地址未配置:请配置服务目录 yh.open.services." + + DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 flow-center.usermsg.base-url"); + } + } public boolean enabled() { return enabled; } + /** 是否走服务目录命名目标(配置自检用) */ + public boolean directoryResolved() { + return directoryResolved; + } + /** 发送站内信(content 必填;msgNo 幂等,重发安全) */ public void sendUserMsg(Integer schId, List userIds, Integer msgType, String bizType, String bizId, String title, String content, String jumpPath, String msgNo, @@ -57,7 +86,11 @@ public class UserMsgClient { body.setSchId(schId); body.setExpireDays(expireDays); try { - http.post(trimTrailing(baseUrl) + "/oapi/msg/sendUserMsg", body, Object.class); + if (directoryResolved) { + YschMsgOpenApi.sendUserMsg(body); + } else { + http.post(legacyBaseUrl + "/oapi/msg/sendUserMsg", body, Object.class); + } } catch (BizException e) { log.warn("[flow-center] UserMsg 发送被拒:msgNo={} msg={}", msgNo, e.getMessage()); } catch (Exception e) { @@ -75,7 +108,11 @@ public class UserMsgClient { body.setCount(count); body.setSchId(schId); try { - http.post(trimTrailing(baseUrl) + "/oapi/msg/todo/inc", body, Object.class); + if (directoryResolved) { + YschMsgOpenApi.todoInc(body); + } else { + http.post(legacyBaseUrl + "/oapi/msg/todo/inc", body, Object.class); + } } catch (Exception e) { log.warn("[flow-center] UserMsg 角标增量失败(不阻断主业务):userIds={} count={} {}", userIds, count, e.toString()); @@ -85,31 +122,4 @@ public class UserMsgClient { private static String trimTrailing(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } - - /** /oapi/msg/sendUserMsg 请求体(UserMsg SendMsgRequest 同构,字段逐字对齐) */ - @Data - public static class SendMsgRequest { - private List userIds; - private String title; - /** 必填(UserMsg @NotBlank) */ - private String content; - private String msgNo; - private String bizType; - private String bizId; - /** 0系统通知/1业务提醒/2审批待办 */ - private Integer msgType; - /** 0无跳转/1应用内路径/2外链 */ - private Integer jumpType; - private String jumpPath; - private Integer schId; - private Integer expireDays; - } - - /** /oapi/msg/todo/inc 请求体(UserMsg TodoCountRequest 同构) */ - @Data - public static class TodoCountRequest { - private List userIds; - private Integer count; - private Integer schId; - } } diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java index 8c0df6d..67bfe98 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java @@ -40,6 +40,12 @@ public class UserMsgNotifier { case TASK_HANDLED: notifyTaskHandled(event); break; + case RETURNED: + notifyReturned(event); + break; + case TRANSFERRED: + notifyTaskCreated(event); + break; case COMPLETED: case REJECTED: case WITHDRAWN: @@ -80,6 +86,36 @@ public class UserMsgNotifier { Collections.singletonList(task.getAssigneeId()), -1); } + /** + * 退回:通知被退回节点的全部新待办人(RETURNED 事件 data.targetNodeIds 由服务层带上—— + * 退回重建任务在事件发布前已提交,这里按节点查待办即可);todo 角标由 notifyTaskCreated + * 通用链路在引擎重建任务时已发过,这里只补一条带退回意见的消息。 + */ + private void notifyReturned(FlowEvent event) { + FlwInstance instance = instanceOf(event.getInstanceId()); + if (instance == null || event.getData() == null) { + return; + } + Object fromNode = event.getData().get("fromNodeName"); + Object opinion = event.getData().get("opinion"); + StringBuilder content = new StringBuilder("「").append(safe(instance.getBizTitle())).append("」") + .append("被退回(").append(fromNode == null ? "" : fromNode).append(")"); + if (opinion != null && !String.valueOf(opinion).isEmpty()) { + content.append(",意见:").append(opinion); + } + java.util.List targets = event.getData().get("targetUserIds") == null + ? Collections.emptyList() + : (java.util.List) event.getData().get("targetUserIds"); + if (targets.isEmpty()) { + return; + } + userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1, + "flow.returned", String.valueOf(instance.getId()), + "办件被退回:" + safe(instance.getBizTitle()), content.toString(), + "/instance/" + instance.getId(), + "returned:" + instance.getId() + ":" + instance.getVersion(), 30); + } + private void notifyResult(FlowEvent event) { FlwInstance instance = instanceOf(event.getInstanceId()); if (instance == null) { diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java index 412ae35..8aa2211 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java @@ -21,15 +21,33 @@ public interface FlwTaskRepository extends JpaRepository { /** 任务办理抢占(概览 §5.4):affected=1 才获得推进权,防双击/并发重复推进 */ @Modifying - @Query("update FlwTask t set t.status = :target, t.handleTime = :handleTime, t.opinion = :opinion " - + "where t.id = :id and t.status = 0") + @Query("update FlwTask t set t.status = :target, t.handleTime = :handleTime, t.opinion = :opinion, " + + "t.edgeName = :edgeName where t.id = :id and t.status = 0") int claimAndFinish(@Param("id") Long id, @Param("target") Integer target, - @Param("handleTime") LocalDateTime handleTime, @Param("opinion") String opinion); + @Param("handleTime") LocalDateTime handleTime, @Param("opinion") String opinion, + @Param("edgeName") String edgeName); @Modifying @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0") int invalidatePendingByInstance(@Param("instanceId") Long instanceId); + /** + * 失效指定节点的待办(退回专用):不能按办件整体失效——并行兄弟分支的待办必须保留。 + */ + @Modifying + @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 " + + "and t.nodeId = :nodeId") + int invalidatePendingByNode(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId); + + /** + * 失效同节点除指定任务外的待办(或签 ANY 专用):任一人同意后兄弟待办作废。 + */ + @Modifying + @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 " + + "and t.nodeId = :nodeId and t.id <> :exceptTaskId") + int invalidatePendingByNodeExcept(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId, + @Param("exceptTaskId") Long exceptTaskId); + @Modifying @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId") int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId); @@ -39,9 +57,9 @@ public interface FlwTaskRepository extends JpaRepository { + "order by t.arriveTime asc, t.id asc") Page pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable); - /** 我的已办(办理过的历史任务) */ - @Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status in (1, 2) " - + "order by t.handleTime desc, t.id desc") + /** 我的已办(办理过的历史任务,含退回/移交) */ + @Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId " + + "and t.status in (1, 2, 4, 5) order by t.handleTime desc, t.id desc") Page pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable); /** 超期待催任务(定时扫描:待办+超期+未达催办上限) */ diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java index 45a504d..36bbe86 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java @@ -5,12 +5,15 @@ import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import lombok.RequiredArgsConstructor; @@ -18,7 +21,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.HashSet; /** * 流程示意图出图(方案 §3.2):后端按视图解析好节点 label/subLabel,前端只画图不解析。 @@ -42,6 +49,8 @@ public class FlowDiagramService { private final FlwDefRepository defRepository; private final FlwAssigneeMapRepository mapRepository; + private final FlwInstanceRepository instanceRepository; + private final FlwTaskRepository taskRepository; private final FlowEngine flowEngine; private final OpenApiUserClient userClient; @@ -87,12 +96,65 @@ public class FlowDiagramService { FlowModels.EdgeVo ev = new FlowModels.EdgeVo(); ev.setFrom(e.from); ev.setTo(e.to); + ev.setName(e.name); edges.add(ev); } vo.setEdges(edges); + // 画布坐标透传(设计器保存;缺失时前端自行 Kahn 布局) + if (model.layout != null && !model.layout.isEmpty()) { + Map> layout = new HashMap<>(); + for (Map.Entry en : model.layout.entrySet()) { + if (en.getValue() == null) { + continue; + } + Map p = new HashMap<>(); + p.put("x", en.getValue().x); + p.put("y", en.getValue().y); + layout.put(en.getKey(), p); + } + vo.setLayout(layout); + } + // instance 口径:按任务轨迹给节点上运行时状态(前端高亮在办节点) + if (instanceId != null) { + Map states = nodeStates(instanceId, model); + for (FlowModels.NodeVo nv : nodes) { + nv.setState(states.get(nv.getId())); + } + } return vo; } + /** + * 办件节点运行时状态:done=已有已同意任务 / rejected=有拒绝任务(终态前)/ + * active=在 currentNodes / pending=其余。active 优先于 done(退回后重到同节点算在办)。 + */ + private Map nodeStates(Long instanceId, FlowDefModel model) { + Map states = new HashMap<>(); + Set activeNodes = new HashSet<>(); + FlwInstance instance = instanceRepository.findById(instanceId).orElse(null); + if (instance != null && instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) { + activeNodes.addAll(java.util.Arrays.asList(instance.getCurrentNodes().split(","))); + } + for (FlowDefModel.Node n : model.nodes) { + states.put(n.id, null); + } + for (FlwTask t : taskRepository.findByInstanceIdOrderByIdAsc(instanceId)) { + String nodeState = states.get(t.getNodeId()); + if (t.getStatus() == FlwEnums.TaskStatus.REJECTED.getCode()) { + states.put(t.getNodeId(), "rejected"); + } else if (t.getStatus() == FlwEnums.TaskStatus.APPROVED.getCode() + && nodeState == null && !activeNodes.contains(t.getNodeId())) { + states.put(t.getNodeId(), "done"); + } + } + for (String nodeId : activeNodes) { + if (states.containsKey(nodeId)) { + states.put(nodeId, "active"); + } + } + return states; + } + private FlowModels.NodeVo toNodeVo(Long schId, FlowDefModel.Node n, String mode) { FlowModels.NodeVo vo = new FlowModels.NodeVo(); vo.setId(n.id); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java index 53a01dd..3407d04 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -126,8 +126,7 @@ public class FlowInstanceService { throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回"); } if (!taskRepository.findByInstanceIdAndStatusIn(instanceId, - java.util.Arrays.asList(FlwEnums.TaskStatus.APPROVED.getCode(), - FlwEnums.TaskStatus.REJECTED.getCode())).isEmpty()) { + FlwEnums.TaskStatus.handledCodes()).isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回"); } instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode()); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java index e416f87..641c23d 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java @@ -5,8 +5,8 @@ import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; -import com.yunhai.platform.openapi.client.OpenApiGroupClient; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiGroupClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.model.BoaGroup; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java index a26258c..5ba198a 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java @@ -1,5 +1,7 @@ package com.yunhai.flowcenter.service; +import com.yunhai.flowcenter.engine.BranchScope; +import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwBranch; import com.yunhai.flowcenter.entity.FlwDef; @@ -111,6 +113,7 @@ public class FlowQueryService { row.put("handleTime", str(t.getHandleTime())); row.put("deadline", str(t.getDeadline())); row.put("urgeCount", t.getUrgeCount()); + row.put("edgeName", t.getEdgeName()); trace.add(row); } detail.setTrace(trace); @@ -135,6 +138,53 @@ public class FlowQueryService { } FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def))); + // 办理面板数据(出口/退回目标/移交标记):仅对持本人待办的查看者填充 + FlwTask myPend = viewerId == null ? null : tasks.stream() + .filter(t -> viewerId.equals(t.getAssigneeId()) + && t.getStatus() == FlwEnums.TaskStatus.PENDING.getCode()) + .findFirst().orElse(null); + if (myPend != null && def != null) { + FlowDefModel model = flowEngine.parseModel(def); + String curNodeId = myPend.getNodeId(); + List outs = model.outEdges(curNodeId); + if (outs.size() > 1) { + List> outlets = new ArrayList<>(); + for (FlowDefModel.Edge e : outs) { + Map o = new HashMap<>(); + o.put("name", e.name); + o.put("toNodeId", e.to); + FlowDefModel.Node to = model.node(e.to); + o.put("toNodeName", to == null ? e.to + : (to.name == null || to.name.isEmpty() ? e.to : to.name)); + outlets.add(o); + } + detail.setOutlets(outlets); + } else { + detail.setOutlets(new ArrayList<>()); + } + // 可退回目标:办件已办过的主线审批节点(去重,排除当前节点) + Map> targets = new java.util.LinkedHashMap<>(); + for (FlwTask t : tasks) { + if (t.getStatus() != FlwEnums.TaskStatus.APPROVED.getCode() + || t.getNodeId().equals(myPend.getNodeId()) + || BranchScope.keyOf(model, t.getNodeId()) != null) { + continue; + } + FlowDefModel.Node node = model.node(t.getNodeId()); + if (node != null && FlowDefModel.TYPE_APPROVAL.equals(node.type) + && !targets.containsKey(t.getNodeId())) { + Map r = new HashMap<>(); + r.put("nodeId", node.id); + r.put("nodeName", node.name == null || node.name.isEmpty() ? node.id : node.name); + targets.put(node.id, r); + } + } + detail.setReturnTargets(new ArrayList<>(targets.values())); + detail.setCanTransfer(true); + } else { + detail.setOutlets(new ArrayList<>()); + detail.setReturnTargets(new ArrayList<>()); + } return detail; } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java index ca64cd7..96fbb9e 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java @@ -1,13 +1,17 @@ package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.BranchScope; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.repository.FlwBranchRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; @@ -38,15 +42,20 @@ public class FlowTaskService { private final FlwTaskRepository taskRepository; private final FlwInstanceRepository instanceRepository; private final FlwBranchRepository branchRepository; + private final FlwDefRepository defRepository; + private final AssigneeResolver assigneeResolver; + private final FlowEngine flowEngine; private final ApplicationEventPublisher eventPublisher; /** * 办理(同意/拒绝收敛点,概览 §8.5 不变式 2)。 * + * @param edgeName 多出口节点的所选出口(办理按钮文案);单出结节忽略,出度>1 时必填 * @return 办理后的任务(含最新状态) */ @Transactional - public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, Integer operatorId) { + public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, + Integer operatorId, String edgeName) { FlwTask task = taskRepository.findById(taskId) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId)); if (!task.getSchId().equals(schId)) { @@ -58,10 +67,12 @@ public class FlowTaskService { if (!approved && (opinion == null || opinion.trim().isEmpty())) { throw new BizException(ErrorCode.PARAM_ERROR, "拒绝必须填写意见"); } + // 多出口选路校验(发布期已保证出口命名唯一):出度>1 必须带上出口名且能命中 + String chosenEdge = normalizeEdgeName(task, approved, edgeName); // 原子抢占:affected=1 才获得办理权(防双击/并发重复推进) int target = approved ? FlwEnums.TaskStatus.APPROVED.getCode() : FlwEnums.TaskStatus.REJECTED.getCode(); LocalDateTime now = LocalDateTime.now(); - if (taskRepository.claimAndFinish(taskId, target, now, opinion) != 1) { + if (taskRepository.claimAndFinish(taskId, target, now, opinion, chosenEdge) != 1) { throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新"); } FlwTask handled = taskRepository.findById(taskId).orElse(null); @@ -82,14 +93,63 @@ public class FlowTaskService { if (opinion != null && !opinion.isEmpty()) { data.put("opinion", opinion); } + if (chosenEdge != null) { + data.put("edgeName", chosenEdge); + } eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_HANDLED, instance.getId(), taskId, data)); if (!approved) { rejectFlow(instance, task, opinion); + } else if (FlowDefModel.COUNTERSIGN_ANY.equals(countersignOf(task))) { + // 或签(ANY):任一人同意即放行,失效同节点兄弟待办—— + // 推进监听器只认 pending==0,不失效会卡到所有人办完(行为等同 ALL 的历史缺陷) + taskRepository.invalidatePendingByNodeExcept(instance.getId(), task.getNodeId(), taskId); } return handled; } + /** 旧签名兼容(单出口调用方) */ + @Transactional + public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, Integer operatorId) { + return handle(schId, taskId, approved, opinion, operatorId, null); + } + + /** 出度>1 的节点必须选有效出口;单出口一律置 null(旧前端零改动) */ + private String normalizeEdgeName(FlwTask task, boolean approved, String edgeName) { + if (!approved) { + return null; + } + FlwDef def = defRepository.findById(instanceRepository.findById(task.getInstanceId()) + .map(FlwInstance::getDefId).orElse(null)).orElse(null); + if (def == null) { + return null; + } + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + java.util.List outs = model.outEdges(task.getNodeId()); + if (outs.size() <= 1) { + return null; + } + if (edgeName == null || edgeName.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该节点有多个出口,请选择办理去向"); + } + FlowDefModel.Edge hit = model.edgeByName(task.getNodeId(), edgeName.trim()); + if (hit == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "无效的办理去向:" + edgeName); + } + return hit.name; + } + + /** 该任务所属节点的会签模式(定义缺失/未配置按 ALL) */ + private String countersignOf(FlwTask task) { + FlwDef def = defRepository.findById(instanceRepository.findById(task.getInstanceId()) + .map(FlwInstance::getDefId).orElse(null)).orElse(null); + if (def == null) { + return FlowDefModel.COUNTERSIGN_ALL; + } + FlowDefModel.Node node = FlowDefModel.parse(objectMapper, def.getContentJson()).node(task.getNodeId()); + return node == null || node.countersign == null ? FlowDefModel.COUNTERSIGN_ALL : node.countersign; + } + /** END_REJECTED 拒绝终态(概览 §5.3):同批待办失效→分支失效→办件=30→拒绝事件 */ private void rejectFlow(FlwInstance instance, FlwTask task, String opinion) { instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode()); @@ -106,6 +166,150 @@ public class FlowTaskService { instance.getId(), task.getId(), data)); } + /** + * 退回(B3):把流程退回上游已办节点重新办理。当前任务置 RETURNED、当前节点待办全部失效、 + * 目标节点重新激活建新任务;办件保持审批中(区别于拒绝=终态)。 + * v1 限定主线节点(BranchScope.keyOf(target)==null),分支内退回登记二期。 + * + * @param targetNodeId 退回目标节点;空=最近一个已办节点(任务轨迹 handleTime 降序) + */ + @Transactional + public void returnBack(Long schId, Long taskId, String targetNodeId, String opinion, Integer operatorId) { + FlwTask task = requirePendingTask(schId, taskId, operatorId); + FlwInstance instance = instanceRepository.findById(task.getInstanceId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + FlwDef def = defRepository.findById(instance.getDefId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义缺失")); + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + + String target = resolveReturnTarget(model, instance, task, targetNodeId); + FlowDefModel.Node targetNode = model.node(target); + if (!FlowDefModel.TYPE_APPROVAL.equals(targetNode.type)) { + throw new BizException(ErrorCode.PARAM_ERROR, "退回目标必须是审批节点:" + target); + } + if (BranchScope.keyOf(model, target) != null) { + throw new BizException(ErrorCode.PARAM_ERROR, "暂不支持退回到并行分支内的节点"); + } + + LocalDateTime now = LocalDateTime.now(); + String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName(); + String targetLabel = targetNode.name == null ? target : targetNode.name; + if (taskRepository.claimAndFinish(taskId, FlwEnums.TaskStatus.RETURNED.getCode(), now, + "退回至 " + targetLabel + (opinion == null || opinion.isEmpty() ? "" : ":" + opinion), null) != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新"); + } + taskRepository.invalidatePendingByNode(instance.getId(), task.getNodeId()); + flowEngine.returnTo(instance, def, task.getNodeId(), target); + instanceRepository.save(instance); + + Map data = new HashMap<>(); + data.put("fromNodeId", task.getNodeId()); + data.put("fromNodeName", fromLabel); + data.put("targetNodeId", target); + data.put("targetNodeName", targetLabel); + if (opinion != null && !opinion.isEmpty()) { + data.put("opinion", opinion); + } + data.put("targetUserIds", taskRepository + .findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream() + .filter(t -> target.equals(t.getNodeId())) + .map(FlwTask::getAssigneeId) + .collect(java.util.stream.Collectors.toList())); + eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.RETURNED, instance.getId(), taskId, data)); + } + + /** 退回目标解析:显式指定须合法;缺省取最近一个已办节点 */ + private String resolveReturnTarget(FlowDefModel model, FlwInstance instance, FlwTask task, + String targetNodeId) { + if (targetNodeId != null && !targetNodeId.trim().isEmpty()) { + FlowDefModel.Node node = model.node(targetNodeId.trim()); + if (node == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "退回目标节点不存在:" + targetNodeId); + } + if (node.id.equals(task.getNodeId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "不能退回当前节点"); + } + return node.id; + } + return taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()).stream() + .filter(t -> FlwEnums.TaskStatus.APPROVED.getCode() == t.getStatus()) + .filter(t -> !task.getNodeId().equals(t.getNodeId())) + .filter(t -> BranchScope.keyOf(model, t.getNodeId()) == null) + .reduce((first, second) -> second) + .map(FlwTask::getNodeId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "无可退回的上游节点,请使用拒绝")); + } + + /** + * 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人 + * (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。 + * 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。 + */ + @Transactional + public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) { + if (toUserId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象"); + } + FlwTask task = requirePendingTask(schId, taskId, operatorId); + if (toUserId.equals(task.getAssigneeId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "不能移交给当前办理人本人"); + } + FlwInstance instance = instanceRepository.findById(task.getInstanceId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + String toUserName = assigneeResolver.requireUserName(toUserId); + + LocalDateTime now = LocalDateTime.now(); + String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName(); + if (taskRepository.claimAndFinish(taskId, FlwEnums.TaskStatus.TRANSFERRED.getCode(), now, + "移交给 " + (toUserName.isEmpty() ? toUserId : toUserName) + + (reason == null || reason.isEmpty() ? "" : ":" + reason), null) != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新"); + } + FlwTask newTask = new FlwTask(); + newTask.setInstanceId(task.getInstanceId()); + newTask.setSchId(task.getSchId()); + newTask.setNodeId(task.getNodeId()); + newTask.setNodeName(task.getNodeName()); + newTask.setAssigneeId(toUserId); + newTask.setAssigneeName(toUserName); + newTask.setStatus(FlwEnums.TaskStatus.PENDING.getCode()); + newTask.setArriveTime(now); + newTask.setDeadline(task.getDeadline()); + newTask.setUrgeCount(0); + taskRepository.save(newTask); + + Map data = new HashMap<>(); + data.put("fromNodeId", task.getNodeId()); + data.put("fromNodeName", fromLabel); + data.put("fromUserId", task.getAssigneeId()); + data.put("fromUserName", task.getAssigneeName()); + data.put("toUserId", toUserId); + data.put("toUserName", toUserName); + data.put("taskId", newTask.getId()); + if (reason != null && !reason.isEmpty()) { + data.put("opinion", reason); + } + eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TRANSFERRED, + instance.getId(), newTask.getId(), data)); + } + + /** 待办任务守卫:存在、本校、本人办理、仍待办 */ + private FlwTask requirePendingTask(Long schId, Long taskId, Integer operatorId) { + FlwTask task = taskRepository.findById(taskId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId)); + if (!task.getSchId().equals(schId)) { + throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校"); + } + if (!task.getAssigneeId().equals(operatorId)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅任务办理人本人可操作"); + } + if (task.getStatus() != FlwEnums.TaskStatus.PENDING.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅待办任务可操作"); + } + return task; + } + /** 手工催办(提交人/管理员):urge_count+1 + URGE 事件(UserMsg 通道) */ @Transactional public void urge(Long schId, Long taskId, int maxTimes) { diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index f6483d2..ac0a832 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -15,7 +15,7 @@ import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f6f97fa..a0ac758 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,12 +66,15 @@ yh: "100005": "2c7885de594742cc840b8762910b2d2e" "100617": "2a3b4c5d6e7f8091a2b3c4d5e6f70819" open: - enabled: true + # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删; # 本地全链测试:指向 local-debug 的 UserBase;生产改回 base.yunhaixingtong.com base-host: http://localhost:8060 base-path: /userbase - app-key: 100019 - app-secret: "flow-center-dev-secret-100019" + # 服务目录(子应用配置优化方案 §4.2):usermsg 未部署时无需配置(enabled=false 即不解析); + # 部署后配容器直连完整 URL(如 http://usermsg-service:100xx/usermsg),或走平台级 + # YH_OPEN_SERVICES_USERMSG 注入。 + # services: + # usermsg: http://localhost:8080 # ===== 流程中心专属配置 ===== flow-center: @@ -97,8 +100,11 @@ flow-center: # 挂起/死信告警接收人(本校管理员 userId;本地联调可留空) alert-user-ids: "" usermsg: - # UserMsg 基址(IDHttp 出站自动签名直达 /oapi/msg/**;UserMsg 侧 trusted-apps 须收本应用占位 appKey=100019) + # 站内信/待办推送:主路径 = SDK 门面 YschMsgOpenApi(服务目录 yh.open.services.usermsg); + # legacy 回落键 base-url 已删(无默认值)——enabled=true 且两者皆缺时构造期 fail-fast。 + # 本地 8080 起 UserMsg 后置 true;生产 UserMsg 未部署 → false(显式关闭,消除空转告警)。 enabled: true + # base-url legacy 回落(仅迁移期兜底,正常不用配):见上方 open.services.usermsg base-url: http://localhost:8080 logging: diff --git a/src/main/resources/sql/20260916_add_flw_task_edge_name.sql b/src/main/resources/sql/20260916_add_flw_task_edge_name.sql new file mode 100644 index 0000000..d7eedbe --- /dev/null +++ b/src/main/resources/sql/20260916_add_flw_task_edge_name.sql @@ -0,0 +1,9 @@ +-- flw_task 增加 edge_name(多出口节点办理时选中的出边名,可空) +-- 用途:审批节点有多条出边时,办理人在办理页选一个出口(名称即按钮文案), +-- 所选出口落库在该任务行上,节点放行推进时按它决定走哪条边。 +-- 落库而非走事件传参:推进发生在 AFTER_COMMIT 的新事务里且带乐观锁重试, +-- 事件传参不可靠;落列后可重放、可审计,并发办理时以最后办理的任务为准。 +-- 单出口节点恒为 NULL(旧数据零影响)。 +-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。 +ALTER TABLE `flw_task` + ADD COLUMN `edge_name` VARCHAR(64) NULL COMMENT '多出口节点所选出口(对应 def 边 name)' AFTER `opinion`; diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java index 141da16..b708078 100644 --- a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java @@ -97,7 +97,8 @@ class CallbackDispatchIT extends FlowTestSupport { new java.util.concurrent.atomic.AtomicInteger(); CountingUserMsgClient() { - super(null); + // enabled=false:本用例只覆盖回调派发,站内信为哑实现(两个方法均覆写) + super(null, null, false, ""); } @Override diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java index d45b646..3b93531 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java @@ -34,6 +34,75 @@ class DefValidatorTest { DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))); } + @Test + void multiOutlet_withoutNames_fails() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}," + + "{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n1\",\"to\":\"end\"}," + + "{\"from\":\"n2\",\"to\":\"end\"}]}"; + BizException e = assertThrows(BizException.class, + () -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")))); + assertTrue(e.getMessage().contains("出口必须命名")); + } + + @Test + void multiOutlet_duplicateNames_fails() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}]}"; + BizException e = assertThrows(BizException.class, + () -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")))); + assertTrue(e.getMessage().contains("出口名称重复")); + } + + @Test + void multiOutlet_namedUnique_passes() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"驳回结束\"}]}"; + DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))); + } + + @Test + void multiOutlet_crossBranchTarget_fails() { + // n1 在并行分支内,出口指向主线 end —— 跨分支连线一期拒绝 + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"分支审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}," + + "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"}," + + "{\"from\":\"split1\",\"to\":\"n1\"}," + + "{\"from\":\"split1\",\"to\":\"end\",\"name\":\"直通\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"分支出口\"}," + + "{\"from\":\"n1\",\"to\":\"join1\",\"name\":\"到汇聚\"}," + + "{\"from\":\"join1\",\"to\":\"end\"}]}"; + BizException e = assertThrows(BizException.class, + () -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")))); + assertTrue(e.getMessage().contains("跨分支连线")); + } + @Test void missingEnd_fails() { String json = "{" diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java index 49d8a1b..d656ca0 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -243,6 +243,131 @@ class FlowEngineIT extends FlowTestSupport { assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(outcome.getInstanceId()).size()); } + @Test + void multiOutlet_approveWithChosenEdge_routesToTarget() { + mapDept("km", 101, "甲"); + mapDept("kf", 102, "乙"); + FlwDef def = saveAndPublishDef("t10", "多出口", multiOutletDef("km", "kf")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-10", 201, "张三"); + Long instanceId = outcome.getInstanceId(); + FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + // 选"通过"出口 → 直达 END 完成 + taskService.handle(SCH, task.getId(), true, "ok", 101, "通过"); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + assertEquals("通过", taskRepository.findById(task.getId()).get().getEdgeName()); + } + + @Test + void multiOutlet_chooseOtherEdge_routesToSecondNode() throws Exception { + mapDept("km", 101, "甲"); + mapDept("kf", 102, "乙"); + FlwDef def = saveAndPublishDef("t11", "多出口转向", multiOutletDef("km", "kf")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-11", 201, "张三"); + Long instanceId = outcome.getInstanceId(); + FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + // 选"转乙科"出口 → 到 n2 建新待办 + taskService.handle(SCH, task.getId(), true, "转给乙科", 101, "转乙科"); + assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertEquals(1, pending.size()); + assertEquals("n2", pending.get(0).getNodeId()); + assertEquals(102, pending.get(0).getAssigneeId()); + // 乙科通过 → 完成 + taskService.handle(SCH, pending.get(0).getId(), true, "ok", 102); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + } + + @Test + void multiOutlet_multiOutletNode_requiresEdgeName() { + mapDept("km", 101, "甲"); + mapDept("kf", 102, "乙"); + FlwDef def = saveAndPublishDef("t12", "多出口必选", multiOutletDef("km", "kf")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-12", 201, "张三"); + FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0); + assertThrows(BizException.class, + () -> taskService.handle(SCH, task.getId(), true, "ok", 101, null)); + assertThrows(BizException.class, + () -> taskService.handle(SCH, task.getId(), true, "ok", 101, "不存在的出口")); + } + + @Test + void anyCountersign_firstApprove_invalidatesSiblings_thenAdvances() { + mapDept("ky", 101, "甲"); + mapDept("ky", 102, "乙"); + FlwDef def = saveAndPublishDef("t13", "或签", anyCountersignDef("ky")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-13", 201, "张三"); + Long instanceId = outcome.getInstanceId(); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertEquals(2, pending.size()); + // 或签:任一人同意即放行,兄弟待办失效(修复前行为=卡到全员办完) + taskService.handle(SCH, pending.get(0).getId(), true, "我先批", 101); + FlwInstance instance = instanceRepository.findById(instanceId).get(); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instance.getStatus()); + List all = taskRepository.findByInstanceIdOrderByIdAsc(instanceId); + assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), all.get(0).getStatus()); + assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus()); + } + + @Test + void returnBack_fromTailNode_backToHead_thenReTraverseParallel_noUkViolation() { + mapDept("km", 103, "主"); + mapDept("ka", 101, "甲"); + mapDept("kb", 102, "乙"); + FlwDef def = saveAndPublishDef("t14", "退回重入", mainlineParallelTailDef("km", "km")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-14", 201, "张三"); + Long instanceId = outcome.getInstanceId(); + // 顺序推进:n1 → 并行双分支 → join → n3(在出口复核节点) + taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); + FlwTask tail = tasksOf(instanceId, 103).get(0); + assertEquals("n3", tail.getNodeId()); + // 退回至入口(缺省主线最近已办=n1;并行内节点被过滤,不会命中 ta/tb) + taskService.returnBack(SCH, tail.getId(), null, "重新审一遍", 103); + FlwInstance returned = instanceRepository.findById(instanceId).get(); + assertEquals("n1", returned.getCurrentNodes()); + assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), returned.getStatus()); + // 重入并行段:split 幂等 upsert,不撞 uk(instance,branchKey) + taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "再入口", 103); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); + FlwTask n3Again = tasksOf(instanceId, 103).get(0); + assertEquals("n3", n3Again.getNodeId()); + taskService.handle(SCH, n3Again.getId(), true, "复核通过", 103); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + // 轨迹含 RETURNED 行 + assertEquals(1, allTasks(instanceId).stream() + .filter(t -> FlwEnums.TaskStatus.RETURNED.getCode() == t.getStatus()).count()); + } + + @Test + void returnBack_noMainlineTarget_throws_andTransfer_reassigns() { + mapDept("km", 101, "甲"); + FlwDef def = saveAndPublishDef("t15", "退回目标", linearDef("km", "首审")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-15", 201, "张三"); + Long instanceId = outcome.getInstanceId(); + FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + // 首节点无上游已办:缺省退回应明确报错 + assertThrows(BizException.class, () -> + taskService.returnBack(SCH, task.getId(), null, "试试", 101)); + // 移交:当前任务置 TRANSFERRED,同节点给新办理人建待办 + taskService.transfer(SCH, task.getId(), 202, "人不在岗", 101); + List handled = allTasks(instanceId); + assertEquals(2, handled.size(), "移交后应产生新待办行"); + assertEquals(FlwEnums.TaskStatus.TRANSFERRED.getCode(), handled.get(0).getStatus()); + FlwTask reassigned = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + assertEquals(202, reassigned.getAssigneeId()); + assertEquals(101, handled.get(0).getAssigneeId()); // 原办理人留在轨迹 + // 新办理人通过 → 完成;已退回/移交后提交人不能撤回 + taskService.handle(SCH, reassigned.getId(), true, "接手批准", 202); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + } + private List branchRows(Long instanceId) { return branchRepository.findByInstanceId(instanceId); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index 934d8ee..cebeea2 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -17,7 +17,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.BeforeEach; @@ -102,7 +102,9 @@ class FlowDiagramServiceTest { FlowEngine engine = new FlowEngine(new ObjectMapper(), taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class), mock(ApplicationEventPublisher.class)); - service = new FlowDiagramService(defRepository, mapRepository, engine, userClient); + service = new FlowDiagramService(defRepository, mapRepository, + mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), + taskRepository, engine, userClient); } private FlwDef def(String json) { diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java index 02eeaaf..2330cef 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowMapAdminServiceGroupImportTest.java @@ -2,8 +2,8 @@ package com.yunhai.flowcenter.service; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; -import com.yunhai.platform.openapi.client.OpenApiGroupClient; -import com.yunhai.platform.openapi.client.OpenApiUserClient; +import com.yunhai.platform.openapi.base.OpenApiGroupClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java index 82cb420..9c02828 100644 --- a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java +++ b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java @@ -1,11 +1,11 @@ package com.yunhai.flowcenter.stub; -import com.yunhai.platform.openapi.client.OpenApiUserClient; -import com.yunhai.platform.openapi.model.SysUserGroupDeleteItem; -import com.yunhai.platform.openapi.model.SysUserGroupStateRequest; -import com.yunhai.platform.openapi.model.SysUserMoveRequest; -import com.yunhai.platform.openapi.model.SysUserRequest; -import com.yunhai.platform.openapi.model.SysUserTransferSchoolRequest; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import com.yunhai.platform.openapi.base.model.SysUserGroupDeleteItem; +import com.yunhai.platform.openapi.base.model.SysUserGroupStateRequest; +import com.yunhai.platform.openapi.base.model.SysUserMoveRequest; +import com.yunhai.platform.openapi.base.model.SysUserRequest; +import com.yunhai.platform.openapi.base.model.SysUserTransferSchoolRequest; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java index 4dfb61f..2620766 100644 --- a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java +++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java @@ -103,4 +103,55 @@ public abstract class FlowTestSupport { private static String codeOf() { return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); } + + /** 多出口流程:START → n1(deptA,出口"转乙科"→n2 / "通过"→end) → end;n2(deptB) → end */ + protected String multiOutletDef(String deptA, String deptB) { + return "{" + + "\"code\":\"" + codeOf() + "\",\"name\":\"多出口测试流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}," + + "{\"from\":\"n1\",\"to\":\"n2\",\"name\":\"转乙科\"}," + + "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}," + + "{\"from\":\"n2\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + /** 或签流程:START → n1(deptKey, ANY 或签) → END */ + protected String anyCountersignDef(String deptKey) { + return "{" + + "\"code\":\"" + codeOf() + "\",\"name\":\"夡签测试流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"任一人审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptKey + "\"},\"countersign\":\"ANY\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + /** 主线带并行段:START → n1(deptA) → split → {ta(ka), tb(kb)} → join → n3(deptA) → END(退回重入用) */ + protected String mainlineParallelTailDef(String deptA, String deptMain) { + return "{" + + "\"code\":\"" + codeOf() + "\",\"name\":\"主线并行尾验流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}}," + + "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"}," + + "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ka\"}}," + + "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kb\"}}," + + "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"}," + + "{\"id\":\"n3\",\"type\":\"APPROVAL\",\"name\":\"出口复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"split1\"}," + + "{\"from\":\"split1\",\"to\":\"ta\"},{\"from\":\"split1\",\"to\":\"tb\"}," + + "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"join1\"}," + + "{\"from\":\"join1\",\"to\":\"n3\"},{\"from\":\"n3\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } }