Merge branch 'feat/s51-settlement' into feat/approval-phase2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user