From ceadce610bb04ce40b815f392ab1b9a338f0234f Mon Sep 17 00:00:00 2001 From: weicw Date: Sat, 31 Oct 2026 06:55:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=93=E7=AE=97=E5=8D=95=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E9=83=A8=E9=97=A8=E4=BC=9A=E7=AD=BE=20+=20=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E8=A1=8C=20+=20=E7=9B=96=E7=AB=A0=E4=BB=B6=E5=BF=85?= =?UTF-8?q?=E4=BC=A0=EF=BC=89#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/06-approval-settlement.sql | 39 ++ .../settlement/ApprovalSettlementForm.java | 45 ++ .../settlement/ApprovalSettlementItem.java | 51 ++ .../settlement/ApprovalSettlementParam.java | 29 + .../settlement/SettlementDocumentType.java | 250 +++++++ .../mapper/ApprovalSettlementFormMapper.java | 10 + .../mapper/ApprovalSettlementItemMapper.java | 10 + .../approval/ApprovalSettlementIT.java | 621 ++++++++++++++++++ 8 files changed, 1055 insertions(+) create mode 100644 sql/06-approval-settlement.sql create mode 100644 src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementItem.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/settlement/SettlementDocumentType.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementItemMapper.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalSettlementIT.java diff --git a/sql/06-approval-settlement.sql b/sql/06-approval-settlement.sql new file mode 100644 index 0000000..40fbf9c --- /dev/null +++ b/sql/06-approval-settlement.sql @@ -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='结算单明细'; diff --git a/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementForm.java b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementForm.java new file mode 100644 index 0000000..57a8f52 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementForm.java @@ -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 对应审批实例。 + * + *

不设币种:客户业务全为人民币,多币种要配套汇率与折算。

+ */ +@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; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementItem.java b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementItem.java new file mode 100644 index 0000000..3de291a --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementItem.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementParam.java b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementParam.java new file mode 100644 index 0000000..3e71dbf --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/settlement/ApprovalSettlementParam.java @@ -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。 + * + *

报送部门与报送人由服务端按登录态带出,不在参数中接收。

+ */ +@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; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/settlement/SettlementDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/settlement/SettlementDocumentType.java new file mode 100644 index 0000000..38d66cb --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/settlement/SettlementDocumentType.java @@ -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):报送部门负责人 → 三部门会签(场馆部/安全运保部/财务部)→ + * 报送部门分管领导 → 报送人存档 → 归档。 + * + *

吃掉引擎三项能力:会签(组声明固定)、明细行(金额服务端重算、合计不落库)、 + * 附件分类(外单位盖章件 SIGNED 必传)。

+ */ +@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 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 lineColumns() { + return LINE_COLUMNS; + } + + /** 外单位盖章件(签字件)至少一张:只要求整单有附件会让报价单顶替掉盖章件(ADR 0005)。 */ + @Override + public String requiredAttachmentCategory() { + return ApprovalConstants.CATEGORY_SIGNED; + } + + @Override + public Class 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。 + * + *

调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。

+ */ + @Override + public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) { + ApprovalSettlementParam settlement = cast(param); + List> 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() + .eq(ApprovalSettlementItem::getInstanceId, instanceId)); + for (Map 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> lines(ApprovalInstance instance) { + List items = items(instance); + List> rows = new ArrayList<>(items.size()); + for (ApprovalSettlementItem item : items) { + Map 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 items(ApprovalInstance instance) { + return settlementItemMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalSettlementItem::getInstanceId, instance.getId()) + .orderByAsc(ApprovalSettlementItem::getLineNo)); + } + + private ApprovalSettlementForm findByInstance(Long instanceId) { + List forms = settlementFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalSettlementForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private String text(Map row, String key) { + Object value = row.get(key); + return value == null ? null : String.valueOf(value); + } + + private BigDecimal decimal(Map 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; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementFormMapper.java new file mode 100644 index 0000000..416b448 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementFormMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementItemMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementItemMapper.java new file mode 100644 index 0000000..3652519 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalSettlementItemMapper.java @@ -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 { +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalSettlementIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalSettlementIT.java new file mode 100644 index 0000000..152b3b4 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalSettlementIT.java @@ -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; + +/** + * 结算单:三部门会签、明细行(金额服务端重算)、盖章件必传与明细导出的集成测试。 + * + *

不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进; + * 引擎表由 {@link ApprovalTestData#cleanup()} 清理,本单业务表在 {@link #tearDown()} 里按实例删除。

+ */ +@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 instanceIds = new ArrayList<>(); + private final List 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 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 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> 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 task : tasks) { + approveTask(task, reportOrgId); + } + } + } + + private void approveTask(Map 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 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 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); + } +}