feat: 车辆免收费登记表(逐车归属 + 车牌区间冲突)#54

This commit is contained in:
weicw
2026-10-31 07:56:54 +08:00
parent 184acfe7ef
commit 25b40d83c6
14 changed files with 1238 additions and 2 deletions
@@ -0,0 +1,613 @@
package com.gxwebsoft.approval;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
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.io.File;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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;
/**
* 车辆免收费登记表:一单多车、车辆归属逐车、车牌有效期区间冲突、节点链与明细导出。
*
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
* 引擎表与车辆业务表由 {@link ApprovalTestData#cleanup()} 按实例清理。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalVehicleIT {
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";
private static final String ARCHIVE = "approval:instance:archive";
private static final String EXPORT = "approval:instance:export";
private static final String FLOW = "vehicle";
/** 车辆归属编码(ADR 0010)。 */
private static final String OWNER_GROUP = "GROUP_EMPLOYEE";
private static final String OWNER_CLUB = "COOPERATING_CLUB_EMPLOYEE";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
@Autowired
private ConfigProperties config;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private Integer reportOrgId;
private ApprovalTestData.TestUser reportLeader;
private ApprovalTestData.TestUser opsLeader;
private ApprovalTestData.TestUser deputy;
private ApprovalTestData.TestUser typist;
private ApprovalTestData.TestUser archivist;
private final List<Path> exportedFiles = new ArrayList<>();
private final List<Integer> userIds = new ArrayList<>();
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
String suffix = data.uniqueSuffix();
reportOrgId = data.createOrganization("车辆登记部门" + suffix, 136);
reporter = createUser("车辆报送人" + suffix, reportOrgId);
reportLeader = bindRole(data.leaderRoleCode(reportOrgId), "部门负责人",
"车辆部门负责人" + suffix, reportOrgId);
opsLeader = bindRole("dept_leader_APPROVAL_OPS", "运营管理部负责人",
"车辆运营负责人" + suffix, reportOrgId);
deputy = bindRole("dept_deputy_" + data.organizationCode(reportOrgId), "分管领导",
"车辆分管领导" + suffix, reportOrgId);
typist = bindRole(ApprovalConstants.ROLE_TYPIST, "录入员",
"车辆录入员" + suffix, reportOrgId);
archivist = bindRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员",
"车辆收文员" + suffix, reportOrgId);
}
@AfterEach
void tearDown() {
for (Path path : exportedFiles) {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
// 文件清理失败不影响断言
}
}
exportedFiles.clear();
try {
// 导出操作日志异步写入
Thread.sleep(300);
for (Integer userId : userIds) {
data.server().update(
"DELETE FROM sys_operation_record WHERE user_id = ? AND url LIKE '%/api/approval/export%'", userId);
}
} catch (Exception ignored) {
// 共享库清理尽力而为
}
userIds.clear();
data.cleanup();
}
@Test
void runsEndToEndThroughTypistConfirmAndArchive() throws Exception {
long instanceId = saveDraft(List.of(
row("广西威宁集团", "张三 工号A001", "桂A12345", true, "13800000000",
"2026-01-01", "2026-06-30", OWNER_GROUP),
row("南宁大型活动主办方", "李四 身份证尾号1234", "桂AD67890", false, "13800000001",
"2026-03-01", "2026-12-31", OWNER_CLUB)), null);
// 草稿:整单无业务字段,一车一行,车辆归属逐车
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("vehicle");
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("vehicle");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_item WHERE instance_id = ?",
instanceId)).isEqualTo(2);
assertThat(data.queryMaster("SELECT plate_no FROM approval_vehicle_item WHERE instance_id = ? AND line_no = 1",
String.class, instanceId)).isEqualTo("桂A12345");
assertThat(data.queryMaster("SELECT vehicle_owner_type FROM approval_vehicle_item "
+ "WHERE instance_id = ? AND line_no = 2", String.class, instanceId)).isEqualTo(OWNER_CLUB);
assertThat(data.queryMaster("SELECT new_energy FROM approval_vehicle_item "
+ "WHERE instance_id = ? AND line_no = 1", Boolean.class, instanceId)).isTrue();
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.docTypeName").value("车辆免收费登记表"))
.andExpect(jsonPath("$.data.statusName").value("未提交"))
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(9))
.andExpect(jsonPath("$.data.document.lineColumns[0].key").value("unitName"))
.andExpect(jsonPath("$.data.document.lineColumns[2].key").value("plateNo"))
.andExpect(jsonPath("$.data.document.lineColumns[8].key").value("vehicleOwnerType"))
.andExpect(jsonPath("$.data.document.lineColumns[8].type").value("ENUM"))
.andExpect(jsonPath("$.data.document.lineColumns[8].options.length()").value(8))
.andExpect(jsonPath("$.data.document.lines.length()").value(2))
.andExpect(jsonPath("$.data.document.lines[0].lineNo").value(1))
.andExpect(jsonPath("$.data.document.lines[0].plateNo").value("桂A12345"))
.andExpect(jsonPath("$.data.document.lines[0].newEnergy").value(true))
.andExpect(jsonPath("$.data.document.lines[0].vehicleOwnerType").value(OWNER_GROUP))
.andExpect(jsonPath("$.data.document.lines[1].plateNo").value("桂AD67890"))
.andExpect(jsonPath("$.data.document.lines[1].vehicleOwnerType").value(OWNER_CLUB))
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(2))
.andExpect(jsonPath("$.data.document.lineSummary.amountTotal").doesNotExist())
.andExpect(jsonPath("$.data.permissions.canEdit").value(true));
// 列表标题与检索文本含全部车牌(列表按车牌核实用)
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("车辆免收费登记(2 辆)");
String searchText = data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
String.class, instanceId);
assertThat(searchText).contains("桂A12345").contains("桂AD67890");
// 提交预检:至少一张附件(不要求分类)
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("请至少上传一张附件"));
uploadAttachment(instanceId, ApprovalConstants.CATEGORY_OTHER);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.bizNo").value(org.hamcrest.Matchers.matchesPattern("CL\\d{8}-\\d{4}")))
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
String bizNo = bizNo(instanceId);
assertThat(pendingTaskIds(instanceId)).contains(reportLeader.userId);
// 节点链固定:报送部门负责人 → 运营管理部负责人(APPROVAL_OPS)→ 分管领导 → 录入员存档 → 归档
String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'vehicle' ORDER BY version DESC LIMIT 1", String.class, TENANT);
assertThat(nodes).contains("\"resolver\": \"reporterOrgLeader\"")
.contains("\"resolver\": \"orgLeaderByCode\"").contains("APPROVAL_OPS")
.contains("\"resolver\": \"reporterOrgDeputy\"")
.contains("\"resolver\": \"roleTypist\"").contains("\"resolver\": \"archivist\"");
// 报送部门负责人
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId)
+ "/approve"), reportLeader, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意登记\"}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("运营管理部负责人"));
// 运营管理部负责人:按固定组织编码 APPROVAL_OPS 解析
long opsTask = pendingTaskId(instanceId, opsLeader.userId);
mockMvc.perform(authed(post("/api/approval/task/" + opsTask + "/approve"), opsLeader, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"核验通过\"}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("分管领导"));
// 分管领导
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, deputy.userId)
+ "/approve"), deputy, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意\"}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("录入员存档"));
// 录入员:CONFIRM 存档确认,不派单;本单录入员按 approval_typist 角色成员解析
long typistTask = pendingTaskId(instanceId, typist.userId);
mockMvc.perform(authed(post("/api/approval/task/" + typistTask + "/confirm"), typist, APPROVE))
.andExpect(jsonPath("$.code").value(0));
// 共享测试库里该角色可能还有其它成员:同节点同轮全部确认后才推进
confirmAllPendingInNode(instanceId, "confirm");
assertThat(instanceStatus(instanceId)).isEqualTo("APPROVED");
assertThat(currentNodeKey(instanceId)).isEqualTo("archive");
// 归档:归档节点或签,任务可能分配给角色内任一成员,按实际指派人操作
long archiveTask = pendingTaskIdInNode(instanceId, "archive");
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"),
assigneeUser(archiveTask), ARCHIVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
.andExpect(jsonPath("$.data.statusName").value("已归档"));
assertThat(instanceStatus(instanceId)).isEqualTo("ARCHIVED");
assertThat(bizNo(instanceId)).isEqualTo(bizNo);
// 录入员角色在共享测试库可能有多名成员,确认记录数按实际确认人数至少 1 条
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'CONFIRM'", instanceId)).isGreaterThanOrEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'ARCHIVE'", instanceId)).isEqualTo(1);
// 明细不随审批推进丢失
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_item WHERE instance_id = ?",
instanceId)).isEqualTo(2);
}
@Test
void blocksOverlappingPlateAndAllowsAfterWithdraw() throws Exception {
long first = saveDraft(List.of(row("合作俱乐部", "王五", "桂A55555", false, "13800000002",
"2026-01-01", "2026-06-30", OWNER_CLUB)), null);
uploadAttachment(first, ApprovalConstants.CATEGORY_OTHER);
submit(first);
String firstBizNo = bizNo(first);
// 第二单重叠区间:提交被拦,消息带出冲突单号与已登记有效期
long overlapping = saveDraft(List.of(row("主办方", "赵六", "桂A55555", false, "13800000003",
"2026-03-01", "2026-09-01", OWNER_CLUB)), null);
uploadAttachment(overlapping, ApprovalConstants.CATEGORY_OTHER);
mockMvc.perform(authed(post("/api/approval/instance/" + overlapping + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("桂A55555")))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(firstBizNo)))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("2026-01-01")))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("2026-06-30")));
assertThat(instanceStatus(overlapping)).isEqualTo("DRAFT");
assertThat(bizNo(overlapping)).isNull();
// 第一单撤回后成为作废态,第二单可以提交(且不会撞上自己)
mockMvc.perform(authed(post("/api/approval/instance/" + first + "/withdraw"), reporter, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"));
mockMvc.perform(authed(post("/api/approval/instance/" + overlapping + "/submit"), reporter, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"));
assertThat(bizNo(overlapping)).isNotBlank();
// 未重叠区间不受影响(第二单已占 2026-03-01 至 2026-09-01)
long adjacent = saveDraft(List.of(row("合作俱乐部", "王五", "桂A55555", false, "13800000002",
"2026-10-01", "2026-12-31", OWNER_CLUB)), null);
uploadAttachment(adjacent, ApprovalConstants.CATEGORY_OTHER);
mockMvc.perform(authed(post("/api/approval/instance/" + adjacent + "/submit"), reporter, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"));
}
@Test
void keepsVehicleOwnerTypePerLineAndNormalizesPlate() throws Exception {
long instanceId = saveDraft(List.of(
row("威宁集团", "员工甲", " 桂a12345 ", true, "13800000004", "2026-01-01", "2026-12-31",
OWNER_GROUP),
row("大型活动主办方", "主办方乙", "桂A66666", false, "13800000005", "2026-02-01", "2026-05-31",
"LARGE_EVENT_ORGANIZER")), null);
// 车牌去空格转大写后入库
assertThat(plateNos(instanceId)).containsExactly("桂A12345", "桂A66666");
// 同一单里两种归属并存,逐车存储编码而非整单枚举
assertThat(ownerTypes(instanceId)).containsExactly(OWNER_GROUP, "LARGE_EVENT_ORGANIZER");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_form WHERE instance_id = ?",
instanceId)).isEqualTo(1);
}
@Test
void validatesPlateFormatAndValidDateRange() throws Exception {
// 有效期止早于起
expectSaveFailure(List.of(row("单位", "员工", "桂A77777", false, "13800000006",
"2026-06-30", "2026-01-01", OWNER_GROUP)), "第1行有效期止不能早于有效期起");
// 车牌格式非法时消息带出该车牌
expectSaveFailure(List.of(row("单位", "员工", "桂A123", false, "13800000006",
"2026-01-01", "2026-06-30", OWNER_GROUP)), "车牌号「桂A123」格式不正确");
expectSaveFailure(List.of(row("单位", "员工", "A12345", false, "13800000006",
"2026-01-01", "2026-06-30", OWNER_GROUP)), "车牌号「A12345」格式不正确");
// 必填列缺失与长度上限(共享明细校验器)
expectSaveFailure(List.of(row("单位", "员工", "桂A88888", false, "13800000006",
"2026-01-01", "2026-06-30", "")), "第1行车辆归属不能为空");
expectSaveFailure(List.of(row("单位", "员工", "桂A88888", false, "13800000006",
"2026-01-01", "2026-06-30", "NOT_A_TYPE")), "第1行车辆归属取值不正确");
expectSaveFailure(List.of(row("单".repeat(101), "员工", "桂A88888", false, "13800000006",
"2026-01-01", "2026-06-30", OWNER_GROUP)), "第1行单位名称不能超过100字");
// 明细至少一行
expectSaveFailure(List.of(), "请至少填写一行明细");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_vehicle_form")).isZero();
}
@Test
void findsByPlateKeywordsInPcListAndMineList() throws Exception {
String uniquePlate = "桂A99999";
long instanceId = saveDraft(List.of(row("检索单位", "员工丙", uniquePlate, false, "13800000007",
"2026-01-01", "2026-12-31", OWNER_GROUP)), null);
mockMvc.perform(authed(get("/api/approval/instance/page").param("keywords", uniquePlate), reporter, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
.andExpect(jsonPath("$.data.list[0].flowCode").value("vehicle"))
.andExpect(jsonPath("$.data.list[0].title").value("车辆免收费登记(1 辆)"));
mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", uniquePlate), reporter, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
// 别的车牌不命中本单
mockMvc.perform(authed(get("/api/approval/instance/page").param("keywords", "桂A00000"), reporter, LIST))
.andExpect(jsonPath("$.data.count").value(0));
}
@Test
void exportsLineCountInSummaryAndVehicleLines() throws Exception {
String mark = "车辆导出" + data.uniqueSuffix();
long instanceId = saveDraft(List.of(
row(mark, "员工丁", "桂A10001", true, "13800000008", "2026-01-01", "2026-06-30", OWNER_GROUP),
row(mark, "员工戊", "桂A10002", false, "13800000009", "2026-02-01", "2026-08-31", OWNER_CLUB)), null);
String reportOrgName = data.server().queryForObject(
"SELECT organization_name FROM sys_organization WHERE organization_id = ?", String.class, reportOrgId);
// 跨单据总表:明细行数 = 2,合计金额留空(无金额列)
String response = mockMvc.perform(authed(post("/api/approval/export"), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keywords\":" + quote("桂A10002") + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
File summary = exportFile(response);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(summary))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(cellText(sheet.getRow(0).getCell(16))).isEqualTo("明细行数");
Row row = sheet.getRow(1);
assertThat(cellText(row.getCell(1))).isEqualTo("车辆免收费登记表");
assertThat(cellText(row.getCell(16))).isEqualTo("2");
assertThat(cellText(row.getCell(17))).isEmpty();
}
// 按类型明细导出:逐行车牌
String lineResponse = mockMvc.perform(authed(post("/api/approval/export/" + FLOW + "/lines"),
reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"flowCode\":\"vehicle\",\"keywords\":" + quote("桂A10001") + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条明细")))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
File lines = exportFile(lineResponse);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(lines))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getSheetName()).isEqualTo("车辆免收费登记表");
assertThat(cellText(sheet.getRow(0).getCell(0))).isEqualTo("单号");
assertThat(cellText(sheet.getRow(0).getCell(3))).isEqualTo("报送人");
assertThat(cellText(sheet.getRow(0).getCell(4))).isEqualTo("单位名称");
assertThat(cellText(sheet.getRow(0).getCell(6))).isEqualTo("车牌号");
assertThat(cellText(sheet.getRow(0).getCell(12))).isEqualTo("车辆归属");
Row first = sheet.getRow(1);
assertThat(cellText(first.getCell(1))).isEqualTo("未提交");
assertThat(cellText(first.getCell(2))).isEqualTo(reportOrgName);
assertThat(cellText(first.getCell(3))).isEqualTo(reporter.username);
assertThat(cellText(first.getCell(4))).isEqualTo(mark);
assertThat(cellText(first.getCell(6))).isEqualTo("桂A10001");
assertThat(cellText(first.getCell(7))).isEqualTo("是");
assertThat(cellText(first.getCell(9))).isEqualTo("2026-01-01");
assertThat(cellText(first.getCell(10))).isEqualTo("2026-06-30");
assertThat(cellText(first.getCell(12))).isEqualTo(OWNER_GROUP);
Row second = sheet.getRow(2);
assertThat(cellText(second.getCell(6))).isEqualTo("桂A10002");
assertThat(cellText(second.getCell(12))).isEqualTo(OWNER_CLUB);
}
}
// ---------- helpers ----------
private Map<String, Object> row(String unitName, String personInfo, String plateNo, boolean newEnergy,
String contactPhone, String validStartDate, String validEndDate,
String vehicleOwnerType) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("unitName", unitName);
row.put("personInfo", personInfo);
row.put("plateNo", plateNo);
row.put("newEnergy", newEnergy);
row.put("contactPhone", contactPhone);
row.put("validStartDate", validStartDate);
row.put("validEndDate", validEndDate);
row.put("remark", "测试备注");
row.put("vehicleOwnerType", vehicleOwnerType);
return row;
}
private long saveDraft(List<Map<String, Object>> lines, Long id) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
if (id != null) {
body.put("id", id);
}
body.put("lines", lines);
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.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 void expectSaveFailure(List<Map<String, Object>> lines, String expectedMessage) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("lines", lines);
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
}
private void submit(long instanceId) throws Exception {
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private ApprovalTestData.TestUser bindRole(String roleCode, String roleName, String realName,
Integer organizationId) {
ApprovalTestData.TestUser member = createUser(realName, organizationId);
data.bind(member.userId, ensureRole(roleCode, roleName));
return member;
}
/** 共享库可能已有同名角色(如运营管理部负责人/录入员),存在就复用,不存在才新建。 */
private Integer ensureRole(String roleCode, String roleName) {
List<Integer> ids = data.server().queryForList(
"SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ? AND deleted = 0 "
+ "ORDER BY role_id LIMIT 1", Integer.class, roleCode, TENANT);
if (!ids.isEmpty()) {
return ids.get(0);
}
return data.createRole(roleCode, roleName, TENANT).roleId;
}
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
ApprovalTestData.TestUser user = data.createUser(realName, organizationId, TENANT);
userIds.add(user.userId);
return user;
}
private void uploadAttachment(long instanceId, String category) 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", category)
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId,
TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
/** 同节点同轮的全部待办逐个确认(共享库里该角色可能有其它成员)。 */
private void confirmAllPendingInNode(long instanceId, String nodeKey) throws Exception {
for (int guard = 0; guard < 20; guard++) {
List<Map<String, Object>> tasks = data.master().queryForList(
"SELECT id, assignee_id, assignee_name FROM approval_task WHERE instance_id = ? "
+ "AND node_key = ? AND status = 'PENDING' ORDER BY id", instanceId, nodeKey);
if (tasks.isEmpty()) {
return;
}
for (Map<String, Object> task : tasks) {
long taskId = ((Number) task.get("id")).longValue();
String name = (String) task.get("assignee_name");
ApprovalTestData.TestUser assignee =
new ApprovalTestData.TestUser(((Number) task.get("assignee_id")).intValue(), name, name);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/confirm"), assignee, APPROVE))
.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 long pendingTaskIdInNode(long instanceId, String nodeKey) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND node_key = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, nodeKey);
assertThat(taskId).as(nodeKey + "待办任务应存在").isNotNull();
return taskId;
}
/** 任务实际指派人:归档/录入员这类岗位角色在共享库可能有多名成员。 */
private ApprovalTestData.TestUser assigneeUser(long taskId) {
Map<String, Object> row = data.master().queryForMap(
"SELECT assignee_id, assignee_name FROM approval_task WHERE id = ?", taskId);
String name = (String) row.get("assignee_name");
return new ApprovalTestData.TestUser(((Number) row.get("assignee_id")).intValue(), name, name);
}
private List<Integer> pendingTaskIds(long instanceId) {
return data.master().queryForList("SELECT assignee_id FROM approval_task "
+ "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
}
private List<String> plateNos(long instanceId) {
return data.master().queryForList("SELECT plate_no FROM approval_vehicle_item "
+ "WHERE instance_id = ? ORDER BY line_no", String.class, instanceId);
}
private List<String> ownerTypes(long instanceId) {
return data.master().queryForList("SELECT vehicle_owner_type FROM approval_vehicle_item "
+ "WHERE instance_id = ? ORDER BY line_no", String.class, instanceId);
}
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);
}
private String currentNodeKey(long instanceId) {
return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
String.class, instanceId);
}
private File exportFile(String response) throws Exception {
JsonNode node = objectMapper.readTree(response).path("data");
File file = new File(node.path("path").asText());
assertThat(file).exists();
exportedFiles.add(file.toPath());
return file;
}
private String cellText(Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == CellType.NUMERIC) {
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros()
.toPlainString();
}
return cell.getStringCellValue();
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
ApprovalTestData.TestUser user, String... authorities) {
return builder.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(user.userId, user.username, reportOrgId,
TENANT, authorities)));
}
private String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}