diff --git a/sql/07-approval-receipt.sql b/sql/07-approval-receipt.sql new file mode 100644 index 0000000..3ea8f05 --- /dev/null +++ b/sql/07-approval-receipt.sql @@ -0,0 +1,27 @@ +-- 收据申请单业务表(可重复执行) +-- 目标库:db_gxtyzx(master) +-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql +-- -> 04-approval_role_grants.sql -> 05-approval_phase2.sql -> 07-approval-receipt.sql +-- +-- 覆盖 ADR 0014(收据申请单):金额、付款方(检索主语)、事由、收据号(出纳回填)、申请日期; +-- 不设「用途分类」等模板之外的自造字段。1:1 审批实例,业务表不随引擎回滚, +-- 撤回/驳回由单据服务显式清空 receipt_no 并写 approval_field_change。 +-- +-- 收据号唯一性范围是「同租户 + 同 flow_code + 实例状态非 REJECTED/WITHDRAWN」, +-- 跨表条件无法用唯一索引表达,因此这里只建普通索引供占用查询。 + +CREATE TABLE IF NOT EXISTS approval_receipt_form ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id INT NOT NULL DEFAULT 10150, + instance_id BIGINT NOT NULL, + amount DECIMAL(12,2) NOT NULL COMMENT '金额(元),0~99999999.99', + payer_name VARCHAR(200) NOT NULL COMMENT '付款方,检索主语', + reason VARCHAR(500) NOT NULL COMMENT '事由', + receipt_no VARCHAR(64) DEFAULT NULL COMMENT '收据号,出纳回填;撤回/驳回清空', + apply_date DATE DEFAULT NULL COMMENT '申请日期', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_instance (instance_id), + KEY idx_tenant_receipt_no (tenant_id, receipt_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收据申请单'; diff --git a/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptForm.java b/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptForm.java new file mode 100644 index 0000000..dd93685 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptForm.java @@ -0,0 +1,52 @@ +package com.gxwebsoft.approval.doc.receipt; + +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.LocalDate; +import java.time.LocalDateTime; + +/** + * 收据申请单业务表(ADR 0014):1:1 审批实例。 + * + *

`receipt_no` 由出纳在审批节点回填,撤回/驳回时由单据服务显式清空 + * (业务表不随引擎回滚)。

+ */ +@Data +@ApiModel(description = "收据申请单") +@TableName("approval_receipt_form") +public class ApprovalReceiptForm implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + @ApiModelProperty("金额(元),0~99999999.99") + private BigDecimal amount; + + @ApiModelProperty("付款方,检索主体") + private String payerName; + + @ApiModelProperty("事由") + private String reason; + + @ApiModelProperty("收据号,出纳回填") + private String receiptNo; + + @ApiModelProperty("申请日期") + private LocalDate applyDate; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptParam.java b/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptParam.java new file mode 100644 index 0000000..4b5dea5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/receipt/ApprovalReceiptParam.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.approval.doc.receipt; + +import com.gxwebsoft.approval.document.ApprovalDocumentParam; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 收据申请单草稿参数;id 为审批实例id,更新草稿时传入。 + * + *

报送部门与报送人由服务端按登录态带出,不在参数中接收。收据号提交时非必填 + * (出纳在审批节点回填),撤回/驳回时由单据服务清空。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalReceiptParam extends ApprovalDocumentParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("金额(元),必填,0~99999999.99") + private BigDecimal amount; + + @ApiModelProperty("付款方,必填") + private String payerName; + + @ApiModelProperty("事由,必填") + private String reason; + + @ApiModelProperty("收据号,提交时非必填,出纳回填") + private String receiptNo; + + @ApiModelProperty("申请日期,选填,yyyy-MM-dd") + private LocalDate applyDate; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/receipt/ReceiptDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/receipt/ReceiptDocumentType.java new file mode 100644 index 0000000..de63230 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/receipt/ReceiptDocumentType.java @@ -0,0 +1,348 @@ +package com.gxwebsoft.approval.doc.receipt; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.document.AbstractApprovalDocumentType; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; +import com.gxwebsoft.approval.document.ApprovalFieldUpdate; +import com.gxwebsoft.approval.document.ApprovalLineSupport; +import com.gxwebsoft.approval.entity.ApprovalFieldChange; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.mapper.ApprovalFieldChangeMapper; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.mapper.ApprovalReceiptFormMapper; +import com.gxwebsoft.approval.support.ApprovalFieldSpec; +import com.gxwebsoft.approval.support.ApprovalTenantContext; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 收据申请单(ADR 0014):报送部门负责人 → 出纳(回填收据号)→ + * 存档(报送人 + 财务部负责人)→ 归档。 + * + *

吃掉引擎两项能力:节点可写字段(出纳「先回填后通过」,ADR 0006)与 + * 必传附件分类(转账凭证 VOUCHER)。受理部门为财务部,第二存档方取 + * {@code dept_leader_APPROVAL_FINANCE}(待客户确认,见 ADR 0014)。

+ */ +@Component +public class ReceiptDocumentType extends AbstractApprovalDocumentType { + + /** 收据号字段元数据:长度与唯一性跟表结构走(ADR 0006)。 */ + static final String FIELD_RECEIPT_NO = "receiptNo"; + static final String LABEL_RECEIPT_NO = "收据号"; + static final int RECEIPT_NO_MAX_LENGTH = 64; + + /** 业务表族:与流程编码同值,便于列表按 docType/flowCode 统一筛选。 */ + private static final String DOC_TYPE = "receipt"; + + /** + * 节点链:报送人提交 → 报送部门负责人 → 出纳(回填收据号,通过时必填)→ + * 存档(报送人 + 财务部负责人)→ 归档。 + * + *

「财务部负责人」用已存在的 orgLeaderByCode + APPROVAL_FINANCE 组合实现, + * 不新增解析器(共享文件由集成方统一改)。

+ */ + static final String NODES_JSON = "[" + + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"}," + + "{\"key\":\"cashier\",\"type\":\"APPROVE\",\"resolver\":\"roleCashier\",\"name\":\"出纳\"," + + "\"writableFields\":[{\"field\":\"receiptNo\",\"required\":true}]}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\",\"groups\":[" + + "{\"key\":\"reporter\",\"name\":\"报送人\",\"resolver\":\"reporter\"}," + + "{\"key\":\"finance\",\"name\":\"财务部负责人\",\"resolver\":\"orgLeaderByCode\"," + + "\"orgCode\":\"APPROVAL_FINANCE\"}]}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + + "]"; + + private static final List FIELD_SPECS = Collections.unmodifiableList( + List.of(ApprovalFieldSpec.uniqueText(FIELD_RECEIPT_NO, LABEL_RECEIPT_NO, RECEIPT_NO_MAX_LENGTH))); + + @Resource + private ApprovalReceiptFormMapper receiptFormMapper; + @Resource + private ApprovalFieldChangeMapper fieldChangeMapper; + @Resource + private ApprovalInstanceMapper instanceMapper; + + @Override + public String flowCode() { + return ApprovalConstants.FLOW_RECEIPT; + } + + @Override + public String docType() { + return DOC_TYPE; + } + + @Override + public String flowName() { + return "收据申请单"; + } + + @Override + public String nodesJson() { + return NODES_JSON; + } + + @Override + public String noPrefix() { + return ApprovalConstants.NO_PREFIX_RECEIPT; + } + + /** 转账凭证至少一张:只要求整单有附件会让别的材料顶替掉凭证(ADR 0014)。 */ + @Override + public String requiredAttachmentCategory() { + return ApprovalConstants.CATEGORY_VOUCHER; + } + + @Override + public List fieldSpecs() { + return FIELD_SPECS; + } + + @Override + public Class paramClass() { + return ApprovalReceiptParam.class; + } + + @Override + public void validate(ApprovalDocumentParam param, User user) { + ApprovalReceiptParam receipt = cast(param); + ApprovalLineSupport.money("金额", receipt.getAmount(), true); + requireText("付款方", receipt.getPayerName(), 200); + requireText("事由", receipt.getReason(), 500); + optionalText(LABEL_RECEIPT_NO, receipt.getReceiptNo(), RECEIPT_NO_MAX_LENGTH); + } + + @Override + public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) { + ApprovalReceiptParam receipt = cast(param); + ApprovalReceiptForm form = bizId == null ? null : receiptFormMapper.selectById(bizId); + if (form == null) { + form = new ApprovalReceiptForm(); + form.setInstanceId(instanceId); + } + form.setAmount(receipt.getAmount()); + form.setPayerName(receipt.getPayerName().trim()); + form.setReason(receipt.getReason().trim()); + String receiptNo = optionalText(LABEL_RECEIPT_NO, receipt.getReceiptNo(), RECEIPT_NO_MAX_LENGTH); + // 报送人若在表单里填了号码,存草稿时就用同一套唯一性校验拦住占号 + if (receiptNo != null) { + requireNotOccupied(tenantIdOf(instanceId), instanceId, receiptNo); + } + form.setReceiptNo(receiptNo); + form.setApplyDate(receipt.getApplyDate()); + if (form.getId() == null) { + receiptFormMapper.insert(form); + } else { + receiptFormMapper.updateById(form); + } + return form.getId(); + } + + @Override + public void load(ApprovalInstance instance, ApprovalDetail detail) { + ApprovalReceiptForm form = findByInstance(instance.getId()); + if (form == null) { + return; + } + detail.addField("amount", "金额(元)", form.getAmount()); + detail.addField("payerName", "付款方", form.getPayerName()); + detail.addField("reason", "事由", form.getReason()); + detail.addField(FIELD_RECEIPT_NO, LABEL_RECEIPT_NO, form.getReceiptNo()); + detail.addField("applyDate", "申请日期", form.getApplyDate()); + } + + @Override + public String readField(ApprovalInstance instance, String field) { + if (!FIELD_RECEIPT_NO.equals(field)) { + return null; + } + ApprovalReceiptForm form = findByInstance(instance.getId()); + return form == null ? null : form.getReceiptNo(); + } + + @Override + public String title(ApprovalInstance instance) { + ApprovalReceiptForm form = findByInstance(instance.getId()); + if (form == null) { + return null; + } + // 列表只有标题列,收据号并入标题才能与实际票据核对(ADR 0014「列表与查询页能看到收据号」) + return StrUtil.isBlank(form.getReceiptNo()) + ? form.getPayerName() + : form.getPayerName() + "(收据号 " + form.getReceiptNo() + ")"; + } + + /** 检索文本:付款方是检索主语,事由与收据号同样可搜(便于与实际票据核对)。 */ + @Override + public String searchText(ApprovalInstance instance) { + ApprovalReceiptForm form = findByInstance(instance.getId()); + if (form == null) { + return null; + } + return String.join(" ", nullToEmpty(form.getPayerName()), nullToEmpty(form.getReason()), + nullToEmpty(form.getReceiptNo())).trim(); + } + + /** + * 出纳回填收据号(ADR 0006 / 0014)。 + * + *

回填(requireComplete=false)允许缺项,通过(true)时必填;唯一性范围是 + * 同租户、同 flow_code、实例状态非 REJECTED/WITHDRAWN,冲突时带出占用单号。

+ */ + @Override + public List applyWritableFields(ApprovalInstance instance, Map fields, + User user, boolean requireComplete) { + if (fields == null || fields.isEmpty()) { + if (requireComplete) { + throw new BusinessException(LABEL_RECEIPT_NO + "不能为空"); + } + return new ArrayList<>(); + } + for (String key : fields.keySet()) { + if (!FIELD_RECEIPT_NO.equals(key)) { + throw new BusinessException("当前节点不支持回填字段:" + key); + } + } + ApprovalReceiptForm form = findByInstance(instance.getId()); + if (form == null) { + throw new BusinessException("单据内容不存在"); + } + String oldValue = form.getReceiptNo(); + String newValue = optionalText(LABEL_RECEIPT_NO, fields.get(FIELD_RECEIPT_NO), RECEIPT_NO_MAX_LENGTH); + if (newValue == null) { + if (requireComplete) { + throw new BusinessException(LABEL_RECEIPT_NO + "不能为空"); + } + if (StrUtil.isBlank(oldValue)) { + return new ArrayList<>(); + } + } else if (newValue.equals(oldValue)) { + return new ArrayList<>(); + } else { + requireNotOccupied(instance.getTenantId() == null + ? ApprovalTenantContext.currentTenantId() : instance.getTenantId(), instance.getId(), newValue); + } + receiptFormMapper.update(null, new UpdateWrapper() + .eq("id", form.getId()) + .set("receipt_no", newValue)); + // 收据号参与检索文本,清空/回填后同步刷新,避免作废号码仍能被搜到 + refreshIndexTexts(instance); + List changes = new ArrayList<>(1); + changes.add(new ApprovalFieldUpdate(FIELD_RECEIPT_NO, LABEL_RECEIPT_NO, oldValue, newValue)); + return changes; + } + + /** + * 撤回与驳回都清空收据号并写改值历史(ADR 0014)。 + * + *

业务表不随引擎回滚,因此由单据服务在状态流转处显式清空;引擎已写 + * 撤回/驳回处理记录,这里只补 {@code approval_field_change}(new_value = NULL)。

+ */ + @Override + public void onRoundReset(ApprovalInstance instance, User user) { + ApprovalReceiptForm form = findByInstance(instance.getId()); + if (form == null || StrUtil.isBlank(form.getReceiptNo())) { + return; + } + String oldValue = form.getReceiptNo(); + receiptFormMapper.update(null, new UpdateWrapper() + .eq("id", form.getId()) + .set("receipt_no", null)); + form.setReceiptNo(null); + refreshIndexTexts(instance); + + ApprovalFieldChange change = new ApprovalFieldChange(); + change.setInstanceId(instance.getId()); + change.setNodeKey(instance.getCurrentNodeKey()); + change.setFieldKey(FIELD_RECEIPT_NO); + change.setFieldLabel(LABEL_RECEIPT_NO); + change.setOldValue(oldValue); + change.setNewValue(null); + change.setOperatorId(user.getUserId()); + change.setOperatorName(displayName(user)); + change.setRound(instance.getRound()); + change.setCreateTime(LocalDateTime.now()); + fieldChangeMapper.insert(change); + } + + /** 唯一性冲突:消息带出占用它的单号,出纳才能排查(ADR 0006)。 */ + private void requireNotOccupied(Integer tenantId, Long instanceId, String receiptNo) { + String occupied = receiptFormMapper.selectOccupiedBizNo(receiptNo, tenantId, flowCode(), instanceId); + if (occupied != null) { + throw new BusinessException(LABEL_RECEIPT_NO + " " + receiptNo + " 已被单据 " + occupied + " 使用"); + } + } + + /** 业务表落库时取实例租户;实例缺失退回当前登录租户上下文。 */ + private Integer tenantIdOf(Long instanceId) { + ApprovalInstance instance = instanceId == null ? null : instanceMapper.selectById(instanceId); + if (instance == null || instance.getTenantId() == null) { + return ApprovalTenantContext.currentTenantId(); + } + return instance.getTenantId(); + } + + /** + * 刷新实例的标题与检索文本(收据号参与检索,也会并入列表标题)。 + * + *

只更新 title/search_text 两列,避免覆盖引擎在锁内维护的其他实例字段。

+ */ + private void refreshIndexTexts(ApprovalInstance instance) { + String title = title(instance); + String searchText = searchText(instance); + if (title != null && title.length() > 200) { + title = title.substring(0, 200); + } + if (searchText != null && searchText.length() > 500) { + searchText = searchText.substring(0, 500); + } + instanceMapper.update(null, new UpdateWrapper() + .eq("id", instance.getId()) + .set("title", title) + .set("search_text", searchText)); + instance.setTitle(title); + instance.setSearchText(searchText); + } + + private ApprovalReceiptForm findByInstance(Long instanceId) { + List forms = receiptFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalReceiptForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + /** 操作人显示名:实名 → 昵称 → 账号。 */ + private String displayName(User user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + + private ApprovalReceiptParam cast(ApprovalDocumentParam param) { + if (!(param instanceof ApprovalReceiptParam)) { + throw new BusinessException("单据类型不匹配"); + } + return (ApprovalReceiptParam) param; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalReceiptFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalReceiptFormMapper.java new file mode 100644 index 0000000..166234d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalReceiptFormMapper.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.doc.receipt.ApprovalReceiptForm; +import org.apache.ibatis.annotations.Param; + +/** + * 收据申请单Mapper。 + * + *

收据号唯一性在业务表,实例状态/流程编码在实例表,因此需要联表查询(ADR 0006 / 0014)。

+ */ +public interface ApprovalReceiptFormMapper extends BaseMapper { + + /** + * 查占用某收据号的同类单据单号。 + * + *

范围:同租户 + 同流程编码 + 实例状态非 REJECTED/WITHDRAWN(ADR 0014)。 + * 占用单据尚无单号(草稿)时返回「草稿#id」,便于出纳定位。

+ * + * @return 占用单据的单号;收据号未被占用返回 null + */ + @InterceptorIgnore(tenantLine = "true") + String selectOccupiedBizNo(@Param("receiptNo") String receiptNo, + @Param("tenantId") Integer tenantId, + @Param("flowCode") String flowCode, + @Param("excludeInstanceId") Long excludeInstanceId); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalReceiptFormMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalReceiptFormMapper.xml new file mode 100644 index 0000000..e65f87d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalReceiptFormMapper.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalReceiptIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalReceiptIT.java new file mode 100644 index 0000000..1c119b3 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalReceiptIT.java @@ -0,0 +1,634 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +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.nio.charset.StandardCharsets; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 收据申请单(ADR 0014)集成测试:出纳回填收据号(先回填后通过)、双存档与归档、 + * 收据号唯一性与冲突单号、撤回/驳回清空留痕、凭证必传与回填权限。 + * + *

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

+ * + *

使用独立租户 10053:出纳/财务部负责人/收文员都按 (tenant_id, role_code) 解析, + * 独立租户可避免与共享租户 10150 的种子角色成员互相污染,保证待办指向唯一。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalReceiptIT { + + private static final int TENANT = 10053; + 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 FLOW = "receipt"; + private static final String FINANCE_ORG_CODE = "APPROVAL_FINANCE"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser reportLeader; + private ApprovalTestData.TestUser cashier; + private ApprovalTestData.TestUser financeLeader; + private ApprovalTestData.TestUser archivist; + private Integer reportOrgId; + private Integer financeOrgId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + reportOrgId = data.createOrganization("收据报送部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("收据报送人" + data.uniqueSuffix(), reportOrgId, TENANT); + + ApprovalTestData.TestRole reportLeaderRole = + data.createRole(data.leaderRoleCode(reportOrgId), "报送部门负责人", TENANT); + reportLeader = data.createUser("收据负责人" + data.uniqueSuffix(), reportOrgId, TENANT); + data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT); + + // 财务部:节点 JSON 固定按组织编码 APPROVAL_FINANCE 解析负责人 + financeOrgId = data.createOrganization("财务部" + data.uniqueSuffix(), 136, TENANT); + data.server().update("UPDATE sys_organization SET organization_code = ? WHERE organization_id = ?", + FINANCE_ORG_CODE, financeOrgId); + ApprovalTestData.TestRole financeLeaderRole = + data.createRole("dept_leader_" + FINANCE_ORG_CODE, "财务部负责人", TENANT); + financeLeader = data.createUser("收据财务负责人" + data.uniqueSuffix(), financeOrgId, TENANT); + data.bindTenant(financeLeader.userId, financeLeaderRole.roleId, TENANT); + + ApprovalTestData.TestRole cashierRole = data.createRole("approval_cashier", "出纳", TENANT); + cashier = data.createUser("收据出纳" + data.uniqueSuffix(), financeOrgId, TENANT); + data.bindTenant(cashier.userId, cashierRole.roleId, TENANT); + + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT); + archivist = data.createUser("收据收文员" + data.uniqueSuffix(), reportOrgId, TENANT); + data.bindTenant(archivist.userId, archivistRole.roleId, TENANT); + } + + @AfterEach + void tearDown() { + // 业务表(approval_receipt_form)与改值历史(approval_field_change)已由共享夹具按实例清理 + data.cleanup(); + } + + @Test + void runsEndToEndWithCashierBackfillThenApprove() throws Exception { + String receiptNo = uniqueReceiptNo(); + long instanceId = saveReceipt("1234.56", "广西某付款单位", "培训场地使用费", null, "2026-10-31", null); + + // 草稿:业务字段落库,收据号为空 + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("receipt"); + assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("receipt"); + assertThat(data.queryMaster("SELECT payer_name FROM approval_receipt_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("广西某付款单位"); + assertThat(data.queryMaster("SELECT amount FROM approval_receipt_form WHERE instance_id = ?", + java.math.BigDecimal.class, instanceId).toPlainString()).isEqualTo("1234.56"); + assertThat(data.queryMaster("SELECT apply_date FROM approval_receipt_form WHERE instance_id = ?", + java.sql.Date.class, instanceId).toLocalDate().toString()).isEqualTo("2026-10-31"); + assertThat(formReceiptNo(instanceId)).isNull(); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.docTypeName").value("收据申请单")) + .andExpect(jsonPath("$.data.statusName").value("未提交")) + .andExpect(jsonPath("$.data.document.fields[0].key").value("amount")) + .andExpect(jsonPath("$.data.document.fields[1].key").value("payerName")) + .andExpect(jsonPath("$.data.document.fields[1].value").value("广西某付款单位")) + .andExpect(jsonPath("$.data.document.fields[3].key").value("receiptNo")); + + // 缺凭证不能提交 + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("凭证"))); + + uploadVoucher(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + + String bizNo = bizNo(instanceId); + assertThat(bizNo).matches("SJ\\d{8}-\\d{4}"); + + // 报送部门负责人通过 + long leaderTask = pendingTaskId(instanceId, "reportLeader", reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"同意开票\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("出纳")); + + // 出纳看到可回填字段(必填、当前为空) + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), cashier, financeOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("cashier")) + .andExpect(jsonPath("$.data.permissions.canApprove").value(true)) + .andExpect(jsonPath("$.data.permissions.writableFields.length()").value(1)) + .andExpect(jsonPath("$.data.permissions.writableFields[0].field").value("receiptNo")) + .andExpect(jsonPath("$.data.permissions.writableFields[0].label").value("收据号")) + .andExpect(jsonPath("$.data.permissions.writableFields[0].required").value(true)); + + // 回填:只写字段不推进节点,写一条改值历史 + long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId); + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("cashier")) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.document.fields[3].value").value(receiptNo)); + + assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'receiptNo' AND old_value IS NULL AND new_value = ? AND round = 1 " + + "AND operator_id = ? AND node_key = 'cashier'", + instanceId, receiptNo, cashier.userId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'BACKFILL' AND operator_id = ?", instanceId, cashier.userId)).isEqualTo(1); + // 回填不推进节点 + assertThat(currentNodeKey(instanceId)).isEqualTo("cashier"); + + // 回填后号码出现在检索文本与列表标题里(列表页只有标题列,需能核对票据) + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST) + .param("keywords", receiptNo)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].title").value( + org.hamcrest.Matchers.containsString("收据号 " + receiptNo))); + + // 再回填同值:不产生新的改值历史 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'receiptNo'", instanceId)).isEqualTo(1); + + // 出纳通过(body 带 fields,值未变不再写改值历史) + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"已到账\",\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'receiptNo'", instanceId)).isEqualTo(1); + + // 双存档:报送人与财务部负责人都在存档节点有任务 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_key = 'confirm' AND status = 'PENDING'", instanceId)).isEqualTo(2); + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), financeLeader, financeOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.document.groupProgress.length()").value(2)) + .andExpect(jsonPath("$.data.document.groupProgress[0].groupKey").value("reporter")) + .andExpect(jsonPath("$.data.document.groupProgress[1].groupKey").value("finance")) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(true)); + + // 只确认一组:停在存档节点 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "confirm", reporter.userId) + + "/confirm"), reporter, reportOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + + // 第二组确认后进入审批完成并为收文员生成归档任务 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "confirm", financeLeader.userId) + + "/confirm"), financeLeader, financeOrgId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.statusName").value("审批完成")) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")); + + // 归档 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, "archive", archivist.userId) + + "/archive"), archivist, reportOrgId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.statusName").value("已归档")) + .andExpect(jsonPath("$.data.archivistName").value(archivist.username)); + + assertThat(instanceStatus(instanceId)).isEqualTo("ARCHIVED"); + assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo); + assertThat(data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?", + String.class, instanceId)).contains(receiptNo); + } + + @Test + void blocksConflictingReceiptNoAndNamesOccupyingDoc() throws Exception { + String receiptNo = uniqueReceiptNo(); + // 先提交一张占用该号码的单据(号码由报送人填在表单上,草稿即占号) + long occupied = saveReceipt("100.00", "占号单位", "先开出的收据", receiptNo, null, null); + uploadVoucher(occupied); + submit(occupied); + String occupiedBizNo = bizNo(occupied); + assertThat(occupiedBizNo).isNotBlank(); + + long instanceId = approvedToCashier("200.00", "后到单位", "同号收据"); + long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId); + + // 回填同号被拦,消息带出占用它的单号 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( + "收据号 " + receiptNo + " 已被单据 " + occupiedBizNo + " 使用"))); + + assertThat(formReceiptNo(instanceId)).isNull(); + assertThat(currentNodeKey(instanceId)).isEqualTo("cashier"); + + // 通过时同样被拦,任务与节点都不动 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"到账\",\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( + "已被单据 " + occupiedBizNo + " 使用"))); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, cashierTask)) + .isEqualTo("PENDING"); + assertThat(currentNodeKey(instanceId)).isEqualTo("cashier"); + + // 换一个未占用的号码即可通过 + String freeNo = uniqueReceiptNo(); + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + freeNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + assertThat(formReceiptNo(instanceId)).isEqualTo(freeNo); + } + + @Test + void blocksApproveWithoutRequiredReceiptNo() throws Exception { + long instanceId = approvedToCashier("300.00", "缺号单位", "通过时缺收据号"); + long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId); + + // 回填允许空值(不完整) + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\" \"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("cashier")); + assertThat(formReceiptNo(instanceId)).isNull(); + + // 通过时完全不带 fields 也不能绕过必填 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"到账\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收据号不能为空"))); + + // 通过时必填项缺项被拦 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"到账\",\"fields\":{\"receiptNo\":\" \"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收据号不能为空"))); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, cashierTask)) + .isEqualTo("PENDING"); + assertThat(currentNodeKey(instanceId)).isEqualTo("cashier"); + + // 补上号码后通过 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + } + + @Test + void withdrawClearsReceiptNoAndLeavesChangeTrace() throws Exception { + String receiptNo = uniqueReceiptNo(); + long instanceId = saveReceipt("400.00", "撤回单位", "撤回前已有号码", receiptNo, null, null); + uploadVoucher(instanceId); + submit(instanceId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("WITHDRAWN")) + .andExpect(jsonPath("$.data.round").value(2)) + .andExpect(jsonPath("$.data.document.fields[3].value").doesNotExist()); + + assertThat(formReceiptNo(instanceId)).isNull(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'receiptNo' AND old_value = ? AND new_value IS NULL AND round = 1 " + + "AND operator_id = ?", instanceId, receiptNo, reporter.userId)).isEqualTo(1); + assertThat(data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?", + String.class, instanceId)).doesNotContain(receiptNo); + + // 重提后号码仍为空,占用查询不再把该单算作占号方 + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)); + assertThat(formReceiptNo(instanceId)).isNull(); + } + + @Test + void rejectClearsReceiptNoAndLeavesChangeTrace() throws Exception { + long instanceId = approvedToCashier("500.00", "驳回单位", "出纳回填后驳回"); + long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId); + String receiptNo = uniqueReceiptNo(); + + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)); + assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo); + + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/reject"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"凭证金额与收据号不符\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("REJECTED")) + .andExpect(jsonPath("$.data.round").value(2)); + + assertThat(formReceiptNo(instanceId)).isNull(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'receiptNo' AND old_value = ? AND new_value IS NULL AND round = 1 " + + "AND node_key = 'cashier' AND operator_id = ?", instanceId, receiptNo, cashier.userId)) + .isEqualTo(1); + + // 驳回后号码可被别的单据使用(清空即释放) + long other = approvedToCashier("600.00", "接号单位", "沿用被释放的号码"); + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(other, "cashier", cashier.userId) + + "/backfill"), cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)); + assertThat(formReceiptNo(other)).isEqualTo(receiptNo); + } + + @Test + void restrictsBackfillToTaskOwnerAndPendingTask() throws Exception { + long instanceId = saveReceipt("700.00", "回填权限单位", "权限校验", null, null, null); + uploadVoucher(instanceId); + submit(instanceId); + + // 报送部门负责人节点未声明可写字段 + long leaderTask = pendingTaskId(instanceId, "reportLeader", reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/backfill"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("当前节点不支持回填"))); + + mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)); + + long cashierTask = pendingTaskId(instanceId, "cashier", cashier.userId); + String receiptNo = uniqueReceiptNo(); + + // 非任务本人(报送人)不能回填 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + reporter, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务"))); + assertThat(formReceiptNo(instanceId)).isNull(); + + // 已处理的任务不能再回填 + mockMvc.perform(authed(post("/api/approval/task/" + leaderTask + "/backfill"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + + // 本人 + 待办才可回填 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)); + assertThat(formReceiptNo(instanceId)).isEqualTo(receiptNo); + + // 通过后该任务已结束,再回填被拒 + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/approve"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + receiptNo + "\"}}")) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + cashierTask + "/backfill"), + cashier, financeOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"receiptNo\":\"" + uniqueReceiptNo() + "\"}}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + } + + @Test + void validatesBusinessFields() throws Exception { + expectSaveFailure(body(null, "付款方", "事由", null, null), "金额不能为空"); + expectSaveFailure(body("-1", "付款方", "事由", null, null), "金额必须为0到99999999.99之间"); + expectSaveFailure(body("100000000", "付款方", "事由", null, null), "金额必须为0到99999999.99之间"); + expectSaveFailure(body("10.001", "付款方", "事由", null, null), "金额最多保留两位小数"); + expectSaveFailure(body("10.00", " ", "事由", null, null), "付款方不能为空"); + expectSaveFailure(body("10.00", repeat("付", 201), "事由", null, null), "付款方不能超过200字"); + expectSaveFailure(body("10.00", "付款方", "", null, null), "事由不能为空"); + expectSaveFailure(body("10.00", "付款方", repeat("事", 501), null, null), "事由不能超过500字"); + expectSaveFailure(body("10.00", "付款方", "事由", repeat("号", 65), null), "收据号不能超过64字"); + + // 金额边界值与选填项留空均可保存 + long instanceId = saveReceipt("99999999.99", "边界单位", "上限金额", " ", null, null); + assertThat(data.queryMaster("SELECT amount FROM approval_receipt_form WHERE instance_id = ?", + java.math.BigDecimal.class, instanceId).toPlainString()).isEqualTo("99999999.99"); + assertThat(formReceiptNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT apply_date FROM approval_receipt_form WHERE instance_id = ?", + java.sql.Date.class, instanceId)).isNull(); + } + + // ---------- helpers ---------- + + private long saveReceipt(String amount, String payerName, String reason, String receiptNo, + String applyDate, Long id) throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), + reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(amount, payerName, reason, receiptNo, applyDate, id))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response); + long instanceId = node.path("data").asLong(); + track(instanceId); + return instanceId; + } + + /** 建单 → 传凭证 → 提交 → 报送部门负责人通过,停在出纳节点。 */ + private long approvedToCashier(String amount, String payerName, String reason) throws Exception { + long instanceId = saveReceipt(amount, payerName, reason, null, null, null); + uploadVoucher(instanceId); + submit(instanceId); + mockMvc.perform(authed(post("/api/approval/task/" + + pendingTaskId(instanceId, "reportLeader", reportLeader.userId) + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("cashier")); + return instanceId; + } + + private void submit(long instanceId) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/" + FLOW), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage))); + } + + private String body(String amount, String payerName, String reason, String receiptNo, + String applyDate) { + return body(amount, payerName, reason, receiptNo, applyDate, null); + } + + private String body(String amount, String payerName, String reason, String receiptNo, + String applyDate, Long id) { + StringBuilder sb = new StringBuilder("{"); + if (id != null) { + sb.append("\"id\":").append(id).append(','); + } + sb.append("\"amount\":").append(amount == null ? "null" : amount).append(',') + .append("\"payerName\":").append(quote(payerName)).append(',') + .append("\"reason\":").append(quote(reason)).append(',') + .append("\"receiptNo\":").append(receiptNo == null ? "null" : quote(receiptNo)).append(',') + .append("\"applyDate\":").append(applyDate == null ? "null" : quote(applyDate)) + .append('}'); + return sb.toString(); + } + + private void uploadVoucher(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "voucher.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", "VOUCHER") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + reportOrgId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, String nodeKey, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND node_key = ? AND assignee_id = ? AND status = 'PENDING' LIMIT 1", + Long.class, instanceId, nodeKey, userId); + assertThat(taskId).as(nodeKey + " 待办任务应存在").isNotNull(); + return taskId; + } + + private void track(long instanceId) { + data.trackInstance(instanceId); + } + + private String uniqueReceiptNo() { + return "R" + data.uniqueSuffix(); + } + + private String formReceiptNo(long instanceId) { + return data.queryMaster("SELECT receipt_no FROM approval_receipt_form WHERE instance_id = ?", + String.class, instanceId); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String bizNo(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String currentNodeKey(long instanceId) { + return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId); + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String value, int count) { + return value.repeat(count); + } +}