feat: 体验券领用申请单(每类券一行 + 相关部门与发放人)#56

This commit is contained in:
weicw
2026-10-31 07:10:54 +08:00
parent 45285952e9
commit 39692e9c90
8 changed files with 976 additions and 0 deletions
+33
View File
@@ -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("\"", "\\\"") + "\"";
}
}