Files
gxtyzx-java/src/test/java/com/gxwebsoft/approval/ApprovalCountersignEngineIT.java
T
weicw 599d85b7da fix: 代码审查修复
- 会签:一人多组时取消其所在**每个组**的其余待办(原先只取消点击任务所在组,另一组永久 PENDING 卡单)
- 跨库:提交首节点审批人解析移出事务;协调单受理部门改为 prepare 事务外解析
- 回填:CONFIRM 节点支持 writableFields(ADR 0006 三类节点均可声明)
- 收据单:受理部门(财务部)快照到实例,列表/导出可见
- 明细导出:ENUM 列输出中文文案而非存储编码
- 价格表:历史版本包含未来生效的已归档版本
- 导出会签进度一次装配,去掉按节点重复查询
- 菜单种子:价格菜单在 superAdmin 授权之前,授权条件覆盖 approval:%
- doc_type 与 flow_code 常量分开;删除未使用的 findByDocType / ApprovalDetail.fieldChanges
- 补附件三档白名单用例(VOUCHER 通过、EXTERNAL 被拒)
2026-10-31 08:22:04 +08:00

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)));
}
}