599d85b7da
- 会签:一人多组时取消其所在**每个组**的其余待办(原先只取消点击任务所在组,另一组永久 PENDING 卡单) - 跨库:提交首节点审批人解析移出事务;协调单受理部门改为 prepare 事务外解析 - 回填:CONFIRM 节点支持 writableFields(ADR 0006 三类节点均可声明) - 收据单:受理部门(财务部)快照到实例,列表/导出可见 - 明细导出:ENUM 列输出中文文案而非存储编码 - 价格表:历史版本包含未来生效的已归档版本 - 导出会签进度一次装配,去掉按节点重复查询 - 菜单种子:价格菜单在 superAdmin 授权之前,授权条件覆盖 approval:% - doc_type 与 flow_code 常量分开;删除未使用的 findByDocType / ApprovalDetail.fieldChanges - 补附件三档白名单用例(VOUCHER 通过、EXTERNAL 被拒)
395 lines
18 KiB
Java
395 lines
18 KiB
Java
package com.gxwebsoft.approval;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
|
import com.gxwebsoft.approval.support.CountersignProbeDocumentType;
|
|
import com.gxwebsoft.support.ApprovalTestData;
|
|
import com.gxwebsoft.support.TestAuthentication;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.mock.web.MockMultipartFile;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import org.springframework.test.web.servlet.ResultActions;
|
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
|
|
|
import javax.sql.DataSource;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
|
/**
|
|
* 引擎级用例:会签分组(#47)与节点回填(#49)。
|
|
*
|
|
* <p>用测试专用探针单据 {@link CountersignProbeDocumentType},会签组按本次用例新建的
|
|
* 组织定位负责人角色,避免共享测试库里常驻账号干扰「某组缺配」的构造。</p>
|
|
*/
|
|
@SpringBootTest
|
|
@AutoConfigureMockMvc
|
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
|
class ApprovalCountersignEngineIT {
|
|
|
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
|
private static final String LIST = "approval:instance:list";
|
|
private static final String SUBMIT = "approval:instance:submit";
|
|
private static final String APPROVE = "approval:instance:approve";
|
|
|
|
@Autowired
|
|
private MockMvc mockMvc;
|
|
@Autowired
|
|
private ObjectMapper objectMapper;
|
|
@Autowired
|
|
private DataSource dataSource;
|
|
|
|
private ApprovalTestData data;
|
|
private ApprovalTestData.TestUser reporter;
|
|
private Integer organizationId;
|
|
private ApprovalTestData.TestUser leader;
|
|
|
|
/** 四个会签组:组织 + 负责人,按顺序。 */
|
|
private final List<Integer> groupOrgs = new ArrayList<>();
|
|
private final List<ApprovalTestData.TestUser> groupLeaders = new ArrayList<>();
|
|
private final List<String> groupNames = List.of("场馆部", "运营管理部", "安全运保部", "财务部");
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
data = new ApprovalTestData(dataSource);
|
|
String suffix = data.uniqueSuffix();
|
|
organizationId = data.createOrganization("会签报送部门" + suffix, 136);
|
|
reporter = data.createUser("会签报送人" + suffix, organizationId);
|
|
|
|
ApprovalTestData.TestRole leaderRole =
|
|
data.createRole(data.leaderRoleCode(organizationId), "部门负责人");
|
|
leader = data.createUser("会签负责人" + suffix, organizationId);
|
|
data.bind(leader.userId, leaderRole.roleId);
|
|
|
|
ApprovalTestData.TestRole archivistRole =
|
|
data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
|
ApprovalTestData.TestUser archivist = data.createUser("会签收文员" + suffix, organizationId);
|
|
data.bind(archivist.userId, archivistRole.roleId);
|
|
|
|
List<String> codes = new ArrayList<>();
|
|
for (int i = 0; i < groupNames.size(); i++) {
|
|
Integer org = data.createOrganization("会签组" + (i + 1) + suffix, 136);
|
|
ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(org), "负责人" + (i + 1));
|
|
ApprovalTestData.TestUser member = data.createUser("会签人" + (i + 1) + suffix, org);
|
|
data.bind(member.userId, role.roleId);
|
|
groupOrgs.add(org);
|
|
groupLeaders.add(member);
|
|
codes.add(data.organizationCode(org));
|
|
}
|
|
// 探针流程定义要按用例重建:旧定义里的组织编码指向上一个用例已删除的组织
|
|
data.master().update("DELETE FROM approval_flow_definition WHERE tenant_id = ? AND flow_code = ?",
|
|
TENANT, CountersignProbeDocumentType.FLOW_CODE);
|
|
CountersignProbeDocumentType.configureGroups(codes, groupNames);
|
|
}
|
|
|
|
@AfterEach
|
|
void tearDown() {
|
|
data.cleanup();
|
|
}
|
|
|
|
@Test
|
|
void activatesAllFourGroupsAndAdvancesOnlyAfterEveryGroupSigns() throws Exception {
|
|
long instanceId = createAndSubmit();
|
|
|
|
assertThat(countersignTaskCount(instanceId)).isEqualTo(4);
|
|
assertThat(data.queryMaster("SELECT COUNT(DISTINCT group_key) FROM approval_task "
|
|
+ "WHERE instance_id = ? AND node_key = 'countersign'", Integer.class, instanceId)).isEqualTo(4);
|
|
|
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.currentNodeName").value("四部门会签"))
|
|
.andExpect(jsonPath("$.data.document.groupProgress.length()").value(4))
|
|
.andExpect(jsonPath("$.data.document.groupProgress[0].groupSeq").value(1))
|
|
.andExpect(jsonPath("$.data.document.groupProgress[0].signed").value(0))
|
|
.andExpect(jsonPath("$.data.document.groupProgress[0].total").value(1));
|
|
|
|
approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0));
|
|
assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
|
|
assertThat(currentNodeKey(instanceId)).isEqualTo("countersign");
|
|
assertThat(countersignPendingCount(instanceId)).isEqualTo(3);
|
|
|
|
approve(taskFor(instanceId, groupLeaders.get(1))).andExpect(jsonPath("$.code").value(0));
|
|
approve(taskFor(instanceId, groupLeaders.get(2))).andExpect(jsonPath("$.code").value(0));
|
|
assertThat(countersignPendingCount(instanceId)).isEqualTo(1);
|
|
|
|
approve(taskFor(instanceId, groupLeaders.get(3)))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
|
assertThat(currentNodeKey(instanceId)).isEqualTo("confirm");
|
|
}
|
|
|
|
@Test
|
|
void onePersonInTwoGroupsCompletesBothWithSingleClick() throws Exception {
|
|
// 让第一组负责人同时挂上第二组负责人角色:他会拿到两条会签任务,
|
|
// 而第二组另有一名成员(groupLeaders.get(1))——该成员的待办必须随这次点击一并取消,
|
|
// 否则第二组永远有 PENDING,节点无法推进。
|
|
Integer secondRoleId = data.server().queryForObject(
|
|
"SELECT role_id FROM sys_role WHERE tenant_id = ? AND deleted = 0 AND role_code = ?",
|
|
Integer.class, TENANT, data.leaderRoleCode(groupOrgs.get(1)));
|
|
data.bind(groupLeaders.get(0).userId, secondRoleId);
|
|
|
|
long instanceId = createAndSubmit();
|
|
assertThat(countersignTaskCount(instanceId)).isEqualTo(5);
|
|
|
|
approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0));
|
|
|
|
// 本人两条任务都完成
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
|
+ "AND assignee_id = ? AND status = 'DONE'", instanceId, groupLeaders.get(0).userId)).isEqualTo(2);
|
|
// 他所在两个组的其余成员待办被取消,只剩另外两组共 2 条待办
|
|
assertThat(countersignPendingCount(instanceId)).isEqualTo(2);
|
|
assertThat(currentNodeKey(instanceId)).isEqualTo("countersign");
|
|
|
|
// 另外两组签完后节点正常推进:证明一人双组没有把节点卡死
|
|
approve(taskFor(instanceId, groupLeaders.get(2))).andExpect(jsonPath("$.code").value(0));
|
|
approve(taskFor(instanceId, groupLeaders.get(3)))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
|
}
|
|
|
|
@Test
|
|
void anyGroupMissingBlocksWholeNodeAndSubmitPreflightNamesTheGroup() throws Exception {
|
|
// 第 3 组(安全运保部)清空负责人:整节点不激活,提交预检指名缺的是哪一组
|
|
data.server().update("DELETE FROM sys_user_role WHERE user_id = ?", groupLeaders.get(2).userId);
|
|
long draft = createDraft();
|
|
uploadAttachment(draft);
|
|
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + draft + "/submit"), reporter, SUBMIT))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.code").value(1))
|
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("安全运保部")));
|
|
|
|
assertThat(instanceStatus(draft)).isEqualTo("DRAFT");
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ?", draft)).isZero();
|
|
}
|
|
|
|
@Test
|
|
void anyGroupRejectEndsWholeNodeAndRaisesRound() throws Exception {
|
|
long instanceId = createAndSubmit();
|
|
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?",
|
|
String.class, instanceId);
|
|
int roundBefore = round(instanceId);
|
|
|
|
approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0));
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, groupLeaders.get(1)) + "/reject"),
|
|
groupLeaders.get(1), APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"opinion\":\"金额对不上\"}"))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.status").value("REJECTED"));
|
|
|
|
assertThat(round(instanceId)).isEqualTo(roundBefore + 1);
|
|
assertThat(data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId))
|
|
.isEqualTo(bizNo);
|
|
// 本轮已通过的组任务保持 DONE 留痕
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
|
+ "AND round = ? AND node_key = 'countersign' AND status = 'DONE'", instanceId, roundBefore))
|
|
.isEqualTo(2);
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
|
+ "AND round = ? AND node_key = 'countersign' AND status = 'PENDING'", instanceId, roundBefore))
|
|
.isZero();
|
|
}
|
|
|
|
@Test
|
|
void backfillWritesFieldWithoutAdvancingNode() throws Exception {
|
|
long instanceId = createDraft();
|
|
uploadAttachment(instanceId);
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/backfill"),
|
|
leader, APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"fields\":{\"probeNo\":\"P-001\"}}"))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
|
|
assertThat(currentNodeKey(instanceId)).isEqualTo("deptLeader");
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
|
+ "AND field_key = 'probeNo' AND new_value = 'P-001'", instanceId)).isEqualTo(1);
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
|
+ "AND action = 'BACKFILL'", instanceId)).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void approveRequiresWritableFieldBeforeAdvancing() throws Exception {
|
|
long instanceId = createDraft();
|
|
uploadAttachment(instanceId);
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"),
|
|
leader, APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"opinion\":\"同意\"}"))
|
|
.andExpect(jsonPath("$.code").value(1))
|
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("测试编号")));
|
|
|
|
assertThat(currentNodeKey(instanceId)).isEqualTo("deptLeader");
|
|
assertThat(taskStatus(instanceId, leader.userId)).isEqualTo("PENDING");
|
|
}
|
|
|
|
@Test
|
|
void approveWithFieldAdvancesAndLocksValue() throws Exception {
|
|
long instanceId = createDraft();
|
|
uploadAttachment(instanceId);
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"),
|
|
leader, APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"opinion\":\"同意\",\"fields\":{\"probeNo\":\"P-002\"}}"))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.currentNodeKey").value("countersign"));
|
|
|
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
|
+ "AND new_value = 'P-002'", instanceId)).isEqualTo(1);
|
|
// 节点已推进,会签组处理人没有该字段的可写权
|
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), groupLeaders.get(0), LIST))
|
|
.andExpect(jsonPath("$.data.permissions.writableFields.length()").value(0));
|
|
}
|
|
|
|
@Test
|
|
void withdrawClearsRoundScopedFields() throws Exception {
|
|
long instanceId = createDraft();
|
|
uploadAttachment(instanceId);
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/backfill"),
|
|
leader, APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"fields\":{\"probeNo\":\"P-003\"}}"))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
|
|
// 本轮尚无人处理,报送人可撤回
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"));
|
|
|
|
assertThat(instanceStatus(instanceId)).isEqualTo("WITHDRAWN");
|
|
assertThat(round(instanceId)).isEqualTo(2);
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
private long createDraft() throws Exception {
|
|
String response = mockMvc.perform(authed(post("/api/approval/instance/probe"), reporter, SUBMIT)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{}"))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
|
data.trackInstance(instanceId);
|
|
return instanceId;
|
|
}
|
|
|
|
/**
|
|
* 建草稿 → 提交 → 报送负责人带着必填字段通过 → 停在会签节点。
|
|
*/
|
|
private long createAndSubmit() throws Exception {
|
|
long instanceId = createDraft();
|
|
uploadAttachment(instanceId);
|
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"),
|
|
leader, APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"opinion\":\"同意\",\"fields\":{\"probeNo\":\"P-INIT\"}}"))
|
|
.andExpect(jsonPath("$.code").value(0))
|
|
.andExpect(jsonPath("$.data.currentNodeKey").value("countersign"));
|
|
return instanceId;
|
|
}
|
|
|
|
private ResultActions approve(long taskId) throws Exception {
|
|
return mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
|
|
operatorOf(taskId), APPROVE)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content("{\"opinion\":\"同意\"}"));
|
|
}
|
|
|
|
private ApprovalTestData.TestUser operatorOf(long taskId) {
|
|
int assigneeId = data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?",
|
|
Integer.class, taskId);
|
|
for (ApprovalTestData.TestUser candidate : groupLeaders) {
|
|
if (candidate.userId == assigneeId) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new IllegalStateException("未知处理人: " + assigneeId);
|
|
}
|
|
|
|
private long taskFor(long instanceId, ApprovalTestData.TestUser user) {
|
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
|
+ "AND assignee_id = ? AND status = 'PENDING' ORDER BY id LIMIT 1", Long.class, instanceId,
|
|
user.userId);
|
|
assertThat(taskId).as("待办任务应存在: " + user.userId).isNotNull();
|
|
return taskId;
|
|
}
|
|
|
|
private String taskStatus(long instanceId, int userId) {
|
|
return data.queryMaster("SELECT status FROM approval_task WHERE instance_id = ? "
|
|
+ "AND assignee_id = ? ORDER BY id DESC LIMIT 1", String.class, instanceId, userId);
|
|
}
|
|
|
|
private int countersignTaskCount(long instanceId) {
|
|
return data.queryMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
|
+ "AND node_key = 'countersign'", Integer.class, instanceId);
|
|
}
|
|
|
|
private int countersignPendingCount(long instanceId) {
|
|
return data.queryMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
|
+ "AND node_key = 'countersign' AND status = 'PENDING'", Integer.class, instanceId);
|
|
}
|
|
|
|
private String instanceStatus(long instanceId) {
|
|
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
|
}
|
|
|
|
private String currentNodeKey(long instanceId) {
|
|
return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
|
String.class, instanceId);
|
|
}
|
|
|
|
private int round(long instanceId) {
|
|
return data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
|
|
}
|
|
|
|
private void uploadAttachment(long instanceId) throws Exception {
|
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
|
.file(file)
|
|
.param("instanceId", String.valueOf(instanceId))
|
|
.param("category", ApprovalConstants.CATEGORY_SIGNED)
|
|
.header("tenantId", TENANT)
|
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
|
organizationId, TENANT, SUBMIT))))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.code").value(0));
|
|
}
|
|
|
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
|
ApprovalTestData.TestUser user,
|
|
String... authorities) {
|
|
return builder.header("tenantId", TENANT)
|
|
.with(authentication(TestAuthentication.user(user.userId, user.username, organizationId,
|
|
TENANT, authorities)));
|
|
}
|
|
}
|