test: 会签与回填引擎级用例(#47/#49);缺配按组名报错、通过时校验必填可写字段
This commit is contained in:
@@ -77,8 +77,9 @@ public class ApprovalApproverResolver {
|
||||
}
|
||||
List<ApprovalApprover> members = resolveSingle(instance, group.getResolver(), group.getOrgCode());
|
||||
if (members.isEmpty()) {
|
||||
return Resolution.missing(missingMessage(instance, group.getResolver(), group.getOrgCode(),
|
||||
groupName(group, seq)));
|
||||
// 会签组的缺配按组声明名报,而不是按组织编码拼出来的名称(ADR 0003 示例:
|
||||
// 「安全运保部负责人角色缺配」)
|
||||
return Resolution.missing(groupMissingMessage(group.getResolver(), groupName(group, seq)));
|
||||
}
|
||||
String groupKey = group.getKey() == null || group.getKey().trim().isEmpty()
|
||||
? "group" + seq : group.getKey().trim();
|
||||
@@ -195,6 +196,23 @@ public class ApprovalApproverResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会签组缺配提示:按解析器种类给出「<组名>负责人角色缺配 / 分管领导角色缺配」。
|
||||
*/
|
||||
private String groupMissingMessage(String resolver, String groupName) {
|
||||
if (ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE.equals(resolver)
|
||||
|| ApprovalConstants.RESOLVER_REPORTER_ORG_DEPUTY.equals(resolver)
|
||||
|| ApprovalConstants.RESOLVER_ACCEPT_ORG_DEPUTY.equals(resolver)) {
|
||||
return groupName + "分管领导角色缺配";
|
||||
}
|
||||
if (ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE.equals(resolver)
|
||||
|| ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER.equals(resolver)
|
||||
|| ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER.equals(resolver)) {
|
||||
return groupName + "负责人角色缺配";
|
||||
}
|
||||
return groupName + "审批人缺配";
|
||||
}
|
||||
|
||||
/** 组织编码 → 展示名;组织不存在时退回编码本身。 */
|
||||
private String organizationLabel(String orgCode) {
|
||||
ApprovalOrganizationVO organization = directoryService.getOrganizationByCode(orgCode);
|
||||
|
||||
@@ -100,6 +100,11 @@ public class ApprovalFlowService {
|
||||
return node == null ? null : node.getName();
|
||||
}
|
||||
|
||||
/** 该流程是否已有发布版本(测试夹具用来清理探针定义)。 */
|
||||
public boolean hasDefinition(String flowCode) {
|
||||
return findLatest(flowCode) != null;
|
||||
}
|
||||
|
||||
/** 流程定义名称(单据类型显示名,ADR 0007);定义缺失时回退实现名称。 */
|
||||
public String flowName(Long definitionId, String flowCode) {
|
||||
ApprovalFlowDefinition definition =
|
||||
|
||||
@@ -30,6 +30,7 @@ import javax.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
@@ -110,7 +111,6 @@ public class ApprovalTaskService {
|
||||
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||
}
|
||||
ensureTaskCurrent(task, instance);
|
||||
requireWritableNode(instance, task);
|
||||
ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
|
||||
List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, false, user);
|
||||
if (changes.isEmpty()) {
|
||||
@@ -503,21 +503,22 @@ public class ApprovalTaskService {
|
||||
private List<ApprovalFieldUpdate> writeFields(ApprovalInstance instance, ApprovalDocumentType type,
|
||||
ApprovalTask task, Map<String, String> fields,
|
||||
boolean requireComplete, User user) {
|
||||
if (type == null || fields == null || fields.isEmpty()) {
|
||||
if (type == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
requireWritableNode(instance, task);
|
||||
return type.applyWritableFields(instance, fields, user, requireComplete);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点必须声明了可写字段,否则拒绝回填。
|
||||
*/
|
||||
private void requireWritableNode(ApprovalInstance instance, ApprovalTask task) {
|
||||
ApprovalNode node = engineService.findNode(instance, task.getNodeKey());
|
||||
if (node == null || node.getWritableFields() == null || node.getWritableFields().isEmpty()) {
|
||||
boolean declaresWritable = node != null && node.getWritableFields() != null
|
||||
&& !node.getWritableFields().isEmpty();
|
||||
boolean hasValues = fields != null && !fields.isEmpty();
|
||||
// 节点没声明可写字段、也没传值:普通通过/回填,无需交给单据实现
|
||||
if (!declaresWritable && !hasValues) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (!declaresWritable) {
|
||||
throw new BusinessException("当前节点不支持回填");
|
||||
}
|
||||
Map<String, String> values = fields == null ? new HashMap<>() : fields;
|
||||
return type.applyWritableFields(instance, values, user, requireComplete);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
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 {
|
||||
// 让第一组负责人同时挂上第二组的部门负责人角色:他会拿到两条会签任务
|
||||
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);
|
||||
// 第二组只留这一个成员,使总任务数仍是 4,其中该用户占 2 条
|
||||
data.server().update("DELETE FROM sys_user_role WHERE user_id = ? AND role_id = ?",
|
||||
groupLeaders.get(1).userId, secondRoleId);
|
||||
|
||||
long instanceId = createAndSubmit();
|
||||
assertThat(countersignTaskCount(instanceId)).isEqualTo(4);
|
||||
|
||||
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);
|
||||
assertThat(currentNodeKey(instanceId)).isEqualTo("countersign");
|
||||
assertThat(countersignPendingCount(instanceId)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.gxwebsoft.approval.support;
|
||||
|
||||
import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
|
||||
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
|
||||
import com.gxwebsoft.approval.document.ApprovalFieldUpdate;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.vo.ApprovalDetail;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 测试专用单据实现:只用于验证引擎能力(会签分组、回填),不承担业务。
|
||||
*
|
||||
* <p>会签组按**固定组织编码**声明,由用例在 {@link #configureGroups} 里指定,每个用例
|
||||
* 用自己的组织;不用租户岗位角色是因为共享测试库里有常驻的 test02 账号持有全部审批角色,
|
||||
* 会让「某组缺配」无法构造。</p>
|
||||
*
|
||||
* <p>节点 JSON 随用例变化,因此每次配置都递增发布版本(流程定义版本不可变)。</p>
|
||||
*/
|
||||
@Component
|
||||
public class CountersignProbeDocumentType extends AbstractApprovalDocumentType {
|
||||
|
||||
/** 测试流程编码。 */
|
||||
public static final String FLOW_CODE = "probe";
|
||||
|
||||
/** 可回填字段键。 */
|
||||
public static final String FIELD_PROBE_NO = "probeNo";
|
||||
|
||||
public static final String NODE_DEPT_LEADER = "deptLeader";
|
||||
public static final String NODE_COUNTERSIGN = "countersign";
|
||||
|
||||
/** 本次用例的会签组组织编码(有序)。 */
|
||||
private static volatile List<String> groupCodes = new ArrayList<>();
|
||||
/** 本次用例的会签组显示名(与 groupCodes 对应)。 */
|
||||
private static volatile List<String> groupNames = new ArrayList<>();
|
||||
/**
|
||||
* 探针流程版本固定 1;用 {@link #reset()} 在用例间清理定义行,
|
||||
* 否则跨 JVM 运行时旧版本行会被 {@code getOrCreate} 当成「已是最新」复用,
|
||||
* 而其组织编码已被上一个用例清理,导致所有组都缺配。
|
||||
*/
|
||||
private static final AtomicInteger VERSION = new AtomicInteger(1);
|
||||
|
||||
private static final Map<Long, Map<String, String>> VALUES = new ConcurrentHashMap<>();
|
||||
|
||||
/** 用例开始前配置会签组(组织编码 + 显示名,一一对应)。 */
|
||||
public static synchronized void configureGroups(List<String> orgCodes, List<String> names) {
|
||||
groupCodes = new ArrayList<>(orgCodes);
|
||||
groupNames = new ArrayList<>(names);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String flowCode() {
|
||||
return FLOW_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String docType() {
|
||||
return FLOW_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String flowName() {
|
||||
return "引擎测试单据";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String nodesJson() {
|
||||
StringBuilder groups = new StringBuilder();
|
||||
List<String> codes = groupCodes;
|
||||
List<String> names = groupNames;
|
||||
for (int i = 0; i < codes.size(); i++) {
|
||||
if (i > 0) {
|
||||
groups.append(',');
|
||||
}
|
||||
groups.append("{\"key\":\"g").append(i + 1).append("\",\"name\":\"")
|
||||
.append(names.size() > i ? names.get(i) : "会签组" + (i + 1))
|
||||
.append("\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"")
|
||||
.append(codes.get(i)).append("\"}");
|
||||
}
|
||||
return "["
|
||||
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\","
|
||||
+ "\"name\":\"报送部门负责人\",\"writableFields\":[{\"field\":\"probeNo\",\"required\":true}]},"
|
||||
+ "{\"key\":\"countersign\",\"type\":\"COUNTERSIGN\",\"name\":\"四部门会签\",\"groups\":["
|
||||
+ groups + "]},"
|
||||
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
|
||||
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
|
||||
+ "]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int latestVersion() {
|
||||
return VERSION.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String noPrefix() {
|
||||
return "PB";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ApprovalDocumentParam> paramClass() {
|
||||
return ProbeParam.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(ApprovalDocumentParam param, User user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
|
||||
return bizId == null ? instanceId : bizId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void load(ApprovalInstance instance, ApprovalDetail detail) {
|
||||
detail.addField("probeNo", "测试编号", readField(instance, FIELD_PROBE_NO));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String title(ApprovalInstance instance) {
|
||||
return "引擎测试单据";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.gxwebsoft.approval.support.ApprovalFieldSpec> fieldSpecs() {
|
||||
List<com.gxwebsoft.approval.support.ApprovalFieldSpec> specs = new ArrayList<>();
|
||||
specs.add(com.gxwebsoft.approval.support.ApprovalFieldSpec.uniqueText(FIELD_PROBE_NO, "测试编号", 40));
|
||||
return specs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalFieldUpdate> applyWritableFields(ApprovalInstance instance, Map<String, String> fields,
|
||||
User user, boolean requireComplete) {
|
||||
String submitted = fields.get(FIELD_PROBE_NO);
|
||||
if (submitted != null) {
|
||||
submitted = submitted.trim();
|
||||
}
|
||||
if (requireComplete && (submitted == null || submitted.isEmpty())) {
|
||||
throw new BusinessException("测试编号不能为空");
|
||||
}
|
||||
if (submitted != null && submitted.length() > 40) {
|
||||
throw new BusinessException("测试编号不能超过40字");
|
||||
}
|
||||
if (submitted == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<String, String> values = VALUES.computeIfAbsent(instance.getId(), ignored -> new ConcurrentHashMap<>());
|
||||
String oldValue = values.get(FIELD_PROBE_NO);
|
||||
values.put(FIELD_PROBE_NO, submitted);
|
||||
List<ApprovalFieldUpdate> changes = new ArrayList<>();
|
||||
changes.add(new ApprovalFieldUpdate(FIELD_PROBE_NO, "测试编号", oldValue, submitted));
|
||||
return changes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readField(ApprovalInstance instance, String field) {
|
||||
Map<String, String> values = VALUES.get(instance.getId());
|
||||
return values == null ? null : values.get(field);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRoundReset(ApprovalInstance instance, User user) {
|
||||
Map<String, String> values = VALUES.get(instance.getId());
|
||||
if (values != null) {
|
||||
values.remove(FIELD_PROBE_NO);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空测试缓存(用例结束时调用)。 */
|
||||
public static void clear(Long instanceId) {
|
||||
VALUES.remove(instanceId);
|
||||
}
|
||||
|
||||
/** 探针单据参数。 */
|
||||
public static class ProbeParam extends ApprovalDocumentParam {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user