diff --git a/sql/11-approval-facility.sql b/sql/11-approval-facility.sql new file mode 100644 index 0000000..e50c7aa --- /dev/null +++ b/sql/11-approval-facility.sql @@ -0,0 +1,41 @@ +-- 设施价格表(设施损坏修复与租用价格表)业务表(可重复执行) +-- 目标库:db_gxtyzx(master) +-- 执行顺序:05-approval_phase2.sql -> ... -> 11-approval-facility.sql +-- +-- ADR 0012:审批实例即版本,不建独立价格表主数据表。 +-- approval_facility_form 每个审批实例一行,业务字段只有生效日期(必填) +-- approval_facility_item 实例的明细行,行即该版本条目,金额服务端重算 +-- +-- 当前生效版本 = 同租户同 flow_code 下 status=ARCHIVED 且 effective_date <= today +-- 中 effective_date 最大者;历史版本按 effective_date 倒序(查询时算,不落库)。 + +CREATE TABLE IF NOT EXISTS approval_facility_form ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + effective_date DATE NOT NULL COMMENT '生效日期,不允许早于归档日', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_instance (instance_id), + KEY idx_effective (tenant_id, effective_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设施价格表'; + +CREATE TABLE IF NOT EXISTS approval_facility_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + line_no INT NOT NULL COMMENT '行号,按提交顺序 1..N', + item_name VARCHAR(200) NOT NULL COMMENT '名称', + location VARCHAR(200) DEFAULT NULL COMMENT '位置', + unit VARCHAR(20) NOT NULL COMMENT '单位', + quantity DECIMAL(12,3) NOT NULL COMMENT '数量', + unit_price DECIMAL(12,2) NOT NULL COMMENT '单价', + amount DECIMAL(12,2) NOT NULL COMMENT '金额,服务端按 quantity × unit_price 重算', + remark VARCHAR(200) DEFAULT NULL COMMENT '备注', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_instance_line (instance_id, line_no), + KEY idx_instance (instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设施价格表明细'; diff --git a/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityDocumentType.java new file mode 100644 index 0000000..3e8c145 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityDocumentType.java @@ -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): + * 报送人 → 报送部门负责人 → 四部门会签 → 分管领导 → 总经理 → 存档(报送人 + 运营管理部)→ 归档。 + * + *

只做价格目录,不含损坏描述/报修时间/修复内容——那是价格表被使用的场景,不是第二张单。 + * 审批实例即版本:每次归档生成一版,明细行即该版条目;不建独立价格表主数据表。

+ * + *

