feat: 设施价格表(四部门会签 + 生效日期 + 实例即版本)#57
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
package com.gxwebsoft.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gxwebsoft.approval.doc.facility.FacilityPriceQueryService;
|
||||
import com.gxwebsoft.approval.document.ApprovalPriceVersion;
|
||||
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.math.BigDecimal;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 设施价格表集成测试:四部门会签(含任一组驳回后全组重审)、双存档与归档、
|
||||
* 生效日期追溯拒绝、实例即版本(当前生效版本不因未来版本切换、历史版本倒序)、
|
||||
* 跨单据总表与明细导出的逐行数据。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
class ApprovalFacilityIT {
|
||||
|
||||
/**
|
||||
* 独立租户:部门负责人、总经理、录入员、收文员等角色按 (tenant_id, role_code) 解析,
|
||||
* 用专属租户避免与并行执行的其它审批测试类互相污染。
|
||||
*/
|
||||
private static final int TENANT = 10026;
|
||||
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 = "facility";
|
||||
|
||||
/** 明细列顺序(与 FacilityDocumentType.lineColumns() 一致)。 */
|
||||
private static final String[] LINE_HEADERS = {
|
||||
"单号", "状态", "报送部门", "报送人", "名称", "位置", "单位", "数量", "单价", "金额", "备注"
|
||||
};
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
@Autowired
|
||||
private FacilityPriceQueryService priceQueryService;
|
||||
|
||||
private ApprovalTestData data;
|
||||
private ApprovalTestData.TestUser reporter;
|
||||
private ApprovalTestData.TestUser reportLeader;
|
||||
private ApprovalTestData.TestUser deputy;
|
||||
private ApprovalTestData.TestUser generalManager;
|
||||
private ApprovalTestData.TestUser typist;
|
||||
private ApprovalTestData.TestUser archivist;
|
||||
private ApprovalTestData.TestUser venueLeader;
|
||||
private ApprovalTestData.TestUser opsLeader;
|
||||
private ApprovalTestData.TestUser safetyLeader;
|
||||
private ApprovalTestData.TestUser financeLeader;
|
||||
private Integer reportOrgId;
|
||||
/** 本用例直接插入的组织id(ApprovalTestData 不跟踪固编码组织)。 */
|
||||
private final List<Integer> fixedOrgIds = new ArrayList<>();
|
||||
/** 本用例产生的导出文件。 */
|
||||
private final List<Path> exportedFiles = new ArrayList<>();
|
||||
/** 本用例创建的实例id:价格表子表不在 ApprovalTestData 清理范围内,需自行删除。 */
|
||||
private final List<Long> instanceIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
String suffix = data.uniqueSuffix();
|
||||
reportOrgId = data.createOrganization("价格报送部门" + suffix, 136, TENANT);
|
||||
reporter = data.createUser("价格报送人" + suffix, reportOrgId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole reportLeaderRole =
|
||||
data.createRole(data.leaderRoleCode(reportOrgId), "价格报送部门负责人", TENANT);
|
||||
reportLeader = data.createUser("价格报送负责人" + suffix, reportOrgId, TENANT);
|
||||
data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole deputyRole =
|
||||
data.createRole(deputyRoleCode(reportOrgId), "价格报送部门分管领导", TENANT);
|
||||
deputy = data.createUser("价格分管领导" + suffix, reportOrgId, TENANT);
|
||||
data.bindTenant(deputy.userId, deputyRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole gmRole =
|
||||
data.createRole("approval_general_manager", "价格总经理", TENANT);
|
||||
generalManager = data.createUser("价格总经理" + suffix, reportOrgId, TENANT);
|
||||
data.bindTenant(generalManager.userId, gmRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole typistRole = data.createRole("approval_typist", "价格录入员", TENANT);
|
||||
typist = data.createUser("价格录入员" + suffix, reportOrgId, TENANT);
|
||||
data.bindTenant(typist.userId, typistRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "价格收文员", TENANT);
|
||||
archivist = data.createUser("价格收文员" + suffix, reportOrgId, TENANT);
|
||||
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
|
||||
|
||||
venueLeader = createOrgLeader("APPROVAL_VENUE", "场馆部", suffix);
|
||||
opsLeader = createOrgLeader("APPROVAL_OPS", "运营管理部", suffix);
|
||||
safetyLeader = createOrgLeader("APPROVAL_SAFETY", "安全运保部", suffix);
|
||||
financeLeader = createOrgLeader("APPROVAL_FINANCE", "财务部", suffix);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (Path path : exportedFiles) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (Exception ignored) {
|
||||
// 文件清理失败不影响断言
|
||||
}
|
||||
}
|
||||
exportedFiles.clear();
|
||||
for (Long instanceId : instanceIds) {
|
||||
data.master().update("DELETE FROM approval_facility_item WHERE instance_id = ?", instanceId);
|
||||
data.master().update("DELETE FROM approval_facility_form WHERE instance_id = ?", instanceId);
|
||||
}
|
||||
instanceIds.clear();
|
||||
for (Integer organizationId : fixedOrgIds) {
|
||||
data.server().update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId);
|
||||
}
|
||||
fixedOrgIds.clear();
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 用例
|
||||
|
||||
/**
|
||||
* 端到端:建草稿 → 逐行填价 → 提交 → 四部门会签(两组通过后第三组驳回 → round+1 →
|
||||
* 重提后四组全部重审)→ 分管领导 → 总经理 → 双存档 → 归档。
|
||||
*/
|
||||
@Test
|
||||
void runsEndToEndAndReopensAllCountersignGroupsAfterRejection() throws Exception {
|
||||
long instanceId = createDraft(LocalDate.now(), priceLines("JG端到端"));
|
||||
uploadSignedAttachment(instanceId);
|
||||
submit(instanceId);
|
||||
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
|
||||
|
||||
int countersignRound = roundOf(instanceId);
|
||||
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("venue", "ops", "safety", "finance");
|
||||
|
||||
// 两组先通过:任务完成留痕,节点不推进
|
||||
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
|
||||
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
|
||||
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("safety", "finance");
|
||||
assertThat(statusOf(instanceId)).isEqualTo("PENDING");
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'DONE'", instanceId, countersignRound))
|
||||
.isEqualTo(2);
|
||||
|
||||
// 安全运保部驳回:整节点结束、round+1、已通过的组任务保持 DONE
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, safetyLeader.userId, "deptCountersign") + "/reject"),
|
||||
safetyLeader, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"单价依据不足,请补充\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("REJECTED"));
|
||||
|
||||
int rejectedRound = roundOf(instanceId);
|
||||
assertThat(rejectedRound).isGreaterThan(countersignRound);
|
||||
// 已通过的组任务保持 DONE 留痕(驳回者本人的任务也是 DONE,带驳回意见)
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'DONE'"
|
||||
+ " AND group_key IN ('venue','ops')", instanceId, countersignRound))
|
||||
.isEqualTo(2);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'PENDING'", instanceId, countersignRound))
|
||||
.isZero();
|
||||
|
||||
// 重提:回到报送部门负责人,会签节点在新一轮重新生成四组待办
|
||||
resubmit(instanceId);
|
||||
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
|
||||
int reopenedRound = roundOf(instanceId);
|
||||
assertThat(reopenedRound).isGreaterThan(rejectedRound);
|
||||
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("venue", "ops", "safety", "finance");
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'deptCountersign' AND round = ?", instanceId, reopenedRound)).isEqualTo(4);
|
||||
|
||||
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
|
||||
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
|
||||
approve(safetyLeader, pendingTask(instanceId, safetyLeader.userId, "deptCountersign"));
|
||||
assertThat(statusOf(instanceId)).isEqualTo("PENDING");
|
||||
approve(financeLeader, pendingTask(instanceId, financeLeader.userId, "deptCountersign"));
|
||||
|
||||
// 分管领导 → 总经理
|
||||
approve(deputy, pendingTask(instanceId, deputy.userId, "deputyLeader"));
|
||||
approve(generalManager, pendingTask(instanceId, generalManager.userId, "generalManager"));
|
||||
|
||||
// 双存档:报送人 + 运营管理部录入员
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, reporter.userId, "confirm") + "/confirm"), reporter, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"));
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, typist.userId, "confirm") + "/confirm"), typist, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"));
|
||||
|
||||
String archived = mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(objectMapper.readTree(archived).path("data").path("bizNo").asText()).startsWith("JG");
|
||||
|
||||
// 详情:明细逐行、合计金额与四组会签进度
|
||||
mockMvc.perform(authed(
|
||||
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(
|
||||
"/api/approval/instance/" + instanceId), reporter, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.document.fields[0].key").value("effectiveDate"))
|
||||
.andExpect(jsonPath("$.data.document.lines.length()").value(3))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].itemName").value("JG端到端-1"))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].location").value("羽毛球馆"))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].unit").value("个"))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].quantity").isNumber())
|
||||
.andExpect(jsonPath("$.data.document.lines[0].unitPrice").isNumber())
|
||||
.andExpect(jsonPath("$.data.document.lines[0].amount").isNumber())
|
||||
.andExpect(jsonPath("$.data.document.lines[0].remark").value("端到端备注"))
|
||||
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(3))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(7))
|
||||
// 会签节点 4 组 + 存档节点 2 组
|
||||
.andExpect(jsonPath("$.data.document.groupProgress.length()").value(6))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[0].groupKey").value("venue"))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[0].signed").value(1))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[0].total").value(1))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[3].groupKey").value("finance"));
|
||||
|
||||
// 明细行落库:金额由服务端重算
|
||||
assertThat(data.queryMaster("SELECT amount FROM approval_facility_item WHERE instance_id = ? "
|
||||
+ "AND line_no = 2", BigDecimal.class, instanceId)).isEqualByComparingTo("125.00");
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_facility_item WHERE instance_id = ?",
|
||||
instanceId)).isEqualTo(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效日期早于当天:草稿保存即拒绝;已进入审批的单据若被改成过去日期,归档时拒绝。
|
||||
*/
|
||||
@Test
|
||||
void rejectsRetroactiveEffectiveDateOnSaveAndOnArchive() throws Exception {
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body(LocalDate.now().minusDays(1), priceLines("JG追溯"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("追溯修改不被允许")));
|
||||
|
||||
long instanceId = createDraft(LocalDate.now(), priceLines("JG归档追溯"));
|
||||
uploadSignedAttachment(instanceId);
|
||||
submit(instanceId);
|
||||
runToApproved(instanceId);
|
||||
|
||||
// 走完审批后把生效日期改到昨天(模拟追溯修改),归档必须被拦下
|
||||
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
|
||||
LocalDate.now().minusDays(1), instanceId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("追溯修改不被允许")));
|
||||
|
||||
assertThat(statusOf(instanceId)).isEqualTo("APPROVED");
|
||||
assertThat(countPendingTasks(instanceId, "archive")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例即版本:归档一版今天生效的价格后,再归档一版未来生效的,当前生效版本不切换。
|
||||
*/
|
||||
@Test
|
||||
void doesNotSwitchCurrentVersionWhenFutureVersionIsArchived() throws Exception {
|
||||
long current = archiveVersion(LocalDate.now(), "JG当前版", "120.00");
|
||||
|
||||
ApprovalPriceVersion before = priceQueryService.current(TENANT);
|
||||
assertThat(before).as("归档后应有当前生效版本").isNotNull();
|
||||
assertThat(before.getInstanceId()).isEqualTo(current);
|
||||
assertThat(before.getEffectiveDate()).isEqualTo(LocalDate.now());
|
||||
assertThat(before.getAmountTotal()).isEqualByComparingTo("120.00");
|
||||
assertThat(before.getLines()).hasSize(1);
|
||||
assertThat(before.getLineColumns()).hasSize(7);
|
||||
|
||||
long future = archiveVersion(LocalDate.now().plusDays(10), "JG未来版", "999.00");
|
||||
assertThat(statusOf(future)).isEqualTo("ARCHIVED");
|
||||
|
||||
ApprovalPriceVersion after = priceQueryService.current(TENANT);
|
||||
assertThat(after).as("未来生效版本归档不改变当前生效版本").isNotNull();
|
||||
assertThat(after.getInstanceId()).isEqualTo(current);
|
||||
assertThat(after.getAmountTotal()).isEqualByComparingTo("120.00");
|
||||
|
||||
// 历史版本只含已生效的版本
|
||||
assertThat(priceQueryService.history(TENANT, 1, 10))
|
||||
.extracting(ApprovalPriceVersion::getInstanceId)
|
||||
.contains(current)
|
||||
.doesNotContain(future);
|
||||
}
|
||||
|
||||
/** 历史版本按生效日期倒序。 */
|
||||
@Test
|
||||
void ordersHistoryByEffectiveDateDescending() throws Exception {
|
||||
long older = archiveVersion(LocalDate.now(), "JG旧版", "100.00");
|
||||
long newer = archiveVersion(LocalDate.now(), "JG新版", "200.00");
|
||||
// 两版都按「今天生效」归档(归档校验要求不早于今天),再回填不同生效日期验证排序
|
||||
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
|
||||
LocalDate.now().minusDays(30), older);
|
||||
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
|
||||
LocalDate.now().minusDays(3), newer);
|
||||
|
||||
List<ApprovalPriceVersion> history = priceQueryService.history(TENANT, 1, 10);
|
||||
assertThat(history).extracting(ApprovalPriceVersion::getInstanceId)
|
||||
.containsSequence(newer, older);
|
||||
assertThat(history.get(0).getEffectiveDate()).isAfter(history.get(1).getEffectiveDate());
|
||||
assertThat(priceQueryService.current(TENANT).getInstanceId()).isEqualTo(newer);
|
||||
}
|
||||
|
||||
/** 明细导出含逐行数据;跨单据总表填「明细行数」「合计金额」。 */
|
||||
@Test
|
||||
void exportsFacilityLinesAndSummaryColumns() throws Exception {
|
||||
long instanceId = createDraft(LocalDate.now(), priceLines("JG导出"));
|
||||
uploadSignedAttachment(instanceId);
|
||||
submit(instanceId);
|
||||
runToApproved(instanceId);
|
||||
archive(instanceId);
|
||||
|
||||
String response = mockMvc.perform(authed(post("/api/approval/export/" + FLOW + "/lines"),
|
||||
archivist, EXPORT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reporterId\":" + reporter.userId + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
File linesFile = exportFile(response, "excel/审批单据明细-" + FLOW + "-");
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(linesFile))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
assertThat(sheet.getSheetName()).isEqualTo("设施损坏修复与租用价格表");
|
||||
List<String> headers = new ArrayList<>();
|
||||
for (int i = 0; i < LINE_HEADERS.length; i++) {
|
||||
headers.add(cellText(sheet.getRow(0).getCell(i)));
|
||||
}
|
||||
assertThat(headers).containsExactly(LINE_HEADERS);
|
||||
assertThat(sheet.getLastRowNum() + 1).isEqualTo(4);
|
||||
Row first = sheet.getRow(1);
|
||||
assertThat(cellText(first.getCell(1))).isEqualTo("已归档");
|
||||
assertThat(cellText(first.getCell(4))).isEqualTo("JG导出-1");
|
||||
assertThat(cellText(first.getCell(6))).isEqualTo("个");
|
||||
assertThat(cellText(first.getCell(7))).isEqualTo("2.5");
|
||||
assertThat(cellText(first.getCell(8))).isEqualTo("100");
|
||||
assertThat(cellText(first.getCell(9))).isEqualTo("250");
|
||||
assertThat(cellText(sheet.getRow(3).getCell(4))).isEqualTo("JG导出-3");
|
||||
}
|
||||
|
||||
String summaryResponse = mockMvc.perform(authed(post("/api/approval/export"), archivist, EXPORT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reporterId\":" + reporter.userId + ",\"flowCode\":\"" + FLOW + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
File summaryFile = exportFile(summaryResponse, "excel/审批单据-");
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(summaryFile))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
assertThat(sheet.getLastRowNum()).isEqualTo(1);
|
||||
Row row = sheet.getRow(1);
|
||||
assertThat(cellText(row.getCell(0))).startsWith("JG");
|
||||
assertThat(cellText(row.getCell(1))).isEqualTo("设施损坏修复与租用价格表");
|
||||
assertThat(cellText(row.getCell(16))).isEqualTo("3");
|
||||
assertThat(cellText(row.getCell(17))).isEqualTo("375.00");
|
||||
assertThat(cellText(row.getCell(18))).contains("四部门会签 已签 4/4");
|
||||
}
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_facility_item WHERE instance_id = ?",
|
||||
instanceId)).isEqualTo(3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 流程辅助
|
||||
|
||||
private long archiveVersion(LocalDate effectiveDate, String itemName, String amount) throws Exception {
|
||||
// 单行版本:数量 1 × 单价 amount,合计即 amount
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
lines.add(line(itemName, null, "项", "1", amount, null));
|
||||
long instanceId = createDraft(effectiveDate, lines);
|
||||
uploadSignedAttachment(instanceId);
|
||||
submit(instanceId);
|
||||
runToApproved(instanceId);
|
||||
archive(instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/** 提交后的完整审批链(不含建草稿与提交)。 */
|
||||
private void runToApproved(long instanceId) throws Exception {
|
||||
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
|
||||
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
|
||||
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
|
||||
approve(safetyLeader, pendingTask(instanceId, safetyLeader.userId, "deptCountersign"));
|
||||
approve(financeLeader, pendingTask(instanceId, financeLeader.userId, "deptCountersign"));
|
||||
approve(deputy, pendingTask(instanceId, deputy.userId, "deputyLeader"));
|
||||
approve(generalManager, pendingTask(instanceId, generalManager.userId, "generalManager"));
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, reporter.userId, "confirm") + "/confirm"), reporter, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, typist.userId, "confirm") + "/confirm"), typist, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"));
|
||||
}
|
||||
|
||||
private void archive(long instanceId) throws Exception {
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
|
||||
}
|
||||
|
||||
private long createDraft(LocalDate effectiveDate, List<Map<String, Object>> lines) throws Exception {
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body(effectiveDate, lines)))
|
||||
.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 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 void resubmit(long instanceId) throws Exception {
|
||||
submit(instanceId);
|
||||
}
|
||||
|
||||
private void approve(ApprovalTestData.TestUser user, long taskId) throws Exception {
|
||||
String response = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), user, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"同意\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(objectMapper.readTree(response).path("code").asInt())
|
||||
.as("通过应成功:" + response).isZero();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 数据辅助
|
||||
|
||||
/** 三行明细:数量 × 单价 由服务端重算,合计 375.00。 */
|
||||
private List<Map<String, Object>> priceLines(String prefix) {
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
lines.add(line(prefix + "-1", "羽毛球馆", "个", "2.5", "100", "端到端备注"));
|
||||
lines.add(line(prefix + "-2", "游泳馆", "块", "1", "125", null));
|
||||
lines.add(line(prefix + "-3", null, "小时", "0", "80", null));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private Map<String, Object> line(String itemName, String location, String unit,
|
||||
String quantity, String unitPrice, String remark) {
|
||||
Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("itemName", itemName);
|
||||
if (location != null) {
|
||||
line.put("location", location);
|
||||
}
|
||||
line.put("unit", unit);
|
||||
line.put("quantity", quantity);
|
||||
line.put("unitPrice", unitPrice);
|
||||
if (remark != null) {
|
||||
line.put("remark", remark);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private String body(LocalDate effectiveDate, List<Map<String, Object>> lines) throws Exception {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("effectiveDate", effectiveDate.toString());
|
||||
body.put("lines", lines);
|
||||
return objectMapper.writeValueAsString(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会签组按固定组织编码解析(APPROVAL_VENUE 等),编码在每个租户内应唯一:
|
||||
* 已存在就复用(不删除),否则新建并在 teardown 删除,避免共享库里堆积同名编码。
|
||||
*/
|
||||
private ApprovalTestData.TestUser createOrgLeader(String code, String name, String suffix) {
|
||||
Integer organizationId = existingOrganization(code);
|
||||
if (organizationId == null) {
|
||||
data.server().update("INSERT INTO sys_organization (parent_id, organization_name, "
|
||||
+ "organization_full_name, organization_code, organization_type, park, phone, email, "
|
||||
+ "business, industry, image, about, sort_number, tenant_id, deleted) "
|
||||
+ "VALUES (136, ?, ?, ?, '部门', '', '', '', '', '', '', '', 100, ?, 0)",
|
||||
name, name, code, TENANT);
|
||||
organizationId = data.server().queryForObject(
|
||||
"SELECT organization_id FROM sys_organization WHERE tenant_id = ? AND organization_code = ? "
|
||||
+ "ORDER BY organization_id DESC LIMIT 1", Integer.class, TENANT, code);
|
||||
fixedOrgIds.add(organizationId);
|
||||
}
|
||||
|
||||
String roleCode = "dept_leader_" + code;
|
||||
Integer leaderRoleId = existingRoleId(roleCode);
|
||||
if (leaderRoleId == null) {
|
||||
leaderRoleId = data.createRole(roleCode, name + "负责人", TENANT).roleId;
|
||||
}
|
||||
ApprovalTestData.TestUser leader = data.createUser(name + "负责人" + suffix, organizationId, TENANT);
|
||||
data.bindTenant(leader.userId, leaderRoleId, TENANT);
|
||||
return leader;
|
||||
}
|
||||
|
||||
private Integer existingOrganization(String code) {
|
||||
List<Integer> ids = data.server().queryForList(
|
||||
"SELECT organization_id FROM sys_organization WHERE tenant_id = ? AND organization_code = ? "
|
||||
+ "AND deleted = 0 ORDER BY organization_id LIMIT 1", Integer.class, TENANT, code);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private Integer existingRoleId(String roleCode) {
|
||||
List<Integer> ids = data.server().queryForList(
|
||||
"SELECT role_id FROM sys_role WHERE tenant_id = ? AND role_code = ? AND deleted = 0 "
|
||||
+ "ORDER BY role_id LIMIT 1", Integer.class, TENANT, roleCode);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private String deputyRoleCode(Integer organizationId) {
|
||||
return "dept_deputy_" + data.organizationCode(organizationId);
|
||||
}
|
||||
|
||||
private long pendingTask(long instanceId, int userId, String nodeKey) {
|
||||
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND assignee_id = ? AND node_key = ? AND status = 'PENDING' ORDER BY id DESC LIMIT 1",
|
||||
Long.class, instanceId, userId, nodeKey);
|
||||
assertThat(taskId).as("节点 " + nodeKey + " 应有待办").isNotNull();
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private List<String> pendingGroupKeys(long instanceId) {
|
||||
int round = roundOf(instanceId);
|
||||
return data.master().queryForList("SELECT group_key FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'PENDING' ORDER BY group_seq",
|
||||
String.class, instanceId, round);
|
||||
}
|
||||
|
||||
private int roundOf(long instanceId) {
|
||||
return data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
|
||||
}
|
||||
|
||||
private String statusOf(long instanceId) {
|
||||
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||
}
|
||||
|
||||
private int countPendingTasks(long instanceId, String nodeKey) {
|
||||
return data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = ? AND status = 'PENDING'", instanceId, nodeKey);
|
||||
}
|
||||
|
||||
private void uploadSignedAttachment(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", "SIGNED")
|
||||
.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||
reportOrgId, TENANT, SUBMIT))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
private File exportFile(String response, String namePrefix) throws Exception {
|
||||
var node = objectMapper.readTree(response).path("data");
|
||||
assertThat(node.path("name").asText()).startsWith(namePrefix);
|
||||
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) {
|
||||
BigDecimal value = BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros();
|
||||
return value.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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user