- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
This commit is contained in:
@@ -82,6 +82,12 @@ public class FlowAdminController {
|
||||
defAdminService.offline(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 启用(与 offline 对称):仅停用版 →已发布,重跑发布期静态校验 */
|
||||
@PostMapping("/def/enable/{id}")
|
||||
public FlwDef defEnable(BoaUser user, @PathVariable("id") Long id) {
|
||||
return defAdminService.enable(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
@GetMapping("/def/versions")
|
||||
public List<FlwDef> defVersions(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
@@ -133,6 +139,12 @@ public class FlowAdminController {
|
||||
mapAdminService.disable(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
|
||||
@PostMapping("/map/enable/{id}")
|
||||
public FlwAssigneeMap mapEnable(BoaUser user, @PathVariable("id") Long id) {
|
||||
return mapAdminService.enable(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 成员检索(UserBase /oapi/user/byId) */
|
||||
@GetMapping("/map/lookupUser")
|
||||
public com.yunhai.platform.utils.model.BoaUser lookupUser(BoaUser user,
|
||||
|
||||
@@ -158,6 +158,26 @@ public class FlowUserController {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:flowGuid 或 instanceId");
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程源码(2026-09-18,图示页"源码"抽屉/复制用):可见性与 {@link #diagram} 同口径——
|
||||
* flowGuid=最高已发布版;instanceId=该办件实际运行的 def 快照(先过 requireInstanceVisible)。
|
||||
*/
|
||||
@GetMapping("/def/source")
|
||||
public FlowModels.DefSourceVo defSource(BoaUser user,
|
||||
@RequestParam(value = "flowGuid", required = false) String flowGuid,
|
||||
@RequestParam(value = "instanceId", required = false) Long instanceId) {
|
||||
Long schId = requireSchId(user);
|
||||
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
|
||||
return diagramService.sourceByGuid(schId, flowGuid.trim());
|
||||
}
|
||||
if (instanceId != null) {
|
||||
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
|
||||
accessGuard.requireInstanceVisible(instance, user);
|
||||
return diagramService.sourceByInstance(instance);
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:flowGuid 或 instanceId");
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图图片流(CAS 会话版"CAS 验证码式嵌入",2026-09-16):流程中心自有页面 <img src>
|
||||
* 直嵌(办件详情页内嵌运行时状态图),免跳 /diagram 全屏页。可见性与 /diagram 同口径。
|
||||
|
||||
@@ -27,9 +27,27 @@ public final class FlwEnums {
|
||||
* 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标)
|
||||
* / 4 已退回(办理人把流程退回上游节点)/ 5 已移交(办理人把办理权转给他人)。
|
||||
* 4/5 都算"已被办理":既进我的已办,也阻止提交人撤回。
|
||||
*
|
||||
* <p><b>6/7 是"留痕事件行",不是审批任务</b>(flw_task 表复用,2026-09-18):流程轨迹
|
||||
* (办件详情/办理页、oapi progress)需要"发起/撤回/重新提交"这几个动作本身有痕迹,
|
||||
* 于是把办件级动作也落成任务行。它们<b>不参与引擎任何判断</b>:状态非 0 故不进待办/不挡
|
||||
* 推进、不进 handledCodes 故不算"已被办理"(撤回守卫只看 1/2/4/5)、不进超期扫描与分支失效
|
||||
* 范围;节点 id 用 {@link #NODE_SUBMIT}/{@link #NODE_RESUBMIT}/{@link #NODE_WITHDRAW}
|
||||
* 这组保留值,定义模型里不存在,故示意图着色/退回目标解析天然跳过。
|
||||
*/
|
||||
public enum TaskStatus {
|
||||
PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3), RETURNED(4), TRANSFERRED(5);
|
||||
PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3), RETURNED(4), TRANSFERRED(5),
|
||||
/** 6 已提交(提交人发起/撤回后重新提交,留痕事件行) */
|
||||
SUBMITTED(6),
|
||||
/** 7 已撤回(提交人撤回,留痕事件行) */
|
||||
WITHDRAWN(7);
|
||||
|
||||
/** 留痕事件行保留节点 id:发起 */
|
||||
public static final String NODE_SUBMIT = "submit";
|
||||
/** 留痕事件行保留节点 id:撤回后重新提交 */
|
||||
public static final String NODE_RESUBMIT = "resubmit";
|
||||
/** 留痕事件行保留节点 id:撤回 */
|
||||
public static final String NODE_WITHDRAW = "withdraw";
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -41,7 +59,15 @@ public final class FlwEnums {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** "已被办理"状态集(本人已办列表、撤回守卫共用口径),勿在各处硬编码字面量 */
|
||||
/** 留痕事件行状态集(发起/重新提交、撤回) */
|
||||
public static java.util.List<Integer> eventCodes() {
|
||||
return java.util.Arrays.asList(SUBMITTED.getCode(), WITHDRAWN.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* "已被办理"状态集(本人已办列表、撤回守卫共用口径),勿在各处硬编码字面量。
|
||||
* 不含 6/7:提交人自己的发起/撤回动作不是"有人办过",不能挡住撤回。
|
||||
*/
|
||||
public static java.util.List<Integer> handledCodes() {
|
||||
return java.util.Arrays.asList(APPROVED.getCode(), REJECTED.getCode(),
|
||||
RETURNED.getCode(), TRANSFERRED.getCode());
|
||||
|
||||
@@ -47,6 +47,11 @@ public final class FlowModels {
|
||||
private String handleTime;
|
||||
private String deadline;
|
||||
private Integer urgeCount;
|
||||
/**
|
||||
* 已办专用:本人对该办件的累计办理次数(同一办件多节点办理/退回重办只出一行,
|
||||
* 次数用这个字段兜住,前端提示"共办理 N 次");待办口径恒 null。
|
||||
*/
|
||||
private Integer doneCount;
|
||||
}
|
||||
|
||||
/** 办件详情(办理页/详情页数据源) */
|
||||
@@ -129,4 +134,20 @@ public final class FlowModels {
|
||||
private String to;
|
||||
private String name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程定义源码(2026-09-18,「图示页-源码」抽屉数据源):只读展示/复制 contentJson,
|
||||
* 可见性口径与 {@link DiagramVo} 完全一致(instanceId 走办件可见性守卫,flowGuid 只给最高已发布版)。
|
||||
*/
|
||||
@Data
|
||||
public static class DefSourceVo {
|
||||
private Long defId;
|
||||
private String flowGuid;
|
||||
private String code;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 流程定义 JSON(nodes/edges/settings/layout,运行时格式) */
|
||||
private String contentJson;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,17 @@ public interface FlwAssigneeMapRepository extends JpaRepository<FlwAssigneeMap,
|
||||
@Query("select m from FlwAssigneeMap m where m.schId = :schId and m.deptKey = :deptKey and m.status = 0")
|
||||
List<FlwAssigneeMap> findEnabled(@Param("schId") Long schId, @Param("deptKey") String deptKey);
|
||||
|
||||
/** 发布校验用:本校全部启用映射 */
|
||||
List<FlwAssigneeMap> findEnabledBySchId(Long schId);
|
||||
/**
|
||||
* 发布校验用:本校全部<b>启用</b>映射。
|
||||
* 注意必须显式写 status 条件——原来叫 findEnabledBySchId 的派生查询里 "Enabled" 只是描述词,
|
||||
* Spring Data 不会当条件用,实际返回含停用行(发布校验因此把停用映射也算"有办理人")。
|
||||
*/
|
||||
@Query("select m from FlwAssigneeMap m where m.schId = :schId and m.status = 0")
|
||||
List<FlwAssigneeMap> findEnabledBySchId(@Param("schId") Long schId);
|
||||
|
||||
/** 管理端列表用:本校全部映射(含停用——列表要能看到停用行才能重新启用) */
|
||||
@Query("select m from FlwAssigneeMap m where m.schId = :schId order by m.deptKey asc, m.userId asc")
|
||||
List<FlwAssigneeMap> findAllBySchId(@Param("schId") Long schId);
|
||||
|
||||
List<FlwAssigneeMap> findBySchIdAndDeptKeyOrderByUserIdAsc(Long schId, String deptKey);
|
||||
|
||||
|
||||
@@ -57,10 +57,28 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
+ "order by t.arriveTime asc, t.id asc")
|
||||
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
|
||||
|
||||
/** 我的已办(办理过的历史任务,含退回/移交) */
|
||||
/**
|
||||
* 我的已办(一个办件一行,2026-09-18):同一办件被本人办理多次(多节点/退回重办/移交后
|
||||
* 再办)时只保留本人最后办理的那一条(该办件下本人已办行的最大 id),避免列表里同一办件
|
||||
* 重复出现。分页与 total 因此按"办件数"计,行内用 countDoneByInstance 提示累计办理次数。
|
||||
*
|
||||
* <p>状态集由调用方传 {@link com.yunhai.flowcenter.enums.FlwEnums.TaskStatus#handledCodes()}
|
||||
* (勿硬编码字面量);留痕事件行 6/7 不在其中,故不会出现在已办里。
|
||||
*/
|
||||
@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<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
|
||||
+ "and t.status in :statuses "
|
||||
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
|
||||
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
|
||||
+ "order by t.handleTime desc, t.id desc")
|
||||
Page<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
|
||||
@Param("statuses") List<Integer> statuses, Pageable pageable);
|
||||
|
||||
/** 已办累计次数(按办件批量,配合 pageDone 的"一个办件一行") */
|
||||
@Query("select t.instanceId, count(t) from FlwTask t where t.instanceId in :instanceIds "
|
||||
+ "and t.assigneeId = :assigneeId and t.status in :statuses group by t.instanceId")
|
||||
List<Object[]> countDoneByInstance(@Param("instanceIds") List<Long> instanceIds,
|
||||
@Param("assigneeId") Integer assigneeId,
|
||||
@Param("statuses") List<Integer> statuses);
|
||||
|
||||
/** 超期待催任务(定时扫描:待办+超期+未达催办上限) */
|
||||
@Query("select t from FlwTask t where t.status = 0 and t.deadline is not null and t.deadline < :now "
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 科室口径文案的唯一出口(2026-09-18):DEPT_KEY 节点的"科室名"取值只在 {@link #mappedDeptName}
|
||||
* 里实现一次——流程图(FlowDiagramService 的科室视图)与流程轨迹(FlowQueryService 的科室口径)
|
||||
* 共用,避免两边对同一份映射表给出不同名字。
|
||||
*/
|
||||
final class DeptLabelSupport {
|
||||
|
||||
private DeptLabelSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射行里的科室名:取第一个非空 deptName(一个 deptKey 的表现通常一致;科室名可空,
|
||||
* 空则返回 null 由调用方决定回落链)。
|
||||
*/
|
||||
static String mappedDeptName(List<FlwAssigneeMap> maps) {
|
||||
if (maps == null) {
|
||||
return null;
|
||||
}
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
if (m.getDeptName() != null && !m.getDeptName().trim().isEmpty()) {
|
||||
return m.getDeptName();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ import java.awt.GraphicsEnvironment;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -30,8 +30,11 @@ import java.util.Map;
|
||||
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
|
||||
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
|
||||
* <p>
|
||||
* 布局:自算拓扑最长路径分层(自上而下),忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);
|
||||
* 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以
|
||||
* SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器
|
||||
* 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。
|
||||
* 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态
|
||||
* done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -42,26 +45,43 @@ public class DiagramImageService {
|
||||
private static final int SCALE = 2;
|
||||
private static final int MARGIN = 36;
|
||||
private static final int TITLE_H = 56;
|
||||
private static final int NODE_W = 210;
|
||||
private static final int NODE_H = 60;
|
||||
private static final int LEVEL_GAP_Y = 92;
|
||||
private static final int GAP_X = 52;
|
||||
private static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
|
||||
private static final int GAP_X = 84; // 列间距(留给正交折线走线)
|
||||
private static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
|
||||
private static final int LEGEND_H = 48;
|
||||
private static final int ARROW = 8;
|
||||
private static final int ARROW = 9;
|
||||
|
||||
private static final Color C_TITLE = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_LINE = new Color(0xA6, 0xB1, 0xD4);
|
||||
private static final Color C_EDGE_LABEL = new Color(0x8A, 0x93, 0xB0);
|
||||
private static final Color C_BORDER_DEFAULT = new Color(0xC9, 0xD2, 0xEF);
|
||||
private static final Color C_DONE_BORDER = new Color(0x59, 0x79, 0xEA);
|
||||
private static final Color C_ACTIVE_FILL = new Color(0xFF, 0xF7, 0xE6);
|
||||
private static final Color C_ACTIVE_BORDER = new Color(0xFF, 0x99, 0x00);
|
||||
private static final Color C_REJECT_FILL = new Color(0xFD, 0xEE, 0xEE);
|
||||
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行菱形(取外接矩形) */
|
||||
private static int[] boxOf(String type) {
|
||||
switch (type == null ? "" : type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
case FlowDefModel.TYPE_END:
|
||||
return new int[]{116, 44};
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
return new int[]{132, 78};
|
||||
default:
|
||||
return new int[]{200, 64};
|
||||
}
|
||||
}
|
||||
|
||||
// —— 配色与前端 FlowDiagram.vue 示意图一致(深色线/高对比文字) ——
|
||||
private static final Color C_TITLE = new Color(0x17, 0x23, 0x3D);
|
||||
private static final Color C_LINE = new Color(0x51, 0x5A, 0x6E);
|
||||
private static final Color C_EDGE_LABEL = new Color(0x51, 0x5A, 0x6E);
|
||||
private static final Color C_TEXT = new Color(0x17, 0x23, 0x3D);
|
||||
private static final Color C_SUB = new Color(0x80, 0x86, 0x95);
|
||||
private static final Color C_APPROVAL_BORDER = new Color(0x2D, 0x8C, 0xF0);
|
||||
private static final Color C_START_FILL = new Color(0x19, 0xBE, 0x6B);
|
||||
private static final Color C_END_FILL = new Color(0x80, 0x86, 0x95);
|
||||
private static final Color C_PARALLEL_FILL = new Color(0xFF, 0xF8, 0xF0);
|
||||
private static final Color C_PARALLEL_BORDER = new Color(0xFF, 0x99, 0x00);
|
||||
private static final Color C_PARALLEL_TEXT = new Color(0xE4, 0x6A, 0x0A);
|
||||
private static final Color C_DONE_BORDER = new Color(0x19, 0xBE, 0x6B);
|
||||
private static final Color C_ACTIVE_BORDER = new Color(0xED, 0x40, 0x14);
|
||||
private static final Color C_REJECT_FILL = new Color(0xFF, 0xF1, 0xF0);
|
||||
private static final Color C_REJECT_BORDER = new Color(0xED, 0x40, 0x14);
|
||||
private static final Color C_PARALLEL_FILL = new Color(0xF8, 0xF8, 0xFF);
|
||||
private static final Color C_PARALLEL_BORDER = new Color(0xA0, 0xA8, 0xC8);
|
||||
private static final Color C_TEXT = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_SUB = new Color(0x8A, 0x93, 0xB0);
|
||||
private static final Color C_PENDING_BORDER = new Color(0xDC, 0xDE, 0xE2);
|
||||
|
||||
/** 出图主入口;instance 口径节点带 state 时自动着色并画图例 */
|
||||
public byte[] renderPng(FlowModels.DiagramVo vo) {
|
||||
@@ -92,7 +112,7 @@ public class DiagramImageService {
|
||||
inDeg.merge(e.getTo(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
// 拓扑最长路径分层;环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
Map<String, Integer> level = new HashMap<>();
|
||||
List<String> queue = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
|
||||
@@ -116,19 +136,48 @@ public class DiagramImageService {
|
||||
level.putIfAbsent(n.getId(), 0);
|
||||
}
|
||||
|
||||
// 每层节点居中排布;画布尺寸由层高/最宽层决定
|
||||
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
int maxRows = 1;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
int contentW = maxLevel * (NODE_W + GAP_X) - GAP_X;
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
contentW = Math.max(contentW, layer.size() * (NODE_W + GAP_X) - GAP_X);
|
||||
maxRows = Math.max(maxRows, layer.size());
|
||||
}
|
||||
int axisH = maxRows * ROW_H;
|
||||
Map<String, int[]> box = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int blockH = layer.size() * ROW_H;
|
||||
int top0 = (axisH - blockH) / 2;
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
FlowModels.NodeVo n = layer.get(i);
|
||||
int[] wh = boxOf(n.getType());
|
||||
box.put(n.getId(), new int[]{
|
||||
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
|
||||
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
|
||||
wh[0], wh[1]});
|
||||
}
|
||||
}
|
||||
|
||||
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
|
||||
centerParallelGroups(nodes, out, box);
|
||||
|
||||
// 依据实际包围盒定画布尺寸并整体平移进留白
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE;
|
||||
int maxY = Integer.MIN_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minX = Math.min(minX, b[0]);
|
||||
minY = Math.min(minY, b[1]);
|
||||
maxX = Math.max(maxX, b[0] + b[2]);
|
||||
maxY = Math.max(maxY, b[1] + b[3]);
|
||||
}
|
||||
contentW = Math.max(contentW, NODE_W);
|
||||
boolean hasState = false;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (n.getState() != null) {
|
||||
@@ -136,9 +185,14 @@ public class DiagramImageService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
int imgW = contentW + MARGIN * 2;
|
||||
int imgH = MARGIN + TITLE_H + (maxLevel + 1) * NODE_H + maxLevel * LEVEL_GAP_Y
|
||||
+ MARGIN + (hasState ? LEGEND_H : 0);
|
||||
int dx = MARGIN - minX;
|
||||
int dy = MARGIN + TITLE_H - minY;
|
||||
for (int[] b : box.values()) {
|
||||
b[0] += dx;
|
||||
b[1] += dy;
|
||||
}
|
||||
int imgW = (maxX - minX) + MARGIN * 2;
|
||||
int imgH = TITLE_H + (maxY - minY) + MARGIN * 2 + (hasState ? LEGEND_H : 0);
|
||||
|
||||
BufferedImage canvas = new BufferedImage(imgW * SCALE, imgH * SCALE, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = canvas.createGraphics();
|
||||
@@ -149,66 +203,60 @@ public class DiagramImageService {
|
||||
g.fillRect(0, 0, imgW, imgH);
|
||||
|
||||
Font labelFont = cjkFont(Font.BOLD, 14);
|
||||
Font subFont = cjkFont(Font.PLAIN, 11);
|
||||
Font titleFont = cjkFont(Font.BOLD, 16);
|
||||
Font subFont = cjkFont(Font.PLAIN, 12);
|
||||
Font titleFont = cjkFont(Font.BOLD, 17);
|
||||
Font smallFont = cjkFont(Font.PLAIN, 12);
|
||||
|
||||
g.setFont(titleFont);
|
||||
g.setColor(C_TITLE);
|
||||
String title = vo.getName() == null || vo.getName().isEmpty() ? "流程示意图" : vo.getName();
|
||||
FontMetrics titleFm = g.getFontMetrics();
|
||||
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 6);
|
||||
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 2);
|
||||
|
||||
// 节点坐标
|
||||
Map<String, int[]> tl = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int rowW = layer.size() * (NODE_W + GAP_X) - GAP_X;
|
||||
int y = MARGIN + TITLE_H + en.getKey() * (NODE_H + LEVEL_GAP_Y);
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
tl.put(layer.get(i).getId(), new int[]{(imgW - rowW) / 2 + i * (NODE_W + GAP_X), y});
|
||||
}
|
||||
}
|
||||
|
||||
// 连线(先画线后画节点,节点底色盖住线头)
|
||||
// 连线(先画线后画节点,节点底色盖住线头):正交折线 右中→中线→左中
|
||||
Map<String, Map<String, String>> edgeNames = new HashMap<>();
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName());
|
||||
}
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.setStroke(new BasicStroke(1.6f));
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (!tl.containsKey(e.getFrom()) || !tl.containsKey(e.getTo())) {
|
||||
int[] from = box.get(e.getFrom());
|
||||
int[] to = box.get(e.getTo());
|
||||
if (from == null || to == null) {
|
||||
continue;
|
||||
}
|
||||
int[] from = tl.get(e.getFrom());
|
||||
int[] to = tl.get(e.getTo());
|
||||
int x1 = from[0] + NODE_W / 2, y1 = from[1] + NODE_H;
|
||||
int x2 = to[0] + NODE_W / 2, y2 = to[1] - ARROW - 1;
|
||||
int x1 = from[0] + from[2];
|
||||
int y1 = from[1] + from[3] / 2;
|
||||
int x2 = to[0];
|
||||
int y2 = to[1] + to[3] / 2;
|
||||
int mid = (x1 + x2) / 2;
|
||||
g.setColor(C_LINE);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
drawArrowHead(g, x2, y2);
|
||||
// 多出口分支文案:中点白底小字(单出口连线不标,避免噪音)
|
||||
g.drawLine(x1, y1, mid, y1);
|
||||
g.drawLine(mid, y1, mid, y2);
|
||||
g.drawLine(mid, y2, x2 - ARROW, y2);
|
||||
drawArrowHead(g, x2 - ARROW, y2);
|
||||
// 多出口分支文案:折线竖段中点白底小字(单出口连线不标,避免噪音)
|
||||
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
|
||||
drawMidLabel(g, x1, y1, x2, y2, e.getName(), smallFont);
|
||||
drawMidLabel(g, mid, (y1 + y2) / 2, e.getName(), smallFont);
|
||||
}
|
||||
}
|
||||
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
drawNode(g, n, tl.get(n.getId()), labelFont, subFont);
|
||||
drawNode(g, n, box.get(n.getId()), labelFont, subFont);
|
||||
}
|
||||
|
||||
// 图例(仅 instance 口径)
|
||||
if (hasState) {
|
||||
g.setFont(smallFont);
|
||||
FontMetrics sFm = g.getFontMetrics();
|
||||
int ly = imgH - LEGEND_H + 18;
|
||||
int ly = imgH - LEGEND_H + 20;
|
||||
int x = MARGIN;
|
||||
String[][] legend = {{"active", "在办"}, {"done", "已办"}, {"pending", "未到"}, {"rejected", "被拒绝"}};
|
||||
for (String[] it : legend) {
|
||||
g.setColor(fillOf(it[0]));
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(borderOf(it[0]));
|
||||
g.setStroke(new BasicStroke(1.2f));
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(C_TEXT);
|
||||
g.drawString(it[1], x + 24, ly + 2);
|
||||
@@ -219,88 +267,212 @@ public class DiagramImageService {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
|
||||
String type = n.getType() == null ? "" : n.getType();
|
||||
Color fill = fillOf(n);
|
||||
Color border = borderOf(n);
|
||||
boolean pill = type.equals(FlowDefModel.TYPE_START) || type.equals(FlowDefModel.TYPE_END);
|
||||
if (pill) { // 开始/结束:胶囊
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke(1.5f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = truncate(g, n.getLabel(), NODE_W - 24);
|
||||
g.drawString(label, p[0] + (NODE_W - fm.stringWidth(label)) / 2f,
|
||||
p[1] + (NODE_H - fm.getHeight()) / 2 + fm.getAscent());
|
||||
return;
|
||||
/**
|
||||
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
|
||||
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
|
||||
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
|
||||
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
|
||||
*/
|
||||
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
|
||||
Map<String, int[]> box) {
|
||||
class Group {
|
||||
final String split;
|
||||
final String join;
|
||||
final List<String> members;
|
||||
final int maxX;
|
||||
Group(String split, String join, List<String> members, int maxX) {
|
||||
this.split = split;
|
||||
this.join = join;
|
||||
this.members = members;
|
||||
this.maxX = maxX;
|
||||
}
|
||||
}
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2f : 1.4f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
// 文本:label 一行 + subLabel 最多两行(截断带 …)
|
||||
FontMetrics lf = g.getFontMetrics(labelF);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
String label = truncate(g, n.getLabel(), NODE_W - 20);
|
||||
g.drawString(label, p[0] + 10, p[1] + 12 + lf.getAscent());
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
FontMetrics sf = g.getFontMetrics(subF);
|
||||
List<String> lines = wrap(g, n.getSubLabel(), NODE_W - 20, 2);
|
||||
// 副标从 label 基线下方一整行处起步,避免与 label 重叠
|
||||
int firstLineY = p[1] + 12 + lf.getAscent() + sf.getAscent();
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
g.drawString(lines.get(i), p[0] + 10, firstLineY + i * sf.getHeight());
|
||||
List<Group> groups = new ArrayList<>();
|
||||
for (FlowModels.NodeVo split : nodes) {
|
||||
if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) {
|
||||
continue;
|
||||
}
|
||||
String sid = split.getId();
|
||||
// BFS 序 + 各分支可达集,用于定位配对 JOIN
|
||||
Map<String, Integer> order = new HashMap<>();
|
||||
order.put(sid, 0);
|
||||
List<String> bfs = new ArrayList<>();
|
||||
bfs.add(sid);
|
||||
while (!bfs.isEmpty()) {
|
||||
String cur = bfs.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (!order.containsKey(to)) {
|
||||
order.put(to, order.size());
|
||||
bfs.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> heads = out.getOrDefault(sid, new ArrayList<>());
|
||||
List<java.util.Set<String>> reachSets = new ArrayList<>();
|
||||
for (String head : heads) {
|
||||
java.util.Set<String> set = new java.util.HashSet<>();
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(head);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
if (set.contains(cur)) {
|
||||
continue;
|
||||
}
|
||||
set.add(cur);
|
||||
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
|
||||
}
|
||||
reachSets.add(set);
|
||||
}
|
||||
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
|
||||
for (java.util.Set<String> set : reachSets) {
|
||||
common = new java.util.HashSet<>(common);
|
||||
common.retainAll(set);
|
||||
}
|
||||
String join = null;
|
||||
int joinOrder = Integer.MAX_VALUE;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId())
|
||||
&& common.contains(n.getId())) {
|
||||
int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE);
|
||||
if (o < joinOrder) {
|
||||
joinOrder = o;
|
||||
join = n.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (join == null) {
|
||||
continue;
|
||||
}
|
||||
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
|
||||
List<String> members = new ArrayList<>();
|
||||
java.util.Set<String> seen = new java.util.HashSet<>();
|
||||
seen.add(join);
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(sid);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (seen.contains(to) || !box.containsKey(to)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(to);
|
||||
members.add(to);
|
||||
q.add(to);
|
||||
}
|
||||
}
|
||||
int maxLevel = 0;
|
||||
for (String m : members) {
|
||||
maxLevel = Math.max(maxLevel, box.get(m)[0]);
|
||||
}
|
||||
groups.add(new Group(sid, join, members, maxLevel));
|
||||
}
|
||||
// maxX 降序 = 内层组先处理
|
||||
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
|
||||
for (Group gp : groups) {
|
||||
int[] s = box.get(gp.split);
|
||||
int axisY = s[1] + s[3] / 2;
|
||||
int top = Integer.MAX_VALUE;
|
||||
int bottom = Integer.MIN_VALUE;
|
||||
for (String m : gp.members) {
|
||||
int[] b = box.get(m);
|
||||
top = Math.min(top, b[1]);
|
||||
bottom = Math.max(bottom, b[1] + b[3]);
|
||||
}
|
||||
int delta = axisY - (top + bottom) / 2;
|
||||
for (String m : gp.members) {
|
||||
box.get(m)[1] += delta;
|
||||
}
|
||||
int[] j = box.get(gp.join);
|
||||
j[1] += axisY - (j[1] + j[3] / 2);
|
||||
}
|
||||
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minY = Math.min(minY, b[1]);
|
||||
}
|
||||
if (minY < 0) {
|
||||
for (int[] b : box.values()) {
|
||||
b[1] -= minY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMidLabel(Graphics2D g, int x1, int y1, int x2, int y2, String s, Font font) {
|
||||
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
|
||||
String type = n.getType() == null ? "" : n.getType();
|
||||
if (FlowDefModel.TYPE_START.equals(type) || FlowDefModel.TYPE_END.equals(type)) {
|
||||
// 开始/结束:实心胶囊(绿/灰)+ 白字
|
||||
g.setColor(FlowDefModel.TYPE_START.equals(type) ? C_START_FILL : C_END_FILL);
|
||||
g.fillRoundRect(p[0], p[1], p[2], p[3], p[3], p[3]);
|
||||
g.setFont(labelF);
|
||||
g.setColor(Color.WHITE);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = truncate(g, n.getLabel(), p[2] - 24);
|
||||
g.drawString(label, p[0] + (p[2] - fm.stringWidth(label)) / 2f,
|
||||
p[1] + (p[3] - fm.getHeight()) / 2 + fm.getAscent());
|
||||
return;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(type) || FlowDefModel.TYPE_PARALLEL_JOIN.equals(type)) {
|
||||
// 并行分发/汇聚等待:菱形(橙框橙字,active 红框)
|
||||
int cx = p[0] + p[2] / 2;
|
||||
int cy = p[1] + p[3] / 2;
|
||||
Path2D d = new Path2D.Float();
|
||||
d.moveTo(cx, p[1]);
|
||||
d.lineTo(p[0] + p[2], cy);
|
||||
d.lineTo(cx, p[1] + p[3]);
|
||||
d.lineTo(p[0], cy);
|
||||
d.closePath();
|
||||
g.setColor(C_PARALLEL_FILL);
|
||||
g.fill(d);
|
||||
g.setColor("active".equals(n.getState()) ? C_ACTIVE_BORDER : C_PARALLEL_BORDER);
|
||||
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.2f : 1.6f));
|
||||
g.draw(d);
|
||||
g.setFont(subF);
|
||||
g.setColor(C_PARALLEL_TEXT);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = truncate(g, n.getLabel(), p[2] - 36);
|
||||
g.drawString(label, cx - fm.stringWidth(label) / 2f, cy + fm.getAscent() / 2f - 1);
|
||||
return;
|
||||
}
|
||||
// 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel
|
||||
g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE);
|
||||
g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
boolean active = "active".equals(n.getState());
|
||||
g.setColor(borderOf(n));
|
||||
g.setStroke(new BasicStroke(active ? 2.4f : 1.6f));
|
||||
g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
String label = truncate(g, n.getLabel(), p[2] - 20);
|
||||
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26);
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
String sub = truncate(g, n.getSubLabel(), p[2] - 20);
|
||||
g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMidLabel(Graphics2D g, int midX, int midY, String s, Font font) {
|
||||
g.setFont(font);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int sw = fm.stringWidth(s);
|
||||
int midX = (x1 + x2) / 2, midY = (y1 + y2) / 2;
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(midX - sw / 2 - 4, midY - fm.getAscent(), sw + 8, fm.getHeight());
|
||||
g.fillRect(midX - sw / 2 - 4, midY - fm.getAscent() / 2 - 3, sw + 8, fm.getHeight() + 2);
|
||||
g.setColor(C_EDGE_LABEL);
|
||||
g.drawString(s, midX - sw / 2f, midY);
|
||||
g.drawString(s, midX - sw / 2f, midY + fm.getAscent() / 2f - 1);
|
||||
}
|
||||
|
||||
/** 箭头指向 +x(分层自左向右,目标恒在右侧) */
|
||||
private void drawArrowHead(Graphics2D g, int x, int y) {
|
||||
Path2D p = new Path2D.Float();
|
||||
p.moveTo(x, y + ARROW);
|
||||
p.lineTo(x - ARROW / 2.0, y);
|
||||
p.lineTo(x + ARROW / 2.0, y);
|
||||
p.moveTo(x + ARROW, y);
|
||||
p.lineTo(x, y - ARROW / 2.0 + 1);
|
||||
p.lineTo(x, y + ARROW / 2.0 - 1);
|
||||
p.closePath();
|
||||
g.setColor(C_LINE);
|
||||
g.fill(p);
|
||||
}
|
||||
|
||||
private Color fillOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
return C_ACTIVE_FILL;
|
||||
}
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_FILL;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_FILL;
|
||||
}
|
||||
if ("done".equals(s)) {
|
||||
return new Color(0xEE, 0xF3, 0xFF);
|
||||
}
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
private Color borderOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
@@ -312,17 +484,14 @@ public class DiagramImageService {
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_BORDER;
|
||||
}
|
||||
if ("pending".equals(s)) {
|
||||
return C_PENDING_BORDER;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_BORDER;
|
||||
}
|
||||
return C_BORDER_DEFAULT;
|
||||
}
|
||||
|
||||
private Color fillOf(String state) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
n.setState(state);
|
||||
return fillOf(n);
|
||||
return C_APPROVAL_BORDER;
|
||||
}
|
||||
|
||||
private Color borderOf(String state) {
|
||||
@@ -330,11 +499,10 @@ public class DiagramImageService {
|
||||
case "active": return C_ACTIVE_BORDER;
|
||||
case "done": return C_DONE_BORDER;
|
||||
case "rejected": return C_REJECT_BORDER;
|
||||
default: return C_BORDER_DEFAULT;
|
||||
default: return C_PENDING_BORDER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 可选指定字体文件(TTF/OTF,支持 .ttc 首个 face):容器/精简 OS 无 CJK 系统字体时的终极兜底,
|
||||
* 配置路径后 createFont 注册为程序字体并优先命中。空(缺省)走系统字体探测。
|
||||
@@ -408,35 +576,6 @@ public class DiagramImageService {
|
||||
return f.canDisplayUpTo(sample) == -1 ? f : null;
|
||||
}
|
||||
|
||||
/** 贪心按像素宽度折行,最多 maxLines 行;溢出行以 … 结尾 */
|
||||
private static List<String> wrap(Graphics2D g, String text, int width, int maxLines) {
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
List<String> lines = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (i < text.length() && lines.size() < maxLines) {
|
||||
StringBuilder line = new StringBuilder();
|
||||
while (i < text.length()
|
||||
&& fm.stringWidth(line + "" + text.charAt(i)) <= width) {
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
if (line.length() == 0) { // 单字超宽防死循环
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
if (i < text.length() && lines.size() == maxLines) {
|
||||
// 溢出截断
|
||||
String last = lines.get(maxLines - 1);
|
||||
while (last.length() > 1 && fm.stringWidth(last + "…") > width) {
|
||||
last = last.substring(0, last.length() - 1);
|
||||
}
|
||||
lines.set(maxLines - 1, last + "…");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String truncate(Graphics2D g, String s, int width) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return "";
|
||||
|
||||
@@ -144,6 +144,29 @@ public class FlowDefAdminService {
|
||||
defRepository.save(def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
|
||||
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
|
||||
* 被停用,这里会点名报错(如"科室 X 无启用办理人"),管理员在办理人映射页点"启用"即可解。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef enable(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该流程已是发布状态,无需启用");
|
||||
}
|
||||
if (def.getStatus() != FlwEnums.DefStatus.DISABLED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布");
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
Set<String> enabledDeptKeys = mapRepository.findEnabledBySchId(schId).stream()
|
||||
.map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet());
|
||||
DefValidator.validate(model, enabledDeptKeys);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 同一流程线全部版本(降序) */
|
||||
public List<FlwDef> versions(Long schId, String flowGuid) {
|
||||
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
|
||||
|
||||
@@ -74,6 +74,40 @@ public class FlowDiagramService {
|
||||
return build(def, instance.getId(), mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 源码(2026-09-18,图示页"源码"抽屉):flowGuid 分支取最高已发布版,可见性口径与
|
||||
* {@link #byGuid} 完全一致;instanceId 分支由控制器先过 requireInstanceVisible 再传办件进来。
|
||||
*/
|
||||
public FlowModels.DefSourceVo sourceByGuid(Long schId, String flowGuid) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布"));
|
||||
if (!schId.equals(def.getSchId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "跨校不可查看");
|
||||
}
|
||||
return toSourceVo(def);
|
||||
}
|
||||
|
||||
/** 源码·办件分支:取该办件实际运行的 def 快照(可能是停用/旧版本) */
|
||||
public FlowModels.DefSourceVo sourceByInstance(FlwInstance instance) {
|
||||
FlwDef def = defRepository.findById(instance.getDefId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程定义不存在:" + instance.getDefId()));
|
||||
return toSourceVo(def);
|
||||
}
|
||||
|
||||
private FlowModels.DefSourceVo toSourceVo(FlwDef def) {
|
||||
FlowModels.DefSourceVo vo = new FlowModels.DefSourceVo();
|
||||
vo.setDefId(def.getId());
|
||||
vo.setFlowGuid(def.getFlowGuid());
|
||||
vo.setCode(def.getCode());
|
||||
vo.setName(def.getName());
|
||||
vo.setVersion(def.getVersion());
|
||||
vo.setStatus(def.getStatus());
|
||||
vo.setContentJson(def.getContentJson());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 出图主体(管理面 defId 分支按 defId 任意版本直调,草稿可预览) */
|
||||
public FlowModels.DiagramVo build(FlwDef def, Long instanceId, String mode) {
|
||||
String m = normalizeMode(mode);
|
||||
@@ -196,15 +230,12 @@ public class FlowDiagramService {
|
||||
|
||||
/** 科室视图 label 兜底链:有映射行取 deptName(全空→deptKey);无映射行→节点名(再退 deptKey) */
|
||||
private String deptLabel(FlowDefModel.Node n, FlowDefModel.Assignee a, List<FlwAssigneeMap> maps) {
|
||||
if (maps.isEmpty()) {
|
||||
return firstNonBlank(n.name, a.deptKey);
|
||||
// 科室名取值与流程轨迹科室口径共用(DeptLabelSupport),只保留本视图特有的兜底链
|
||||
String deptName = DeptLabelSupport.mappedDeptName(maps);
|
||||
if (deptName != null) {
|
||||
return deptName;
|
||||
}
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
if (m.getDeptName() != null && !m.getDeptName().trim().isEmpty()) {
|
||||
return m.getDeptName();
|
||||
}
|
||||
}
|
||||
return firstNonBlank(a.deptKey, n.name);
|
||||
return maps.isEmpty() ? firstNonBlank(n.name, a.deptKey) : firstNonBlank(a.deptKey, n.name);
|
||||
}
|
||||
|
||||
/** 人员名单(顿号连接)+ 会签/时限后缀(" · "分隔);名单与后缀均空则返回 null */
|
||||
@@ -287,9 +318,14 @@ public class FlowDiagramService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 出图口径归一化(2026-09-18 起两档,缺省=人员):dept=科室视图(DEPT_KEY 显科室名+人数、
|
||||
* USERS 只显"指定办理人"不列名单),user=人员视图;auto=按节点自适应(老深链兼容保留,
|
||||
* 不再出现在前端两档选择里,缺省值已改为 user)。
|
||||
*/
|
||||
private String normalizeMode(String mode) {
|
||||
if (mode == null || mode.trim().isEmpty()) {
|
||||
return MODE_AUTO;
|
||||
return MODE_USER;
|
||||
}
|
||||
String m = mode.trim();
|
||||
if (MODE_DEPT.equals(m) || MODE_USER.equals(m) || MODE_AUTO.equals(m)) {
|
||||
|
||||
@@ -89,6 +89,8 @@ public class FlowInstanceService {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
|
||||
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
return new ApplyOutcome(instance.getId(), false);
|
||||
@@ -112,6 +114,8 @@ public class FlowInstanceService {
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
taskRepository.invalidatePendingByInstance(instance.getId());
|
||||
branchRepository.deleteByInstance(instance.getId());
|
||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_RESUBMIT, "重新提交",
|
||||
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
}
|
||||
@@ -134,11 +138,39 @@ public class FlowInstanceService {
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
taskRepository.invalidatePendingByInstance(instanceId);
|
||||
branchRepository.invalidateRunningByInstance(instanceId);
|
||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_WITHDRAW, "撤回申请",
|
||||
FlwEnums.TaskStatus.WITHDRAWN, instance.getSubmitterId(), instance.getSubmitterName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("result", "WITHDRAWN");
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.WITHDRAWN, instanceId, null, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 轨迹留痕事件行(2026-09-18):办件级动作(发起/重新提交、撤回)也落一条 flw_task 行,
|
||||
* 让流程轨迹的时间线里这些动作本身可见(原实现只把待办批量置失效,撤回在轨迹上是空白)。
|
||||
*
|
||||
* <p>行 status=6/7 且 node_id 用保留值(submit/resubmit/withdraw,定义模型里不存在)——
|
||||
* 不参与引擎任何判断:不进待办(非 0)、不进已办(handledCodes 不含 6/7)、不挡撤回守卫、
|
||||
* 不进超期扫描与分支失效范围、示意图着色与退回目标解析也天然跳过。
|
||||
*/
|
||||
private void appendTraceEvent(FlwInstance instance, String nodeId, String nodeName,
|
||||
FlwEnums.TaskStatus status, Integer operatorId, String operatorName) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
|
||||
row.setInstanceId(instance.getId());
|
||||
row.setSchId(instance.getSchId());
|
||||
row.setNodeId(nodeId);
|
||||
row.setNodeName(nodeName);
|
||||
// assignee_id 非空:发起/撤回的操作人恒为提交人;缺失(脏数据)时落 0,不产生僵尸待办
|
||||
row.setAssigneeId(operatorId == null ? 0 : operatorId);
|
||||
row.setAssigneeName(operatorName);
|
||||
row.setStatus(status.getCode());
|
||||
row.setArriveTime(now);
|
||||
row.setHandleTime(now);
|
||||
row.setUrgeCount(0);
|
||||
taskRepository.save(row);
|
||||
}
|
||||
|
||||
public FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
|
||||
@@ -49,7 +49,8 @@ public class FlowMapAdminService {
|
||||
|
||||
public List<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
|
||||
if (deptKey == null || deptKey.trim().isEmpty()) {
|
||||
return mapRepository.findEnabledBySchId(schId);
|
||||
// 全量(含停用行):管理端要能看到停用行才能点"启用"恢复
|
||||
return mapRepository.findAllBySchId(schId);
|
||||
}
|
||||
return mapRepository.findBySchIdAndDeptKeyOrderByUserIdAsc(schId, deptKey.trim());
|
||||
}
|
||||
@@ -89,6 +90,18 @@ public class FlowMapAdminService {
|
||||
mapRepository.save(map);
|
||||
}
|
||||
|
||||
/** 启用映射(2026-09-18 补齐:原实现只有停用,停用后只能重录一条 sch+dept+user 来恢复) */
|
||||
@Transactional
|
||||
public FlwAssigneeMap enable(Long schId, Long id) {
|
||||
FlwAssigneeMap map = mapRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
|
||||
if (!map.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
|
||||
}
|
||||
map.setStatus(0);
|
||||
return mapRepository.save(map);
|
||||
}
|
||||
|
||||
/** 成员检索:按用户 id 从 UserBase 取姓名快照(管理端录入校验) */
|
||||
public BoaUser lookupUser(Integer userId) {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
@@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -39,6 +41,7 @@ public class FlowQueryService {
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
|
||||
public BasePageResult<FlowModels.InstanceItem> pageMy(Long schId, Integer submitterId,
|
||||
@@ -59,16 +62,36 @@ public class FlowQueryService {
|
||||
return toResult(p.getTotalElements(), items, page, size);
|
||||
}
|
||||
|
||||
/** 已办:一个办件一行(保留本人最后办理的那条),并回填累计办理次数 */
|
||||
public BasePageResult<FlowModels.TaskItem> pageDone(Long schId, Integer assigneeId,
|
||||
Integer page, Integer size) {
|
||||
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId,
|
||||
List<Integer> handled = FlwEnums.TaskStatus.handledCodes();
|
||||
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId, handled,
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.TaskItem> items = p.getContent().stream()
|
||||
.map(this::toTaskItem).collect(Collectors.toList());
|
||||
fillInstanceInfo(items);
|
||||
fillDoneCount(items, assigneeId, handled);
|
||||
return toResult(p.getTotalElements(), items, page, size);
|
||||
}
|
||||
|
||||
/** 已办行的累计办理次数(同一办件多节点办理/退回重办时的"共办理 N 次") */
|
||||
private void fillDoneCount(List<FlowModels.TaskItem> items, Integer assigneeId,
|
||||
List<Integer> handled) {
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> ids = items.stream().map(FlowModels.TaskItem::getInstanceId)
|
||||
.distinct().collect(Collectors.toList());
|
||||
Map<Long, Integer> counts = new HashMap<>();
|
||||
for (Object[] row : taskRepository.countDoneByInstance(ids, assigneeId, handled)) {
|
||||
counts.put((Long) row[0], ((Number) row[1]).intValue());
|
||||
}
|
||||
for (FlowModels.TaskItem item : items) {
|
||||
item.setDoneCount(counts.getOrDefault(item.getInstanceId(), 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件详情:快照 + 任务轨迹 + 分支进度 + 本人待办标记(办理页/详情页共用数据源)。
|
||||
* viewerId 必须由控制器从 BoaUser.getId() 传入(CAS 会话 LoginUser.userId 是登录名字符串,
|
||||
@@ -99,24 +122,9 @@ public class FlowQueryService {
|
||||
detail.setFinishTime(str(instance.getFinishTime()));
|
||||
detail.setVersion(instance.getVersion());
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
List<Map<String, Object>> trace = new ArrayList<>();
|
||||
for (FlwTask t : tasks) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("taskId", t.getId());
|
||||
row.put("nodeId", t.getNodeId());
|
||||
row.put("nodeName", t.getNodeName());
|
||||
row.put("assigneeId", t.getAssigneeId());
|
||||
row.put("assigneeName", t.getAssigneeName());
|
||||
row.put("status", t.getStatus());
|
||||
row.put("opinion", t.getOpinion());
|
||||
row.put("arriveTime", str(t.getArriveTime()));
|
||||
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);
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
|
||||
detail.setTrace(buildTrace(instance, tasks, model));
|
||||
List<Map<String, Object>> branches = new ArrayList<>();
|
||||
for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
@@ -136,15 +144,13 @@ public class FlowQueryService {
|
||||
detail.setMyPendingTask(!myPending.isEmpty());
|
||||
detail.setMyPendingTaskIds(myPending);
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
|
||||
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(model));
|
||||
// 办理面板数据(出口/退回目标/移交标记):仅对持本人待办的查看者填充
|
||||
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<FlowDefModel.Edge> outs = model.outEdges(curNodeId);
|
||||
if (outs.size() > 1) {
|
||||
@@ -213,6 +219,153 @@ public class FlowQueryService {
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程轨迹(2026-09-18 重排为"时间流"):审批任务行 + 留痕事件行(6 发起/重新提交、7 撤回)
|
||||
* 合成一条按时间升序的轨迹,替掉原先"按 id 升序"的排法(id 序在撤回重走场景下与
|
||||
* 真实发生顺序不一致)。
|
||||
*
|
||||
* <p>每条行补四个前端直接用、不再自行推断的字段:
|
||||
* <ul>
|
||||
* <li>{@code time} 该行实际发生的时间(已办/退回/移交/撤回=handleTime,在办/失效=arriveTime);</li>
|
||||
* <li>{@code kind} SUBMIT|RESUBMIT|WITHDRAW|APPROVE|REJECT|RETURN|TRANSFER|INVALID|PENDING
|
||||
* (前端配色与文案的唯一依据);</li>
|
||||
* <li>{@code round} 第几轮审批(遇"重新提交"进位,撤回属于它结束的那一轮);</li>
|
||||
* <li>{@code rowKey} 去重 key(任务行 t{id}、合成行 submit —— taskId 可能为 null 不能当 key)。</li>
|
||||
* </ul>
|
||||
* 科室口径(视图两档:dept/user)另需 assigneeType/deptKey/deptName —— 只有 DEPT_KEY 节点有
|
||||
* 科室概念,故仅对该类节点回填;节点在定义里找不到(重提后老节点、留痕事件行)不回填,
|
||||
* 前端回落人名显示。
|
||||
*
|
||||
* <p>老办件(改造前发起)没有"发起"留痕行,按办件快照的 submitTime/submitterName 合成一条
|
||||
* 兜底插入到时间流对应位置,保证轨迹首条仍是"发起申请"。
|
||||
*/
|
||||
private List<Map<String, Object>> buildTrace(FlwInstance instance, List<FlwTask> tasks,
|
||||
FlowDefModel model) {
|
||||
List<FlwTask> sorted = new ArrayList<>(tasks);
|
||||
sorted.sort(Comparator.comparing(FlowQueryService::happenedAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(FlwTask::getId));
|
||||
Map<String, String> deptNames = new HashMap<>();
|
||||
List<Map<String, Object>> trace = new ArrayList<>();
|
||||
int headIndex = 0;
|
||||
LocalDateTime submitTime = instance.getSubmitTime();
|
||||
for (FlwTask t : sorted) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("rowKey", "t" + t.getId());
|
||||
row.put("taskId", t.getId());
|
||||
row.put("kind", kindOf(t));
|
||||
row.put("nodeId", t.getNodeId());
|
||||
row.put("nodeName", t.getNodeName());
|
||||
row.put("assigneeId", t.getAssigneeId());
|
||||
row.put("assigneeName", t.getAssigneeName());
|
||||
row.put("status", t.getStatus());
|
||||
row.put("opinion", t.getOpinion());
|
||||
row.put("arriveTime", str(t.getArriveTime()));
|
||||
row.put("handleTime", str(t.getHandleTime()));
|
||||
row.put("time", str(happenedAt(t)));
|
||||
row.put("deadline", str(t.getDeadline()));
|
||||
row.put("urgeCount", t.getUrgeCount());
|
||||
row.put("edgeName", t.getEdgeName());
|
||||
fillAssigneeScope(row, model, t.getNodeId(), instance.getSchId(), deptNames);
|
||||
trace.add(row);
|
||||
// 老办件兜底行的插入位:时间早于提交时间的行数(老办件里恒为 0;重提过的老办件按时间放在中段)
|
||||
if (submitTime != null && happenedAt(t).isBefore(submitTime)) {
|
||||
headIndex = trace.size();
|
||||
}
|
||||
}
|
||||
if (!hasSubmitRow(sorted)) {
|
||||
trace.add(headIndex, syntheticSubmitRow(instance));
|
||||
}
|
||||
int round = 1;
|
||||
for (Map<String, Object> row : trace) {
|
||||
if ("RESUBMIT".equals(row.get("kind"))) {
|
||||
round++;
|
||||
}
|
||||
row.put("round", round);
|
||||
}
|
||||
return trace;
|
||||
}
|
||||
|
||||
private static boolean hasSubmitRow(List<FlwTask> tasks) {
|
||||
for (FlwTask t : tasks) {
|
||||
if (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 老办件兜底:改造前发起不留痕,用快照补一条"发起申请"(taskId 为空,前端按 rowKey 去重) */
|
||||
private static Map<String, Object> syntheticSubmitRow(FlwInstance instance) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("rowKey", "submit");
|
||||
row.put("taskId", null);
|
||||
row.put("kind", "SUBMIT");
|
||||
row.put("nodeId", FlwEnums.TaskStatus.NODE_SUBMIT);
|
||||
row.put("nodeName", "发起申请");
|
||||
row.put("assigneeId", instance.getSubmitterId());
|
||||
row.put("assigneeName", instance.getSubmitterName());
|
||||
row.put("status", FlwEnums.TaskStatus.SUBMITTED.getCode());
|
||||
row.put("time", str(instance.getSubmitTime()));
|
||||
row.put("synthetic", true);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 该行实际发生的时间:办结过看 handleTime,否则看 arriveTime */
|
||||
private static LocalDateTime happenedAt(FlwTask t) {
|
||||
return t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime();
|
||||
}
|
||||
|
||||
private static String kindOf(FlwTask t) {
|
||||
int s = t.getStatus() == null ? FlwEnums.TaskStatus.PENDING.getCode() : t.getStatus();
|
||||
if (s == FlwEnums.TaskStatus.SUBMITTED.getCode()) {
|
||||
return FlwEnums.TaskStatus.NODE_RESUBMIT.equals(t.getNodeId()) ? "RESUBMIT" : "SUBMIT";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.WITHDRAWN.getCode()) {
|
||||
return "WITHDRAW";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.APPROVED.getCode()) {
|
||||
return "APPROVE";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.REJECTED.getCode()) {
|
||||
return "REJECT";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.INVALIDATED.getCode()) {
|
||||
return "INVALID";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.RETURNED.getCode()) {
|
||||
return "RETURN";
|
||||
}
|
||||
if (s == FlwEnums.TaskStatus.TRANSFERRED.getCode()) {
|
||||
return "TRANSFER";
|
||||
}
|
||||
return "PENDING";
|
||||
}
|
||||
|
||||
/**
|
||||
* 科室口径富化:节点是 DEPT_KEY(科室办理)时带上 deptKey + 映射里的科室名,
|
||||
* 前端"按科室显示"据此把人名换成科室名;USERS(指定人员)节点只带 assigneeType,
|
||||
* 由前端显示"指定办理人"(口径同示意图的科室视图)。
|
||||
*/
|
||||
private void fillAssigneeScope(Map<String, Object> row, FlowDefModel model, String nodeId,
|
||||
Long schId, Map<String, String> deptNames) {
|
||||
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
|
||||
FlowDefModel.Assignee assignee = node == null ? null : node.assignee;
|
||||
if (assignee == null || assignee.type == null) {
|
||||
return;
|
||||
}
|
||||
row.put("assigneeType", assignee.type);
|
||||
if (!FlowDefModel.ASSIGNEE_DEPT_KEY.equals(assignee.type) || assignee.deptKey == null) {
|
||||
return;
|
||||
}
|
||||
row.put("deptKey", assignee.deptKey);
|
||||
String deptName = deptNames.computeIfAbsent(assignee.deptKey,
|
||||
k -> DeptLabelSupport.mappedDeptName(mapRepository.findEnabled(schId, k)));
|
||||
if (deptName != null) {
|
||||
row.put("deptName", deptName);
|
||||
}
|
||||
}
|
||||
|
||||
private FlowModels.TaskItem toTaskItem(FlwTask t) {
|
||||
FlowModels.TaskItem item = new FlowModels.TaskItem();
|
||||
item.setTaskId(t.getId());
|
||||
|
||||
@@ -217,7 +217,54 @@ public class OapiFlowService {
|
||||
if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
|
||||
activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(",")));
|
||||
}
|
||||
// 第 0 步固定"发起申请"(对外时间线图3/图4口径)
|
||||
List<FlwTask> rows = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||||
boolean hasSubmitRow = rows.stream()
|
||||
.anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()));
|
||||
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
|
||||
// 第 0 步"发起申请"(对外时间线图3/图4口径):2026-09-18 起发起会留痕成真实任务行,
|
||||
// 有真实行时由下面的循环出这一步(可见内容与这里的合成步一致);老办件没有真实行,
|
||||
// 仍按办件快照合成,存量数据对外表现不变。
|
||||
if (!hasSubmitRow) {
|
||||
steps.add(submitStep(instance));
|
||||
}
|
||||
// 轨迹留痕事件行(6 发起/重新提交、7 撤回)单独成步;步骤 label 直接用事件语义,
|
||||
// 不参与下面的"任一拒绝>任一同意>全失效"汇总(否则会被判成"失效")。
|
||||
Set<OapiModels.ProgressStepVo> eventSteps =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
|
||||
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
|
||||
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
|
||||
for (FlwTask t : rows) {
|
||||
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
|
||||
OapiModels.ProgressStepVo es = eventStep(t);
|
||||
eventSteps.add(es);
|
||||
steps.add(es);
|
||||
continue;
|
||||
}
|
||||
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
|
||||
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
|
||||
s.setNodeId(t.getNodeId());
|
||||
s.setNodeName(t.getNodeName());
|
||||
s.setAssignees(new ArrayList<>());
|
||||
steps.add(s);
|
||||
return s;
|
||||
});
|
||||
step.getAssignees().add(taskVo(t));
|
||||
}
|
||||
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
|
||||
for (OapiModels.ProgressStepVo s : steps) {
|
||||
if (eventSteps.contains(s)) {
|
||||
continue;
|
||||
}
|
||||
s.setActive(activeNodes.contains(s.getNodeId()));
|
||||
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
|
||||
}
|
||||
vo.setSteps(steps);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 合成"发起申请"步(老办件:无真实留痕行,取办件快照的提交人/提交时间) */
|
||||
private OapiModels.ProgressStepVo submitStep(FlwInstance instance) {
|
||||
OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo();
|
||||
submit.setNodeId("submit");
|
||||
submit.setNodeName("发起申请");
|
||||
@@ -228,44 +275,49 @@ public class OapiFlowService {
|
||||
submitter.setUserName(instance.getSubmitterName());
|
||||
submitter.setStatus(1);
|
||||
submitter.setStatusLabel("已提交");
|
||||
submitter.setArriveTime(vo.getSubmitTime());
|
||||
submitter.setArriveTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
|
||||
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
|
||||
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
|
||||
steps.add(submit);
|
||||
return submit;
|
||||
}
|
||||
|
||||
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
|
||||
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
|
||||
for (FlwTask t : taskRepository.findByInstanceIdOrderByIdAsc(instance.getId())) {
|
||||
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
|
||||
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
|
||||
s.setNodeId(t.getNodeId());
|
||||
s.setNodeName(t.getNodeName());
|
||||
s.setAssignees(new ArrayList<>());
|
||||
steps.add(s);
|
||||
return s;
|
||||
});
|
||||
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
|
||||
row.setTaskId(t.getId());
|
||||
row.setUserId(t.getAssigneeId());
|
||||
row.setUserName(t.getAssigneeName());
|
||||
row.setStatus(t.getStatus());
|
||||
row.setStatusLabel(taskLabel(t.getStatus()));
|
||||
row.setOpinion(t.getOpinion());
|
||||
row.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||||
row.setHandleTime(t.getHandleTime() == null ? null : t.getHandleTime().toString());
|
||||
row.setEdgeName(t.getEdgeName());
|
||||
step.getAssignees().add(row);
|
||||
/**
|
||||
* 留痕事件步(发起/重新提交/撤回):nodeId 用保留值 submit/resubmit/withdraw。
|
||||
* 发起步保留原合成步的口径(nodeName/label=发起申请、参与行 status=1/已提交),
|
||||
* 使新老办件对外输出一致;撤回与重新提交是本轮新增的对外可见动作。
|
||||
*/
|
||||
private OapiModels.ProgressStepVo eventStep(FlwTask t) {
|
||||
boolean submitted = FlwEnums.TaskStatus.SUBMITTED.getCode() == t.getStatus();
|
||||
OapiModels.ProgressStepVo step = new OapiModels.ProgressStepVo();
|
||||
step.setNodeId(t.getNodeId());
|
||||
step.setNodeName(t.getNodeName());
|
||||
step.setActive(false);
|
||||
step.setLabel(!submitted ? "已撤回"
|
||||
: (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()) ? "发起申请" : "已提交"));
|
||||
OapiModels.ProgressTaskVo vo = taskVo(t);
|
||||
if (submitted) {
|
||||
// 发起/重新提交沿用"已提交"文案(status=1 与老合成步一致,消费方无需适配)
|
||||
vo.setStatus(1);
|
||||
vo.setStatusLabel("已提交");
|
||||
} else {
|
||||
vo.setStatusLabel("已撤回");
|
||||
}
|
||||
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
|
||||
for (OapiModels.ProgressStepVo s : steps) {
|
||||
if (s == submit) {
|
||||
continue;
|
||||
}
|
||||
s.setActive(activeNodes.contains(s.getNodeId()));
|
||||
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
|
||||
}
|
||||
vo.setSteps(steps);
|
||||
return vo;
|
||||
step.setAssignees(new ArrayList<>(java.util.Collections.singletonList(vo)));
|
||||
return step;
|
||||
}
|
||||
|
||||
/** 任务行 → 对外步骤里的办理人行 */
|
||||
private OapiModels.ProgressTaskVo taskVo(FlwTask t) {
|
||||
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
|
||||
row.setTaskId(t.getId());
|
||||
row.setUserId(t.getAssigneeId());
|
||||
row.setUserName(t.getAssigneeName());
|
||||
row.setStatus(t.getStatus());
|
||||
row.setStatusLabel(taskLabel(t.getStatus()));
|
||||
row.setOpinion(t.getOpinion());
|
||||
row.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||||
row.setHandleTime(t.getHandleTime() == null ? null : t.getHandleTime().toString());
|
||||
row.setEdgeName(t.getEdgeName());
|
||||
return row;
|
||||
}
|
||||
|
||||
private String taskLabel(Integer status) {
|
||||
@@ -279,6 +331,8 @@ public class OapiFlowService {
|
||||
case 3: return "失效";
|
||||
case 4: return "已退回";
|
||||
case 5: return "已移交";
|
||||
case 6: return "已提交";
|
||||
case 7: return "已撤回";
|
||||
default: return String.valueOf(status);
|
||||
}
|
||||
}
|
||||
@@ -408,7 +462,13 @@ public class OapiFlowService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 办件出参:轨迹(含 assigneeId,拍板项#3 办理人放行判定依赖) */
|
||||
/**
|
||||
* 办件出参:轨迹(含 assigneeId,拍板项#3 办理人放行判定依赖)。
|
||||
*
|
||||
* <p>2026-09-18:留痕事件行(6 发起/重新提交、7 撤回)<b>不进</b>本出参——它是"办理人与进度"
|
||||
* 的对账口径(assigneeIds 明确是"办理人集合",提交人另有 submitterId 字段,混进来会让
|
||||
* 消费方把提交人当办理人)。撤回/重提的对外可见性走 /oapi/flow/progress 的 steps(人读轨迹)。
|
||||
*/
|
||||
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
|
||||
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
|
||||
vo.setInstanceId(instance.getId());
|
||||
@@ -425,6 +485,9 @@ public class OapiFlowService {
|
||||
List<Map<String, Object>> trace = new ArrayList<>();
|
||||
Set<Integer> assigneeIds = new LinkedHashSet<>();
|
||||
for (com.yunhai.flowcenter.entity.FlwTask t : tasks) {
|
||||
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
assigneeIds.add(t.getAssigneeId());
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("taskId", t.getId());
|
||||
|
||||
@@ -67,14 +67,17 @@ yh:
|
||||
"100617": "2a3b4c5d6e7f8091a2b3c4d5e6f70819"
|
||||
open:
|
||||
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删;
|
||||
# 本地全链测试:指向 local-debug 的 UserBase;生产改回 base.yunhaixingtong.com
|
||||
# 本地全链测试:指向 local-debug 的 UserBase(本地部署无 context-path,必须经 services 显式
|
||||
# 覆盖枚举兜底的 /userbase 前缀,否则签名剥离与服务端不对齐 → 验签失败);
|
||||
# 生产改回 base.yunhaixingtong.com(经 Caddy 挂 /userbase,届时删掉 services 覆盖)
|
||||
base-host: http://localhost:8060
|
||||
base-path: /userbase
|
||||
# 服务目录(子应用配置优化方案 §4.2):usermsg 未部署时无需配置(enabled=false 即不解析);
|
||||
# 部署后配容器直连完整 URL(如 http://usermsg-service:100xx/usermsg),或走平台级
|
||||
# YH_OPEN_SERVICES_USERMSG 注入。
|
||||
# services:
|
||||
# usermsg: http://localhost:8080
|
||||
base-path: ""
|
||||
# 服务目录(子应用配置优化方案 §4.2):显式覆盖 PlatformService 枚举兜底的 /userbase basePath;
|
||||
# usermsg 未部署时无需配置(enabled=false 即不解析);部署后配容器直连完整 URL
|
||||
services:
|
||||
userbase: http://localhost:8060
|
||||
# usermsg 未部署时无需配置(enabled=false 即不解析);部署后配容器直连完整 URL
|
||||
# usermsg: http://localhost:8080
|
||||
|
||||
# ===== 流程中心专属配置 =====
|
||||
flow-center:
|
||||
|
||||
@@ -41,6 +41,8 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
private FlowTaskService taskService;
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.service.FlowQueryService queryService;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@@ -107,7 +109,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus());
|
||||
assertNotNull(instance.getFinishTime());
|
||||
// 同批另一待办已失效
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||||
List<FlwTask> all = allTasks(instance.getId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus());
|
||||
}
|
||||
@@ -129,6 +131,15 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
instanceService.withdraw(SCH, outcome.getInstanceId());
|
||||
assertEquals(FlwEnums.InstanceStatus.WITHDRAWN.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
// 撤回留痕(2026-09-18):轨迹里必须有"撤回申请"行,否则撤回在时间线上是空白
|
||||
List<FlwTask> afterWithdraw = traceRows(outcome.getInstanceId());
|
||||
FlwTask withdrawRow = afterWithdraw.stream()
|
||||
.filter(t -> FlwEnums.TaskStatus.NODE_WITHDRAW.equals(t.getNodeId())).findFirst().orElse(null);
|
||||
assertNotNull(withdrawRow, "撤回必须留痕");
|
||||
assertEquals(FlwEnums.TaskStatus.WITHDRAWN.getCode(), withdrawRow.getStatus());
|
||||
assertEquals(201, withdrawRow.getAssigneeId()); // 操作人=提交人
|
||||
// 留痕行不算"已被办理":重提前的撤回守卫口径不能被自己写的事件行挡住
|
||||
assertFalse(FlwEnums.TaskStatus.handledCodes().contains(withdrawRow.getStatus()));
|
||||
|
||||
// 重提重启后再办理,随后撤回必须被拒(已有任务被办理)
|
||||
FlowInstanceService.ApplyOutcome reapplied = apply(def.getFlowGuid(), "biz-4", 201, "张三");
|
||||
@@ -137,6 +148,60 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertThrows(BizException.class, () -> instanceService.withdraw(SCH, reapplied.getInstanceId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 轨迹时间流 + 已办去重(2026-09-18):
|
||||
* ① 发起/重新提交留痕成事件行,detail.trace 按发生时间升序且带 kind/round/科室富化;
|
||||
* ② 同一办件被本人办理两次(驳回 + 重提后再同意)时,已办列表只出一行、doneCount=2、
|
||||
* 结果取最后一次(同意),并保留"共办理 N 次"的提示依据。
|
||||
*/
|
||||
@Test
|
||||
void trace_timeline_kindsRounds_andDoneDedup() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t20", "轨迹时间流", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-20", 201, "张三");
|
||||
Long instanceId = first.getInstanceId();
|
||||
|
||||
// 一轮:办理人驳回;提交人重提;二轮:同一个人同意 → 同一办件上本人两条已办
|
||||
taskService.handle(SCH, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(),
|
||||
false, "材料不全", 101);
|
||||
apply(def.getFlowGuid(), "biz-20", 201, "张三");
|
||||
taskService.handle(SCH, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(),
|
||||
true, "补齐了", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
|
||||
// ① 轨迹时间流:发起 → 驳回 → 重新提交 → 同意
|
||||
java.util.List<java.util.Map<String, Object>> trace =
|
||||
queryService.detail(SCH, instanceId, null).getTrace();
|
||||
java.util.List<String> kinds = new java.util.ArrayList<>();
|
||||
for (java.util.Map<String, Object> row : trace) {
|
||||
kinds.add((String) row.get("kind"));
|
||||
}
|
||||
assertEquals(java.util.Arrays.asList("SUBMIT", "REJECT", "RESUBMIT", "APPROVE"), kinds);
|
||||
// 时间为升序(前端直接按后端顺序渲染)
|
||||
for (int i = 1; i < trace.size(); i++) {
|
||||
assertTrue(String.valueOf(trace.get(i).get("time"))
|
||||
.compareTo(String.valueOf(trace.get(i - 1).get("time"))) >= 0,
|
||||
"轨迹必须按发生时间升序:" + trace);
|
||||
}
|
||||
// 轮次:驳回属于第 1 轮,重新提交与二轮同意属于第 2 轮
|
||||
assertEquals(1, trace.get(1).get("round"));
|
||||
assertEquals(2, trace.get(2).get("round"));
|
||||
assertEquals(2, trace.get(3).get("round"));
|
||||
// 科室口径富化(DEPT_KEY 节点):deptName 取映射里的科室名,前端"按科室"据此换掉人名
|
||||
assertEquals("DEPT_KEY", trace.get(3).get("assigneeType"));
|
||||
assertEquals("ybb", trace.get(3).get("deptKey"));
|
||||
assertEquals("ybb科", trace.get(3).get("deptName"));
|
||||
|
||||
// ② 已办去重:同一办件一行,结果是最后一次(同意),次数=2
|
||||
com.yunhai.platform.utils.model.BasePageResult<com.yunhai.flowcenter.model.FlowModels.TaskItem>
|
||||
done = queryService.pageDone(SCH, 101, 1, 10);
|
||||
assertEquals(1, done.getContent().size(), "一个办件只能有一条已办记录");
|
||||
assertEquals(2, done.getContent().get(0).getDoneCount());
|
||||
assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), done.getContent().get(0).getStatus());
|
||||
assertEquals("审核", done.getContent().get(0).getNodeName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reapply_inFlight_duplicated_noNewInstance() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
@@ -171,7 +236,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), after.getStatus());
|
||||
assertTrue(after.getVersion() > versionBefore, "重启后 instanceVersion 必须单调递增");
|
||||
// 轨迹连续:历史拒绝任务保留 + 新一轮任务待办
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(first.getInstanceId());
|
||||
List<FlwTask> all = allTasks(first.getInstanceId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
assertNull(after.getFinishTime());
|
||||
@@ -240,7 +305,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
// 只应有一轮 END 完成(无重复推进导致的重复任务创建:总任务数=2)
|
||||
assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(outcome.getInstanceId()).size());
|
||||
assertEquals(2, allTasks(outcome.getInstanceId()).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -306,7 +371,7 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
taskService.handle(SCH, pending.get(0).getId(), true, "我先批", 101);
|
||||
FlwInstance instance = instanceRepository.findById(instanceId).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instance.getStatus());
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
List<FlwTask> all = allTasks(instanceId);
|
||||
assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus());
|
||||
}
|
||||
@@ -375,7 +440,18 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository;
|
||||
|
||||
/**
|
||||
* 审批任务行(剔除 6/7 留痕事件行,2026-09-18 起"发起/重新提交/撤回"也落 flw_task 行):
|
||||
* 只关心审批动作的断言(按序号取值、按数量计数)必须走这个口,否则会取到事件行。
|
||||
*/
|
||||
private List<FlwTask> allTasks(Long instanceId) {
|
||||
return taskRepository.findByInstanceIdOrderByIdAsc(instanceId).stream()
|
||||
.filter(t -> !FlwEnums.TaskStatus.eventCodes().contains(t.getStatus()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/** 全量轨迹行(含 6/7 留痕事件行):留痕类断言用 */
|
||||
private List<FlwTask> traceRows(Long instanceId) {
|
||||
return taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,25 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
assertEquals(40, instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertEquals(0, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), "待办任务须全部失效");
|
||||
|
||||
// 对外轨迹(progress steps)必须能看到"撤回"这一步(2026-09-18 撤回留痕)
|
||||
JsonNode progress = unwrap(client.get(
|
||||
"/oapi/flow/progress?schId=1&instanceId=" + instanceId, JsonNode.class).getBody());
|
||||
JsonNode steps = progress.get("steps");
|
||||
JsonNode withdrawStep = null;
|
||||
for (JsonNode s : steps) {
|
||||
if ("withdraw".equals(s.get("nodeId").asText())) {
|
||||
withdrawStep = s;
|
||||
}
|
||||
}
|
||||
assertNotNull(withdrawStep, "对外进度须含撤回步骤:" + steps);
|
||||
assertEquals("撤回申请", withdrawStep.get("nodeName").asText());
|
||||
assertEquals("已撤回", withdrawStep.get("label").asText());
|
||||
assertEquals("已撤回", withdrawStep.get("assignees").get(0).get("statusLabel").asText());
|
||||
assertEquals(201, withdrawStep.get("assignees").get(0).get("userId").asInt());
|
||||
// 发起步仍在首位且文案不变(新办件用真实留痕行,老办件走合成步——对外表现一致)
|
||||
assertEquals("submit", steps.get(0).get("nodeId").asText());
|
||||
assertEquals("发起申请", steps.get(0).get("label").asText());
|
||||
|
||||
// 重复撤回(已 40)→ 拒绝
|
||||
ResponseEntity<JsonNode> again = client.post(
|
||||
"/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=201", null, JsonNode.class);
|
||||
|
||||
@@ -210,10 +210,22 @@ class FlowDiagramServiceTest {
|
||||
void c1_autoView_deptKeyNode_rendersDeptStyle() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, null), "n1");
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "auto"), "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
assertEquals("auto", service.build(def(DEPT_DEF_JSON), null, null).getMode());
|
||||
assertEquals("auto", service.build(def(DEPT_DEF_JSON), null, "auto").getMode());
|
||||
}
|
||||
|
||||
/** 2026-09-18:mode 缺省改为人员视图(原 auto 自适应)——老深链显式带 auto 仍按自适应出图 */
|
||||
@Test
|
||||
void c1_defaultMode_isUserView() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, null);
|
||||
assertEquals("user", vo.getMode());
|
||||
FlowModels.NodeVo n1 = node(vo, "n1");
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("张三、李四 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
// ==================== C2:USERS 节点人员视图(UserBase 补名降级) ====================
|
||||
@@ -289,7 +301,8 @@ class FlowDiagramServiceTest {
|
||||
FlowModels.DiagramVo vo = service.byGuid(SCH, "g1", null);
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
assertEquals("auto", vo.getMode());
|
||||
// 2026-09-18:缺省口径=人员视图
|
||||
assertEquals("user", vo.getMode());
|
||||
assertNull(vo.getInstanceId());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user