feat: 结算单(三部门会签 + 明细行 + 盖章件必传)#52

This commit is contained in:
weicw
2026-10-31 06:55:31 +08:00
parent 80e1837f75
commit ceadce610b
8 changed files with 1055 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
-- 结算单业务表与明细子表(可重复执行)
-- 目标库:db_gxtyzx(master)
-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql
-- -> 04-approval_role_grants.sql -> 05-approval_phase2.sql -> 06-approval-settlement.sql
--
-- 覆盖 ADR 0011(结算单):三部门会签、明细行(金额服务端重算、合计不落库)、
-- 外单位盖章件(SIGNED)必传。业务表 1:1 实例,明细子表按 instance_id + line_no 唯一。
CREATE TABLE IF NOT EXISTS approval_settlement_form (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id INT NOT NULL DEFAULT 10150,
instance_id BIGINT NOT NULL,
settlement_reason VARCHAR(500) NOT NULL COMMENT '结算事由',
settlement_period VARCHAR(100) DEFAULT NULL COMMENT '结算期间',
counterparty_name VARCHAR(200) NOT NULL COMMENT '结算对象名称',
contract_no VARCHAR(100) DEFAULT NULL COMMENT '合同/项目编号',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算单';
CREATE TABLE IF NOT EXISTS approval_settlement_item (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id INT NOT NULL DEFAULT 10150,
instance_id BIGINT NOT NULL,
line_no INT NOT NULL COMMENT '行号,按提交顺序重排为 1..N',
item_name VARCHAR(200) NOT NULL COMMENT '项目名称',
spec VARCHAR(200) DEFAULT NULL COMMENT '规格说明',
quantity DECIMAL(12,3) NOT NULL COMMENT '数量',
unit VARCHAR(20) NOT NULL COMMENT '单位',
unit_price DECIMAL(12,2) NOT NULL COMMENT '单价',
amount DECIMAL(12,2) NOT NULL COMMENT '金额,服务端按数量×单价重算',
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_instance_line (instance_id, line_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算单明细';
@@ -0,0 +1,45 @@
package com.gxwebsoft.approval.doc.settlement;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 结算单(ADR 0011):与外单位结算的业务字段,1:1 对应审批实例。
*
* <p>不设币种:客户业务全为人民币,多币种要配套汇率与折算。</p>
*/
@Data
@ApiModel(description = "结算单")
@TableName("approval_settlement_form")
public class ApprovalSettlementForm implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Integer tenantId;
private Long instanceId;
/** 结算事由。 */
private String settlementReason;
/** 结算期间。 */
private String settlementPeriod;
/** 结算对象名称。 */
private String counterpartyName;
/** 合同/项目编号。 */
private String contractNo;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,51 @@
package com.gxwebsoft.approval.doc.settlement;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 结算单明细行(ADR 0011 / 0004):金额由服务端按数量 × 单价重算,合计不落库。
*/
@Data
@ApiModel(description = "结算单明细")
@TableName("approval_settlement_item")
public class ApprovalSettlementItem implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Integer tenantId;
private Long instanceId;
/** 行号:按提交顺序重排为 1..N。 */
private Integer lineNo;
private String itemName;
private String spec;
/** 数量,3 位小数(租用时长常带小数)。 */
private BigDecimal quantity;
private String unit;
private BigDecimal unitPrice;
/** 金额:服务端按 quantity × unitPrice 重算,不信任客户端值。 */
private BigDecimal amount;
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,29 @@
package com.gxwebsoft.approval.doc.settlement;
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 结算单草稿参数;id 为审批实例id,更新草稿时传入,明细行走基类 lines。
*
* <p>报送部门与报送人由服务端按登录态带出,不在参数中接收。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ApprovalSettlementParam extends ApprovalDocumentParam {
private static final long serialVersionUID = 1L;
@ApiModelProperty("结算事由,必填")
private String settlementReason;
@ApiModelProperty("结算期间,选填")
private String settlementPeriod;
@ApiModelProperty("结算对象名称,必填")
private String counterpartyName;
@ApiModelProperty("合同/项目编号,选填")
private String contractNo;
}
@@ -0,0 +1,250 @@
package com.gxwebsoft.approval.doc.settlement;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
import com.gxwebsoft.approval.document.ApprovalLineSupport;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.mapper.ApprovalSettlementFormMapper;
import com.gxwebsoft.approval.mapper.ApprovalSettlementItemMapper;
import com.gxwebsoft.approval.support.ApprovalLineSpec;
import com.gxwebsoft.approval.vo.ApprovalDetail;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.User;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 结算单(ADR 0011):报送部门负责人 → 三部门会签(场馆部/安全运保部/财务部)→
* 报送部门分管领导 → 报送人存档 → 归档。
*
* <p>吃掉引擎三项能力:会签(组声明固定)、明细行(金额服务端重算、合计不落库)、
* 附件分类(外单位盖章件 SIGNED 必传)。</p>
*/
@Component
public class SettlementDocumentType extends AbstractApprovalDocumentType {
/** 节点链:报送人提交后从报送部门负责人开始。 */
static final String NODES_JSON = "["
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ "{\"key\":\"countersign\",\"type\":\"COUNTERSIGN\",\"name\":\"三部门会签\",\"groups\":["
+ "{\"key\":\"venue\",\"name\":\"场馆部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_VENUE\"},"
+ "{\"key\":\"safety\",\"name\":\"安全运保部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_SAFETY\"},"
+ "{\"key\":\"finance\",\"name\":\"财务部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_FINANCE\"}"
+ "]},"
+ "{\"key\":\"deputy\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgDeputy\",\"name\":\"分管领导\"},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
/** 明细列(ADR 0004 / 0011):列结构随表结构走。 */
private static final List<ApprovalLineSpec> LINE_COLUMNS = Collections.unmodifiableList(
List.of(
ApprovalLineSpec.text("itemName", "项目名称", true, 200),
ApprovalLineSpec.text("spec", "规格说明", false, 200),
ApprovalLineSpec.decimal("quantity", "数量", true, 3),
ApprovalLineSpec.text("unit", "单位", true, 20),
ApprovalLineSpec.decimal("unitPrice", "单价", true, 2),
// 金额由服务端重算:客户端可不传,传了就必须与数量×单价一致
ApprovalLineSpec.decimal("amount", "金额", false, 2).asMoney(),
ApprovalLineSpec.text("remark", "备注", false, 200)
));
@Resource
private ApprovalSettlementFormMapper settlementFormMapper;
@Resource
private ApprovalSettlementItemMapper settlementItemMapper;
@Override
public String flowCode() {
return ApprovalConstants.FLOW_SETTLEMENT;
}
@Override
public String docType() {
return ApprovalConstants.FLOW_SETTLEMENT;
}
@Override
public String flowName() {
return "结算单";
}
@Override
public String nodesJson() {
return NODES_JSON;
}
@Override
public String noPrefix() {
return ApprovalConstants.NO_PREFIX_SETTLEMENT;
}
@Override
public List<ApprovalLineSpec> lineColumns() {
return LINE_COLUMNS;
}
/** 外单位盖章件(签字件)至少一张:只要求整单有附件会让报价单顶替掉盖章件(ADR 0005)。 */
@Override
public String requiredAttachmentCategory() {
return ApprovalConstants.CATEGORY_SIGNED;
}
@Override
public Class<? extends ApprovalDocumentParam> paramClass() {
return ApprovalSettlementParam.class;
}
@Override
public void validate(ApprovalDocumentParam param, User user) {
ApprovalSettlementParam settlement = cast(param);
requireText("结算事由", settlement.getSettlementReason(), 500);
requireText("结算对象", settlement.getCounterpartyName(), 200);
optionalText("结算期间", settlement.getSettlementPeriod(), 100);
optionalText("合同号", settlement.getContractNo(), 100);
// 明细在持久化前统一校验;这里先做结构校验,避免先落业务主表再报错
ApprovalLineSupport.normalize(settlement.getLines(), LINE_COLUMNS);
}
/**
* 写业务表:明细整表替换(先删后插),line_no 由校验器重排为 1..N。
*
* <p>调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。</p>
*/
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
ApprovalSettlementParam settlement = cast(param);
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(settlement.getLines(), LINE_COLUMNS);
ApprovalSettlementForm form = bizId == null ? null : settlementFormMapper.selectById(bizId);
if (form == null) {
form = new ApprovalSettlementForm();
form.setInstanceId(instanceId);
}
form.setSettlementReason(settlement.getSettlementReason().trim());
form.setSettlementPeriod(optionalText("结算期间", settlement.getSettlementPeriod(), 100));
form.setCounterpartyName(settlement.getCounterpartyName().trim());
form.setContractNo(optionalText("合同号", settlement.getContractNo(), 100));
if (form.getId() == null) {
settlementFormMapper.insert(form);
} else {
settlementFormMapper.updateById(form);
}
settlementItemMapper.delete(new LambdaQueryWrapper<ApprovalSettlementItem>()
.eq(ApprovalSettlementItem::getInstanceId, instanceId));
for (Map<String, Object> row : rows) {
ApprovalSettlementItem item = new ApprovalSettlementItem();
item.setInstanceId(instanceId);
item.setLineNo((Integer) row.get(ApprovalLineSupport.KEY_LINE_NO));
item.setItemName(text(row, "itemName"));
item.setSpec(text(row, "spec"));
item.setQuantity(decimal(row, "quantity"));
item.setUnit(text(row, "unit"));
item.setUnitPrice(decimal(row, "unitPrice"));
item.setAmount(decimal(row, "amount"));
item.setRemark(text(row, "remark"));
settlementItemMapper.insert(item);
}
return form.getId();
}
@Override
public void load(ApprovalInstance instance, ApprovalDetail detail) {
ApprovalSettlementForm form = findByInstance(instance.getId());
if (form == null) {
return;
}
detail.addField("settlementReason", "结算事由", form.getSettlementReason());
detail.addField("settlementPeriod", "结算期间", form.getSettlementPeriod());
detail.addField("counterpartyName", "结算对象", form.getCounterpartyName());
detail.addField("contractNo", "合同号", form.getContractNo());
detail.setLines(lines(instance));
}
@Override
public List<Map<String, Object>> lines(ApprovalInstance instance) {
List<ApprovalSettlementItem> items = items(instance);
List<Map<String, Object>> rows = new ArrayList<>(items.size());
for (ApprovalSettlementItem item : items) {
Map<String, Object> row = new LinkedHashMap<>();
row.put(ApprovalLineSupport.KEY_LINE_NO, item.getLineNo());
row.put("itemName", item.getItemName());
row.put("spec", item.getSpec());
row.put("quantity", item.getQuantity());
row.put("unit", item.getUnit());
row.put("unitPrice", item.getUnitPrice());
row.put("amount", item.getAmount());
row.put("remark", item.getRemark());
rows.add(row);
}
return rows;
}
/** 合计金额:查询时求和,不落库(ADR 0004 / 0011)。 */
@Override
public BigDecimal lineAmountTotal(ApprovalInstance instance) {
return ApprovalLineSupport.amountTotal(lines(instance));
}
@Override
public String title(ApprovalInstance instance) {
ApprovalSettlementForm form = findByInstance(instance.getId());
return form == null ? null : form.getCounterpartyName();
}
/** 检索文本:结算对象、事由、合同号与结算期间都可搜。 */
@Override
public String searchText(ApprovalInstance instance) {
ApprovalSettlementForm form = findByInstance(instance.getId());
if (form == null) {
return null;
}
return String.join(" ", nullToEmpty(form.getCounterpartyName()), nullToEmpty(form.getSettlementReason()),
nullToEmpty(form.getContractNo()), nullToEmpty(form.getSettlementPeriod())).trim();
}
private List<ApprovalSettlementItem> items(ApprovalInstance instance) {
return settlementItemMapper.selectList(new LambdaQueryWrapper<ApprovalSettlementItem>()
.eq(ApprovalSettlementItem::getInstanceId, instance.getId())
.orderByAsc(ApprovalSettlementItem::getLineNo));
}
private ApprovalSettlementForm findByInstance(Long instanceId) {
List<ApprovalSettlementForm> forms = settlementFormMapper.selectList(
new LambdaQueryWrapper<ApprovalSettlementForm>()
.eq(ApprovalSettlementForm::getInstanceId, instanceId)
.last("LIMIT 1"));
return forms.isEmpty() ? null : forms.get(0);
}
private String text(Map<String, Object> row, String key) {
Object value = row.get(key);
return value == null ? null : String.valueOf(value);
}
private BigDecimal decimal(Map<String, Object> row, String key) {
Object value = row.get(key);
return value instanceof BigDecimal ? (BigDecimal) value : null;
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private ApprovalSettlementParam cast(ApprovalDocumentParam param) {
if (!(param instanceof ApprovalSettlementParam)) {
throw new BusinessException("单据类型不匹配");
}
return (ApprovalSettlementParam) param;
}
}
@@ -0,0 +1,10 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.approval.doc.settlement.ApprovalSettlementForm;
/**
* 结算单Mapper。
*/
public interface ApprovalSettlementFormMapper extends BaseMapper<ApprovalSettlementForm> {
}
@@ -0,0 +1,10 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.approval.doc.settlement.ApprovalSettlementItem;
/**
* 结算单明细Mapper。
*/
public interface ApprovalSettlementItemMapper extends BaseMapper<ApprovalSettlementItem> {
}
@@ -0,0 +1,621 @@
package com.gxwebsoft.approval;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import javax.sql.DataSource;
import java.io.File;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 结算单:三部门会签、明细行(金额服务端重算)、盖章件必传与明细导出的集成测试。
*
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
* 引擎表由 {@link ApprovalTestData#cleanup()} 清理,本单业务表在 {@link #tearDown()} 里按实例删除。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalSettlementIT {
private static final int TENANT = TestAuthentication.TENANT_ID;
private static final String LIST = "approval:instance:list";
private static final String SUBMIT = "approval:instance:submit";
private static final String APPROVE = "approval:instance:approve";
private static final String ARCHIVE = "approval:instance:archive";
private static final String EXPORT = "approval:instance:export";
private static final String FLOW = "settlement";
/** 会签组(节点定义固定)。 */
private static final String VENUE = "dept_leader_APPROVAL_VENUE";
private static final String SAFETY = "dept_leader_APPROVAL_SAFETY";
private static final String FINANCE = "dept_leader_APPROVAL_FINANCE";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
@Autowired
private ConfigProperties config;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private Integer reportOrgId;
private ApprovalTestData.TestUser reportLeader;
private ApprovalTestData.TestUser deputy;
/** 结算对象带唯一后缀:导出用例按它检索,避免命中共享库其他单据。 */
private String counterparty;
private final List<Long> instanceIds = new ArrayList<>();
private final List<Path> exportedFiles = new ArrayList<>();
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
reportOrgId = data.createOrganization("结算报送部门" + data.uniqueSuffix(), 136);
reporter = data.createUser("结算报送人" + data.uniqueSuffix(), reportOrgId);
counterparty = "结算对象" + data.uniqueSuffix();
reportLeader = bindRole("dept_leader_" + data.organizationCode(reportOrgId), "部门负责人",
"结算负责人" + data.uniqueSuffix(), reportOrgId);
deputy = bindRole("dept_deputy_" + data.organizationCode(reportOrgId), "分管领导",
"结算分管领导" + data.uniqueSuffix(), reportOrgId);
// 收文员角色:共享库里已有成员,这里再建一个,归档或签不依赖既有种子数据
bindRole("approval_archivist", "收文员", "结算收文员" + data.uniqueSuffix(), reportOrgId);
}
@AfterEach
void tearDown() {
for (Long instanceId : instanceIds) {
data.master().update("DELETE FROM approval_settlement_item WHERE instance_id = ?", instanceId);
data.master().update("DELETE FROM approval_settlement_form WHERE instance_id = ?", instanceId);
}
instanceIds.clear();
for (Path path : exportedFiles) {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
// 文件清理失败不影响断言结果
}
}
exportedFiles.clear();
data.cleanup();
}
@Test
void runsEndToEndThroughThreeDepartmentsCountersign() throws Exception {
// 一人同属场馆部与安全运保部:一次点击完成两组
ApprovalTestData.TestUser both = createUser("结算双部门负责人" + data.uniqueSuffix(), reportOrgId);
data.bind(both.userId, ensureRole(VENUE, "场馆部负责人"));
data.bind(both.userId, ensureRole(SAFETY, "安全运保部负责人"));
long instanceId = saveDraft(linesJson(
"场馆租用|一次|2|200|400", "安保服务|全天|1.5|100|150"), 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("settlement");
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("settlement");
assertThat(data.queryMaster("SELECT counterparty_name FROM approval_settlement_form "
+ "WHERE instance_id = ?", String.class, instanceId)).isEqualTo(counterparty);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_settlement_item WHERE instance_id = ?",
instanceId)).isEqualTo(2);
// 详情:业务字段、明细、列元数据、合计与会签进度
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("settlementReason"))
.andExpect(jsonPath("$.data.document.lines.length()").value(2))
.andExpect(jsonPath("$.data.document.lines[0].lineNo").value(1))
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(7))
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(2))
.andExpect(jsonPath("$.data.document.lineSummary.amountTotal").value(550.00));
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].docTypeName").value("结算单"));
// 提交:节点链首节点为报送部门负责人,会签进度分三组
uploadAttachment(instanceId, reporter, reportOrgId, "SIGNED");
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("JS\\d{8}-\\d{4}");
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
.andExpect(jsonPath("$.data.document.lineSummary.amountTotal").value(550.00));
// 报送部门负责人
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId)
+ "/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), reportLeader, reportOrgId, LIST))
.andExpect(jsonPath("$.data.document.groupProgress.length()").value(3))
.andExpect(jsonPath("$.data.document.groupProgress[0].groupKey").value("venue"))
.andExpect(jsonPath("$.data.document.groupProgress[1].groupKey").value("safety"))
.andExpect(jsonPath("$.data.document.groupProgress[2].groupKey").value("finance"));
// 一人双组:点一次场馆部任务,同时完成安全运保部任务
long bothVenueTask = pendingTaskId(instanceId, both.userId);
long beforeSafetyDone = countDoneInNode(instanceId, both.userId);
mockMvc.perform(authed(post("/api/approval/task/" + bothVenueTask + "/approve"),
both, reportOrgId, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"场馆无异议\"}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("三部门会签"));
// 一次点击完成他在本节点本轮的全部待办(两个组各一条)
assertThat(countDoneInNode(instanceId, both.userId)).isEqualTo(beforeSafetyDone + 2);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND status = 'PENDING'", instanceId, both.userId)).isZero();
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'countersign' AND group_key = 'safety' AND assignee_id = ? AND status = 'DONE'",
instanceId, both.userId)).isEqualTo(1);
// 剩余组陆续签完(既有的共享库成员也可能在这些组里,这里按实际待办逐个通过)
approveAllByGroup(instanceId, "venue");
approveAllByGroup(instanceId, "safety");
approveAllByGroup(instanceId, "finance");
assertThat(currentNodeKey(instanceId)).isEqualTo("deputy");
// 报送部门分管领导
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, deputy.userId)
+ "/approve"), deputy, reportOrgId, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意\"}"))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
// 报送人存档
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId)
+ "/confirm"), reporter, reportOrgId, APPROVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
// 归档
long archiveTaskId = pendingTaskIdInNode(instanceId, "archive");
MockHttpServletRequestBuilder archive = post("/api/approval/task/" + archiveTaskId + "/archive");
mockMvc.perform(authed(archive, assigneeUser(archiveTaskId), reportOrgId, ARCHIVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
.andExpect(jsonPath("$.data.statusName").value("已归档"));
assertThat(instanceStatus(instanceId)).isEqualTo("ARCHIVED");
assertThat(data.queryMaster("SELECT archive_time FROM approval_instance WHERE id = ?",
Object.class, instanceId)).isNotNull();
assertThat(data.queryMaster("SELECT archivist_name FROM approval_instance WHERE id = ?",
String.class, instanceId)).isNotBlank();
}
@Test
void rejectsAmountMismatchWithLineNumber() throws Exception {
expectSaveFailure(body("结算对象", linesJson("场馆租用|一次|2|200|999"), null),
"第1行金额与数量×单价不一致");
// 第二行不一致时点名第2行
expectSaveFailure(body("结算对象", linesJson("正常|一次|1|10|10", "异常|一次|3|5|20"), null),
"第2行金额与数量×单价不一致");
}
@Test
void rejectsMoreThanOneHundredLines() throws Exception {
StringBuilder lines = new StringBuilder("[");
for (int i = 1; i <= 101; i++) {
if (i > 1) {
lines.append(',');
}
lines.append(lineJson("项目" + i, "规格", "1", "次", "1", "1", ""));
}
lines.append(']');
expectSaveFailure(body("结算对象", lines.toString(), null), "明细不能超过100行");
}
@Test
void replacesAllLinesAndRenumbersOnDraftUpdate() throws Exception {
long instanceId = saveDraft(linesJson("甲|一|1|10|10", "乙|二|2|10|20", "丙|三|3|10|30"), null);
assertThat(lineNos(instanceId)).containsExactly(1, 2, 3);
// 更新为两行:旧行全部删除,行号重排为 1..2
mockMvc.perform(authed(post("/api/approval/instance/settlement"), reporter, reportOrgId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body("结算对象B", linesJson("新甲|一|4|10|40", "新乙|二|5|10|50"), instanceId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value((int) instanceId));
assertThat(lineNos(instanceId)).containsExactly(1, 2);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_settlement_item WHERE instance_id = ?",
instanceId)).isEqualTo(2);
assertThat(data.queryMaster("SELECT item_name FROM approval_settlement_item "
+ "WHERE instance_id = ? AND line_no = 1", String.class, instanceId)).isEqualTo("新甲");
assertThat(data.queryMaster("SELECT counterparty_name FROM approval_settlement_form "
+ "WHERE instance_id = ?", String.class, instanceId)).isEqualTo("结算对象B");
// 客户端伪造的行号被忽略,一律按提交顺序重排
String forged = body("结算对象C", "[{\"lineNo\":99,\"itemName\":\"伪造行号\",\"quantity\":1,"
+ "\"unit\":\"次\",\"unitPrice\":1,\"amount\":1}]", instanceId);
mockMvc.perform(authed(post("/api/approval/instance/settlement"), reporter, reportOrgId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON).content(forged))
.andExpect(jsonPath("$.code").value(0));
assertThat(lineNos(instanceId)).containsExactly(1);
// 金额由服务端重算,客户端不传也落正确值
assertThat(data.queryMaster("SELECT amount FROM approval_settlement_item WHERE instance_id = ?",
java.math.BigDecimal.class, instanceId).stripTrailingZeros().toPlainString()).isEqualTo("1");
}
@Test
void rejectsSubmitWithoutSignedAttachment() throws Exception {
long instanceId = saveDraft(linesJson("场馆租用|一次|2|200|400"), null);
// 只有凭证类附件,不算外单位盖章件
uploadAttachment(instanceId, reporter, reportOrgId, "VOUCHER");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, reportOrgId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("签字件")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
// 补上签字件后可提交
uploadAttachment(instanceId, reporter, reportOrgId, "SIGNED");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, reportOrgId, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"));
}
@Test
void validatesBusinessFields() throws Exception {
String oneLine = "[" + lineJson("项目", "规格", "1", "次", "10", "10", "") + "]";
expectSaveFailure("{\"settlementReason\":\" \",\"counterpartyName\":\"对象\",\"lines\":" + oneLine + "}",
"结算事由不能为空");
expectSaveFailure("{\"settlementReason\":\"" + repeat("事", 501) + "\",\"counterpartyName\":\"对象\","
+ "\"lines\":" + oneLine + "}", "结算事由不能超过500字");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"\",\"lines\":" + oneLine + "}",
"结算对象不能为空");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"" + repeat("对", 201) + "\","
+ "\"lines\":" + oneLine + "}", "结算对象不能超过200字");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\","
+ "\"settlementPeriod\":\"" + repeat("期", 101) + "\",\"lines\":" + oneLine + "}",
"结算期间不能超过100字");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\",\"contractNo\":\""
+ repeat("合", 101) + "\",\"lines\":" + oneLine + "}", "合同号不能超过100字");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\",\"lines\":[]}",
"请至少填写一行明细");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\",\"lines\":"
+ "[" + lineJson("", "规格", "1", "次", "10", "10", "") + "]}", "第1行项目名称不能为空");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\",\"lines\":"
+ "[" + lineJson("项目", "规格", "1.2345", "次", "10", "10", "") + "]}", "第1行数量最多保留3位小数");
expectSaveFailure("{\"settlementReason\":\"事由\",\"counterpartyName\":\"对象\",\"lines\":"
+ "[" + lineJson("项目", "规格", "1", "", "10", "10", "") + "]}", "第1行单位不能为空");
}
@Test
void exportsLineDetailsForSettlement() throws Exception {
long instanceId = saveDraft(linesJson(
"场馆租用|一次|2.5|200|500", "安保服务|全天|1|100|100"), null);
uploadAttachment(instanceId, reporter, reportOrgId, "SIGNED");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, reportOrgId, SUBMIT))
.andExpect(jsonPath("$.code").value(0));
String response = mockMvc.perform(authed(post("/api/approval/export/" + FLOW + "/lines"),
reporter, reportOrgId, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"flowCode\":\"" + FLOW + "\",\"keywords\":\"" + counterparty + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条明细")))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode node = objectMapper.readTree(response).path("data");
File file = new File(node.path("path").asText());
assertThat(file).exists();
exportedFiles.add(file.toPath());
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getSheetName()).isEqualTo("结算单");
assertThat(sheet.getLastRowNum() + 1).isEqualTo(3);
Row header = sheet.getRow(0);
List<String> headers = new ArrayList<>();
for (int i = 0; i < 11; i++) {
headers.add(cellText(header.getCell(i)));
}
assertThat(headers).containsExactly("单号", "状态", "报送部门", "报送人",
"项目名称", "规格说明", "数量", "单位", "单价", "金额", "备注");
Row first = sheet.getRow(1);
assertThat(cellText(first.getCell(0))).isEqualTo(bizNo(instanceId));
assertThat(cellText(first.getCell(1))).isEqualTo("审批中");
assertThat(cellText(first.getCell(2))).isEqualTo(orgName(reportOrgId));
// 报送人姓名取实例快照(来自登录态),测试登录态 realName 与账号一致
assertThat(cellText(first.getCell(3))).isEqualTo(reporter.username);
assertThat(cellText(first.getCell(4))).isEqualTo("场馆租用");
assertThat(cellText(first.getCell(5))).isEqualTo("一次");
assertThat(cellText(first.getCell(6))).isEqualTo("2.5");
assertThat(cellText(first.getCell(7))).isEqualTo("次");
assertThat(cellText(first.getCell(8))).isEqualTo("200");
assertThat(cellText(first.getCell(9))).isEqualTo("500");
Row second = sheet.getRow(2);
assertThat(cellText(second.getCell(4))).isEqualTo("安保服务");
assertThat(cellText(second.getCell(9))).isEqualTo("100");
}
}
@Test
void rejectsUserWithoutSubmitAuthority() throws Exception {
// 只有列表权限:保存与提交都是 403(权限串来自方法级 @PreAuthorize)
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, reportOrgId, LIST)
.contentType(MediaType.APPLICATION_JSON)
.content(body("结算对象", linesJson("甲|一|1|10|10"), null)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
long instanceId = saveDraft(linesJson("甲|一|1|10|10"), null);
uploadAttachment(instanceId, reporter, reportOrgId, "SIGNED");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, reportOrgId, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
}
// ---------- helpers ----------
private long saveDraft(String linesJson, Long id) throws Exception {
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW),
reporter, reportOrgId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body(counterparty, linesJson, id)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
long id1 = objectMapper.readTree(response).path("data").asLong();
if (!instanceIds.contains(id1)) {
instanceIds.add(id1);
}
data.trackInstance(id1);
return id1;
}
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 counterparty, String linesJson, Long id) {
return body("结算事由:与外单位结算场馆租用与安保费用", counterparty, linesJson, id);
}
private String body(String reason, String counterparty, String linesJson, Long id) {
StringBuilder sb = new StringBuilder("{");
if (id != null) {
sb.append("\"id\":").append(id).append(',');
}
sb.append("\"settlementReason\":").append(quote(reason)).append(',')
.append("\"settlementPeriod\":\"2026年10月\",")
.append("\"counterpartyName\":").append(quote(counterparty)).append(',')
.append("\"contractNo\":\"HT-2026-001\",")
.append("\"lines\":").append(linesJson)
.append('}');
return sb.toString();
}
/** 每行格式:itemName|spec|quantity|unitPrice|amount。 */
private String linesJson(String... rows) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < rows.length; i++) {
if (i > 0) {
sb.append(',');
}
String[] parts = rows[i].split("\\|", -1);
sb.append(lineJson(parts[0], parts[1], parts[2], "次", parts[3], parts[4], "备注" + (i + 1)));
}
sb.append(']');
return sb.toString();
}
private String lineJson(String itemName, String spec, String quantity, String unit,
String unitPrice, String amount, String remark) {
return "{\"itemName\":" + quote(itemName)
+ ",\"spec\":" + quote(spec)
+ ",\"quantity\":" + quantity
+ ",\"unit\":" + quote(unit)
+ ",\"unitPrice\":" + unitPrice
+ ",\"amount\":" + amount
+ ",\"remark\":" + quote(remark) + "}";
}
private ApprovalTestData.TestUser bindRole(String roleCode, String roleName,
String realName, Integer organizationId) {
ApprovalTestData.TestUser member = createUser(realName, organizationId);
data.bind(member.userId, ensureRole(roleCode, roleName));
return member;
}
/** 共享库可能已有同名角色(如三部门负责人),存在就复用,不存在才新建。 */
private Integer ensureRole(String roleCode, String roleName) {
List<Integer> ids = data.server().queryForList(
"SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ? AND deleted = 0 "
+ "ORDER BY role_id LIMIT 1", Integer.class, roleCode, TENANT);
if (!ids.isEmpty()) {
return ids.get(0);
}
return data.createRole(roleCode, roleName).roleId;
}
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
return data.createUser(realName, organizationId);
}
private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org,
String category) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(file)
.param("instanceId", String.valueOf(instanceId))
.param("category", category)
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
/** 按组把剩余待办逐个通过(会签组内成员来自共享库角色,逐个处理最稳)。 */
private void approveAllByGroup(long instanceId, String groupKey) throws Exception {
for (int guard = 0; guard < 20; guard++) {
List<Map<String, Object>> tasks = data.master().queryForList(
"SELECT id, assignee_id, assignee_name FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'countersign' AND group_key = ? AND status = 'PENDING' ORDER BY id",
instanceId, groupKey);
if (tasks.isEmpty()) {
return;
}
for (Map<String, Object> task : tasks) {
approveTask(task, reportOrgId);
}
}
}
private void approveTask(Map<String, Object> task, Integer org) throws Exception {
long taskId = ((Number) task.get("id")).longValue();
String name = (String) task.get("assignee_name");
ApprovalTestData.TestUser assignee =
new ApprovalTestData.TestUser(((Number) task.get("assignee_id")).intValue(), name, name);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), assignee, org, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private ApprovalTestData.TestUser assigneeUser(long taskId) {
Map<String, Object> row = data.master().queryForMap(
"SELECT assignee_id, assignee_name FROM approval_task WHERE id = ?", taskId);
String name = (String) row.get("assignee_name");
return new ApprovalTestData.TestUser(((Number) row.get("assignee_id")).intValue(), name, name);
}
private long pendingTaskId(long instanceId, int userId) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
assertThat(taskId).as("待办任务应存在").isNotNull();
return taskId;
}
private long pendingTaskIdInNode(long instanceId, String nodeKey) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND node_key = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, nodeKey);
assertThat(taskId).as(nodeKey + "待办任务应存在").isNotNull();
return taskId;
}
private long countDoneInNode(long instanceId, int userId) {
Long count = data.queryMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'countersign' AND assignee_id = ? AND status = 'DONE'",
Long.class, instanceId, userId);
return count == null ? 0 : count;
}
private List<Integer> lineNos(long instanceId) {
return data.master().queryForList("SELECT line_no FROM approval_settlement_item "
+ "WHERE instance_id = ? ORDER BY line_no", Integer.class, instanceId);
}
private String instanceStatus(long instanceId) {
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private String bizNo(long instanceId) {
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private String currentNodeKey(long instanceId) {
return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
String.class, instanceId);
}
private String orgName(Integer organizationId) {
return data.server().queryForObject("SELECT organization_name FROM sys_organization "
+ "WHERE organization_id = ?", String.class, organizationId);
}
private String cellText(Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == CellType.NUMERIC) {
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros()
.toPlainString();
}
return cell.getStringCellValue();
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
ApprovalTestData.TestUser user, 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);
}
}