feat: 收据申请单(出纳回填收据号,撤回驳回清空)#53
This commit is contained in:
@@ -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='收据申请单';
|
||||
@@ -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 审批实例。
|
||||
*
|
||||
* <p>`receipt_no` 由出纳在审批节点回填,撤回/驳回时由单据服务显式清空
|
||||
* (业务表不随引擎回滚)。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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,更新草稿时传入。
|
||||
*
|
||||
* <p>报送部门与报送人由服务端按登录态带出,不在参数中接收。收据号提交时非必填
|
||||
* (出纳在审批节点回填),撤回/驳回时由单据服务清空。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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):报送部门负责人 → 出纳(回填收据号)→
|
||||
* 存档(报送人 + 财务部负责人)→ 归档。
|
||||
*
|
||||
* <p>吃掉引擎两项能力:节点可写字段(出纳「先回填后通过」,ADR 0006)与
|
||||
* 必传附件分类(转账凭证 VOUCHER)。受理部门为财务部,第二存档方取
|
||||
* {@code dept_leader_APPROVAL_FINANCE}(待客户确认,见 ADR 0014)。</p>
|
||||
*/
|
||||
@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";
|
||||
|
||||
/**
|
||||
* 节点链:报送人提交 → 报送部门负责人 → 出纳(回填收据号,通过时必填)→
|
||||
* 存档(报送人 + 财务部负责人)→ 归档。
|
||||
*
|
||||
* <p>「财务部负责人」用已存在的 orgLeaderByCode + APPROVAL_FINANCE 组合实现,
|
||||
* 不新增解析器(共享文件由集成方统一改)。</p>
|
||||
*/
|
||||
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<ApprovalFieldSpec> 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<ApprovalFieldSpec> fieldSpecs() {
|
||||
return FIELD_SPECS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ApprovalDocumentParam> 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)。
|
||||
*
|
||||
* <p>回填(requireComplete=false)允许缺项,通过(true)时必填;唯一性范围是
|
||||
* 同租户、同 flow_code、实例状态非 REJECTED/WITHDRAWN,冲突时带出占用单号。</p>
|
||||
*/
|
||||
@Override
|
||||
public List<ApprovalFieldUpdate> applyWritableFields(ApprovalInstance instance, Map<String, String> 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<ApprovalReceiptForm>()
|
||||
.eq("id", form.getId())
|
||||
.set("receipt_no", newValue));
|
||||
// 收据号参与检索文本,清空/回填后同步刷新,避免作废号码仍能被搜到
|
||||
refreshIndexTexts(instance);
|
||||
List<ApprovalFieldUpdate> changes = new ArrayList<>(1);
|
||||
changes.add(new ApprovalFieldUpdate(FIELD_RECEIPT_NO, LABEL_RECEIPT_NO, oldValue, newValue));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回与驳回都清空收据号并写改值历史(ADR 0014)。
|
||||
*
|
||||
* <p>业务表不随引擎回滚,因此由单据服务在状态流转处显式清空;引擎已写
|
||||
* 撤回/驳回处理记录,这里只补 {@code approval_field_change}(new_value = NULL)。</p>
|
||||
*/
|
||||
@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<ApprovalReceiptForm>()
|
||||
.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新实例的标题与检索文本(收据号参与检索,也会并入列表标题)。
|
||||
*
|
||||
* <p>只更新 title/search_text 两列,避免覆盖引擎在锁内维护的其他实例字段。</p>
|
||||
*/
|
||||
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<ApprovalInstance>()
|
||||
.eq("id", instance.getId())
|
||||
.set("title", title)
|
||||
.set("search_text", searchText));
|
||||
instance.setTitle(title);
|
||||
instance.setSearchText(searchText);
|
||||
}
|
||||
|
||||
private ApprovalReceiptForm findByInstance(Long instanceId) {
|
||||
List<ApprovalReceiptForm> forms = receiptFormMapper.selectList(
|
||||
new LambdaQueryWrapper<ApprovalReceiptForm>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
*
|
||||
* <p>收据号唯一性在业务表,实例状态/流程编码在实例表,因此需要联表查询(ADR 0006 / 0014)。</p>
|
||||
*/
|
||||
public interface ApprovalReceiptFormMapper extends BaseMapper<ApprovalReceiptForm> {
|
||||
|
||||
/**
|
||||
* 查占用某收据号的同类单据单号。
|
||||
*
|
||||
* <p>范围:同租户 + 同流程编码 + 实例状态非 REJECTED/WITHDRAWN(ADR 0014)。
|
||||
* 占用单据尚无单号(草稿)时返回「草稿#id」,便于出纳定位。</p>
|
||||
*
|
||||
* @return 占用单据的单号;收据号未被占用返回 null
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
String selectOccupiedBizNo(@Param("receiptNo") String receiptNo,
|
||||
@Param("tenantId") Integer tenantId,
|
||||
@Param("flowCode") String flowCode,
|
||||
@Param("excludeInstanceId") Long excludeInstanceId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalReceiptFormMapper">
|
||||
|
||||
<!-- 收据号占用查询:同租户 + 同流程 + 未作废实例;占用方无单号时给出草稿标识 -->
|
||||
<select id="selectOccupiedBizNo" resultType="java.lang.String">
|
||||
SELECT COALESCE(i.biz_no, CONCAT('草稿#', i.id))
|
||||
FROM approval_receipt_form f
|
||||
JOIN approval_instance i ON i.id = f.instance_id
|
||||
WHERE f.receipt_no = #{receiptNo}
|
||||
AND i.tenant_id = #{tenantId}
|
||||
AND i.flow_code = #{flowCode}
|
||||
AND i.status NOT IN ('REJECTED', 'WITHDRAWN')
|
||||
<if test="excludeInstanceId != null">
|
||||
AND i.id != #{excludeInstanceId}
|
||||
</if>
|
||||
ORDER BY i.id
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -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)集成测试:出纳回填收据号(先回填后通过)、双存档与归档、
|
||||
* 收据号唯一性与冲突单号、撤回/驳回清空留痕、凭证必传与回填权限。
|
||||
*
|
||||
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进。
|
||||
* 引擎表由 {@link ApprovalTestData#cleanup()} 清理,本单业务表在 {@link #tearDown()} 里按实例删除。</p>
|
||||
*
|
||||
* <p>使用独立租户 10053:出纳/财务部负责人/收文员都按 (tenant_id, role_code) 解析,
|
||||
* 独立租户可避免与共享租户 10150 的种子角色成员互相污染,保证待办指向唯一。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user