Merge branch 'feat/s55-coupon' into feat/approval-phase2

This commit is contained in:
2026-10-31 07:10:59 +08:00
8 changed files with 976 additions and 0 deletions
@@ -0,0 +1,532 @@
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.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
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.time.LocalDate;
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;
/**
* 体验券领用申请单集成测试(ADR 0013):多券种草稿、每类券唯一、六审批节点
* (部门负责人 → 相关部门 → 分管领导 → 总经理 → 发放人 → 存档)到归档、
* 明细导出与「整单至少一张附件」。
*
* <p>独立租户:相关角色(approval_related_dept 等)按 tenant_id + role_code 解析,
* 用专属租户(10052,测试库中无审批角色成员)避免与其它并行测试类共享角色成员。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalCouponIT {
private static final int TENANT = 10052;
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 APPLY_REASON = "员工回馈活动领用体验券";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private ApprovalTestData.TestUser reportLeader;
private ApprovalTestData.TestUser relatedDept;
private ApprovalTestData.TestUser deputy;
private ApprovalTestData.TestUser generalManager;
private ApprovalTestData.TestUser issuer;
private ApprovalTestData.TestUser archivist;
private Integer organizationId;
/** 本次用例生成的文件。 */
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();
organizationId = data.createOrganization("体验券申请部门" + suffix, 136, TENANT);
reporter = createUser("体验券申请人" + suffix, organizationId);
reportLeader = createUser("体验券部门负责人" + suffix, organizationId);
data.bindTenant(reportLeader.userId,
data.createRole(data.leaderRoleCode(organizationId), "部门负责人", TENANT).roleId, TENANT);
deputy = createUser("体验券分管领导" + suffix, organizationId);
data.bindTenant(deputy.userId,
data.createRole("dept_deputy_" + data.organizationCode(organizationId), "分管领导", TENANT).roleId,
TENANT);
relatedDept = createUser("体验券相关部门" + suffix, organizationId);
data.bindTenant(relatedDept.userId,
data.createRole(ApprovalConstants.ROLE_RELATED_DEPT, "相关部门", TENANT).roleId, TENANT);
generalManager = createUser("体验券总经理" + suffix, organizationId);
data.bindTenant(generalManager.userId,
data.createRole(ApprovalConstants.ROLE_GENERAL_MANAGER, "总经理", TENANT).roleId, TENANT);
issuer = createUser("体验券发放人" + suffix, organizationId);
data.bindTenant(issuer.userId,
data.createRole(ApprovalConstants.ROLE_ISSUER, "发放人", TENANT).roleId, TENANT);
archivist = createUser("体验券收文员" + suffix, organizationId);
data.bindTenant(archivist.userId,
data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员", TENANT).roleId, TENANT);
}
@AfterEach
void tearDown() {
// 体验券业务表与明细表已并入 ApprovalTestData.INSTANCE_CHILD_TABLES,随 cleanup 一并清理
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 createsDraftWithMultipleCouponTypesAndShowsLines() throws Exception {
long instanceId = saveDraft(reporter, APPLY_REASON, List.of(
row("TENNIS_CENTER", 10, LocalDate.now().plusMonths(3)),
row("SWIMMING_DIVING", 20, LocalDate.now().plusMonths(6)),
row("GYM_MONTHLY", 5, LocalDate.now().plusMonths(1))));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("coupon");
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("coupon");
assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?",
String.class, instanceId)).isEqualTo(reporter.username);
assertThat(data.queryMaster("SELECT apply_reason FROM approval_coupon_form WHERE instance_id = ?",
String.class, instanceId)).isEqualTo(APPLY_REASON);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
instanceId)).isEqualTo(3);
assertThat(data.queryMaster("SELECT coupon_type FROM approval_coupon_item WHERE instance_id = ? AND line_no = 1",
String.class, instanceId)).isEqualTo("TENNIS_CENTER");
assertThat(data.queryMaster("SELECT quantity FROM approval_coupon_item WHERE instance_id = ? AND line_no = 2",
Integer.class, instanceId)).isEqualTo(20);
// 标题按申请事由生成,检索文本含券种中文名
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("体验券领用:" + APPLY_REASON);
String searchText = data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
String.class, instanceId);
assertThat(searchText).contains("网球中心").contains("游泳跳水馆单次").contains("威拓普健身馆月卡");
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.docType").value("coupon"))
.andExpect(jsonPath("$.data.docTypeName").value("体验券领用申请单"))
.andExpect(jsonPath("$.data.statusName").value("未提交"))
.andExpect(jsonPath("$.data.document.fields.length()").value(3))
.andExpect(jsonPath("$.data.document.fields[0].key").value("applyReason"))
.andExpect(jsonPath("$.data.document.fields[0].label").value("申请事由"))
.andExpect(jsonPath("$.data.document.fields[0].value").value(APPLY_REASON))
.andExpect(jsonPath("$.data.document.fields[1].key").value("reporterName"))
.andExpect(jsonPath("$.data.document.fields[1].value").value(reporter.username))
.andExpect(jsonPath("$.data.document.fields[2].key").value("reportOrgName"))
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(3))
.andExpect(jsonPath("$.data.document.lineColumns[0].key").value("couponType"))
.andExpect(jsonPath("$.data.document.lineColumns[0].type").value("ENUM"))
.andExpect(jsonPath("$.data.document.lineColumns[0].options.length()").value(7))
.andExpect(jsonPath("$.data.document.lineColumns[1].key").value("quantity"))
.andExpect(jsonPath("$.data.document.lineColumns[2].key").value("validUntil"))
.andExpect(jsonPath("$.data.document.lines.length()").value(3))
.andExpect(jsonPath("$.data.document.lines[0].lineNo").value(1))
.andExpect(jsonPath("$.data.document.lines[0].couponType").value("TENNIS_CENTER"))
.andExpect(jsonPath("$.data.document.lines[0].quantity").value(10))
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(3))
.andExpect(jsonPath("$.data.permissions.canEdit").value(true));
// 列表标题即「体验券领用:<申请事由>」,类型名取流程名
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
.andExpect(jsonPath("$.data.list[0].docTypeName").value("体验券领用申请单"))
.andExpect(jsonPath("$.data.list[0].title").value("体验券领用:" + APPLY_REASON))
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
// 草稿整表替换:改两行后子表只剩两行且行号重排
long updated = saveDraft(reporter, "调整后的领用事由", List.of(
row("TRAINING_HALL", 8, LocalDate.now().plusDays(30)),
row("FIVE_A_SIDE", 4, LocalDate.now().plusDays(60))), instanceId);
assertThat(updated).isEqualTo(instanceId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
instanceId)).isEqualTo(2);
assertThat(data.queryMaster("SELECT coupon_type FROM approval_coupon_item WHERE instance_id = ? AND line_no = 1",
String.class, instanceId)).isEqualTo("TRAINING_HALL");
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("体验券领用:调整后的领用事由");
}
@Test
void rejectsDuplicateCouponTypeAndNonPositiveQuantity() throws Exception {
Map<String, Object> repeat = new LinkedHashMap<>();
repeat.put("applyReason", APPLY_REASON);
repeat.put("lines", List.of(
row("SWIMMING_DIVING", 2, LocalDate.now().plusDays(10)),
row("TENNIS_CENTER", 2, LocalDate.now().plusDays(10)),
row("SWIMMING_DIVING", 3, LocalDate.now().plusDays(10))));
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(repeat)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.allOf(
org.hamcrest.Matchers.containsString("第3行"),
org.hamcrest.Matchers.containsString("游泳跳水馆单次"),
org.hamcrest.Matchers.containsString("第1行"),
org.hamcrest.Matchers.containsString("每类券最多一行"))));
expectSaveFailure(List.of(row("SWIMMING_DIVING", 0, LocalDate.now().plusDays(10))),
"第1行数量必须大于等于1");
expectSaveFailure(List.of(row("SWIMMING_DIVING", -3, LocalDate.now().plusDays(10))),
"第1行数量必须大于等于1");
expectSaveFailure(List.of(row("NOT_A_COUPON", 1, LocalDate.now().plusDays(10))),
"第1行券种取值不正确");
expectSaveFailure(List.of(Map.of("couponType", "TENNIS_CENTER", "quantity", 1)),
"第1行有效期至不能为空");
expectSaveFailure(List.of(Map.of("quantity", 1, "validUntil", LocalDate.now().plusDays(10).toString())),
"第1行券种不能为空");
// 申请事由必填且不超过 500 字,明细至少一行
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("applyReason", " ", "lines",
List.of(row("TENNIS_CENTER", 1, LocalDate.now().plusDays(10)))))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("申请事由不能为空"));
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("applyReason", "券".repeat(501), "lines",
List.of(row("TENNIS_CENTER", 1, LocalDate.now().plusDays(10)))))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("申请事由不能超过500字"));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_form")).isZero();
}
@Test
void rejectsSubmitWithoutAnyAttachmentButDoesNotRequireCategory() throws Exception {
long instanceId = saveDraft(reporter, APPLY_REASON,
List.of(row("TENNIS_CENTER", 10, LocalDate.now().plusMonths(2))));
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("请至少上传一张附件"));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
// 任意分类一张即可,不要求签字件或凭证
uploadAttachment(instanceId, ApprovalConstants.CATEGORY_OTHER);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.currentNodeName").value("申请部门负责人"));
}
@Test
void runsThroughSixApprovalNodesThenArchives() throws Exception {
long instanceId = saveDraft(reporter, APPLY_REASON, List.of(
row("SWIMMING_DIVING", 30, LocalDate.now().plusMonths(6)),
row("TENNIS_CENTER", 20, LocalDate.now().plusMonths(6)),
row("GYM_BIWEEKLY", 6, LocalDate.now().plusMonths(3))));
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("TY\\d{8}-\\d{4}")))
.andExpect(jsonPath("$.data.currentNodeName").value("申请部门负责人"))
.andExpect(jsonPath("$.data.document.lines.length()").value(3));
assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId);
assertThat(approve(instanceId, reportLeader, "相关部门")).isEqualTo("relatedDept");
assertThat(pendingTaskIds(instanceId)).containsExactly(relatedDept.userId);
assertThat(approve(instanceId, relatedDept, "分管领导")).isEqualTo("deputy");
assertThat(pendingTaskIds(instanceId)).containsExactly(deputy.userId);
assertThat(approve(instanceId, deputy, "总经理")).isEqualTo("generalManager");
assertThat(pendingTaskIds(instanceId)).containsExactly(generalManager.userId);
assertThat(approve(instanceId, generalManager, "发放人")).isEqualTo("issuer");
assertThat(pendingTaskIds(instanceId)).containsExactly(issuer.userId);
// 发放人用「通过」而不是存档确认(ADR 0013)
assertThat(approve(instanceId, issuer, "存档")).isEqualTo("confirm");
assertThat(pendingTaskIds(instanceId)).containsExactly(reporter.userId);
long confirmTask = pendingTaskId(instanceId, reporter.userId);
mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, APPROVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
assertThat(pendingTaskIds(instanceId)).containsExactly(archivist.userId);
long archiveTask = pendingTaskId(instanceId, archivist.userId);
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, 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))
.andExpect(jsonPath("$.data.permissions.canEdit").value(false));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
instanceId)).isEqualTo(5);
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);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
instanceId)).isEqualTo(3);
}
@Test
void exportsLineCountInSummaryAndCouponLines() throws Exception {
String mark = "券导出" + data.uniqueSuffix();
long instanceId = saveDraft(reporter, mark, List.of(
row("GYM_WEEKLY", 7, LocalDate.parse("2027-01-31")),
row("GYM_MONTHLY", 2, LocalDate.parse("2027-06-30"))));
String reportOrgName = data.server().queryForObject(
"SELECT organization_name FROM sys_organization WHERE organization_id = ?", String.class, organizationId);
// 跨单据总表:明细行数 = 2,合计金额为空(券单无金额列)
String response = mockMvc.perform(authed(post("/api/approval/export"), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keywords\":" + quote(mark) + "}"))
.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("明细行数");
assertThat(cellText(sheet.getRow(0).getCell(17))).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/coupon/lines"), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"flowCode\":\"coupon\",\"keywords\":" + quote(mark) + "}"))
.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(cellText(sheet.getRow(0).getCell(0))).isEqualTo("单号");
assertThat(cellText(sheet.getRow(0).getCell(4))).isEqualTo("券种");
assertThat(cellText(sheet.getRow(0).getCell(5))).isEqualTo("数量");
assertThat(cellText(sheet.getRow(0).getCell(6))).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("GYM_WEEKLY");
assertThat(cellText(first.getCell(5))).isEqualTo("7");
assertThat(cellText(first.getCell(6))).isEqualTo("2027-01-31");
Row second = sheet.getRow(2);
assertThat(cellText(second.getCell(4))).isEqualTo("GYM_MONTHLY");
assertThat(cellText(second.getCell(5))).isEqualTo("2");
assertThat(cellText(second.getCell(6))).isEqualTo("2027-06-30");
}
}
// ---------- helpers ----------
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
ApprovalTestData.TestUser user = data.createUser(realName, organizationId, TENANT);
userIds.add(user.userId);
return user;
}
private long saveDraft(ApprovalTestData.TestUser user, String applyReason,
List<Map<String, Object>> lines) throws Exception {
return saveDraft(user, applyReason, lines, null);
}
private long saveDraft(ApprovalTestData.TestUser user, String applyReason,
List<Map<String, Object>> lines, Long id) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
if (id != null) {
body.put("id", id);
}
body.put("applyReason", applyReason);
body.put("lines", lines);
String response = mockMvc.perform(authed(post("/api/approval/instance/coupon"), user, 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("applyReason", APPLY_REASON);
body.put("lines", lines);
mockMvc.perform(authed(post("/api/approval/instance/coupon"), 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 Map<String, Object> row(String couponType, int quantity, LocalDate validUntil) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("couponType", couponType);
row.put("quantity", quantity);
row.put("validUntil", validUntil.toString());
return row;
}
/** 逐个节点通过:断言推进到下一节点名称,并返回该节点 key 供顺序校验。 */
private String approve(long instanceId, ApprovalTestData.TestUser approver, String nextNodeName)
throws Exception {
long taskId = pendingTaskId(instanceId, approver.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), approver, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\"同意\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.currentNodeName").value(nextNodeName));
return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
String.class, instanceId);
}
private void uploadAttachment(long instanceId, String category) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "voucher.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, 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 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 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 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(org.apache.poi.ss.usermodel.Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == org.apache.poi.ss.usermodel.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, organizationId, TENANT,
authorities)));
}
private String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}