Merge branch 'feat/s55-coupon' into feat/approval-phase2
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- 体验券领用申请单业务表与明细子表(可重复执行)
|
||||
-- 目标库:db_gxtyzx(master)
|
||||
-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql
|
||||
-- -> 04-approval_role_grants.sql -> 05-approval_phase2.sql -> 10-approval-coupon.sql
|
||||
--
|
||||
-- 覆盖 ADR 0013(体验券领用):申请事由为唯一业务字段(经办人/经办部门复用实例快照),
|
||||
-- 明细按券种每类最多一行(uk_instance_type 兜底),不记券号、不核销、不统计剩余数量。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_coupon_form (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
apply_reason VARCHAR(500) NOT NULL COMMENT '申请事由',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_instance (instance_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体验券领用申请单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_coupon_item (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
line_no INT NOT NULL COMMENT '行号,按提交顺序重排为 1..N',
|
||||
coupon_type VARCHAR(40) NOT NULL COMMENT '券种编码:7 类固定编码(ADR 0013)',
|
||||
quantity INT NOT NULL COMMENT '数量,≥1',
|
||||
valid_until DATE NOT NULL COMMENT '有效期至(截止日单点)',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_instance_line (instance_id, line_no),
|
||||
UNIQUE KEY uk_instance_type (instance_id, coupon_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体验券领用明细';
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gxwebsoft.approval.doc.coupon;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 体验券领用申请单(ADR 0013):业务字段只有申请事由;
|
||||
* 经办人复用实例的 reporter_name、经办部门复用 report_org_name,不落本表。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "体验券领用申请单")
|
||||
@TableName("approval_coupon_form")
|
||||
public class ApprovalCouponForm implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
/** 申请事由,必填,≤500 字。 */
|
||||
private String applyReason;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.gxwebsoft.approval.doc.coupon;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 体验券领用明细行(ADR 0013):7 类券固定编码,每类券最多一行(uk_instance_type)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "体验券领用明细")
|
||||
@TableName("approval_coupon_item")
|
||||
public class ApprovalCouponItem implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
/** 行号:按提交顺序重排为 1..N。 */
|
||||
private Integer lineNo;
|
||||
|
||||
/** 券种编码,取值见 {@link CouponDocumentType} 的 7 类固定编码。 */
|
||||
private String couponType;
|
||||
|
||||
/** 数量,≥1。 */
|
||||
private Integer quantity;
|
||||
|
||||
/** 有效期至(截止日单点)。 */
|
||||
private LocalDate validUntil;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.gxwebsoft.approval.doc.coupon;
|
||||
|
||||
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 体验券领用申请单草稿参数;id 为审批实例id,更新草稿时传入,明细行走基类 lines。
|
||||
*
|
||||
* <p>经办人/经办部门由服务端按登录态带出,不在参数中接收。</p>
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ApprovalCouponParam extends ApprovalDocumentParam {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("申请事由,必填,最多500字")
|
||||
private String applyReason;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.gxwebsoft.approval.doc.coupon;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
|
||||
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
|
||||
import com.gxwebsoft.approval.document.ApprovalLineSupport;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalCouponFormMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalCouponItemMapper;
|
||||
import com.gxwebsoft.approval.support.ApprovalLineSpec;
|
||||
import com.gxwebsoft.approval.vo.ApprovalDetail;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 体验券领用申请单(ADR 0013)。
|
||||
*
|
||||
* <p>节点链:报送人 → 申请部门负责人 → 相关部门(固定租户角色 approval_related_dept)→
|
||||
* 分管领导 → 总经理 → 发放人(通过即表示已发出,可驳回)→ 报送人存档 → 归档。
|
||||
* 领用人即申请人,不占节点,只作存档确认人。</p>
|
||||
*
|
||||
* <p>明细按券种每类最多一行:同一单内重复直接拒绝并指出券种与行号,数据库另有
|
||||
* {@code uk_instance_type(instance_id, coupon_type)} 兜底。</p>
|
||||
*/
|
||||
@Component
|
||||
public class CouponDocumentType extends AbstractApprovalDocumentType {
|
||||
|
||||
/** 节点链:相关部门为固定角色,不按券种动态生成会签组(ADR 0013)。 */
|
||||
static final String NODES_JSON = "["
|
||||
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"申请部门负责人\"},"
|
||||
+ "{\"key\":\"relatedDept\",\"type\":\"APPROVE\",\"resolver\":\"roleRelatedDept\",\"name\":\"相关部门\"},"
|
||||
+ "{\"key\":\"deputy\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgDeputy\",\"name\":\"分管领导\"},"
|
||||
+ "{\"key\":\"generalManager\",\"type\":\"APPROVE\",\"resolver\":\"roleGeneralManager\",\"name\":\"总经理\"},"
|
||||
+ "{\"key\":\"issuer\",\"type\":\"APPROVE\",\"resolver\":\"roleIssuer\",\"name\":\"发放人\"},"
|
||||
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
|
||||
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
|
||||
+ "]";
|
||||
|
||||
/** 7 类固定券种(ADR 0013):顺序即前端下拉顺序。 */
|
||||
private static final Map<String, String> COUPON_TYPES;
|
||||
|
||||
static {
|
||||
Map<String, String> types = new LinkedHashMap<>();
|
||||
types.put("SWIMMING_DIVING", "游泳跳水馆单次");
|
||||
types.put("TENNIS_CENTER", "网球中心");
|
||||
types.put("TRAINING_HALL", "综合训练馆");
|
||||
types.put("FIVE_A_SIDE", "五人制足球场单次");
|
||||
types.put("GYM_BIWEEKLY", "威拓普健身馆VIP双周卡");
|
||||
types.put("GYM_WEEKLY", "威拓普健身馆周卡");
|
||||
types.put("GYM_MONTHLY", "威拓普健身馆月卡");
|
||||
COUPON_TYPES = Collections.unmodifiableMap(types);
|
||||
}
|
||||
|
||||
/** 明细列(ADR 0004 / 0013):券种、数量、有效期至。 */
|
||||
private static final List<ApprovalLineSpec> LINE_COLUMNS;
|
||||
|
||||
static {
|
||||
List<ApprovalLineSpec.Option> options = new ArrayList<>();
|
||||
for (Map.Entry<String, String> entry : COUPON_TYPES.entrySet()) {
|
||||
options.add(new ApprovalLineSpec.Option(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
LINE_COLUMNS = Collections.unmodifiableList(List.of(
|
||||
ApprovalLineSpec.enumOf("couponType", "券种", true, options),
|
||||
ApprovalLineSpec.integer("quantity", "数量", true),
|
||||
ApprovalLineSpec.date("validUntil", "有效期至", true)
|
||||
));
|
||||
}
|
||||
|
||||
@Resource
|
||||
private ApprovalCouponFormMapper couponFormMapper;
|
||||
@Resource
|
||||
private ApprovalCouponItemMapper couponItemMapper;
|
||||
|
||||
@Override
|
||||
public String flowCode() {
|
||||
return ApprovalConstants.FLOW_COUPON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String docType() {
|
||||
return ApprovalConstants.FLOW_COUPON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String flowName() {
|
||||
return "体验券领用申请单";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String nodesJson() {
|
||||
return NODES_JSON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String noPrefix() {
|
||||
return ApprovalConstants.NO_PREFIX_COUPON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalLineSpec> lineColumns() {
|
||||
return LINE_COLUMNS;
|
||||
}
|
||||
|
||||
/** 附件不设必传分类,沿用「整单至少一张附件」(ADR 0013)。 */
|
||||
@Override
|
||||
public String requiredAttachmentCategory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ApprovalDocumentParam> paramClass() {
|
||||
return ApprovalCouponParam.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(ApprovalDocumentParam param, User user) {
|
||||
ApprovalCouponParam coupon = cast(param);
|
||||
requireText("申请事由", coupon.getApplyReason(), 500);
|
||||
// 明细在持久化前统一校验;这里先做结构校验与业务校验,避免先落业务主表再报错
|
||||
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(coupon.getLines(), LINE_COLUMNS);
|
||||
validateRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写业务表:明细整表替换(先删后插),line_no 由校验器重排为 1..N。
|
||||
*
|
||||
* <p>调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。</p>
|
||||
*/
|
||||
@Override
|
||||
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
|
||||
ApprovalCouponParam coupon = cast(param);
|
||||
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(coupon.getLines(), LINE_COLUMNS);
|
||||
validateRows(rows);
|
||||
|
||||
ApprovalCouponForm form = bizId == null ? null : couponFormMapper.selectById(bizId);
|
||||
if (form == null) {
|
||||
form = new ApprovalCouponForm();
|
||||
form.setInstanceId(instanceId);
|
||||
}
|
||||
form.setApplyReason(coupon.getApplyReason().trim());
|
||||
if (form.getId() == null) {
|
||||
couponFormMapper.insert(form);
|
||||
} else {
|
||||
couponFormMapper.updateById(form);
|
||||
}
|
||||
|
||||
couponItemMapper.delete(new LambdaQueryWrapper<ApprovalCouponItem>()
|
||||
.eq(ApprovalCouponItem::getInstanceId, instanceId));
|
||||
for (Map<String, Object> row : rows) {
|
||||
ApprovalCouponItem item = new ApprovalCouponItem();
|
||||
item.setInstanceId(instanceId);
|
||||
item.setLineNo((Integer) row.get(ApprovalLineSupport.KEY_LINE_NO));
|
||||
item.setCouponType(text(row, "couponType"));
|
||||
item.setQuantity((Integer) row.get("quantity"));
|
||||
item.setValidUntil((LocalDate) row.get("validUntil"));
|
||||
couponItemMapper.insert(item);
|
||||
}
|
||||
return form.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void load(ApprovalInstance instance, ApprovalDetail detail) {
|
||||
ApprovalCouponForm form = findByInstance(instance.getId());
|
||||
if (form == null) {
|
||||
return;
|
||||
}
|
||||
detail.addField("applyReason", "申请事由", form.getApplyReason());
|
||||
// 经办人/经办部门复用实例快照,只展示不落业务表
|
||||
detail.addField("reporterName", "经办人", instance.getReporterName());
|
||||
detail.addField("reportOrgName", "经办部门", instance.getReportOrgName());
|
||||
detail.setLines(lines(instance));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> lines(ApprovalInstance instance) {
|
||||
List<ApprovalCouponItem> items = items(instance);
|
||||
List<Map<String, Object>> rows = new ArrayList<>(items.size());
|
||||
for (ApprovalCouponItem item : items) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put(ApprovalLineSupport.KEY_LINE_NO, item.getLineNo());
|
||||
row.put("couponType", item.getCouponType());
|
||||
row.put("quantity", item.getQuantity());
|
||||
row.put("validUntil", item.getValidUntil());
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String title(ApprovalInstance instance) {
|
||||
ApprovalCouponForm form = findByInstance(instance.getId());
|
||||
if (form == null || form.getApplyReason() == null) {
|
||||
return null;
|
||||
}
|
||||
String reason = form.getApplyReason().trim();
|
||||
String shortReason = reason.length() <= 20 ? reason : reason.substring(0, 20);
|
||||
return "体验券领用:" + shortReason;
|
||||
}
|
||||
|
||||
/** 检索文本:申请事由 + 本单全部券种中文名(按券种搜索能命中)。 */
|
||||
@Override
|
||||
public String searchText(ApprovalInstance instance) {
|
||||
ApprovalCouponForm form = findByInstance(instance.getId());
|
||||
if (form == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (form.getApplyReason() != null) {
|
||||
builder.append(form.getApplyReason().trim());
|
||||
}
|
||||
for (ApprovalCouponItem item : items(instance)) {
|
||||
String label = couponTypeName(item.getCouponType());
|
||||
if (label != null) {
|
||||
builder.append(' ').append(label);
|
||||
}
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细业务校验:数量 ≥1、有效期至必填、同一单内券种不得重复。
|
||||
*
|
||||
* <p>重复时消息带券种中文名与两行行号,便于直接定位到表格行。</p>
|
||||
*/
|
||||
private void validateRows(List<Map<String, Object>> rows) {
|
||||
Map<String, Integer> firstLineByType = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
int lineNo = (Integer) row.get(ApprovalLineSupport.KEY_LINE_NO);
|
||||
String couponType = text(row, "couponType");
|
||||
Object quantity = row.get("quantity");
|
||||
if (quantity == null) {
|
||||
throw new BusinessException("第" + lineNo + "行数量不能为空");
|
||||
}
|
||||
if ((Integer) quantity < 1) {
|
||||
throw new BusinessException("第" + lineNo + "行数量必须大于等于1");
|
||||
}
|
||||
if (row.get("validUntil") == null) {
|
||||
throw new BusinessException("第" + lineNo + "行有效期至不能为空");
|
||||
}
|
||||
Integer firstLine = firstLineByType.putIfAbsent(couponType, lineNo);
|
||||
if (firstLine != null) {
|
||||
throw new BusinessException("第" + lineNo + "行券种「" + couponTypeName(couponType)
|
||||
+ "」与第" + firstLine + "行重复,每类券最多一行");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ApprovalCouponItem> items(ApprovalInstance instance) {
|
||||
return couponItemMapper.selectList(new LambdaQueryWrapper<ApprovalCouponItem>()
|
||||
.eq(ApprovalCouponItem::getInstanceId, instance.getId())
|
||||
.orderByAsc(ApprovalCouponItem::getLineNo));
|
||||
}
|
||||
|
||||
private ApprovalCouponForm findByInstance(Long instanceId) {
|
||||
List<ApprovalCouponForm> forms = couponFormMapper.selectList(
|
||||
new LambdaQueryWrapper<ApprovalCouponForm>()
|
||||
.eq(ApprovalCouponForm::getInstanceId, instanceId)
|
||||
.last("LIMIT 1"));
|
||||
return forms.isEmpty() ? null : forms.get(0);
|
||||
}
|
||||
|
||||
/** 券种编码 → 中文名;未知编码返回编码本身。 */
|
||||
private String couponTypeName(String couponType) {
|
||||
if (couponType == null) {
|
||||
return null;
|
||||
}
|
||||
String name = COUPON_TYPES.get(couponType);
|
||||
return name == null ? couponType : name;
|
||||
}
|
||||
|
||||
private String text(Map<String, Object> row, String key) {
|
||||
Object value = row.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private ApprovalCouponParam cast(ApprovalDocumentParam param) {
|
||||
if (!(param instanceof ApprovalCouponParam)) {
|
||||
throw new BusinessException("单据类型不匹配");
|
||||
}
|
||||
return (ApprovalCouponParam) param;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.doc.coupon.ApprovalCouponForm;
|
||||
|
||||
/**
|
||||
* 体验券领用申请单Mapper。
|
||||
*/
|
||||
public interface ApprovalCouponFormMapper extends BaseMapper<ApprovalCouponForm> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.doc.coupon.ApprovalCouponItem;
|
||||
|
||||
/**
|
||||
* 体验券领用明细Mapper。
|
||||
*/
|
||||
public interface ApprovalCouponItemMapper extends BaseMapper<ApprovalCouponItem> {
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package com.gxwebsoft.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.support.ApprovalTestData;
|
||||
import com.gxwebsoft.support.TestAuthentication;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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 0013):多券种草稿、每类券唯一、六审批节点
|
||||
* (部门负责人 → 相关部门 → 分管领导 → 总经理 → 发放人 → 存档)到归档、
|
||||
* 明细导出与「整单至少一张附件」。
|
||||
*
|
||||
* <p>独立租户:相关角色(approval_related_dept 等)按 tenant_id + role_code 解析,
|
||||
* 用专属租户(10052,测试库中无审批角色成员)避免与其它并行测试类共享角色成员。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
class ApprovalCouponIT {
|
||||
|
||||
private static final int TENANT = 10052;
|
||||
private static final String LIST = "approval:instance:list";
|
||||
private static final String SUBMIT = "approval:instance:submit";
|
||||
private static final String APPROVE = "approval:instance:approve";
|
||||
private static final String ARCHIVE = "approval:instance:archive";
|
||||
private static final String EXPORT = "approval:instance:export";
|
||||
|
||||
private static final String APPLY_REASON = "员工回馈活动领用体验券";
|
||||
|
||||
@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 relatedDept;
|
||||
private ApprovalTestData.TestUser deputy;
|
||||
private ApprovalTestData.TestUser generalManager;
|
||||
private ApprovalTestData.TestUser issuer;
|
||||
private ApprovalTestData.TestUser archivist;
|
||||
private Integer organizationId;
|
||||
|
||||
/** 本次用例生成的文件。 */
|
||||
private final List<Path> exportedFiles = new ArrayList<>();
|
||||
/** 本次用例创建的用户,用于清理异步操作日志。 */
|
||||
private final List<Integer> userIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
String suffix = data.uniqueSuffix();
|
||||
organizationId = data.createOrganization("体验券申请部门" + suffix, 136, TENANT);
|
||||
reporter = createUser("体验券申请人" + suffix, organizationId);
|
||||
|
||||
reportLeader = createUser("体验券部门负责人" + suffix, organizationId);
|
||||
data.bindTenant(reportLeader.userId,
|
||||
data.createRole(data.leaderRoleCode(organizationId), "部门负责人", TENANT).roleId, TENANT);
|
||||
|
||||
deputy = createUser("体验券分管领导" + suffix, organizationId);
|
||||
data.bindTenant(deputy.userId,
|
||||
data.createRole("dept_deputy_" + data.organizationCode(organizationId), "分管领导", TENANT).roleId,
|
||||
TENANT);
|
||||
|
||||
relatedDept = createUser("体验券相关部门" + suffix, organizationId);
|
||||
data.bindTenant(relatedDept.userId,
|
||||
data.createRole(ApprovalConstants.ROLE_RELATED_DEPT, "相关部门", TENANT).roleId, TENANT);
|
||||
|
||||
generalManager = createUser("体验券总经理" + suffix, organizationId);
|
||||
data.bindTenant(generalManager.userId,
|
||||
data.createRole(ApprovalConstants.ROLE_GENERAL_MANAGER, "总经理", TENANT).roleId, TENANT);
|
||||
|
||||
issuer = createUser("体验券发放人" + suffix, organizationId);
|
||||
data.bindTenant(issuer.userId,
|
||||
data.createRole(ApprovalConstants.ROLE_ISSUER, "发放人", TENANT).roleId, TENANT);
|
||||
|
||||
archivist = createUser("体验券收文员" + suffix, organizationId);
|
||||
data.bindTenant(archivist.userId,
|
||||
data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员", TENANT).roleId, TENANT);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// 体验券业务表与明细表已并入 ApprovalTestData.INSTANCE_CHILD_TABLES,随 cleanup 一并清理
|
||||
for (Path path : exportedFiles) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (Exception ignored) {
|
||||
// 文件清理失败不影响断言
|
||||
}
|
||||
}
|
||||
exportedFiles.clear();
|
||||
try {
|
||||
// 导出操作日志异步写入
|
||||
Thread.sleep(300);
|
||||
for (Integer userId : userIds) {
|
||||
data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url LIKE '%/api/approval/export%'",
|
||||
userId);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 共享库清理尽力而为
|
||||
}
|
||||
userIds.clear();
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsDraftWithMultipleCouponTypesAndShowsLines() throws Exception {
|
||||
long instanceId = saveDraft(reporter, APPLY_REASON, List.of(
|
||||
row("TENNIS_CENTER", 10, LocalDate.now().plusMonths(3)),
|
||||
row("SWIMMING_DIVING", 20, LocalDate.now().plusMonths(6)),
|
||||
row("GYM_MONTHLY", 5, LocalDate.now().plusMonths(1))));
|
||||
|
||||
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||
assertThat(bizNo(instanceId)).isNull();
|
||||
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("coupon");
|
||||
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("coupon");
|
||||
assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId)).isEqualTo(reporter.username);
|
||||
assertThat(data.queryMaster("SELECT apply_reason FROM approval_coupon_form WHERE instance_id = ?",
|
||||
String.class, instanceId)).isEqualTo(APPLY_REASON);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
|
||||
instanceId)).isEqualTo(3);
|
||||
assertThat(data.queryMaster("SELECT coupon_type FROM approval_coupon_item WHERE instance_id = ? AND line_no = 1",
|
||||
String.class, instanceId)).isEqualTo("TENNIS_CENTER");
|
||||
assertThat(data.queryMaster("SELECT quantity FROM approval_coupon_item WHERE instance_id = ? AND line_no = 2",
|
||||
Integer.class, instanceId)).isEqualTo(20);
|
||||
// 标题按申请事由生成,检索文本含券种中文名
|
||||
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("体验券领用:" + APPLY_REASON);
|
||||
String searchText = data.queryMaster("SELECT search_text FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId);
|
||||
assertThat(searchText).contains("网球中心").contains("游泳跳水馆单次").contains("威拓普健身馆月卡");
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.docType").value("coupon"))
|
||||
.andExpect(jsonPath("$.data.docTypeName").value("体验券领用申请单"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("未提交"))
|
||||
.andExpect(jsonPath("$.data.document.fields.length()").value(3))
|
||||
.andExpect(jsonPath("$.data.document.fields[0].key").value("applyReason"))
|
||||
.andExpect(jsonPath("$.data.document.fields[0].label").value("申请事由"))
|
||||
.andExpect(jsonPath("$.data.document.fields[0].value").value(APPLY_REASON))
|
||||
.andExpect(jsonPath("$.data.document.fields[1].key").value("reporterName"))
|
||||
.andExpect(jsonPath("$.data.document.fields[1].value").value(reporter.username))
|
||||
.andExpect(jsonPath("$.data.document.fields[2].key").value("reportOrgName"))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns.length()").value(3))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns[0].key").value("couponType"))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns[0].type").value("ENUM"))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns[0].options.length()").value(7))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns[1].key").value("quantity"))
|
||||
.andExpect(jsonPath("$.data.document.lineColumns[2].key").value("validUntil"))
|
||||
.andExpect(jsonPath("$.data.document.lines.length()").value(3))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].lineNo").value(1))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].couponType").value("TENNIS_CENTER"))
|
||||
.andExpect(jsonPath("$.data.document.lines[0].quantity").value(10))
|
||||
.andExpect(jsonPath("$.data.document.lineSummary.lineCount").value(3))
|
||||
.andExpect(jsonPath("$.data.permissions.canEdit").value(true));
|
||||
|
||||
// 列表标题即「体验券领用:<申请事由>」,类型名取流程名
|
||||
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||
.andExpect(jsonPath("$.data.list[0].docTypeName").value("体验券领用申请单"))
|
||||
.andExpect(jsonPath("$.data.list[0].title").value("体验券领用:" + APPLY_REASON))
|
||||
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
|
||||
|
||||
// 草稿整表替换:改两行后子表只剩两行且行号重排
|
||||
long updated = saveDraft(reporter, "调整后的领用事由", List.of(
|
||||
row("TRAINING_HALL", 8, LocalDate.now().plusDays(30)),
|
||||
row("FIVE_A_SIDE", 4, LocalDate.now().plusDays(60))), instanceId);
|
||||
assertThat(updated).isEqualTo(instanceId);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
|
||||
instanceId)).isEqualTo(2);
|
||||
assertThat(data.queryMaster("SELECT coupon_type FROM approval_coupon_item WHERE instance_id = ? AND line_no = 1",
|
||||
String.class, instanceId)).isEqualTo("TRAINING_HALL");
|
||||
assertThat(data.queryMaster("SELECT title FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("体验券领用:调整后的领用事由");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateCouponTypeAndNonPositiveQuantity() throws Exception {
|
||||
Map<String, Object> repeat = new LinkedHashMap<>();
|
||||
repeat.put("applyReason", APPLY_REASON);
|
||||
repeat.put("lines", List.of(
|
||||
row("SWIMMING_DIVING", 2, LocalDate.now().plusDays(10)),
|
||||
row("TENNIS_CENTER", 2, LocalDate.now().plusDays(10)),
|
||||
row("SWIMMING_DIVING", 3, LocalDate.now().plusDays(10))));
|
||||
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(repeat)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.allOf(
|
||||
org.hamcrest.Matchers.containsString("第3行"),
|
||||
org.hamcrest.Matchers.containsString("游泳跳水馆单次"),
|
||||
org.hamcrest.Matchers.containsString("第1行"),
|
||||
org.hamcrest.Matchers.containsString("每类券最多一行"))));
|
||||
|
||||
expectSaveFailure(List.of(row("SWIMMING_DIVING", 0, LocalDate.now().plusDays(10))),
|
||||
"第1行数量必须大于等于1");
|
||||
expectSaveFailure(List.of(row("SWIMMING_DIVING", -3, LocalDate.now().plusDays(10))),
|
||||
"第1行数量必须大于等于1");
|
||||
expectSaveFailure(List.of(row("NOT_A_COUPON", 1, LocalDate.now().plusDays(10))),
|
||||
"第1行券种取值不正确");
|
||||
expectSaveFailure(List.of(Map.of("couponType", "TENNIS_CENTER", "quantity", 1)),
|
||||
"第1行有效期至不能为空");
|
||||
expectSaveFailure(List.of(Map.of("quantity", 1, "validUntil", LocalDate.now().plusDays(10).toString())),
|
||||
"第1行券种不能为空");
|
||||
|
||||
// 申请事由必填且不超过 500 字,明细至少一行
|
||||
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("applyReason", " ", "lines",
|
||||
List.of(row("TENNIS_CENTER", 1, LocalDate.now().plusDays(10)))))))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value("申请事由不能为空"));
|
||||
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("applyReason", "券".repeat(501), "lines",
|
||||
List.of(row("TENNIS_CENTER", 1, LocalDate.now().plusDays(10)))))))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value("申请事由不能超过500字"));
|
||||
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_form")).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSubmitWithoutAnyAttachmentButDoesNotRequireCategory() throws Exception {
|
||||
long instanceId = saveDraft(reporter, APPLY_REASON,
|
||||
List.of(row("TENNIS_CENTER", 10, LocalDate.now().plusMonths(2))));
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value("请至少上传一张附件"));
|
||||
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||
assertThat(bizNo(instanceId)).isNull();
|
||||
|
||||
// 任意分类一张即可,不要求签字件或凭证
|
||||
uploadAttachment(instanceId, ApprovalConstants.CATEGORY_OTHER);
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("申请部门负责人"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsThroughSixApprovalNodesThenArchives() throws Exception {
|
||||
long instanceId = saveDraft(reporter, APPLY_REASON, List.of(
|
||||
row("SWIMMING_DIVING", 30, LocalDate.now().plusMonths(6)),
|
||||
row("TENNIS_CENTER", 20, LocalDate.now().plusMonths(6)),
|
||||
row("GYM_BIWEEKLY", 6, LocalDate.now().plusMonths(3))));
|
||||
uploadAttachment(instanceId, ApprovalConstants.CATEGORY_OTHER);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.bizNo").value(org.hamcrest.Matchers.matchesPattern("TY\\d{8}-\\d{4}")))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("申请部门负责人"))
|
||||
.andExpect(jsonPath("$.data.document.lines.length()").value(3));
|
||||
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId);
|
||||
assertThat(approve(instanceId, reportLeader, "相关部门")).isEqualTo("relatedDept");
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(relatedDept.userId);
|
||||
assertThat(approve(instanceId, relatedDept, "分管领导")).isEqualTo("deputy");
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(deputy.userId);
|
||||
assertThat(approve(instanceId, deputy, "总经理")).isEqualTo("generalManager");
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(generalManager.userId);
|
||||
assertThat(approve(instanceId, generalManager, "发放人")).isEqualTo("issuer");
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(issuer.userId);
|
||||
// 发放人用「通过」而不是存档确认(ADR 0013)
|
||||
assertThat(approve(instanceId, issuer, "存档")).isEqualTo("confirm");
|
||||
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(reporter.userId);
|
||||
long confirmTask = pendingTaskId(instanceId, reporter.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, APPROVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
|
||||
assertThat(pendingTaskIds(instanceId)).containsExactly(archivist.userId);
|
||||
|
||||
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, ARCHIVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||
.andExpect(jsonPath("$.data.archivistName").value(archivist.username))
|
||||
.andExpect(jsonPath("$.data.permissions.canEdit").value(false));
|
||||
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
|
||||
instanceId)).isEqualTo(5);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
|
||||
instanceId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'",
|
||||
instanceId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_coupon_item WHERE instance_id = ?",
|
||||
instanceId)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsLineCountInSummaryAndCouponLines() throws Exception {
|
||||
String mark = "券导出" + data.uniqueSuffix();
|
||||
long instanceId = saveDraft(reporter, mark, List.of(
|
||||
row("GYM_WEEKLY", 7, LocalDate.parse("2027-01-31")),
|
||||
row("GYM_MONTHLY", 2, LocalDate.parse("2027-06-30"))));
|
||||
String reportOrgName = data.server().queryForObject(
|
||||
"SELECT organization_name FROM sys_organization WHERE organization_id = ?", String.class, organizationId);
|
||||
|
||||
// 跨单据总表:明细行数 = 2,合计金额为空(券单无金额列)
|
||||
String response = mockMvc.perform(authed(post("/api/approval/export"), reporter, LIST, EXPORT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"keywords\":" + quote(mark) + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
File summary = exportFile(response);
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(summary))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
assertThat(cellText(sheet.getRow(0).getCell(16))).isEqualTo("明细行数");
|
||||
assertThat(cellText(sheet.getRow(0).getCell(17))).isEqualTo("合计金额");
|
||||
Row row = sheet.getRow(1);
|
||||
assertThat(cellText(row.getCell(1))).isEqualTo("体验券领用申请单");
|
||||
assertThat(cellText(row.getCell(16))).isEqualTo("2");
|
||||
assertThat(cellText(row.getCell(17))).isEmpty();
|
||||
}
|
||||
|
||||
// 按类型明细导出:逐行券种/数量/有效期至
|
||||
String lineResponse = mockMvc.perform(authed(post("/api/approval/export/coupon/lines"), reporter, LIST, EXPORT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"flowCode\":\"coupon\",\"keywords\":" + quote(mark) + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条明细")))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
File lines = exportFile(lineResponse);
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(lines))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
assertThat(cellText(sheet.getRow(0).getCell(0))).isEqualTo("单号");
|
||||
assertThat(cellText(sheet.getRow(0).getCell(4))).isEqualTo("券种");
|
||||
assertThat(cellText(sheet.getRow(0).getCell(5))).isEqualTo("数量");
|
||||
assertThat(cellText(sheet.getRow(0).getCell(6))).isEqualTo("有效期至");
|
||||
Row first = sheet.getRow(1);
|
||||
assertThat(cellText(first.getCell(1))).isEqualTo("未提交");
|
||||
assertThat(cellText(first.getCell(2))).isEqualTo(reportOrgName);
|
||||
assertThat(cellText(first.getCell(3))).isEqualTo(reporter.username);
|
||||
assertThat(cellText(first.getCell(4))).isEqualTo("GYM_WEEKLY");
|
||||
assertThat(cellText(first.getCell(5))).isEqualTo("7");
|
||||
assertThat(cellText(first.getCell(6))).isEqualTo("2027-01-31");
|
||||
Row second = sheet.getRow(2);
|
||||
assertThat(cellText(second.getCell(4))).isEqualTo("GYM_MONTHLY");
|
||||
assertThat(cellText(second.getCell(5))).isEqualTo("2");
|
||||
assertThat(cellText(second.getCell(6))).isEqualTo("2027-06-30");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
|
||||
ApprovalTestData.TestUser user = data.createUser(realName, organizationId, TENANT);
|
||||
userIds.add(user.userId);
|
||||
return user;
|
||||
}
|
||||
|
||||
private long saveDraft(ApprovalTestData.TestUser user, String applyReason,
|
||||
List<Map<String, Object>> lines) throws Exception {
|
||||
return saveDraft(user, applyReason, lines, null);
|
||||
}
|
||||
|
||||
private long saveDraft(ApprovalTestData.TestUser user, String applyReason,
|
||||
List<Map<String, Object>> lines, Long id) throws Exception {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (id != null) {
|
||||
body.put("id", id);
|
||||
}
|
||||
body.put("applyReason", applyReason);
|
||||
body.put("lines", lines);
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/coupon"), user, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(body)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||
data.trackInstance(instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
private void expectSaveFailure(List<Map<String, Object>> lines, String expectedMessage) throws Exception {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("applyReason", APPLY_REASON);
|
||||
body.put("lines", lines);
|
||||
mockMvc.perform(authed(post("/api/approval/instance/coupon"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(body)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String couponType, int quantity, LocalDate validUntil) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("couponType", couponType);
|
||||
row.put("quantity", quantity);
|
||||
row.put("validUntil", validUntil.toString());
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 逐个节点通过:断言推进到下一节点名称,并返回该节点 key 供顺序校验。 */
|
||||
private String approve(long instanceId, ApprovalTestData.TestUser approver, String nextNodeName)
|
||||
throws Exception {
|
||||
long taskId = pendingTaskId(instanceId, approver.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), approver, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"同意\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value(nextNodeName));
|
||||
return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId);
|
||||
}
|
||||
|
||||
private void uploadAttachment(long instanceId, String category) 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", category)
|
||||
.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId,
|
||||
TENANT, SUBMIT))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
private long pendingTaskId(long instanceId, int userId) {
|
||||
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private List<Integer> pendingTaskIds(long instanceId) {
|
||||
return data.master().queryForList("SELECT assignee_id FROM approval_task "
|
||||
+ "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
|
||||
}
|
||||
|
||||
private String instanceStatus(long instanceId) {
|
||||
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||
}
|
||||
|
||||
private String bizNo(long instanceId) {
|
||||
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||
}
|
||||
|
||||
private File exportFile(String response) throws Exception {
|
||||
JsonNode node = objectMapper.readTree(response).path("data");
|
||||
File file = new File(node.path("path").asText());
|
||||
assertThat(file).exists();
|
||||
exportedFiles.add(file.toPath());
|
||||
return file;
|
||||
}
|
||||
|
||||
private String cellText(org.apache.poi.ss.usermodel.Cell cell) {
|
||||
if (cell == null) {
|
||||
return "";
|
||||
}
|
||||
if (cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
|
||||
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue()))
|
||||
.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||
ApprovalTestData.TestUser user,
|
||||
String... authorities) {
|
||||
return builder.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(user.userId, user.username, organizationId, TENANT,
|
||||
authorities)));
|
||||
}
|
||||
|
||||
private String quote(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user