Merge branch 'feat/s54-venue' into feat/approval-phase2

This commit is contained in:
2026-10-31 06:54:03 +08:00
7 changed files with 911 additions and 0 deletions
@@ -0,0 +1,443 @@
package com.gxwebsoft.approval;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.request.MockHttpServletRequestBuilder;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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;
/**
* 场地使用申请表(#55):字段/时段校验、场地快照、8 条条款渲染、列表检索与五节点闭环。
*
* <p>不开启测试事务:提交与审批各自提交,校验跨库的组织/角色解析;数据由
* {@link ApprovalTestData#cleanup()} 与本地 {@code approval_venue_form} 清理。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalVenueIT {
/** 独立租户:角色成员按 (tenant_id, role_code) 匹配,专属租户避免与其它测试类串味。 */
private static final int TENANT = 10026;
private static final String FLOW = "venue";
private static final String LIST = "approval:instance:list";
private static final String SUBMIT = "approval:instance:submit";
private static final String APPROVE = "approval:instance:approve";
private static final String ARCHIVE = "approval:instance:archive";
private static final String SITE_NAME = "广西体育中心体育场";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private Integer organizationId;
private final List<Long> instanceIds = new ArrayList<>();
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
organizationId = data.createOrganization("场地报送部门" + data.uniqueSuffix(), 136, TENANT);
reporter = data.createUser("场地报送人" + data.uniqueSuffix(), organizationId, TENANT);
}
@AfterEach
void tearDown() {
for (Long instanceId : instanceIds) {
data.master().update("DELETE FROM approval_venue_form WHERE instance_id = ?", instanceId);
}
instanceIds.clear();
data.cleanup();
}
@Test
void completesFiveNodeFlowWithVenueSnapshot() throws Exception {
ApprovalTestData.TestUser deptLeader = grantDeptRole("dept_leader_", "部门负责人");
ApprovalTestData.TestUser deputy = grantDeptRole("dept_deputy_", "分管领导");
ApprovalTestData.TestUser generalManager = grantRole("approval_general_manager", "总经理");
ApprovalTestData.TestUser archivist = grantRole("approval_archivist", "收文员");
long instanceId = saveVenue("广西某某文化传媒有限公司", "ORG", "2026-11-08 09:00:00",
"2026-11-08 18:00:00", "举办企业运动会", "1234.5");
// 草稿:标题=申请单位(人),无单号,场地快照落库
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?",
String.class, instanceId)).isEqualTo("venue");
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?",
String.class, instanceId)).isEqualTo("venue");
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?",
String.class, instanceId)).isEqualTo("广西某某文化传媒有限公司");
assertThat(data.queryMaster("SELECT applicant_type FROM approval_venue_form WHERE instance_id = ?",
String.class, instanceId)).isEqualTo("ORG");
assertThat(data.queryMaster("SELECT site_id FROM approval_venue_form WHERE instance_id = ?",
Integer.class, instanceId)).isEqualTo(88001);
assertThat(data.queryMaster("SELECT site_name FROM approval_venue_form WHERE instance_id = ?",
String.class, instanceId)).isEqualTo(SITE_NAME);
assertThat(data.queryMaster("SELECT activity_fee FROM approval_venue_form WHERE instance_id = ?",
java.math.BigDecimal.class, instanceId)).isEqualByComparingTo("1234.50");
// 节点定义:五个节点按顺序发布,存档节点为报送人(JSON 列会重排空格,按关键片段断言)
String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'venue' ORDER BY version DESC LIMIT 1", String.class, TENANT);
assertThat(nodes).contains("reporterOrgLeader").contains("reporterOrgDeputy")
.contains("roleGeneralManager").contains("archivist");
assertThat(nodes.indexOf("deptLeader")).isLessThan(nodes.indexOf("deputyLeader"));
assertThat(nodes.indexOf("deputyLeader")).isLessThan(nodes.indexOf("generalManager"));
assertThat(nodes.indexOf("generalManager")).isLessThan(nodes.indexOf("confirm"));
assertThat(nodes.indexOf("confirm")).isLessThan(nodes.indexOf("archive"));
uploadAttachment(instanceId);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
.andExpect(jsonPath("$.data.attachments.length()").value(1));
assertThat(bizNo(instanceId)).matches("CD\\d{8}-\\d{4,}");
approve(instanceId, deptLeader, "分管领导", "同意使用");
approve(instanceId, deputy, "总经理", "同意");
approve(instanceId, generalManager, "报送人存档", "批准");
// 存档节点:报送人确认后才进入归档
long confirmTask = pendingTaskId(instanceId, reporter.userId);
mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
long archiveTask = pendingTaskId(instanceId, archivist.userId);
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
.andExpect(jsonPath("$.data.statusName").value("已归档"))
.andExpect(jsonPath("$.data.archivistName").value(archivist.username));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'APPROVE'", instanceId)).isEqualTo(3);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'CONFIRM'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'ARCHIVE'", instanceId)).isEqualTo(1);
}
@Test
void rejectsCrossDayAndReversedUseTime() throws Exception {
// 跨自然日:起止不在同一天
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body("跨天单位", "ORG", "2026-11-08 09:00:00", "2026-11-09 18:00:00",
SITE_NAME, "跨天活动", "100.00")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不能跨自然日")));
// 结束早于开始:同一自然日但时间倒挂
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body("倒挂单位", "ORG", "2026-11-08 18:00:00", "2026-11-08 09:00:00",
SITE_NAME, "倒挂活动", "100.00")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("必须晚于使用开始时间")));
// 起止相同也算不晚于
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body("同刻单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 09:00:00",
SITE_NAME, "同刻活动", "100.00")))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("必须晚于使用开始时间")));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_venue_form "
+ "WHERE applicant_name IN ('跨天单位', '倒挂单位', '同刻单位')")).isZero();
}
@Test
void validatesVenueFields() throws Exception {
expectSaveFailure(body("", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "100.00"), "申请单位(人)不能为空");
expectSaveFailure(body("单位", null, "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "100.00"), "申请单位(人)类型不能为空");
expectSaveFailure(body("单位", "COMPANY", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "100.00"), "申请单位(人)类型只能是单位或个人");
expectSaveFailure(body("单位", "ORG", null, "2026-11-08 18:00:00",
SITE_NAME, "用途", "100.00"), "使用开始时间不能为空");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", null,
SITE_NAME, "用途", "100.00"), "使用结束时间不能为空");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "-1"), "活动费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "100000000"), "活动费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "用途", "1.234"), "活动费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
"", "用途", "100.00"), "场地名称不能为空");
expectSaveFailure(body("单位", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
SITE_NAME, "", "100.00"), "用途说明不能为空");
// 场地 id 必填
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"applicantName\":\"单位\",\"applicantType\":\"ORG\","
+ "\"useStartTime\":\"2026-11-08 09:00:00\",\"useEndTime\":\"2026-11-08 18:00:00\","
+ "\"siteName\":\"" + SITE_NAME + "\",\"purpose\":\"用途\",\"activityFee\":100.00}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请选择场地")));
// 个人申请合法落库
long instanceId = saveVenue("张三", "PERSON", "2026-11-08 09:00:00", "2026-11-08 12:00:00",
"个人健身", "0");
assertThat(data.queryMaster("SELECT applicant_type FROM approval_venue_form WHERE instance_id = ?",
String.class, instanceId)).isEqualTo("PERSON");
}
@Test
void rendersApplicantTypeAndEightTermsInDetail() throws Exception {
long instanceId = saveVenue("广西某某体育俱乐部", "ORG", "2026-11-08 09:00:00",
"2026-11-08 18:00:00", "举办篮球邀请赛", "8888");
Map<String, String> fields = detailFields(instanceId);
// 审批人能从详情看出是单位还是个人
assertThat(fields).containsEntry("applicantType", "单位");
assertThat(fields).containsEntry("applicantName", "广西某某体育俱乐部");
assertThat(fields).containsEntry("siteName", SITE_NAME);
assertThat(fields).containsEntry("activityFee", "8888.00");
// 8 条条款逐条输出,第 1 条渲染场地名与活动费用
for (int i = 1; i <= 8; i++) {
assertThat(fields).as("条款" + i).containsKey("term" + i);
assertThat(fields.get("term" + i)).isNotBlank();
}
assertThat(fields.get("term1")).contains(SITE_NAME).contains("8888.00元");
assertThat(fields.get("term2")).contains("费用交付完成");
assertThat(fields.get("term8")).contains("空调费");
// 个人申请在详情显示「个人」
long personInstance = saveVenue("李四", "PERSON", "2026-11-08 09:00:00",
"2026-11-08 18:00:00", "个人活动", "100");
assertThat(detailFields(personInstance)).containsEntry("applicantType", "个人");
}
@Test
void listFindsByApplicantNameKeywords() throws Exception {
saveVenue("广西甲体育馆运营有限公司", "ORG", "2026-11-08 09:00:00", "2026-11-08 18:00:00",
"甲方活动", "100");
long target = saveVenue("南宁乙文化传播有限公司", "PERSON", "2026-11-09 09:00:00",
"2026-11-09 18:00:00", "乙方活动", "200");
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, LIST)
.param("keywords", "乙文化"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) target))
.andExpect(jsonPath("$.data.list[0].title").value("南宁乙文化传播有限公司"))
.andExpect(jsonPath("$.data.list[0].docTypeName").value("场地使用申请表"));
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, LIST)
.param("keywords", "甲体育馆"))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].title").value("广西甲体育馆运营有限公司"));
mockMvc.perform(authed(get("/api/approval/instance/page"), reporter, organizationId, LIST)
.param("keywords", "乙文化"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) target));
}
@Test
void rejectsSubmitWithoutAttachment() throws Exception {
grantDeptRole("dept_leader_", "部门负责人");
grantDeptRole("dept_deputy_", "分管领导");
grantRole("approval_general_manager", "总经理");
grantRole("approval_archivist", "收文员");
long instanceId = saveVenue("无附件单位", "ORG", "2026-11-08 09:00:00",
"2026-11-08 18:00:00", "无附件活动", "100");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
}
@Test
void rejectsSubmitWhenDeputyLeaderMissing() throws Exception {
grantDeptRole("dept_leader_", "部门负责人");
grantRole("approval_archivist", "收文员");
long instanceId = saveVenue("缺分管单位", "ORG", "2026-11-08 09:00:00",
"2026-11-08 18:00:00", "缺分管活动", "100");
uploadAttachment(instanceId);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门分管领导角色缺配")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
}
// ---------- helpers ----------
private void expectSaveFailure(String body, String expectedMessage) throws Exception {
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
}
private long saveVenue(String applicantName, String applicantType, String start, String end,
String purpose, String activityFee) throws Exception {
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW),
reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body(applicantName, applicantType, start, end, SITE_NAME, purpose, activityFee)))
.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);
instanceIds.add(instanceId);
return instanceId;
}
private void approve(long instanceId, ApprovalTestData.TestUser approver,
String expectedNextNodeName, String opinion) throws Exception {
long taskId = pendingTaskId(instanceId, approver.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), approver, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":" + quote(opinion) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value(expectedNextNodeName));
}
private Map<String, String> detailFields(long instanceId) throws Exception {
String response = mockMvc.perform(authed(get("/api/approval/instance/" + instanceId),
reporter, organizationId, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode fields = objectMapper.readTree(response).path("data").path("document").path("fields");
Map<String, String> result = new LinkedHashMap<>();
for (JsonNode field : fields) {
result.put(field.path("key").asText(), field.path("value").asText());
}
return result;
}
private ApprovalTestData.TestUser grantDeptRole(String prefix, String roleName) {
String roleCode = prefix + data.organizationCode(organizationId);
ApprovalTestData.TestRole role = data.createRole(roleCode, roleName, TENANT);
ApprovalTestData.TestUser member = data.createUser(roleName + data.uniqueSuffix(), organizationId, TENANT);
data.bindTenant(member.userId, role.roleId, TENANT);
return member;
}
private ApprovalTestData.TestUser grantRole(String roleCode, String roleName) {
ApprovalTestData.TestRole role = data.createRole(roleCode, roleName, TENANT);
ApprovalTestData.TestUser member = data.createUser(roleName + data.uniqueSuffix(), organizationId, TENANT);
data.bindTenant(member.userId, role.roleId, TENANT);
return member;
}
private void uploadAttachment(long instanceId) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "quote.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", "OTHER")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
organizationId, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private long pendingTaskId(long instanceId, int userId) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
assertThat(taskId).as("待办任务应存在").isNotNull();
return taskId;
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
ApprovalTestData.TestUser user, Integer org,
String... authorities) {
return builder.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
}
private String body(String applicantName, String applicantType, String start, String end,
String siteName, String purpose, String activityFee) {
return "{\"applicantName\":" + quote(applicantName)
+ ",\"applicantType\":" + quote(applicantType)
+ ",\"useStartTime\":" + quote(start)
+ ",\"useEndTime\":" + quote(end)
+ ",\"siteId\":88001"
+ ",\"siteName\":" + quote(siteName)
+ ",\"purpose\":" + quote(purpose)
+ ",\"activityFee\":" + activityFee
+ "}";
}
private String quote(String value) {
if (value == null) {
return "null";
}
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private String instanceStatus(long instanceId) {
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private String bizNo(long instanceId) {
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
}
}