From 39692e9c9016ff75d49e79f296b8e5a19c8dd0fb Mon Sep 17 00:00:00 2001 From: weicw Date: Sat, 31 Oct 2026 07:10:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=93=E9=AA=8C=E5=88=B8=E9=A2=86?= =?UTF-8?q?=E7=94=A8=E7=94=B3=E8=AF=B7=E5=8D=95=EF=BC=88=E6=AF=8F=E7=B1=BB?= =?UTF-8?q?=E5=88=B8=E4=B8=80=E8=A1=8C=20+=20=E7=9B=B8=E5=85=B3=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E4=B8=8E=E5=8F=91=E6=94=BE=E4=BA=BA=EF=BC=89#56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/10-approval-coupon.sql | 33 ++ .../doc/coupon/ApprovalCouponForm.java | 35 ++ .../doc/coupon/ApprovalCouponItem.java | 44 ++ .../doc/coupon/ApprovalCouponParam.java | 20 + .../doc/coupon/CouponDocumentType.java | 292 ++++++++++ .../mapper/ApprovalCouponFormMapper.java | 10 + .../mapper/ApprovalCouponItemMapper.java | 10 + .../gxwebsoft/approval/ApprovalCouponIT.java | 532 ++++++++++++++++++ 8 files changed, 976 insertions(+) create mode 100644 sql/10-approval-coupon.sql create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponItem.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coupon/CouponDocumentType.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponItemMapper.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCouponIT.java diff --git a/sql/10-approval-coupon.sql b/sql/10-approval-coupon.sql new file mode 100644 index 0000000..fea4a98 --- /dev/null +++ b/sql/10-approval-coupon.sql @@ -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='体验券领用明细'; diff --git a/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponForm.java b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponForm.java new file mode 100644 index 0000000..5de029b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponForm.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponItem.java b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponItem.java new file mode 100644 index 0000000..bcd940a --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponItem.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponParam.java b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponParam.java new file mode 100644 index 0000000..3bdacad --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/coupon/ApprovalCouponParam.java @@ -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。 + * + *

经办人/经办部门由服务端按登录态带出,不在参数中接收。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApprovalCouponParam extends ApprovalDocumentParam { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("申请事由,必填,最多500字") + private String applyReason; +} diff --git a/src/main/java/com/gxwebsoft/approval/doc/coupon/CouponDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/coupon/CouponDocumentType.java new file mode 100644 index 0000000..b35b7fb --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/doc/coupon/CouponDocumentType.java @@ -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)。 + * + *

节点链:报送人 → 申请部门负责人 → 相关部门(固定租户角色 approval_related_dept)→ + * 分管领导 → 总经理 → 发放人(通过即表示已发出,可驳回)→ 报送人存档 → 归档。 + * 领用人即申请人,不占节点,只作存档确认人。

+ * + *

明细按券种每类最多一行:同一单内重复直接拒绝并指出券种与行号,数据库另有 + * {@code uk_instance_type(instance_id, coupon_type)} 兜底。

+ */ +@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 COUPON_TYPES; + + static { + Map 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 LINE_COLUMNS; + + static { + List options = new ArrayList<>(); + for (Map.Entry 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 lineColumns() { + return LINE_COLUMNS; + } + + /** 附件不设必传分类,沿用「整单至少一张附件」(ADR 0013)。 */ + @Override + public String requiredAttachmentCategory() { + return null; + } + + @Override + public Class paramClass() { + return ApprovalCouponParam.class; + } + + @Override + public void validate(ApprovalDocumentParam param, User user) { + ApprovalCouponParam coupon = cast(param); + requireText("申请事由", coupon.getApplyReason(), 500); + // 明细在持久化前统一校验;这里先做结构校验与业务校验,避免先落业务主表再报错 + List> rows = ApprovalLineSupport.normalize(coupon.getLines(), LINE_COLUMNS); + validateRows(rows); + } + + /** + * 写业务表:明细整表替换(先删后插),line_no 由校验器重排为 1..N。 + * + *

调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。

+ */ + @Override + public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) { + ApprovalCouponParam coupon = cast(param); + List> 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() + .eq(ApprovalCouponItem::getInstanceId, instanceId)); + for (Map 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> lines(ApprovalInstance instance) { + List items = items(instance); + List> rows = new ArrayList<>(items.size()); + for (ApprovalCouponItem item : items) { + Map 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、有效期至必填、同一单内券种不得重复。 + * + *

重复时消息带券种中文名与两行行号,便于直接定位到表格行。

+ */ + private void validateRows(List> rows) { + Map firstLineByType = new LinkedHashMap<>(); + for (Map 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 items(ApprovalInstance instance) { + return couponItemMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalCouponItem::getInstanceId, instance.getId()) + .orderByAsc(ApprovalCouponItem::getLineNo)); + } + + private ApprovalCouponForm findByInstance(Long instanceId) { + List forms = couponFormMapper.selectList( + new LambdaQueryWrapper() + .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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponFormMapper.java new file mode 100644 index 0000000..ce44bd7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponFormMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponItemMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponItemMapper.java new file mode 100644 index 0000000..161561f --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCouponItemMapper.java @@ -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 { +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCouponIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCouponIT.java new file mode 100644 index 0000000..57f6e05 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCouponIT.java @@ -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):多券种草稿、每类券唯一、六审批节点 + * (部门负责人 → 相关部门 → 分管领导 → 总经理 → 发放人 → 存档)到归档、 + * 明细导出与「整单至少一张附件」。 + * + *

独立租户:相关角色(approval_related_dept 等)按 tenant_id + role_code 解析, + * 用专属租户(10052,测试库中无审批角色成员)避免与其它并行测试类共享角色成员。

+ */ +@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 exportedFiles = new ArrayList<>(); + /** 本次用例创建的用户,用于清理异步操作日志。 */ + private final List 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 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> lines) throws Exception { + return saveDraft(user, applyReason, lines, null); + } + + private long saveDraft(ApprovalTestData.TestUser user, String applyReason, + List> lines, Long id) throws Exception { + Map 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> lines, String expectedMessage) throws Exception { + Map 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 row(String couponType, int quantity, LocalDate validUntil) { + Map 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 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("\"", "\\\"") + "\""; + } +}