Merge branch 'feat/approval-phase2' into feat/s56-facility

This commit is contained in:
2026-10-31 07:42:38 +08:00
10 changed files with 1195 additions and 2 deletions
@@ -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);
}
}