fix(approval): 派单归属受理部门负责人、PC 全租户待办改派与 keywords 统一

- 协调单流程定义升至 v2:dispatch 节点 resolver 改为 acceptOrgLeader,v1 行不可变保留;getOrCreate 取最新版本
- 详情新增 pendingTasks,canAssign 在持有 assign 权限且实例 PENDING 时可用
- /todo/page 支持 scope=all(仅 assign 权限),详情/列表/导出可见性 SQL 与 countVisible 一致
- 查询参数 keyword 统一为 BaseParam.keywords;术语统一为报送人
- 删除 ApprovalDirectoryService.hasDepartmentLeader 与 ApprovalFileService.getById 死代码
This commit is contained in:
2026-10-30 22:28:05 +08:00
parent b8ee1bb3b0
commit 252e99421e
26 changed files with 317 additions and 82 deletions
@@ -120,17 +120,22 @@ class ApprovalCoordinationIT {
.andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty())
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
// 流程定义按租户幂等,节点顺序固定;一期不产生外部协调流程
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", TENANT)).isEqualTo(1);
// 流程定义按租户幂等发布最新版本,节点顺序固定;v1 保留不改,v2 派单归受理部门负责人
assertThat(data.queryMaster("SELECT MAX(version) FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal'", Integer.class, TENANT)).isEqualTo(2);
String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", String.class, TENANT);
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' "
+ "ORDER BY version DESC LIMIT 1", String.class, TENANT);
assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch")
.contains("confirm").contains("archive");
assertThat(nodes).contains("\"type\": \"DISPATCH\", \"resolver\": \"acceptOrgLeader\"");
// 实例固定引用创建时的定义版本(v2)
assertThat(data.queryMaster("SELECT definition_version FROM approval_instance WHERE id = ?",
Integer.class, instanceId)).isEqualTo(2);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
// 报送部门与经办人只取登录态,客户端伪造字段被忽略
// 报送部门与报送人只取登录态,客户端伪造字段被忽略
ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId);
String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\","
+ "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}";
@@ -350,7 +355,10 @@ class ApprovalCoordinationIT {
String.class, instanceId)).isEqualTo("dispatch");
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
String.class, instanceId)).isEqualTo("DISPATCH");
assertThat(pendingTaskIds(instanceId)).contains(acceptMember.userId);
// 派单节点只分配给受理部门负责人,普通部门成员无派单待办
assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'APPROVE'", instanceId)).isEqualTo(2);
assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask))
@@ -122,6 +122,12 @@ class ApprovalDispatchIT {
long instanceId = reachDispatch("部门候选池");
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
// 派单节点只落在受理部门负责人(或签),普通部门成员不产生派单待办
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
// 部门内停用成员不进入候选池
ApprovalTestData.TestUser disabled =
data.createUser("派单停用成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
@@ -158,6 +164,11 @@ class ApprovalDispatchIT {
// 受理部门成员全部调离后部门为空,派单待办仍在负责人手中
data.setUserOrganization(acceptLeader.userId, reportOrgId);
data.setUserOrganization(acceptMember.userId, reportOrgId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH' AND status = 'PENDING'",
instanceId, acceptLeader.userId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
acceptLeader, reportOrgId, APPROVE))
@@ -179,7 +190,6 @@ class ApprovalDispatchIT {
void dispatchesToSelectedCandidateAndCreatesBothConfirmTasks() throws Exception {
long instanceId = reachDispatch("派单存档");
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
long otherDispatchTask = pendingTaskId(instanceId, acceptMember.userId);
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
acceptLeader, acceptOrgId, APPROVE)
@@ -193,13 +203,13 @@ class ApprovalDispatchIT {
.andExpect(jsonPath("$.data.dispatchTargetName").value(acceptMember.realName))
.andExpect(jsonPath("$.data.myTask").doesNotExist());
// 派单任务完成并快照派单对象,同节点其余派单待办取消
// 派单任务完成并快照派单对象;派单节点只有负责人的一条或签待办
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask))
.isEqualTo("DONE");
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_task WHERE id = ?",
Integer.class, dispatchTask)).isEqualTo(acceptMember.userId);
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, otherDispatchTask))
.isEqualTo("CANCELLED");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
Integer.class, instanceId)).isEqualTo(acceptMember.userId);
assertThat(data.queryMaster("SELECT dispatch_target_name FROM approval_instance WHERE id = ?",
@@ -273,7 +283,6 @@ class ApprovalDispatchIT {
void concurrentDispatchOnlySucceedsOnce() throws Exception {
long instanceId = reachDispatch("并发派单");
long leaderTask = pendingTaskId(instanceId, acceptLeader.userId);
long memberTask = pendingTaskId(instanceId, acceptMember.userId);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
@@ -283,7 +292,7 @@ class ApprovalDispatchIT {
try {
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
acceptMember.userId, success, conflict);
dispatchAsync(executor, start, done, memberTask, acceptMember, acceptOrgId,
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
acceptLeader.userId, success, conflict);
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
@@ -298,7 +307,7 @@ class ApprovalDispatchIT {
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH' AND status = 'DONE'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH' AND status = 'CANCELLED'", instanceId)).isEqualTo(1);
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
}
@@ -139,7 +139,7 @@ class ApprovalExportIT {
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围")));
@@ -164,7 +164,7 @@ class ApprovalExportIT {
String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
@@ -244,7 +244,7 @@ class ApprovalExportIT {
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条")));
@@ -281,10 +281,10 @@ class ApprovalExportIT {
return null;
}
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keyword) throws Exception {
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keywords) throws Exception {
String response = mockMvc.perform(authed(post(URL), user, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(keyword) + "}"))
.content("{\"keywords\":" + quote(keywords) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
@@ -142,6 +142,54 @@ class ApprovalPcTodoIT {
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
}
@Test
void listsAllTenantPendingTasksForAssignUserAndReassignsOthers() throws Exception {
String title = "全租户待办" + data.uniqueSuffix();
long instanceId = createAndSubmit(title);
long taskId = pendingTaskId(instanceId, leader.userId);
// 有 assign 权限的 PC 用户通过 scope=all 看到他人待办
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all")
.param("keywords", title), admin, organizationId, LIST, ASSIGN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
// 非处理人也能打开详情,并按任务 id 拿到待办任务与改派权限
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), admin, organizationId, LIST, ASSIGN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.permissions.canAssign").value(true))
.andExpect(jsonPath("$.data.myTask").doesNotExist())
.andExpect(jsonPath("$.data.pendingTasks.length()").value(1))
.andExpect(jsonPath("$.data.pendingTasks[0].id").value((int) taskId))
.andExpect(jsonPath("$.data.pendingTasks[0].assigneeId").value(leader.userId))
.andExpect(jsonPath("$.data.pendingTasks[0].nodeName").value("报送部门负责人"));
// 按待办任务 id 改派成功,待办转移到新处理人
ApprovalTestData.TestUser target = data.createUser("全租户改派目标" + data.uniqueSuffix(), organizationId);
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "全租户改派" + data.uniqueSuffix()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.assigneeId").value(target.userId));
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
}
@Test
void rejectsScopeAllWithoutAssignAuthority() throws Exception {
long instanceId = createAndSubmit("全租户越权" + data.uniqueSuffix());
assertThat(instanceId).isPositive();
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all"), admin, organizationId, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void rejectsAssignToUnknownOrInactiveUser() throws Exception {
long instanceId = createAndSubmit("改派无效目标");
@@ -81,25 +81,25 @@ class ApprovalQueryIT {
long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
// 单据类型 + 关键词命中实施单位
assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted);
assertThat(queryIds("?docType=completion&keywords=实施部")).containsExactly(submitted);
// 关键词命中协调单标题
assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination);
assertThat(queryIds("?docType=coordination&keywords=维修协调")).containsExactly(coordination);
// 关键词命中单号
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted);
assertThat(queryIds("?keywords=" + bizNo)).containsExactly(submitted);
// 报送部门:两类单据都按报送部门过滤,其它部门不命中
assertThat(queryIds("?reportOrgId=" + reportOrgId))
.containsExactlyInAnyOrder(submitted, coordination, draft);
assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
// 受理部门
assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
// 发起人
// 报送人
assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
// 状态
assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
// 总数与分页总数一致
assertThat(queryCount("?keyword=实施部")).isEqualTo(1);
assertThat(queryCount("?keywords=实施部")).isEqualTo(1);
// 单号精确筛选
assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
// 提交时间范围
@@ -142,7 +142,7 @@ class ApprovalQueryIT {
@Test
void limitsListByDataPermission() throws Exception {
long draft = createCompletionDraft("仅发起人可见草稿");
long draft = createCompletionDraft("仅报送人可见草稿");
long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
@@ -161,7 +161,7 @@ class ApprovalQueryIT {
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
data.bind(archivist.userId, archivistRole.roleId);
// 发起人可见自己的草稿与在途单据
// 报送人可见自己的草稿与在途单据
assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
// 参与人:有任务、有处理记录、被派单
assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
@@ -170,7 +170,7 @@ class ApprovalQueryIT {
// 无关人员看不到他人单据
assertThat(queryIdsAs(outsider, "")).isEmpty();
assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
// 收文员看全租户,但草稿仍只属于发起人
// 收文员看全租户,但草稿仍只属于报送人
assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
}