feat: 设施价格表(四部门会签 + 生效日期 + 实例即版本)#57

This commit is contained in:
weicw
2026-10-31 07:42:22 +08:00
parent 7c47d4f9ac
commit 82042d3725
12 changed files with 1361 additions and 0 deletions
@@ -0,0 +1,313 @@
package com.gxwebsoft.approval.doc.facility;
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.ApprovalFacilityForm;
import com.gxwebsoft.approval.entity.ApprovalFacilityItem;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.mapper.ApprovalFacilityFormMapper;
import com.gxwebsoft.approval.mapper.ApprovalFacilityItemMapper;
import com.gxwebsoft.approval.param.ApprovalFacilityParam;
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.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 设施价格表(设施损坏修复与租用价格表,ADR 0012):
* 报送人 → 报送部门负责人 → 四部门会签 → 分管领导 → 总经理 → 存档(报送人 + 运营管理部)→ 归档。
*
* <p>只做价格目录,不含损坏描述/报修时间/修复内容——那是价格表被使用的场景,不是第二张单。
* 审批实例即版本:每次归档生成一版,明细行即该版条目;不建独立价格表主数据表。</p>
*
* <p>生效日期允许未来(提前批),不允许早于当天(追溯修改会让历史结算失去依据):
* 草稿保存即拒绝早于今天的日期,归档前由 {@link #validateBeforeArchive} 再校验一次。</p>
*/
@Component
public class FacilityDocumentType extends AbstractApprovalDocumentType {
private static final String NODES_JSON = "["
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\","
+ "\"name\":\"报送部门负责人\"},"
+ "{\"key\":\"deptCountersign\",\"type\":\"COUNTERSIGN\",\"name\":\"四部门会签\",\"groups\":["
+ "{\"key\":\"venue\",\"name\":\"场馆部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_VENUE\"},"
+ "{\"key\":\"ops\",\"name\":\"运营管理部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_OPS\"},"
+ "{\"key\":\"safety\",\"name\":\"安全运保部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_SAFETY\"},"
+ "{\"key\":\"finance\",\"name\":\"财务部\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"APPROVAL_FINANCE\"}"
+ "]},"
+ "{\"key\":\"deputyLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgDeputy\","
+ "\"name\":\"分管领导\"},"
+ "{\"key\":\"generalManager\",\"type\":\"APPROVE\",\"resolver\":\"roleGeneralManager\","
+ "\"name\":\"总经理\"},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\",\"groups\":["
+ "{\"key\":\"reporter\",\"name\":\"报送人\",\"resolver\":\"reporter\"},"
+ "{\"key\":\"ops\",\"name\":\"运营管理部\",\"resolver\":\"roleTypist\"}"
+ "]},"
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
/** 检索文本里最多拼接的明细名称条数,避免超出 search_text 长度。 */
private static final int SEARCH_ITEM_LIMIT = 10;
@Resource
private ApprovalFacilityFormMapper facilityFormMapper;
@Resource
private ApprovalFacilityItemMapper facilityItemMapper;
@Override
public String flowCode() {
return ApprovalConstants.FLOW_FACILITY;
}
@Override
public String docType() {
return ApprovalConstants.FLOW_FACILITY;
}
@Override
public String flowName() {
return "设施损坏修复与租用价格表";
}
@Override
public String nodesJson() {
return NODES_JSON;
}
@Override
public String noPrefix() {
return ApprovalConstants.NO_PREFIX_FACILITY;
}
/** 明细列:名称/位置/单位/数量/单价/金额(服务端重算)/备注。 */
@Override
public List<ApprovalLineSpec> lineColumns() {
List<ApprovalLineSpec> columns = new ArrayList<>();
columns.add(ApprovalLineSpec.text("itemName", "名称", true, 200));
columns.add(ApprovalLineSpec.text("location", "位置", false, 200));
columns.add(ApprovalLineSpec.text("unit", "单位", true, 20));
columns.add(ApprovalLineSpec.decimal("quantity", "数量", true, 3));
columns.add(ApprovalLineSpec.decimal("unitPrice", "单价", true, 2));
// 金额由服务端重算,客户端不传也允许(客户端传了则必须与 数量 × 单价 一致)
columns.add(ApprovalLineSpec.decimal("amount", "金额", false, 2).asMoney());
columns.add(ApprovalLineSpec.text("remark", "备注", false, 200));
return columns;
}
@Override
public String requiredAttachmentCategory() {
return ApprovalConstants.CATEGORY_SIGNED;
}
@Override
public Class<? extends ApprovalDocumentParam> paramClass() {
return ApprovalFacilityParam.class;
}
/**
* 草稿校验:生效日期必填且不早于今天;明细逐行校验并重算金额。
*/
@Override
public void validate(ApprovalDocumentParam param, User user) {
ApprovalFacilityParam facility = cast(param);
requireEffectiveDate(facility.getEffectiveDate());
ApprovalLineSupport.normalize(facility.getLines(), lineColumns());
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
ApprovalFacilityParam facility = cast(param);
LocalDate effectiveDate = requireEffectiveDate(facility.getEffectiveDate());
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(facility.getLines(), lineColumns());
ApprovalFacilityForm form = bizId == null ? null : facilityFormMapper.selectById(bizId);
if (form == null) {
form = findByInstance(instanceId);
}
if (form == null) {
form = new ApprovalFacilityForm();
form.setInstanceId(instanceId);
}
form.setEffectiveDate(effectiveDate);
if (form.getId() == null) {
facilityFormMapper.insert(form);
} else {
facilityFormMapper.updateById(form);
}
// 明细整表替换(ADR 0004):先删旧行再按提交顺序写入
facilityItemMapper.delete(new LambdaQueryWrapper<ApprovalFacilityItem>()
.eq(ApprovalFacilityItem::getInstanceId, instanceId));
for (Map<String, Object> row : rows) {
ApprovalFacilityItem item = new ApprovalFacilityItem();
item.setInstanceId(instanceId);
item.setLineNo((Integer) row.get(ApprovalLineSupport.KEY_LINE_NO));
item.setItemName((String) row.get("itemName"));
item.setLocation((String) row.get("location"));
item.setUnit((String) row.get("unit"));
item.setQuantity(decimal(row.get("quantity")));
item.setUnitPrice(decimal(row.get("unitPrice")));
item.setAmount(decimal(row.get(ApprovalLineSupport.KEY_AMOUNT)));
item.setRemark((String) row.get("remark"));
facilityItemMapper.insert(item);
}
return form.getId();
}
@Override
public void load(ApprovalInstance instance, ApprovalDetail detail) {
ApprovalFacilityForm form = findByInstance(instance.getId());
if (form != null) {
detail.addField("effectiveDate", "生效日期", format(form.getEffectiveDate()));
}
detail.setLines(lines(instance));
detail.setLineColumns(lineColumns());
com.gxwebsoft.approval.vo.ApprovalLineSummaryVO summary =
new com.gxwebsoft.approval.vo.ApprovalLineSummaryVO();
summary.setLineCount(lineCount(instance));
summary.setAmountTotal(lineAmountTotal(instance));
detail.setLineSummary(summary);
}
@Override
public List<Map<String, Object>> lines(ApprovalInstance instance) {
List<ApprovalFacilityItem> items = findItems(instance.getId());
List<Map<String, Object>> rows = new ArrayList<>(items.size());
for (ApprovalFacilityItem item : items) {
Map<String, Object> row = new LinkedHashMap<>();
row.put(ApprovalLineSupport.KEY_LINE_NO, item.getLineNo());
row.put("itemName", item.getItemName());
row.put("location", item.getLocation());
row.put("unit", item.getUnit());
row.put("quantity", item.getQuantity());
row.put("unitPrice", item.getUnitPrice());
row.put(ApprovalLineSupport.KEY_AMOUNT, item.getAmount());
row.put("remark", item.getRemark());
rows.add(row);
}
return rows;
}
@Override
public BigDecimal lineAmountTotal(ApprovalInstance instance) {
List<ApprovalFacilityItem> items = findItems(instance.getId());
if (items.isEmpty()) {
return null;
}
BigDecimal total = BigDecimal.ZERO;
for (ApprovalFacilityItem item : items) {
if (item.getAmount() != null) {
total = total.add(item.getAmount());
}
}
return total.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String readField(ApprovalInstance instance, String field) {
if (!"effectiveDate".equals(field)) {
return null;
}
ApprovalFacilityForm form = findByInstance(instance.getId());
return form == null ? null : format(form.getEffectiveDate());
}
@Override
public String title(ApprovalInstance instance) {
ApprovalFacilityForm form = findByInstance(instance.getId());
if (form == null || form.getEffectiveDate() == null) {
return "设施价格表";
}
return "设施价格表(生效 " + format(form.getEffectiveDate()) + ")";
}
@Override
public String searchText(ApprovalInstance instance) {
String title = title(instance);
List<ApprovalFacilityItem> items = findItems(instance.getId());
if (items.isEmpty()) {
return title;
}
String names = items.stream()
.limit(SEARCH_ITEM_LIMIT)
.map(ApprovalFacilityItem::getItemName)
.filter(name -> name != null && !name.isEmpty())
.collect(Collectors.joining(" "));
return names.isEmpty() ? title : title + " " + names;
}
/**
* 归档前校验(引擎在 {@code ApprovalTaskService.doArchive} 中调用):
* 生效日期早于当天即拒绝,提示追溯修改不被允许。
*/
@Override
public void validateBeforeArchive(ApprovalInstance instance) {
ApprovalFacilityForm form = findByInstance(instance.getId());
if (form == null || form.getEffectiveDate() == null) {
throw new BusinessException("设施价格表缺少生效日期,不能归档");
}
if (form.getEffectiveDate().isBefore(LocalDate.now())) {
throw new BusinessException("生效日期不能早于归档日,追溯修改不被允许");
}
}
private LocalDate requireEffectiveDate(LocalDate effectiveDate) {
if (effectiveDate == null) {
throw new BusinessException("生效日期不能为空");
}
if (effectiveDate.isBefore(LocalDate.now())) {
throw new BusinessException("生效日期不能早于今天,追溯修改不被允许");
}
return effectiveDate;
}
private String format(LocalDate date) {
return date == null ? null : date.toString();
}
private BigDecimal decimal(Object value) {
if (value == null) {
return null;
}
return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(String.valueOf(value));
}
private ApprovalFacilityForm findByInstance(Long instanceId) {
List<ApprovalFacilityForm> forms = facilityFormMapper.selectList(
new LambdaQueryWrapper<ApprovalFacilityForm>()
.eq(ApprovalFacilityForm::getInstanceId, instanceId)
.last("LIMIT 1"));
return forms.isEmpty() ? null : forms.get(0);
}
private List<ApprovalFacilityItem> findItems(Long instanceId) {
if (instanceId == null) {
return Collections.emptyList();
}
return facilityItemMapper.selectList(new LambdaQueryWrapper<ApprovalFacilityItem>()
.eq(ApprovalFacilityItem::getInstanceId, instanceId)
.orderByAsc(ApprovalFacilityItem::getLineNo));
}
private ApprovalFacilityParam cast(ApprovalDocumentParam param) {
if (!(param instanceof ApprovalFacilityParam)) {
throw new BusinessException("单据类型不匹配");
}
return (ApprovalFacilityParam) param;
}
}
@@ -0,0 +1,136 @@
package com.gxwebsoft.approval.doc.facility;
import com.gxwebsoft.approval.document.ApprovalLineSupport;
import com.gxwebsoft.approval.document.ApprovalPriceProvider;
import com.gxwebsoft.approval.document.ApprovalPriceVersion;
import com.gxwebsoft.approval.entity.ApprovalFacilityItem;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.mapper.ApprovalFacilityPriceMapper;
import com.gxwebsoft.approval.support.ApprovalTenantContext;
import com.gxwebsoft.approval.vo.ApprovalFieldVO;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 设施价格表版本查询(ADR 0012):当前生效版本与历史版本。
*
* <p>审批实例即版本,不存在独立主数据表:
* 当前生效版本 = 同租户同 flow_code 下已归档且生效日期不晚于今天的最新一版;
* 历史版本 = 同条件按生效日期倒序。</p>
*
* <p>所有查询显式带 tenantId,不依赖请求头里的租户上下文——价格查看接口、员工端首页
* 与内部调用都复用本服务。</p>
*
* <p>价格表只作参照,不参与其他单据的金额计算(ADR 0012),因此这里只读。</p>
*/
@Service
public class FacilityPriceQueryService implements ApprovalPriceProvider {
/** 未指定条数时的历史上限。 */
private static final int DEFAULT_HISTORY_LIMIT = 50;
@Resource
private ApprovalFacilityPriceMapper priceMapper;
@Resource
private FacilityDocumentType facilityDocumentType;
/** 当前登录租户的当前生效版本;无已归档版本返回 null。 */
@Override
public ApprovalPriceVersion current() {
return current(ApprovalTenantContext.currentTenantId());
}
/** 指定租户的当前生效版本;无已归档版本返回 null。 */
public ApprovalPriceVersion current(Integer tenantId) {
if (tenantId == null) {
return null;
}
List<FacilityPriceVersionRow> rows = priceMapper.selectArchivedVersions(
tenantId, LocalDate.now(), 0, 1);
return rows.isEmpty() ? null : toVersion(tenantId, rows.get(0));
}
/** 当前登录租户的历史版本,按生效日期倒序。 */
@Override
public List<ApprovalPriceVersion> history(int limit) {
return history(ApprovalTenantContext.currentTenantId(), 1, limit);
}
/**
* 指定租户的历史版本分页查询,按生效日期倒序。
*
* @param page 页码,从 1 开始
* @param limit 每页条数,≤0 时取默认值
*/
public List<ApprovalPriceVersion> history(Integer tenantId, int page, int limit) {
if (tenantId == null) {
return new ArrayList<>();
}
int size = limit <= 0 ? DEFAULT_HISTORY_LIMIT : limit;
int pageNo = page <= 0 ? 1 : page;
List<FacilityPriceVersionRow> rows = priceMapper.selectArchivedVersions(
tenantId, LocalDate.now(), (pageNo - 1) * size, size);
List<ApprovalPriceVersion> versions = new ArrayList<>(rows.size());
for (FacilityPriceVersionRow row : rows) {
ApprovalPriceVersion version = toVersion(tenantId, row);
if (version != null) {
versions.add(version);
}
}
return versions;
}
/**
* 装配一个版本:实例定位字段 + 业务字段 + 明细 + 合计金额。
*/
private ApprovalPriceVersion toVersion(Integer tenantId, FacilityPriceVersionRow row) {
ApprovalInstance instance = row.getInstanceId() == null ? null
: priceMapper.selectInstance(tenantId, row.getInstanceId());
if (instance == null) {
return null;
}
ApprovalPriceVersion version = new ApprovalPriceVersion();
version.setInstanceId(instance.getId());
version.setBizNo(instance.getBizNo());
version.setEffectiveDate(row.getEffectiveDate());
version.setArchiveTime(row.getArchiveTime());
version.setReportOrgName(row.getReportOrgName());
List<ApprovalFieldVO> fields = new ArrayList<>();
fields.add(new ApprovalFieldVO("effectiveDate", "生效日期",
row.getEffectiveDate() == null ? null : row.getEffectiveDate().toString()));
version.setFields(fields);
List<Map<String, Object>> lines = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
boolean hasAmount = false;
for (ApprovalFacilityItem item : priceMapper.selectItems(tenantId, row.getInstanceId())) {
Map<String, Object> line = new LinkedHashMap<>();
line.put(ApprovalLineSupport.KEY_LINE_NO, item.getLineNo());
line.put("itemName", item.getItemName());
line.put("location", item.getLocation());
line.put("unit", item.getUnit());
line.put("quantity", item.getQuantity());
line.put("unitPrice", item.getUnitPrice());
line.put(ApprovalLineSupport.KEY_AMOUNT, item.getAmount());
line.put("remark", item.getRemark());
lines.add(line);
if (item.getAmount() != null) {
hasAmount = true;
total = total.add(item.getAmount());
}
}
version.setLines(lines);
version.setLineColumns(facilityDocumentType.lineColumns());
version.setAmountTotal(hasAmount ? total.setScale(2, RoundingMode.HALF_UP) : null);
return version;
}
}
@@ -0,0 +1,25 @@
package com.gxwebsoft.approval.doc.facility;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 已归档价格表版本行(版本列表查询结果,只含定位字段;明细按实例另取)。
*/
@Data
public class FacilityPriceVersionRow implements Serializable {
private static final long serialVersionUID = 1L;
private Long instanceId;
private String bizNo;
private LocalDate effectiveDate;
private LocalDateTime archiveTime;
private String reportOrgName;
}
@@ -0,0 +1,40 @@
package com.gxwebsoft.approval.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 设施价格表(设施损坏修复与租用价格表):一个审批实例一行,明细见
* {@link ApprovalFacilityItem}。
*
* <p>审批实例即版本(ADR 0012):本表不承载版本状态,「当前生效版本」由
* 实例的归档状态与生效日期查询得出。</p>
*/
@Data
@ApiModel(description = "设施价格表")
@TableName("approval_facility_form")
public class ApprovalFacilityForm implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Integer tenantId;
private Long instanceId;
@ApiModelProperty("生效日期,必填;不允许早于归档日(禁止追溯修改)")
private LocalDate effectiveDate;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,57 @@
package com.gxwebsoft.approval.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 设施价格表明细行:一版价格的条目(ADR 0012),保存时整表替换。
*/
@Data
@ApiModel(description = "设施价格表明细")
@TableName("approval_facility_item")
public class ApprovalFacilityItem implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Integer tenantId;
private Long instanceId;
@ApiModelProperty("行号,按提交顺序 1..N")
private Integer lineNo;
@ApiModelProperty("名称")
private String itemName;
@ApiModelProperty("位置")
private String location;
@ApiModelProperty("单位")
private String unit;
@ApiModelProperty("数量,3 位小数")
private BigDecimal quantity;
@ApiModelProperty("单价,2 位小数")
private BigDecimal unitPrice;
@ApiModelProperty("金额,服务端按数量 × 单价重算")
private BigDecimal amount;
@ApiModelProperty("备注")
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,10 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.approval.entity.ApprovalFacilityForm;
/**
* 设施价格表 Mapper。
*/
public interface ApprovalFacilityFormMapper extends BaseMapper<ApprovalFacilityForm> {
}
@@ -0,0 +1,10 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.approval.entity.ApprovalFacilityItem;
/**
* 设施价格表明细 Mapper。
*/
public interface ApprovalFacilityItemMapper extends BaseMapper<ApprovalFacilityItem> {
}
@@ -0,0 +1,41 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.gxwebsoft.approval.doc.facility.FacilityPriceVersionRow;
import com.gxwebsoft.approval.entity.ApprovalFacilityItem;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
/**
* 设施价格表版本查询(ADR 0012):实例状态与生效日期的联表。
*
* <p>显式传 tenantId 并跳过租户插件改写:价格表查看接口与测试都按指定租户查询,
* 不依赖请求头里的租户上下文(服务也可能被定时任务或内部调用)。</p>
*/
public interface ApprovalFacilityPriceMapper {
/**
* 已归档且生效日期不晚于今天的版本,按生效日期倒序、同日期按归档时间倒序。
*
* @param offset 起始偏移,从 0 开始
* @param limit 最多返回条数;小于 0 表示不限
*/
@InterceptorIgnore(tenantLine = "true")
List<FacilityPriceVersionRow> selectArchivedVersions(@Param("tenantId") Integer tenantId,
@Param("today") LocalDate today,
@Param("offset") int offset,
@Param("limit") int limit);
/** 按实例id取实例(显式租户)。 */
@InterceptorIgnore(tenantLine = "true")
ApprovalInstance selectInstance(@Param("tenantId") Integer tenantId,
@Param("instanceId") Long instanceId);
/** 按实例id取明细行(显式租户,按行号升序)。 */
@InterceptorIgnore(tenantLine = "true")
List<ApprovalFacilityItem> selectItems(@Param("tenantId") Integer tenantId,
@Param("instanceId") Long instanceId);
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalFacilityPriceMapper">
<!-- 当前生效版本 = 已归档且生效日期不晚于今天里 effective_date 最大的那版(ADR 0012) -->
<select id="selectArchivedVersions" resultType="com.gxwebsoft.approval.doc.facility.FacilityPriceVersionRow">
SELECT i.id AS instance_id,
i.biz_no AS biz_no,
f.effective_date AS effective_date,
i.archive_time AS archive_time,
i.report_org_name AS report_org_name
FROM approval_instance i
JOIN approval_facility_form f ON f.instance_id = i.id
WHERE i.tenant_id = #{tenantId}
AND i.flow_code = 'facility'
AND i.status = 'ARCHIVED'
AND f.effective_date &lt;= #{today}
ORDER BY f.effective_date DESC, i.archive_time DESC, i.id DESC
<if test="limit >= 0">
LIMIT #{limit} OFFSET #{offset}
</if>
</select>
<select id="selectInstance" resultType="com.gxwebsoft.approval.entity.ApprovalInstance">
SELECT *
FROM approval_instance
WHERE id = #{instanceId} AND tenant_id = #{tenantId}
LIMIT 1
</select>
<select id="selectItems" resultType="com.gxwebsoft.approval.entity.ApprovalFacilityItem">
SELECT *
FROM approval_facility_item
WHERE instance_id = #{instanceId} AND tenant_id = #{tenantId}
ORDER BY line_no
</select>
</mapper>
@@ -0,0 +1,23 @@
package com.gxwebsoft.approval.param;
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
/**
* 设施价格表草稿参数;id 为审批实例id,更新草稿时传入。
*
* <p>明细行在基类 {@link ApprovalDocumentParam#getLines()} 中按列 key 提交
* (itemName/location/unit/quantity/unitPrice/amount/remark);金额由服务端重算。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ApprovalFacilityParam extends ApprovalDocumentParam {
private static final long serialVersionUID = 1L;
@ApiModelProperty("生效日期,必填,yyyy-MM-dd;允许未来生效,不允许早于今天")
private LocalDate effectiveDate;
}
@@ -0,0 +1,627 @@
package com.gxwebsoft.approval;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.approval.doc.facility.FacilityPriceQueryService;
import com.gxwebsoft.approval.document.ApprovalPriceVersion;
import com.gxwebsoft.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
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.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
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.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;
/**
* 设施价格表集成测试:四部门会签(含任一组驳回后全组重审)、双存档与归档、
* 生效日期追溯拒绝、实例即版本(当前生效版本不因未来版本切换、历史版本倒序)、
* 跨单据总表与明细导出的逐行数据。
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalFacilityIT {
/**
* 独立租户:部门负责人、总经理、录入员、收文员等角色按 (tenant_id, role_code) 解析,
* 用专属租户避免与并行执行的其它审批测试类互相污染。
*/
private static final int TENANT = 10026;
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 = "facility";
/** 明细列顺序(与 FacilityDocumentType.lineColumns() 一致)。 */
private static final String[] LINE_HEADERS = {
"单号", "状态", "报送部门", "报送人", "名称", "位置", "单位", "数量", "单价", "金额", "备注"
};
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
@Autowired
private FacilityPriceQueryService priceQueryService;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private ApprovalTestData.TestUser reportLeader;
private ApprovalTestData.TestUser deputy;
private ApprovalTestData.TestUser generalManager;
private ApprovalTestData.TestUser typist;
private ApprovalTestData.TestUser archivist;
private ApprovalTestData.TestUser venueLeader;
private ApprovalTestData.TestUser opsLeader;
private ApprovalTestData.TestUser safetyLeader;
private ApprovalTestData.TestUser financeLeader;
private Integer reportOrgId;
/** 本用例直接插入的组织id(ApprovalTestData 不跟踪固编码组织)。 */
private final List<Integer> fixedOrgIds = new ArrayList<>();
/** 本用例产生的导出文件。 */
private final List<Path> exportedFiles = new ArrayList<>();
/** 本用例创建的实例id:价格表子表不在 ApprovalTestData 清理范围内,需自行删除。 */
private final List<Long> instanceIds = new ArrayList<>();
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
String suffix = data.uniqueSuffix();
reportOrgId = data.createOrganization("价格报送部门" + suffix, 136, TENANT);
reporter = data.createUser("价格报送人" + suffix, reportOrgId, TENANT);
ApprovalTestData.TestRole reportLeaderRole =
data.createRole(data.leaderRoleCode(reportOrgId), "价格报送部门负责人", TENANT);
reportLeader = data.createUser("价格报送负责人" + suffix, reportOrgId, TENANT);
data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT);
ApprovalTestData.TestRole deputyRole =
data.createRole(deputyRoleCode(reportOrgId), "价格报送部门分管领导", TENANT);
deputy = data.createUser("价格分管领导" + suffix, reportOrgId, TENANT);
data.bindTenant(deputy.userId, deputyRole.roleId, TENANT);
ApprovalTestData.TestRole gmRole =
data.createRole("approval_general_manager", "价格总经理", TENANT);
generalManager = data.createUser("价格总经理" + suffix, reportOrgId, TENANT);
data.bindTenant(generalManager.userId, gmRole.roleId, TENANT);
ApprovalTestData.TestRole typistRole = data.createRole("approval_typist", "价格录入员", TENANT);
typist = data.createUser("价格录入员" + suffix, reportOrgId, TENANT);
data.bindTenant(typist.userId, typistRole.roleId, TENANT);
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "价格收文员", TENANT);
archivist = data.createUser("价格收文员" + suffix, reportOrgId, TENANT);
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
venueLeader = createOrgLeader("APPROVAL_VENUE", "场馆部", suffix);
opsLeader = createOrgLeader("APPROVAL_OPS", "运营管理部", suffix);
safetyLeader = createOrgLeader("APPROVAL_SAFETY", "安全运保部", suffix);
financeLeader = createOrgLeader("APPROVAL_FINANCE", "财务部", suffix);
}
@AfterEach
void tearDown() {
for (Path path : exportedFiles) {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
// 文件清理失败不影响断言
}
}
exportedFiles.clear();
for (Long instanceId : instanceIds) {
data.master().update("DELETE FROM approval_facility_item WHERE instance_id = ?", instanceId);
data.master().update("DELETE FROM approval_facility_form WHERE instance_id = ?", instanceId);
}
instanceIds.clear();
for (Integer organizationId : fixedOrgIds) {
data.server().update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId);
}
fixedOrgIds.clear();
data.cleanup();
}
// ---------------------------------------------------------------- 用例
/**
* 端到端:建草稿 → 逐行填价 → 提交 → 四部门会签(两组通过后第三组驳回 → round+1 →
* 重提后四组全部重审)→ 分管领导 → 总经理 → 双存档 → 归档。
*/
@Test
void runsEndToEndAndReopensAllCountersignGroupsAfterRejection() throws Exception {
long instanceId = createDraft(LocalDate.now(), priceLines("JG端到端"));
uploadSignedAttachment(instanceId);
submit(instanceId);
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
int countersignRound = roundOf(instanceId);
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("venue", "ops", "safety", "finance");
// 两组先通过:任务完成留痕,节点不推进
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("safety", "finance");
assertThat(statusOf(instanceId)).isEqualTo("PENDING");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'DONE'", instanceId, countersignRound))
.isEqualTo(2);
// 安全运保部驳回:整节点结束、round+1、已通过的组任务保持 DONE
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, safetyLeader.userId, "deptCountersign") + "/reject"),
safetyLeader, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\"单价依据不足,请补充\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("REJECTED"));
int rejectedRound = roundOf(instanceId);
assertThat(rejectedRound).isGreaterThan(countersignRound);
// 已通过的组任务保持 DONE 留痕(驳回者本人的任务也是 DONE,带驳回意见)
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'DONE'"
+ " AND group_key IN ('venue','ops')", instanceId, countersignRound))
.isEqualTo(2);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'PENDING'", instanceId, countersignRound))
.isZero();
// 重提:回到报送部门负责人,会签节点在新一轮重新生成四组待办
resubmit(instanceId);
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
int reopenedRound = roundOf(instanceId);
assertThat(reopenedRound).isGreaterThan(rejectedRound);
assertThat(pendingGroupKeys(instanceId)).containsExactlyInAnyOrder("venue", "ops", "safety", "finance");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'deptCountersign' AND round = ?", instanceId, reopenedRound)).isEqualTo(4);
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
approve(safetyLeader, pendingTask(instanceId, safetyLeader.userId, "deptCountersign"));
assertThat(statusOf(instanceId)).isEqualTo("PENDING");
approve(financeLeader, pendingTask(instanceId, financeLeader.userId, "deptCountersign"));
// 分管领导 → 总经理
approve(deputy, pendingTask(instanceId, deputy.userId, "deputyLeader"));
approve(generalManager, pendingTask(instanceId, generalManager.userId, "generalManager"));
// 双存档:报送人 + 运营管理部录入员
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, reporter.userId, "confirm") + "/confirm"), reporter, APPROVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"));
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, typist.userId, "confirm") + "/confirm"), typist, APPROVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"));
String archived = mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(objectMapper.readTree(archived).path("data").path("bizNo").asText()).startsWith("JG");
// 详情:明细逐行、合计金额与四组会签进度
mockMvc.perform(authed(
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(
"/api/approval/instance/" + instanceId), reporter, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.document.fields[0].key").value("effectiveDate"))
.andExpect(jsonPath("$.data.document.lines.length()").value(3))
.andExpect(jsonPath("$.data.document.lines[0].itemName").value("JG端到端-1"))
.andExpect(jsonPath("$.data.document.lines[0].location").value("羽毛球馆"))
.andExpect(jsonPath("$.data.document.lines[0].unit").value("个"))
.andExpect(jsonPath("$.data.document.lines[0].quantity").isNumber())
.andExpect(jsonPath("$.data.document.lines[0].unitPrice").isNumber())
.andExpect(jsonPath("$.data.document.lines[0].amount").isNumber())
.andExpect(jsonPath("$.data.document.lines[0].remark").value("端到端备注"))
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(3))
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(7))
// 会签节点 4 组 + 存档节点 2 组
.andExpect(jsonPath("$.data.document.groupProgress.length()").value(6))
.andExpect(jsonPath("$.data.document.groupProgress[0].groupKey").value("venue"))
.andExpect(jsonPath("$.data.document.groupProgress[0].signed").value(1))
.andExpect(jsonPath("$.data.document.groupProgress[0].total").value(1))
.andExpect(jsonPath("$.data.document.groupProgress[3].groupKey").value("finance"));
// 明细行落库:金额由服务端重算
assertThat(data.queryMaster("SELECT amount FROM approval_facility_item WHERE instance_id = ? "
+ "AND line_no = 2", BigDecimal.class, instanceId)).isEqualByComparingTo("125.00");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_facility_item WHERE instance_id = ?",
instanceId)).isEqualTo(3);
}
/**
* 生效日期早于当天:草稿保存即拒绝;已进入审批的单据若被改成过去日期,归档时拒绝。
*/
@Test
void rejectsRetroactiveEffectiveDateOnSaveAndOnArchive() throws Exception {
mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body(LocalDate.now().minusDays(1), priceLines("JG追溯"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("追溯修改不被允许")));
long instanceId = createDraft(LocalDate.now(), priceLines("JG归档追溯"));
uploadSignedAttachment(instanceId);
submit(instanceId);
runToApproved(instanceId);
// 走完审批后把生效日期改到昨天(模拟追溯修改),归档必须被拦下
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
LocalDate.now().minusDays(1), instanceId);
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("追溯修改不被允许")));
assertThat(statusOf(instanceId)).isEqualTo("APPROVED");
assertThat(countPendingTasks(instanceId, "archive")).isEqualTo(1);
}
/**
* 实例即版本:归档一版今天生效的价格后,再归档一版未来生效的,当前生效版本不切换。
*/
@Test
void doesNotSwitchCurrentVersionWhenFutureVersionIsArchived() throws Exception {
long current = archiveVersion(LocalDate.now(), "JG当前版", "120.00");
ApprovalPriceVersion before = priceQueryService.current(TENANT);
assertThat(before).as("归档后应有当前生效版本").isNotNull();
assertThat(before.getInstanceId()).isEqualTo(current);
assertThat(before.getEffectiveDate()).isEqualTo(LocalDate.now());
assertThat(before.getAmountTotal()).isEqualByComparingTo("120.00");
assertThat(before.getLines()).hasSize(1);
assertThat(before.getLineColumns()).hasSize(7);
long future = archiveVersion(LocalDate.now().plusDays(10), "JG未来版", "999.00");
assertThat(statusOf(future)).isEqualTo("ARCHIVED");
ApprovalPriceVersion after = priceQueryService.current(TENANT);
assertThat(after).as("未来生效版本归档不改变当前生效版本").isNotNull();
assertThat(after.getInstanceId()).isEqualTo(current);
assertThat(after.getAmountTotal()).isEqualByComparingTo("120.00");
// 历史版本只含已生效的版本
assertThat(priceQueryService.history(TENANT, 1, 10))
.extracting(ApprovalPriceVersion::getInstanceId)
.contains(current)
.doesNotContain(future);
}
/** 历史版本按生效日期倒序。 */
@Test
void ordersHistoryByEffectiveDateDescending() throws Exception {
long older = archiveVersion(LocalDate.now(), "JG旧版", "100.00");
long newer = archiveVersion(LocalDate.now(), "JG新版", "200.00");
// 两版都按「今天生效」归档(归档校验要求不早于今天),再回填不同生效日期验证排序
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
LocalDate.now().minusDays(30), older);
data.master().update("UPDATE approval_facility_form SET effective_date = ? WHERE instance_id = ?",
LocalDate.now().minusDays(3), newer);
List<ApprovalPriceVersion> history = priceQueryService.history(TENANT, 1, 10);
assertThat(history).extracting(ApprovalPriceVersion::getInstanceId)
.containsSequence(newer, older);
assertThat(history.get(0).getEffectiveDate()).isAfter(history.get(1).getEffectiveDate());
assertThat(priceQueryService.current(TENANT).getInstanceId()).isEqualTo(newer);
}
/** 明细导出含逐行数据;跨单据总表填「明细行数」「合计金额」。 */
@Test
void exportsFacilityLinesAndSummaryColumns() throws Exception {
long instanceId = createDraft(LocalDate.now(), priceLines("JG导出"));
uploadSignedAttachment(instanceId);
submit(instanceId);
runToApproved(instanceId);
archive(instanceId);
String response = mockMvc.perform(authed(post("/api/approval/export/" + FLOW + "/lines"),
archivist, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reporterId\":" + reporter.userId + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
File linesFile = exportFile(response, "excel/审批单据明细-" + FLOW + "-");
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(linesFile))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getSheetName()).isEqualTo("设施损坏修复与租用价格表");
List<String> headers = new ArrayList<>();
for (int i = 0; i < LINE_HEADERS.length; i++) {
headers.add(cellText(sheet.getRow(0).getCell(i)));
}
assertThat(headers).containsExactly(LINE_HEADERS);
assertThat(sheet.getLastRowNum() + 1).isEqualTo(4);
Row first = sheet.getRow(1);
assertThat(cellText(first.getCell(1))).isEqualTo("已归档");
assertThat(cellText(first.getCell(4))).isEqualTo("JG导出-1");
assertThat(cellText(first.getCell(6))).isEqualTo("个");
assertThat(cellText(first.getCell(7))).isEqualTo("2.5");
assertThat(cellText(first.getCell(8))).isEqualTo("100");
assertThat(cellText(first.getCell(9))).isEqualTo("250");
assertThat(cellText(sheet.getRow(3).getCell(4))).isEqualTo("JG导出-3");
}
String summaryResponse = mockMvc.perform(authed(post("/api/approval/export"), archivist, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reporterId\":" + reporter.userId + ",\"flowCode\":\"" + FLOW + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
File summaryFile = exportFile(summaryResponse, "excel/审批单据-");
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(summaryFile))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getLastRowNum()).isEqualTo(1);
Row row = sheet.getRow(1);
assertThat(cellText(row.getCell(0))).startsWith("JG");
assertThat(cellText(row.getCell(1))).isEqualTo("设施损坏修复与租用价格表");
assertThat(cellText(row.getCell(16))).isEqualTo("3");
assertThat(cellText(row.getCell(17))).isEqualTo("375.00");
assertThat(cellText(row.getCell(18))).contains("四部门会签 已签 4/4");
}
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_facility_item WHERE instance_id = ?",
instanceId)).isEqualTo(3);
}
// ---------------------------------------------------------------- 流程辅助
private long archiveVersion(LocalDate effectiveDate, String itemName, String amount) throws Exception {
// 单行版本:数量 1 × 单价 amount,合计即 amount
List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line(itemName, null, "项", "1", amount, null));
long instanceId = createDraft(effectiveDate, lines);
uploadSignedAttachment(instanceId);
submit(instanceId);
runToApproved(instanceId);
archive(instanceId);
return instanceId;
}
/** 提交后的完整审批链(不含建草稿与提交)。 */
private void runToApproved(long instanceId) throws Exception {
approve(reportLeader, pendingTask(instanceId, reportLeader.userId, "reportLeader"));
approve(venueLeader, pendingTask(instanceId, venueLeader.userId, "deptCountersign"));
approve(opsLeader, pendingTask(instanceId, opsLeader.userId, "deptCountersign"));
approve(safetyLeader, pendingTask(instanceId, safetyLeader.userId, "deptCountersign"));
approve(financeLeader, pendingTask(instanceId, financeLeader.userId, "deptCountersign"));
approve(deputy, pendingTask(instanceId, deputy.userId, "deputyLeader"));
approve(generalManager, pendingTask(instanceId, generalManager.userId, "generalManager"));
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, reporter.userId, "confirm") + "/confirm"), reporter, APPROVE))
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, typist.userId, "confirm") + "/confirm"), typist, APPROVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"));
}
private void archive(long instanceId) throws Exception {
mockMvc.perform(authed(post("/api/approval/task/"
+ pendingTask(instanceId, archivist.userId, "archive") + "/archive"), archivist, ARCHIVE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
}
private long createDraft(LocalDate effectiveDate, List<Map<String, Object>> lines) throws Exception {
String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body(effectiveDate, lines)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
long instanceId = objectMapper.readTree(response).path("data").asLong();
data.trackInstance(instanceId);
instanceIds.add(instanceId);
return instanceId;
}
private void submit(long instanceId) throws Exception {
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private void resubmit(long instanceId) throws Exception {
submit(instanceId);
}
private void approve(ApprovalTestData.TestUser user, long taskId) throws Exception {
String response = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), user, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\"同意\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(objectMapper.readTree(response).path("code").asInt())
.as("通过应成功:" + response).isZero();
}
// ---------------------------------------------------------------- 数据辅助
/** 三行明细:数量 × 单价 由服务端重算,合计 375.00。 */
private List<Map<String, Object>> priceLines(String prefix) {
List<Map<String, Object>> lines = new ArrayList<>();
lines.add(line(prefix + "-1", "羽毛球馆", "个", "2.5", "100", "端到端备注"));
lines.add(line(prefix + "-2", "游泳馆", "块", "1", "125", null));
lines.add(line(prefix + "-3", null, "小时", "0", "80", null));
return lines;
}
private Map<String, Object> line(String itemName, String location, String unit,
String quantity, String unitPrice, String remark) {
Map<String, Object> line = new LinkedHashMap<>();
line.put("itemName", itemName);
if (location != null) {
line.put("location", location);
}
line.put("unit", unit);
line.put("quantity", quantity);
line.put("unitPrice", unitPrice);
if (remark != null) {
line.put("remark", remark);
}
return line;
}
private String body(LocalDate effectiveDate, List<Map<String, Object>> lines) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("effectiveDate", effectiveDate.toString());
body.put("lines", lines);
return objectMapper.writeValueAsString(body);
}
/**
* 会签组按固定组织编码解析(APPROVAL_VENUE 等),编码在每个租户内应唯一:
* 已存在就复用(不删除),否则新建并在 teardown 删除,避免共享库里堆积同名编码。
*/
private ApprovalTestData.TestUser createOrgLeader(String code, String name, String suffix) {
Integer organizationId = existingOrganization(code);
if (organizationId == null) {
data.server().update("INSERT INTO sys_organization (parent_id, organization_name, "
+ "organization_full_name, organization_code, organization_type, park, phone, email, "
+ "business, industry, image, about, sort_number, tenant_id, deleted) "
+ "VALUES (136, ?, ?, ?, '部门', '', '', '', '', '', '', '', 100, ?, 0)",
name, name, code, TENANT);
organizationId = data.server().queryForObject(
"SELECT organization_id FROM sys_organization WHERE tenant_id = ? AND organization_code = ? "
+ "ORDER BY organization_id DESC LIMIT 1", Integer.class, TENANT, code);
fixedOrgIds.add(organizationId);
}
String roleCode = "dept_leader_" + code;
Integer leaderRoleId = existingRoleId(roleCode);
if (leaderRoleId == null) {
leaderRoleId = data.createRole(roleCode, name + "负责人", TENANT).roleId;
}
ApprovalTestData.TestUser leader = data.createUser(name + "负责人" + suffix, organizationId, TENANT);
data.bindTenant(leader.userId, leaderRoleId, TENANT);
return leader;
}
private Integer existingOrganization(String code) {
List<Integer> ids = data.server().queryForList(
"SELECT organization_id FROM sys_organization WHERE tenant_id = ? AND organization_code = ? "
+ "AND deleted = 0 ORDER BY organization_id LIMIT 1", Integer.class, TENANT, code);
return ids.isEmpty() ? null : ids.get(0);
}
private Integer existingRoleId(String roleCode) {
List<Integer> ids = data.server().queryForList(
"SELECT role_id FROM sys_role WHERE tenant_id = ? AND role_code = ? AND deleted = 0 "
+ "ORDER BY role_id LIMIT 1", Integer.class, TENANT, roleCode);
return ids.isEmpty() ? null : ids.get(0);
}
private String deputyRoleCode(Integer organizationId) {
return "dept_deputy_" + data.organizationCode(organizationId);
}
private long pendingTask(long instanceId, int userId, String nodeKey) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_key = ? AND status = 'PENDING' ORDER BY id DESC LIMIT 1",
Long.class, instanceId, userId, nodeKey);
assertThat(taskId).as("节点 " + nodeKey + " 应有待办").isNotNull();
return taskId;
}
private List<String> pendingGroupKeys(long instanceId) {
int round = roundOf(instanceId);
return data.master().queryForList("SELECT group_key FROM approval_task WHERE instance_id = ? "
+ "AND node_key = 'deptCountersign' AND round = ? AND status = 'PENDING' ORDER BY group_seq",
String.class, instanceId, round);
}
private int roundOf(long instanceId) {
return data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
}
private String statusOf(long instanceId) {
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private int countPendingTasks(long instanceId, String nodeKey) {
return data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_key = ? AND status = 'PENDING'", instanceId, nodeKey);
}
private void uploadSignedAttachment(long instanceId) 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", "SIGNED")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
reportOrgId, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private File exportFile(String response, String namePrefix) throws Exception {
var node = objectMapper.readTree(response).path("data");
assertThat(node.path("name").asText()).startsWith(namePrefix);
File file = new File(node.path("path").asText());
assertThat(file).exists();
exportedFiles.add(file.toPath());
return file;
}
private String cellText(org.apache.poi.ss.usermodel.Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
BigDecimal value = BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros();
return value.toPlainString();
}
return cell.getStringCellValue();
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
ApprovalTestData.TestUser user,
String... authorities) {
return builder.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(user.userId, user.username, reportOrgId,
TENANT, authorities)));
}
}