action) {
+ return new TransactionTemplate(transactionManager).execute(status -> action.get());
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java
new file mode 100644
index 0000000..955954c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java
@@ -0,0 +1,37 @@
+package com.gxwebsoft.approval.service;
+
+import com.gxwebsoft.approval.entity.ApprovalNoSequence;
+import com.gxwebsoft.approval.mapper.ApprovalNoSequenceMapper;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 审批单号服务:事务内行锁取号,格式 前缀 + yyyyMMdd + - + 至少 4 位流水。
+ *
+ * 调用方必须开启事务,锁会在事务提交/回滚时释放。
+ */
+@Service
+public class ApprovalNoService {
+
+ private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
+
+ @Resource
+ private ApprovalNoSequenceMapper noSequenceMapper;
+
+ /**
+ * 生成当天单号;同租户+前缀+日期每日重置,超过 9999 自然进位。
+ */
+ public String nextNo(String prefix) {
+ noSequenceMapper.insertTodayIfAbsent(prefix);
+ ApprovalNoSequence sequence = noSequenceMapper.selectTodayForUpdate(prefix);
+ if (sequence == null) {
+ throw new BusinessException("单号序列初始化失败");
+ }
+ noSequenceMapper.increment(sequence.getId());
+ long current = sequence.getCurrentSeq() == null ? 1 : sequence.getCurrentSeq() + 1L;
+ return prefix + sequence.getSeqDate().format(DATE_FORMAT) + "-" + String.format("%04d", current);
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java
new file mode 100644
index 0000000..40e7537
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java
@@ -0,0 +1,16 @@
+package com.gxwebsoft.approval.support;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+/**
+ * 节点激活时快照的审批人。
+ */
+@Data
+@AllArgsConstructor
+public class ApprovalApprover {
+
+ private Integer userId;
+
+ private String userName;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java
new file mode 100644
index 0000000..c5a8bc0
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java
@@ -0,0 +1,28 @@
+package com.gxwebsoft.approval.support;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 列表查询数据库行,额外携带当前节点key与流程定义id供服务端翻译。
+ */
+@Data
+public class ApprovalItemRow implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+ private String bizNo;
+ private String docType;
+ private String status;
+ private String title;
+ private LocalDateTime submitTime;
+ private Integer reporterId;
+ private String reporterName;
+ private String reportOrgName;
+ private String acceptOrgName;
+ private LocalDateTime archiveTime;
+ private String currentNodeKey;
+ private Long definitionId;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java
new file mode 100644
index 0000000..968fac5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java
@@ -0,0 +1,24 @@
+package com.gxwebsoft.approval.support;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+/**
+ * 流程定义中的节点描述(nodes_json 数组元素)。
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ApprovalNode {
+
+ /** 节点标识,仅在同一流程内唯一。 */
+ private String key;
+
+ /** 节点类型:APPROVE/COUNTERSIGN/DISPATCH/CONFIRM/ARCHIVE。 */
+ private String type;
+
+ /** 审批人解析器标识,由 ApprovalDirectoryService 负责解析。 */
+ private String resolver;
+
+ /** 节点显示名称。 */
+ private String name;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java
new file mode 100644
index 0000000..6ce3fdc
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java
@@ -0,0 +1,48 @@
+package com.gxwebsoft.approval.support;
+
+import com.gxwebsoft.common.system.entity.User;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 审批域租户上下文。
+ *
+ * gxtyzx_core 的查询使用独立连接(不受 master 事务绑定影响),
+ * 因此需要显式拼 tenant_id;解析顺序与登录用户一致:
+ * 登录用户 → tenantId 请求头 → 兜底 10150。
+ */
+public final class ApprovalTenantContext {
+
+ public static final int DEFAULT_TENANT_ID = 10150;
+
+ private ApprovalTenantContext() {
+ }
+
+ public static Integer currentTenantId() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication != null && authentication.getPrincipal() instanceof User) {
+ Integer tenantId = ((User) authentication.getPrincipal()).getTenantId();
+ if (tenantId != null) {
+ return tenantId;
+ }
+ }
+ RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
+ if (attributes instanceof ServletRequestAttributes) {
+ HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest();
+ String header = request.getHeader("tenantId");
+ if (header != null && !header.isEmpty()) {
+ try {
+ return Integer.valueOf(header);
+ } catch (NumberFormatException ignored) {
+ // 非法租户头按兜底租户处理
+ }
+ }
+ }
+ return DEFAULT_TENANT_ID;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java
new file mode 100644
index 0000000..3034537
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java
@@ -0,0 +1,36 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 详情中的附件。
+ */
+@Data
+@ApiModel(description = "审批附件")
+public class ApprovalAttachmentVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private Integer fileId;
+
+ private String name;
+
+ private String url;
+
+ private Long length;
+
+ private String category;
+
+ private String categoryName;
+
+ private Integer uploaderId;
+
+ private String uploaderName;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java
new file mode 100644
index 0000000..4409f5b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java
@@ -0,0 +1,26 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 当前轮的存档确认状态。
+ */
+@Data
+@ApiModel(description = "存档确认状态")
+public class ApprovalConfirmVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Integer userId;
+
+ private String userName;
+
+ private String status;
+
+ private String statusName;
+
+ private LocalDateTime handleTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java
new file mode 100644
index 0000000..55235d2
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java
@@ -0,0 +1,92 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 审批实例详情。
+ */
+@Data
+@ApiModel(description = "审批实例详情")
+public class ApprovalInstanceVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private String bizNo;
+
+ private String docType;
+
+ private String docTypeName;
+
+ private String flowCode;
+
+ private String status;
+
+ private String statusName;
+
+ private Integer round;
+
+ private Integer reporterId;
+
+ private String reporterName;
+
+ private Integer reportOrgId;
+
+ private String reportOrgName;
+
+ private Integer acceptOrgId;
+
+ private String acceptOrgName;
+
+ private String currentNodeKey;
+
+ private String currentNodeType;
+
+ private String currentNodeName;
+
+ private LocalDateTime submitTime;
+
+ private LocalDateTime approveTime;
+
+ private LocalDateTime archiveTime;
+
+ private Integer archivistId;
+
+ private String archivistName;
+
+ // 业务完成确认单字段
+ private String name;
+
+ private String implementOrg;
+
+ private BigDecimal fee;
+
+ private String serviceContent;
+
+ // 内部协调单字段
+ private String title;
+
+ private String content;
+
+ private String remark;
+
+ private Integer dispatchTargetId;
+
+ private String dispatchTargetName;
+
+ private List attachments;
+
+ private List records;
+
+ private List confirms;
+
+ private ApprovalMyTaskVO myTask;
+
+ private ApprovalPermissionsVO permissions;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java
new file mode 100644
index 0000000..4a58e90
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java
@@ -0,0 +1,46 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 我发起的列表行。
+ */
+@Data
+@ApiModel(description = "审批单据列表行")
+public class ApprovalItemVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private String bizNo;
+
+ private String docType;
+
+ private String docTypeName;
+
+ private String status;
+
+ private String statusName;
+
+ @ApiModelProperty("名称或标题")
+ private String title;
+
+ private LocalDateTime submitTime;
+
+ private String currentNodeName;
+
+ private Integer reporterId;
+
+ private String reporterName;
+
+ private String reportOrgName;
+
+ private String acceptOrgName;
+
+ private LocalDateTime archiveTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java
new file mode 100644
index 0000000..0ca7eef
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java
@@ -0,0 +1,23 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 当前登录人在该实例上的待办任务。
+ */
+@Data
+@ApiModel(description = "当前用户待办任务")
+public class ApprovalMyTaskVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private String nodeKey;
+
+ private String nodeType;
+
+ private String status;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java
new file mode 100644
index 0000000..a24b3a0
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java
@@ -0,0 +1,27 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 启用组织。
+ */
+@Data
+@ApiModel(description = "审批目录组织")
+public class ApprovalOrganizationVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Integer organizationId;
+
+ private Integer parentId;
+
+ private String organizationName;
+
+ private String organizationFullName;
+
+ private String organizationCode;
+
+ private Integer leaderId;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java
new file mode 100644
index 0000000..a37dfc8
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java
@@ -0,0 +1,33 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 详情动作权限,前端据此显示按钮。
+ */
+@Data
+@ApiModel(description = "单据动作权限")
+public class ApprovalPermissionsVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private boolean canEdit;
+
+ private boolean canSubmit;
+
+ private boolean canWithdraw;
+
+ private boolean canApprove;
+
+ private boolean canReject;
+
+ private boolean canConfirm;
+
+ private boolean canArchive;
+
+ private boolean canDispatch;
+
+ private boolean canAssign;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java
new file mode 100644
index 0000000..99cf750
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java
@@ -0,0 +1,38 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 详情时间线记录。
+ */
+@Data
+@ApiModel(description = "审批处理记录")
+public class ApprovalRecordVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ private Long taskId;
+
+ private String nodeKey;
+
+ private String nodeName;
+
+ private Integer operatorId;
+
+ private String operatorName;
+
+ private String action;
+
+ private String actionName;
+
+ private String opinion;
+
+ private Integer round;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java
new file mode 100644
index 0000000..3046425
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java
@@ -0,0 +1,27 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 审批目录用户。
+ */
+@Data
+@ApiModel(description = "审批目录用户")
+public class ApprovalUserVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Integer userId;
+
+ private String username;
+
+ private String nickname;
+
+ private String realName;
+
+ private Integer organizationId;
+
+ private String organizationName;
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java
new file mode 100644
index 0000000..953aa0e
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java
@@ -0,0 +1,519 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+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.delete;
+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;
+
+/**
+ * 业务完成确认单:草稿、附件、提交预检与引擎起步的集成测试。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+@Transactional
+class ApprovalCompletionDraftIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private Integer organizationId;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ organizationId = data.createOrganization("测试部门" + data.uniqueSuffix(), 136);
+ reporter = data.createUser("报送人" + data.uniqueSuffix(), organizationId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void createsDraftWithoutBizNoAndListsAsUnsubmitted() throws Exception {
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "场馆维修");
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isEqualTo(reporter.userId);
+ assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isEqualTo(organizationId);
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.statusName").value("未提交"))
+ .andExpect(jsonPath("$.data.name").value("场馆维修"))
+ .andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true))
+ .andExpect(jsonPath("$.data.bizNo").doesNotExist());
+
+ mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.list[0].statusName").value("未提交"))
+ .andExpect(jsonPath("$.data.list[0].title").value("场馆维修"))
+ .andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist());
+ }
+
+ @Test
+ void validatesCompletionFields() throws Exception {
+ expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空");
+ expectSaveFailure(body(repeat("名", 101), "实施单位", "10.00", "服务内容"), "名称不能超过100字");
+ expectSaveFailure(body("名称", "", "10.00", "服务内容"), "实施单位不能为空");
+ expectSaveFailure(body("名称", "实施单位", "-1", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
+ expectSaveFailure(body("名称", "实施单位", "100000000", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
+ expectSaveFailure(body("名称", "实施单位", "1.234", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
+ expectSaveFailure(body("名称", "实施单位", "10.00", ""), "服务内容不能为空");
+ expectSaveFailure(body("名称", "实施单位", "10.00", repeat("内", 2001)), "服务内容不能超过2000字");
+ }
+
+ @Test
+ void rejectsSubmitWhenReporterHasNoOrganization() throws Exception {
+ ApprovalTestData.TestUser noOrgReporter = data.createUser("无部门" + data.uniqueSuffix(), null);
+ long instanceId = createCompletion(noOrgReporter, null, TENANT, "无部门单据");
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ noOrgReporter, null, TENANT, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("未归属部门")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ }
+
+ @Test
+ void rejectsSubmitWhenDepartmentLeaderRoleMissing() throws Exception {
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "缺负责人");
+ uploadAttachment(instanceId, reporter, organizationId, "image/png");
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ }
+
+ @Test
+ void rejectsSubmitWhenArchivistRoleMissing() throws Exception {
+ grantDepartmentLeader(organizationId, TENANT);
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "缺收文员");
+ uploadAttachment(instanceId, reporter, organizationId, "image/png");
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收文员角色缺配")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ }
+
+ @Test
+ void rejectsSubmitWhenAttachmentMissing() throws Exception {
+ grantDepartmentLeader(organizationId, TENANT);
+ grantArchivist(TENANT);
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "无附件");
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ }
+
+ @Test
+ void submitsAndStartsDepartmentLeaderCountersign() throws Exception {
+ List leaders = grantDepartmentLeader(organizationId, TENANT, 2);
+ grantArchivist(TENANT);
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "完工确认");
+ long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.statusName").value("审批中"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(false))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
+ .andExpect(jsonPath("$.data.attachments.length()").value(1))
+ .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
+ .andExpect(jsonPath("$.data.records[0].actionName").value("提交"));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
+ assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)).isEqualTo(1);
+ assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)).isNotNull();
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("deptLeader");
+ assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("APPROVE");
+ String bizNo = bizNo(instanceId);
+ assertThat(bizNo).matches("WG\\d{8}-\\d{4,}");
+ assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
+
+ List assignees = data.master().queryForList(
+ "SELECT assignee_id FROM approval_task WHERE instance_id = ? AND status = 'PENDING' ORDER BY id",
+ Integer.class, instanceId);
+ assertThat(assignees).containsExactlyInAnyOrder(leaders.get(0).userId, leaders.get(1).userId);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'SUBMIT'",
+ instanceId)).isEqualTo(1);
+
+ // 提交后字段与附件均只读
+ mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body("改名", "实施单位", "10.00", "服务内容", instanceId)))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
+
+ MockMultipartFile file = new MockMultipartFile("file", "locked.png", "image/png", pngBytes());
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "OTHER")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
+
+ // 重复提交保持审批中
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1));
+ assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
+ assertThat(attachmentId).isPositive();
+ }
+
+ @Test
+ void allocatesUniqueDailySequenceByTenant() throws Exception {
+ grantDepartmentLeader(organizationId, TENANT);
+ grantArchivist(TENANT);
+
+ long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号一"));
+ long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号二"));
+
+ String firstNo = bizNo(first);
+ String secondNo = bizNo(second);
+ String date = todayFromDatabase();
+ assertThat(firstNo).matches("WG" + date + "-\\d{4,}");
+ assertThat(secondNo).matches("WG" + date + "-\\d{4,}");
+ int firstSeq = Integer.parseInt(firstNo.substring(firstNo.indexOf('-') + 1));
+ int secondSeq = Integer.parseInt(secondNo.substring(secondNo.indexOf('-') + 1));
+ assertThat(secondSeq).isEqualTo(firstSeq + 1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
+ TENANT, firstNo)).isEqualTo(1);
+
+ // 另一租户使用同一天同一前缀独立取号,互不冲突
+ int otherTenant = 10023;
+ Integer otherOrg = data.createOrganization("外租户部门" + data.uniqueSuffix(), 136, otherTenant);
+ ApprovalTestData.TestUser otherReporter = data.createUser("外租户报送人" + data.uniqueSuffix(), otherOrg, otherTenant);
+ ApprovalTestData.TestRole otherLeaderRole = data.createRole("dept_leader_" + otherOrg, "外租户负责人", otherTenant);
+ ApprovalTestData.TestUser otherLeader = data.createUser("外租户负责人" + data.uniqueSuffix(), otherOrg, otherTenant);
+ data.bindTenant(otherLeader.userId, otherLeaderRole.roleId, otherTenant);
+ ApprovalTestData.TestRole otherArchivistRole = data.createRole("approval_archivist", "外租户收文员", otherTenant);
+ ApprovalTestData.TestUser otherArchivist = data.createUser("外租户收文员" + data.uniqueSuffix(), otherOrg, otherTenant);
+ data.bindTenant(otherArchivist.userId, otherArchivistRole.roleId, otherTenant);
+
+ long otherInstance = submitCompletionFor(createCompletion(otherReporter, otherOrg, otherTenant, "外租户单据"),
+ otherReporter, otherOrg, otherTenant);
+ String otherNo = bizNo(otherInstance);
+ assertThat(otherNo).matches("WG" + date + "-\\d{4,}");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
+ otherTenant, otherNo)).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
+ TENANT, firstNo)).isEqualTo(1);
+ }
+
+ @Test
+ void enforcesAttachmentRules() throws Exception {
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "附件规则");
+
+ MockMultipartFile text = new MockMultipartFile("file", "evil.txt", "text/plain",
+ "x".getBytes(StandardCharsets.UTF_8));
+ mockMvc.perform(multipart("/api/approval/attachment/upload").file(text)
+ .param("instanceId", String.valueOf(instanceId))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
+
+ MockMultipartFile mismatch = new MockMultipartFile("file", "fake.jpg", "application/pdf", pngBytes());
+ mockMvc.perform(multipart("/api/approval/attachment/upload").file(mismatch)
+ .param("instanceId", String.valueOf(instanceId))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
+
+ MockMultipartFile tooLarge = new MockMultipartFile("file", "big.png", "image/png",
+ new byte[20 * 1024 * 1024 + 1]);
+ mockMvc.perform(multipart("/api/approval/attachment/upload").file(tooLarge)
+ .param("instanceId", String.valueOf(instanceId))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("20MB")));
+
+ for (int i = 0; i < 9; i++) {
+ uploadAttachment(instanceId, reporter, organizationId, "image/png");
+ }
+ MockMultipartFile tenth = new MockMultipartFile("file", "tenth.png", "image/png", pngBytes());
+ mockMvc.perform(multipart("/api/approval/attachment/upload").file(tenth)
+ .param("instanceId", String.valueOf(instanceId))
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("9张")));
+ }
+
+ @Test
+ void deletesAttachmentLogicallyAndKeepsFileRecord() throws Exception {
+ long instanceId = createCompletion(reporter, organizationId, TENANT, "删除附件");
+ long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
+ Integer fileId = data.queryMaster("SELECT file_id FROM approval_attachment WHERE id = ?", Integer.class, attachmentId);
+
+ mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, TENANT, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+
+ assertThat(data.queryMaster("SELECT deleted FROM approval_attachment WHERE id = ?", Integer.class, attachmentId))
+ .isEqualTo(1);
+ assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND deleted = 0", fileId))
+ .isEqualTo(1);
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.data.attachments.length()").value(0));
+ }
+
+ @Test
+ void mineListIsOrderedAndScoped() throws Exception {
+ grantDepartmentLeader(organizationId, TENANT);
+ grantArchivist(TENANT);
+ long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "先提交"));
+ long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "后提交"));
+
+ mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(2))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) second))
+ .andExpect(jsonPath("$.data.list[1].id").value((int) first))
+ .andExpect(jsonPath("$.data.list[0].statusName").value("审批中"))
+ .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
+
+ ApprovalTestData.TestUser outsider = data.createUser("路人" + data.uniqueSuffix(), organizationId);
+ mockMvc.perform(authed(get("/api/approval/mine/page"), outsider, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + first), outsider, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
+
+ ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
+ mockMvc.perform(authed(get("/api/approval/instance/" + first), archivist, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value((int) first));
+ }
+
+ @Test
+ void draftStaysInvisibleEvenToAllVisibleArchivist() throws Exception {
+ ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
+ long draft = createCompletion(reporter, organizationId, TENANT, "草稿保密");
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + draft), archivist, organizationId, TENANT, LIST))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
+ }
+
+ // ---------- helpers ----------
+
+ private void expectSaveFailure(String body, String expectedMessage) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
+ }
+
+ private long createCompletion(ApprovalTestData.TestUser user, Integer org, int tenant, String name) throws Exception {
+ String body = body(name, "广西体育中心实施部", "123.45", "完成场馆设施维修与验收");
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), user, org, tenant, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ JsonNode node = objectMapper.readTree(response);
+ long id = node.path("data").asLong();
+ data.trackInstance(id);
+ return id;
+ }
+
+ private long submitCompletion(long instanceId) throws Exception {
+ return submitCompletionFor(instanceId, reporterFor(instanceId), organizationIdFor(instanceId), TENANT);
+ }
+
+ private long submitCompletionFor(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant)
+ throws Exception {
+ uploadAttachment(instanceId, user, org, tenant, "image/png");
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, tenant, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ return instanceId;
+ }
+
+ private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org,
+ String contentType) throws Exception {
+ return uploadAttachment(instanceId, user, org, TENANT, contentType);
+ }
+
+ private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant,
+ String contentType) throws Exception {
+ String filename = "sign." + extensionOf(contentType);
+ MockMultipartFile file = new MockMultipartFile("file", filename, contentType, pngBytes());
+ String response = mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", tenant)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ return objectMapper.readTree(response).path("data").path("id").asLong();
+ }
+
+ private String extensionOf(String contentType) {
+ switch (contentType) {
+ case "image/png":
+ return "png";
+ case "image/jpeg":
+ return "jpg";
+ default:
+ return "bin";
+ }
+ }
+
+ private byte[] pngBytes() {
+ return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, ApprovalTestData.TestUser user,
+ Integer org, int tenant, String... authorities) {
+ return builder.header("tenantId", tenant)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, authorities)));
+ }
+
+ private ApprovalTestData.TestUser reporterFor(long instanceId) {
+ Integer reporterId = data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
+ Integer.class, instanceId);
+ return new ApprovalTestData.TestUser(reporterId, "reporter-" + reporterId, "报送人");
+ }
+
+ private Integer organizationIdFor(long instanceId) {
+ return data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId);
+ }
+
+ private List grantDepartmentLeader(int orgId, int tenant) {
+ return grantDepartmentLeader(orgId, tenant, 1);
+ }
+
+ private List grantDepartmentLeader(int orgId, int tenant, int memberCount) {
+ ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人", tenant);
+ List members = new ArrayList<>();
+ for (int i = 0; i < memberCount; i++) {
+ ApprovalTestData.TestUser member = data.createUser("负责人" + data.uniqueSuffix(), orgId, tenant);
+ data.bindTenant(member.userId, role.roleId, tenant);
+ members.add(member);
+ }
+ return members;
+ }
+
+ private ApprovalTestData.TestUser grantArchivist(int tenant) {
+ ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员", tenant);
+ ApprovalTestData.TestUser member = data.createUser("收文员" + data.uniqueSuffix(), organizationId, tenant);
+ data.bindTenant(member.userId, role.roleId, tenant);
+ return member;
+ }
+
+ private String body(String name, String implementOrg, String fee, String serviceContent) {
+ return body(name, implementOrg, fee, serviceContent, null);
+ }
+
+ private String body(String name, String implementOrg, String fee, String serviceContent, Long id) {
+ StringBuilder sb = new StringBuilder("{");
+ if (id != null) {
+ sb.append("\"id\":").append(id).append(',');
+ }
+ sb.append("\"name\":").append(quote(name)).append(',')
+ .append("\"implementOrg\":").append(quote(implementOrg)).append(',')
+ .append("\"fee\":").append(fee).append(',')
+ .append("\"serviceContent\":").append(quote(serviceContent))
+ .append('}');
+ return sb.toString();
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+
+ private String repeat(String value, int count) {
+ return value.repeat(count);
+ }
+
+ private String instanceStatus(long instanceId) {
+ return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String bizNo(long instanceId) {
+ return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String todayFromDatabase() {
+ return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/support/ApprovalTestData.java b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java
new file mode 100644
index 0000000..913b843
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java
@@ -0,0 +1,203 @@
+package com.gxwebsoft.support;
+
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import javax.sql.DataSource;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。
+ *
+ * sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate,
+ * 避免测试事务与动态数据源切换互相影响。
+ */
+public class ApprovalTestData {
+
+ private static final AtomicInteger SEQ = new AtomicInteger();
+
+ private final JdbcTemplate master;
+ private final JdbcTemplate server;
+
+ private final List userIds = new ArrayList<>();
+ private final List roleIds = new ArrayList<>();
+ private final List organizationIds = new ArrayList<>();
+ private final List instanceIds = new ArrayList<>();
+
+ public ApprovalTestData(DataSource dataSource) {
+ this.master = new JdbcTemplate(dataSource);
+ DataSource serverDataSource = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
+ this.server = new JdbcTemplate(serverDataSource);
+ }
+
+ /** 唯一后缀,避免共享库中的账号/角色/组织重名。 */
+ public String uniqueSuffix() {
+ return System.nanoTime() + "-" + SEQ.incrementAndGet();
+ }
+
+ public TestUser createUser(String realName, Integer organizationId, int tenantId) {
+ String suffix = uniqueSuffix();
+ String username = "it_" + suffix;
+ Integer id = insert(server,
+ "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
+ + "VALUES (?, 'it', ?, ?, ?, 0, 0, ?)",
+ username, realName, realName, organizationId, tenantId);
+ userIds.add(id);
+ return new TestUser(id, username, realName);
+ }
+
+ public TestUser createUser(String realName, Integer organizationId) {
+ return createUser(realName, organizationId, TestAuthentication.TENANT_ID);
+ }
+
+ public TestRole createRole(String roleCode, String roleName, int tenantId) {
+ Integer id = insert(server,
+ "INSERT INTO sys_role (role_name, role_code, sort_number, deleted, tenant_id) VALUES (?, ?, 1, 0, ?)",
+ roleName, roleCode, tenantId);
+ roleIds.add(id);
+ return new TestRole(id, roleCode);
+ }
+
+ public TestRole createRole(String roleCode, String roleName) {
+ return createRole(roleCode, roleName, TestAuthentication.TENANT_ID);
+ }
+
+ public void bind(int userId, int roleId) {
+ insert(server,
+ "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
+ userId, roleId, TestAuthentication.TENANT_ID);
+ }
+
+ public void bindTenant(int userId, int roleId, int tenantId) {
+ insert(server,
+ "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
+ userId, roleId, tenantId);
+ }
+
+ public Integer createOrganization(String name, int parentId, int tenantId) {
+ Integer id = insert(server,
+ "INSERT INTO sys_organization (organization_name, parent_id, organization_type, deleted, tenant_id) "
+ + "VALUES (?, ?, 'dept', 0, ?)",
+ name, parentId, tenantId);
+ organizationIds.add(id);
+ return id;
+ }
+
+ public Integer createOrganization(String name, int parentId) {
+ return createOrganization(name, parentId, TestAuthentication.TENANT_ID);
+ }
+
+ public void disableOrganization(int organizationId) {
+ server.update("UPDATE sys_organization SET deleted = 1 WHERE organization_id = ?", organizationId);
+ }
+
+ public void setUserOrganization(int userId, Integer organizationId) {
+ server.update("UPDATE sys_user SET organization_id = ? WHERE user_id = ?", organizationId, userId);
+ }
+
+ public Integer countServer(String sql, Object... args) {
+ return server.queryForObject(sql, Integer.class, args);
+ }
+
+ public Integer countMaster(String sql, Object... args) {
+ return master.queryForObject(sql, Integer.class, args);
+ }
+
+ public T queryMaster(String sql, Class type, Object... args) {
+ return master.queryForObject(sql, type, args);
+ }
+
+ public JdbcTemplate master() {
+ return master;
+ }
+
+ public JdbcTemplate server() {
+ return server;
+ }
+
+ /** 记录实例 id,便于非事务用例结束后清理。 */
+ public void trackInstance(Long instanceId) {
+ if (instanceId != null) {
+ instanceIds.add(instanceId);
+ }
+ }
+
+ /** 清理本次用例创建的核心库数据与审批数据。 */
+ public void cleanup() {
+ try {
+ for (Long instanceId : instanceIds) {
+ deleteInstance(instanceId);
+ }
+ instanceIds.clear();
+ for (Integer userId : userIds) {
+ server.update("DELETE FROM sys_user_role WHERE user_id = ?", userId);
+ server.update("DELETE FROM sys_file_record WHERE create_user_id = ?", userId);
+ server.update("DELETE FROM sys_user WHERE user_id = ?", userId);
+ }
+ userIds.clear();
+ for (Integer roleId : roleIds) {
+ server.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId);
+ server.update("DELETE FROM sys_role WHERE role_id = ?", roleId);
+ }
+ roleIds.clear();
+ for (Integer organizationId : organizationIds) {
+ server.update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId);
+ }
+ organizationIds.clear();
+ } catch (Exception ignored) {
+ // 清理失败不阻断测试报告;残留行均由唯一后缀标识。
+ }
+ }
+
+ private void deleteInstance(Long instanceId) {
+ master.update("DELETE FROM approval_attachment WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_record WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_task WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_completion_form WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_coordination_form WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_instance WHERE id = ?", instanceId);
+ }
+
+ private Integer insert(JdbcTemplate jdbcTemplate, String sql, Object... args) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbcTemplate.update(connection -> {
+ PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
+ for (int i = 0; i < args.length; i++) {
+ statement.setObject(i + 1, args[i]);
+ }
+ return statement;
+ }, keyHolder);
+ Number key = keyHolder.getKey();
+ if (key == null) {
+ throw new IllegalStateException("未取得自增主键: " + sql);
+ }
+ return key.intValue();
+ }
+
+ public static class TestUser {
+ public final int userId;
+ public final String username;
+ public final String realName;
+
+ public TestUser(int userId, String username, String realName) {
+ this.userId = userId;
+ this.username = username;
+ this.realName = realName;
+ }
+ }
+
+ public static class TestRole {
+ public final int roleId;
+ public final String roleCode;
+
+ TestRole(int roleId, String roleCode) {
+ this.roleId = roleId;
+ this.roleCode = roleCode;
+ }
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/support/TestAuthentication.java b/src/test/java/com/gxwebsoft/support/TestAuthentication.java
index 21a2051..cb7f05f 100644
--- a/src/test/java/com/gxwebsoft/support/TestAuthentication.java
+++ b/src/test/java/com/gxwebsoft/support/TestAuthentication.java
@@ -23,6 +23,20 @@ public final class TestAuthentication {
}
public static Authentication admin(String... authorities) {
+ return user(USER_ID, "admin", null, TENANT_ID, authorities);
+ }
+
+ /**
+ * 构造指定用户/组织/租户的登录态,供审批流集成测试使用。
+ *
+ * @param userId 用户id
+ * @param username 账号(同时作为姓名)
+ * @param organizationId 所属部门id,可为 null
+ * @param tenantId 租户id
+ * @param authorities 权限串
+ */
+ public static Authentication user(int userId, String username, Integer organizationId,
+ int tenantId, String... authorities) {
List