生效日期允许未来(提前批),不允许早于当天(追溯修改会让历史结算失去依据): + * 草稿保存即拒绝早于今天的日期,归档前由 {@link #validateBeforeArchive} 再校验一次。

+ */ +@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 lineColumns() { + List 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 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> 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() + .eq(ApprovalFacilityItem::getInstanceId, instanceId)); + for (Map 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> lines(ApprovalInstance instance) { + List items = findItems(instance.getId()); + List> rows = new ArrayList<>(items.size()); + for (ApprovalFacilityItem item : items) { + Map 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 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 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 forms = facilityFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalFacilityForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private List findItems(Long instanceId) { + if (instanceId == null) { + return Collections.emptyList(); + } + return facilityItemMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalFacilityItem::getInstanceId, instanceId) + .orderByAsc(ApprovalFacilityItem::getLineNo)); + } + + private ApprovalFacilityParam cast(ApprovalDocumentParam param) { + if (!(param instanceof ApprovalFacilityParam)) { + throw new BusinessException("单据类型不匹配"); + } + return (ApprovalFacilityParam) param; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceQueryService.java b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceQueryService.java new file mode 100644 index 0000000..8fea5df --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceQueryService.java @@ -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):当前生效版本与历史版本。 + * + *

审批实例即版本,不存在独立主数据表: + * 当前生效版本 = 同租户同 flow_code 下已归档且生效日期不晚于今天的最新一版; + * 历史版本 = 同条件按生效日期倒序。

+ * + *

所有查询显式带 tenantId,不依赖请求头里的租户上下文——价格查看接口、员工端首页 + * 与内部调用都复用本服务。

+ * + *

价格表只作参照,不参与其他单据的金额计算(ADR 0012),因此这里只读。

+ */ +@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 rows = priceMapper.selectArchivedVersions( + tenantId, LocalDate.now(), 0, 1); + return rows.isEmpty() ? null : toVersion(tenantId, rows.get(0)); + } + + /** 当前登录租户的历史版本,按生效日期倒序。 */ + @Override + public List history(int limit) { + return history(ApprovalTenantContext.currentTenantId(), 1, limit); + } + + /** + * 指定租户的历史版本分页查询,按生效日期倒序。 + * + * @param page 页码,从 1 开始 + * @param limit 每页条数,≤0 时取默认值 + */ + public List 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 rows = priceMapper.selectArchivedVersions( + tenantId, LocalDate.now(), (pageNo - 1) * size, size); + List 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 fields = new ArrayList<>(); + fields.add(new ApprovalFieldVO("effectiveDate", "生效日期", + row.getEffectiveDate() == null ? null : row.getEffectiveDate().toString())); + version.setFields(fields); + + List> lines = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + boolean hasAmount = false; + for (ApprovalFacilityItem item : priceMapper.selectItems(tenantId, row.getInstanceId())) { + Map 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; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceVersionRow.java b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceVersionRow.java new file mode 100644 index 0000000..7548e56 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/facility/FacilityPriceVersionRow.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityForm.java new file mode 100644 index 0000000..91eb198 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityForm.java @@ -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}。 + * + *

审批实例即版本(ADR 0012):本表不承载版本状态,「当前生效版本」由 + * 实例的归档状态与生效日期查询得出。

+ */ +@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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityItem.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityItem.java new file mode 100644 index 0000000..60f6117 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFacilityItem.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityFormMapper.java new file mode 100644 index 0000000..562b954 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityFormMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityItemMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityItemMapper.java new file mode 100644 index 0000000..d40f2da --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityItemMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityPriceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityPriceMapper.java new file mode 100644 index 0000000..2fd925b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFacilityPriceMapper.java @@ -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):实例状态与生效日期的联表。 + * + *

显式传 tenantId 并跳过租户插件改写:价格表查看接口与测试都按指定租户查询, + * 不依赖请求头里的租户上下文(服务也可能被定时任务或内部调用)。

+ */ +public interface ApprovalFacilityPriceMapper { + + /** + * 已归档且生效日期不晚于今天的版本,按生效日期倒序、同日期按归档时间倒序。 + * + * @param offset 起始偏移,从 0 开始 + * @param limit 最多返回条数;小于 0 表示不限 + */ + @InterceptorIgnore(tenantLine = "true") + List 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 selectItems(@Param("tenantId") Integer tenantId, + @Param("instanceId") Long instanceId); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalFacilityPriceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalFacilityPriceMapper.xml new file mode 100644 index 0000000..9dd2792 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalFacilityPriceMapper.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalFacilityParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalFacilityParam.java new file mode 100644 index 0000000..bd2297a --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalFacilityParam.java @@ -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,更新草稿时传入。 + * + *

明细行在基类 {@link ApprovalDocumentParam#getLines()} 中按列 key 提交 + * (itemName/location/unit/quantity/unitPrice/amount/remark);金额由服务端重算。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalFacilityParam extends ApprovalDocumentParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("生效日期,必填,yyyy-MM-dd;允许未来生效,不允许早于今天") + private LocalDate effectiveDate; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalFacilityIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalFacilityIT.java new file mode 100644 index 0000000..2bb856e --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalFacilityIT.java @@ -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 fixedOrgIds = new ArrayList<>(); + /** 本用例产生的导出文件。 */ + private final List exportedFiles = new ArrayList<>(); + /** 本用例创建的实例id:价格表子表不在 ApprovalTestData 清理范围内,需自行删除。 */ + private final List 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 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 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> 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> 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> priceLines(String prefix) { + List> 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 line(String itemName, String location, String unit, + String quantity, String unitPrice, String remark) { + Map 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> lines) throws Exception { + Map 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 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 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 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))); + } +}