Merge branch 'feat/approval-phase2' into feat/s56-facility
This commit is contained in:
@@ -132,8 +132,11 @@ class ApprovalCoordinationIT {
|
||||
// 实例固定引用创建时的定义版本(v2)
|
||||
assertThat(data.queryMaster("SELECT definition_version FROM approval_instance WHERE id = ?",
|
||||
Integer.class, instanceId)).isEqualTo(2);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
|
||||
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
|
||||
// 外部协调单是独立流程(#51):共用 doc_type,但节点链多一个受理部门分管领导
|
||||
String externalNodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
|
||||
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external' "
|
||||
+ "ORDER BY version DESC LIMIT 1", String.class, TENANT);
|
||||
assertThat(externalNodes).contains("acceptLeader").contains("acceptOrgDeputy");
|
||||
|
||||
// 报送部门与报送人只取登录态,客户端伪造字段被忽略
|
||||
ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId);
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
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 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 0014)集成测试:出纳回填收据号(先回填后通过)、双存档与归档、
|
||||
* 收据号唯一性与冲突单号、撤回/驳回清空留痕、凭证必传与回填权限。
|
||||
*
|
||||
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进。
|
||||
* 引擎表由 {@link ApprovalTestData#cleanup()} 清理,本单业务表在 {@link #tearDown()} 里按实例删除。</p>
|
||||
*
|
||||
* <p>使用独立租户 10053:出纳/财务部负责人/收文员都按 (tenant_id, role_code) 解析,
|
||||
* 独立租户可避免与共享租户 10150 的种子角色成员互相污染,保证待办指向唯一。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
class ApprovalReceiptIT {
|
||||
|
||||
private static final int TENANT = 10053;
|
||||
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 FLOW = "receipt";
|
||||
private static final String FINANCE_ORG_CODE = "APPROVAL_FINANCE";
|
||||
|
||||
@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 cashier;
|
||||
private ApprovalTestData.TestUser financeLeader;
|
||||
private ApprovalTestData.TestUser archivist;
|
||||
private Integer reportOrgId;
|
||||
private Integer financeOrgId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
reportOrgId = data.createOrganization("收据报送部门" + data.uniqueSuffix(), 136, TENANT);
|
||||
reporter = data.createUser("收据报送人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole reportLeaderRole =
|
||||
data.createRole(data.leaderRoleCode(reportOrgId), "报送部门负责人", TENANT);
|
||||
reportLeader = data.createUser("收据负责人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT);
|
||||
|
||||
// 财务部:节点 JSON 固定按组织编码 APPROVAL_FINANCE 解析负责人
|
||||
financeOrgId = data.createOrganization("财务部" + data.uniqueSuffix(), 136, TENANT);
|
||||
data.server().update("UPDATE sys_organization SET organization_code = ? WHERE organization_id = ?",
|
||||
FINANCE_ORG_CODE, financeOrgId);
|
||||
ApprovalTestData.TestRole financeLeaderRole =
|
||||
data.createRole("dept_leader_" + FINANCE_ORG_CODE, "财务部负责人", TENANT);
|
||||
financeLeader = data.createUser("收据财务负责人" + data.uniqueSuffix(), financeOrgId, TENANT);
|
||||
data.bindTenant(financeLeader.userId, financeLeaderRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole cashierRole = data.createRole("approval_cashier", "出纳", TENANT);
|
||||
cashier = data.createUser("收据出纳" + data.uniqueSuffix(), financeOrgId, TENANT);
|
||||
data.bindTenant(cashier.userId, cashierRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
|
||||
archivist = data.createUser("收据收文员" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// 业务表(approval_receipt_form)与改值历史(approval_field_change)已由共享夹具按实例清理
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsEndToEndWithCashierBackfillThenApprove() throws Exception {
|
||||
String receiptNo = uniqueReceiptNo();
|
||||
long instanceId = saveReceipt("1234.56", "广西某付款单位", "培训场地使用费", null, "2026-10-31", null);
|
||||
|
||||
// 草稿:业务字段落库,收据号为空
|
||||
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||
assertThat(bizNo(instanceId)).isNull();
|
||||
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("receipt");
|
||||
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("receipt");
|
||||
assertThat(data.queryMaster("SELECT payer_name FROM approval_receipt_form WHERE instance_id = ?",
|
||||
String.class, instanceId)).isEqualTo("广西某付款单位");
|
||||
assertThat(data.queryMaster("SELECT amount FROM approval_receipt_form WHERE instance_id = ?",
|
||||
java.math.BigDecimal.class, instanceId).toPlainString()).isEqualTo("1234.56");
|
||||
assertThat(data.queryMaster("SELECT apply_date FROM approval_receipt_form WHERE instance_id = ?",
|
||||
java.sql.Date.class, instanceId).toLocalDate().toString()).isEqualTo("2026-10-31");
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.docTypeName").value("收据申请单"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("未提交"))
|
||||
.andExpect(jsonPath("$.data.document.fields[0].key").value("amount"))
|
||||
.andExpect(jsonPath("$.data.document.fields[1].key").value("payerName"))
|
||||
.andExpect(jsonPath("$.data.document.fields[1].value").value("广西某付款单位"))
|
||||
.andExpect(jsonPath("$.data.document.fields[3].key").value("receiptNo"));
|
||||
|
||||
// 缺凭证不能提交
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||
reporter, reportOrgId, SUBMIT))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("凭证")));
|
||||
|
||||
uploadVoucher(instanceId);
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||
reporter, reportOrgId, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||
|
||||
String bizNo = bizNo(instanceId);
|
||||
assertThat(bizNo).matches("SJ\\d{8}-\\d{4}");
|
||||
|
||||
// 报送部门负责人通过
|
||||
long leaderTask = pendingTaskId(instanceId, "reportLeader", reportLeader.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/approve"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意开票\"}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("出纳"));
|
||||
|
||||
// 出纳看到可回填字段(必填、当前为空)
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), cashier, financeOrgId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeKey").value("cashier"))
|
||||
.andExpect(jsonPath("$.data.permissions.canApprove").value(true))
|
||||
.andExpect(jsonPath("$.data.permissions.writableFields.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.permissions.writableFields[0].field").value("receiptNo"))
|
||||
.andExpect(jsonPath("$.data.permissions.writableFields[0].label").value("收据号"))
|
||||
.andExpect(jsonPath("$.data.permissions.writableFields[0].required").value(true));
|
||||
|
||||
// 回填:只写字段不推进节点,写一条改值历史
|
||||
long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeKey").value("cashier"))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.document.fields[3].value").value(receiptNo));
|
||||
|
||||
assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
||||
+ "AND field_key = 'receiptNo' AND old_value IS NULL AND new_value = ? AND round = 1 "
|
||||
+ "AND operator_id = ? AND node_key = 'cashier'",
|
||||
instanceId, receiptNo, cashier.userId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||
+ "AND action = 'BACKFILL' AND operator_id = ?", instanceId, cashier.userId)).isEqualTo(1);
|
||||
// 回填不推进节点
|
||||
assertThat(currentNodeKey(instanceId)).isEqualTo("cashier");
|
||||
|
||||
// 回填后号码出现在检索文本与列表标题里(列表页只有标题列,需能核对票据)
|
||||
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST)
|
||||
.param("keywords", receiptNo))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||
.andExpect(jsonPath("$.data.list[0].title").value(
|
||||
org.hamcrest.Matchers.containsString("收据号 " + receiptNo)));
|
||||
|
||||
// 再回填同值:不产生新的改值历史
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
||||
+ "AND field_key = 'receiptNo'", instanceId)).isEqualTo(1);
|
||||
|
||||
// 出纳通过(body 带 fields,值未变不再写改值历史)
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"已到账\",\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
||||
+ "AND field_key = 'receiptNo'", instanceId)).isEqualTo(1);
|
||||
|
||||
// 双存档:报送人与财务部负责人都在存档节点有任务
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = 'confirm' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), financeLeader, financeOrgId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[0].groupKey").value("reporter"))
|
||||
.andExpect(jsonPath("$.data.document.groupProgress[1].groupKey").value("finance"))
|
||||
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
|
||||
|
||||
// 只确认一组:停在存档节点
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "confirm", reporter.userId)
|
||||
+ "/confirm"), reporter, reportOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||
|
||||
// 第二组确认后进入审批完成并为收文员生成归档任务
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "confirm", financeLeader.userId)
|
||||
+ "/confirm"), financeLeader, financeOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
|
||||
|
||||
// 归档
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "archive", archivist.userId)
|
||||
+ "/archive"), archivist, reportOrgId, ARCHIVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||
.andExpect(jsonPath("$.data.archivistName").value(archivist.username));
|
||||
|
||||
assertThat(instanceStatus(instanceId)).isEqualTo("ARCHIVED");
|
||||
assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo);
|
||||
assertThat(data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId)).contains(receiptNo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksConflictingReceiptNoAndNamesOccupyingDoc() throws Exception {
|
||||
String receiptNo = uniqueReceiptNo();
|
||||
// 先提交一张占用该号码的单据(号码由报送人填在表单上,草稿即占号)
|
||||
long occupied = saveReceipt("100.00", "占号单位", "先开出的收据", receiptNo, null, null);
|
||||
uploadVoucher(occupied);
|
||||
submit(occupied);
|
||||
String occupiedBizNo = bizNo(occupied);
|
||||
assertThat(occupiedBizNo).isNotBlank();
|
||||
|
||||
long instanceId = approvedToCashier("200.00", "后到单位", "同号收据");
|
||||
long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId);
|
||||
|
||||
// 回填同号被拦,消息带出占用它的单号
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
|
||||
"收据号 " + receiptNo + " 已被单据 " + occupiedBizNo + " 使用")));
|
||||
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
assertThat(currentNodeKey(instanceId)).isEqualTo("cashier");
|
||||
|
||||
// 通过时同样被拦,任务与节点都不动
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"到账\",\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(
|
||||
"已被单据 " + occupiedBizNo + " 使用")));
|
||||
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, cashierTask))
|
||||
.isEqualTo("PENDING");
|
||||
assertThat(currentNodeKey(instanceId)).isEqualTo("cashier");
|
||||
|
||||
// 换一个未占用的号码即可通过
|
||||
String freeNo = uniqueReceiptNo();
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + freeNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||
assertThat(formReceiptNo(instanceId)).isEqualTo(freeNo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksApproveWithoutRequiredReceiptNo() throws Exception {
|
||||
long instanceId = approvedToCashier("300.00", "缺号单位", "通过时缺收据号");
|
||||
long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId);
|
||||
|
||||
// 回填允许空值(不完整)
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\" \"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeKey").value("cashier"));
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
|
||||
// 通过时完全不带 fields 也不能绕过必填
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"到账\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收据号不能为空")));
|
||||
|
||||
// 通过时必填项缺项被拦
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"到账\",\"fields\":{\"receiptNo\":\" \"}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收据号不能为空")));
|
||||
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, cashierTask))
|
||||
.isEqualTo("PENDING");
|
||||
assertThat(currentNodeKey(instanceId)).isEqualTo("cashier");
|
||||
|
||||
// 补上号码后通过
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawClearsReceiptNoAndLeavesChangeTrace() throws Exception {
|
||||
String receiptNo = uniqueReceiptNo();
|
||||
long instanceId = saveReceipt("400.00", "撤回单位", "撤回前已有号码", receiptNo, null, null);
|
||||
uploadVoucher(instanceId);
|
||||
submit(instanceId);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"),
|
||||
reporter, reportOrgId, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
|
||||
.andExpect(jsonPath("$.data.round").value(2))
|
||||
.andExpect(jsonPath("$.data.document.fields[3].value").doesNotExist());
|
||||
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
||||
+ "AND field_key = 'receiptNo' AND old_value = ? AND new_value IS NULL AND round = 1 "
|
||||
+ "AND operator_id = ?", instanceId, receiptNo, reporter.userId)).isEqualTo(1);
|
||||
assertThat(data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId)).doesNotContain(receiptNo);
|
||||
|
||||
// 重提后号码仍为空,占用查询不再把该单算作占号方
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||
reporter, reportOrgId, SUBMIT))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.round").value(3));
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectClearsReceiptNoAndLeavesChangeTrace() throws Exception {
|
||||
long instanceId = approvedToCashier("500.00", "驳回单位", "出纳回填后驳回");
|
||||
long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId);
|
||||
String receiptNo = uniqueReceiptNo();
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/reject"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"凭证金额与收据号不符\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("REJECTED"))
|
||||
.andExpect(jsonPath("$.data.round").value(2));
|
||||
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? "
|
||||
+ "AND field_key = 'receiptNo' AND old_value = ? AND new_value IS NULL AND round = 1 "
|
||||
+ "AND node_key = 'cashier' AND operator_id = ?", instanceId, receiptNo, cashier.userId))
|
||||
.isEqualTo(1);
|
||||
|
||||
// 驳回后号码可被别的单据使用(清空即释放)
|
||||
long other = approvedToCashier("600.00", "接号单位", "沿用被释放的号码");
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(other, "cashier", cashier.userId)
|
||||
+ "/backfill"), cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(formReceiptNo(other)).isEqualTo(receiptNo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictsBackfillToTaskOwnerAndPendingTask() throws Exception {
|
||||
long instanceId = saveReceipt("700.00", "回填权限单位", "权限校验", null, null, null);
|
||||
uploadVoucher(instanceId);
|
||||
submit(instanceId);
|
||||
|
||||
// 报送部门负责人节点未声明可写字段
|
||||
long leaderTask = pendingTaskId(instanceId, "reportLeader", reportLeader.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/backfill"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("当前节点不支持回填")));
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/approve"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId);
|
||||
String receiptNo = uniqueReceiptNo();
|
||||
|
||||
// 非任务本人(报送人)不能回填
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
reporter, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
|
||||
// 已处理的任务不能再回填
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/backfill"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||
|
||||
// 本人 + 待办才可回填
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo);
|
||||
|
||||
// 通过后该任务已结束,再回填被拒
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"),
|
||||
cashier, financeOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesBusinessFields() throws Exception {
|
||||
expectSaveFailure(body(null, "付款方", "事由", null, null), "金额不能为空");
|
||||
expectSaveFailure(body("-1", "付款方", "事由", null, null), "金额必须为0到99999999.99之间");
|
||||
expectSaveFailure(body("100000000", "付款方", "事由", null, null), "金额必须为0到99999999.99之间");
|
||||
expectSaveFailure(body("10.001", "付款方", "事由", null, null), "金额最多保留两位小数");
|
||||
expectSaveFailure(body("10.00", " ", "事由", null, null), "付款方不能为空");
|
||||
expectSaveFailure(body("10.00", repeat("付", 201), "事由", null, null), "付款方不能超过200字");
|
||||
expectSaveFailure(body("10.00", "付款方", "", null, null), "事由不能为空");
|
||||
expectSaveFailure(body("10.00", "付款方", repeat("事", 501), null, null), "事由不能超过500字");
|
||||
expectSaveFailure(body("10.00", "付款方", "事由", repeat("号", 65), null), "收据号不能超过64字");
|
||||
|
||||
// 金额边界值与选填项留空均可保存
|
||||
long instanceId = saveReceipt("99999999.99", "边界单位", "上限金额", " ", null, null);
|
||||
assertThat(data.queryMaster("SELECT amount FROM approval_receipt_form WHERE instance_id = ?",
|
||||
java.math.BigDecimal.class, instanceId).toPlainString()).isEqualTo("99999999.99");
|
||||
assertThat(formReceiptNo(instanceId)).isNull();
|
||||
assertThat(data.queryMaster("SELECT apply_date FROM approval_receipt_form WHERE instance_id = ?",
|
||||
java.sql.Date.class, instanceId)).isNull();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private long saveReceipt(String amount, String payerName, String reason, String receiptNo,
|
||||
String applyDate, Long id) throws Exception {
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW),
|
||||
reporter, reportOrgId, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body(amount, payerName, reason, receiptNo, applyDate, id)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode node = objectMapper.readTree(response);
|
||||
long instanceId = node.path("data").asLong();
|
||||
track(instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/** 建单 → 传凭证 → 提交 → 报送部门负责人通过,停在出纳节点。 */
|
||||
private long approvedToCashier(String amount, String payerName, String reason) throws Exception {
|
||||
long instanceId = saveReceipt(amount, payerName, reason, null, null, null);
|
||||
uploadVoucher(instanceId);
|
||||
submit(instanceId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/"
|
||||
+ pendingTaskId(instanceId, "reportLeader", reportLeader.userId) + "/approve"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeKey").value("cashier"));
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
private void submit(long instanceId) throws Exception {
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||
reporter, reportOrgId, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"));
|
||||
}
|
||||
|
||||
private void expectSaveFailure(String body, String expectedMessage) throws Exception {
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, reportOrgId, 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 String body(String amount, String payerName, String reason, String receiptNo,
|
||||
String applyDate) {
|
||||
return body(amount, payerName, reason, receiptNo, applyDate, null);
|
||||
}
|
||||
|
||||
private String body(String amount, String payerName, String reason, String receiptNo,
|
||||
String applyDate, Long id) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
if (id != null) {
|
||||
sb.append("\"id\":").append(id).append(',');
|
||||
}
|
||||
sb.append("\"amount\":").append(amount == null ? "null" : amount).append(',')
|
||||
.append("\"payerName\":").append(quote(payerName)).append(',')
|
||||
.append("\"reason\":").append(quote(reason)).append(',')
|
||||
.append("\"receiptNo\":").append(receiptNo == null ? "null" : quote(receiptNo)).append(',')
|
||||
.append("\"applyDate\":").append(applyDate == null ? "null" : quote(applyDate))
|
||||
.append('}');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void uploadVoucher(long instanceId) 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", "VOUCHER")
|
||||
.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||
reportOrgId, TENANT, SUBMIT))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
private long pendingTaskId(long instanceId, String nodeKey, int userId) {
|
||||
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_key = ? AND assignee_id = ? AND status = 'PENDING' LIMIT 1",
|
||||
Long.class, instanceId, nodeKey, userId);
|
||||
assertThat(taskId).as(nodeKey + " 待办任务应存在").isNotNull();
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private void track(long instanceId) {
|
||||
data.trackInstance(instanceId);
|
||||
}
|
||||
|
||||
private String uniqueReceiptNo() {
|
||||
return "R" + data.uniqueSuffix();
|
||||
}
|
||||
|
||||
private String formReceiptNo(long instanceId) {
|
||||
return data.queryMaster("SELECT receipt_no FROM approval_receipt_form WHERE instance_id = ?",
|
||||
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 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 quote(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private String repeat(String value, int count) {
|
||||
return value.repeat(count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user