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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user