feat(approval): 业务完成确认单草稿、附件、提交与引擎起步 (#19)
This commit is contained in:
@@ -39,6 +39,11 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- spring-boot-web -->
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
-- 一期线下审批公共表与业务表(可重复执行)
|
||||
-- 目标库:db_gxtyzx(master)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_flow_definition (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
flow_code VARCHAR(40) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
nodes_json JSON NOT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_flow_ver (tenant_id, flow_code, version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_instance (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
biz_no VARCHAR(32) DEFAULT NULL,
|
||||
flow_code VARCHAR(40) NOT NULL,
|
||||
definition_id BIGINT NOT NULL,
|
||||
definition_version INT NOT NULL,
|
||||
doc_type VARCHAR(40) NOT NULL,
|
||||
biz_id BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
round INT NOT NULL DEFAULT 0,
|
||||
reporter_id INT NOT NULL,
|
||||
reporter_name VARCHAR(100) DEFAULT NULL,
|
||||
report_org_id INT DEFAULT NULL,
|
||||
report_org_name VARCHAR(200) DEFAULT NULL,
|
||||
accept_org_id INT DEFAULT NULL,
|
||||
accept_org_name VARCHAR(200) DEFAULT NULL,
|
||||
current_node_key VARCHAR(40) DEFAULT NULL,
|
||||
current_node_type VARCHAR(20) DEFAULT NULL,
|
||||
dispatch_target_id INT DEFAULT NULL,
|
||||
dispatch_target_name VARCHAR(100) DEFAULT NULL,
|
||||
submit_time DATETIME DEFAULT NULL,
|
||||
approve_time DATETIME DEFAULT NULL,
|
||||
archive_time DATETIME DEFAULT NULL,
|
||||
archivist_id INT DEFAULT NULL,
|
||||
archivist_name VARCHAR(100) DEFAULT NULL,
|
||||
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_biz_no (tenant_id, biz_no),
|
||||
KEY idx_status_submit (tenant_id, status, submit_time),
|
||||
KEY idx_reporter (tenant_id, reporter_id),
|
||||
KEY idx_report_org (tenant_id, report_org_id),
|
||||
KEY idx_accept_org (tenant_id, accept_org_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_task (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
node_key VARCHAR(40) NOT NULL,
|
||||
node_type VARCHAR(20) NOT NULL,
|
||||
round INT NOT NULL,
|
||||
task_type VARCHAR(20) NOT NULL,
|
||||
assignee_id INT NOT NULL,
|
||||
assignee_name VARCHAR(100) DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
handle_time DATETIME DEFAULT NULL,
|
||||
opinion VARCHAR(500) DEFAULT NULL,
|
||||
dispatch_target_id INT DEFAULT NULL,
|
||||
dispatch_target_name VARCHAR(100) DEFAULT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_instance (instance_id),
|
||||
KEY idx_assignee_status (tenant_id, assignee_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_record (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
task_id BIGINT DEFAULT NULL,
|
||||
node_key VARCHAR(40) DEFAULT NULL,
|
||||
operator_id INT NOT NULL,
|
||||
operator_name VARCHAR(100) DEFAULT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
opinion VARCHAR(500) DEFAULT NULL,
|
||||
round INT NOT NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_instance_time (instance_id, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批处理记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_attachment (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
file_id INT NOT NULL,
|
||||
category VARCHAR(20) NOT NULL,
|
||||
uploader_id INT NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_instance (instance_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批附件';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_no_sequence (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
prefix VARCHAR(8) NOT NULL,
|
||||
seq_date DATE NOT NULL,
|
||||
current_seq INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_seq (tenant_id, prefix, seq_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批单号序列';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_completion_form (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
implement_org VARCHAR(100) NOT NULL,
|
||||
fee DECIMAL(12,2) NOT NULL,
|
||||
service_content VARCHAR(2000) NOT NULL,
|
||||
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_coordination_form (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id INT NOT NULL DEFAULT 10150,
|
||||
instance_id BIGINT NOT NULL,
|
||||
accept_org_id INT NOT NULL,
|
||||
accept_org_name VARCHAR(200) NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
content VARCHAR(2000) NOT NULL,
|
||||
remark VARCHAR(500) DEFAULT NULL,
|
||||
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='协调单';
|
||||
@@ -0,0 +1,77 @@
|
||||
-- 一期线下审批菜单与权限种子(可重复执行)
|
||||
-- 目标库:gxtyzx_core(server)
|
||||
|
||||
-- 顶级菜单:审批管理
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT 0, '审批管理', '/approval', '', 18, 'approval:instance:list', 'AuditOutlined', 0, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0
|
||||
);
|
||||
|
||||
SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
|
||||
|
||||
-- 子菜单:待办审批
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @parent, '待办审批', '/approval/todo', '/approval/todo', 1, 'approval:instance:list', '', 0, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/todo' AND menu_type = 0 AND deleted = 0
|
||||
);
|
||||
|
||||
-- 子菜单:单据查询
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @parent, '单据查询', '/approval/instance', '/approval/instance', 2, 'approval:instance:list', '', 0, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0
|
||||
);
|
||||
|
||||
SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
|
||||
|
||||
-- 按钮权限(挂在单据查询下)
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @instanceMenu, '提交单据', '', '', 1, 'approval:instance:submit', '', 1, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:submit' AND menu_type = 1 AND deleted = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @instanceMenu, '审批通过', '', '', 2, 'approval:instance:approve', '', 1, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:approve' AND menu_type = 1 AND deleted = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @instanceMenu, '归档', '', '', 3, 'approval:instance:archive', '', 1, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:archive' AND menu_type = 1 AND deleted = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @instanceMenu, '改派', '', '', 4, 'approval:instance:assign', '', 1, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:assign' AND menu_type = 1 AND deleted = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||
SELECT @instanceMenu, '导出', '', '', 5, 'approval:instance:export', '', 1, 10150, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:export' AND menu_type = 1 AND deleted = 0
|
||||
);
|
||||
|
||||
-- 授权 superAdmin(role_id = 586, tenant 10150)
|
||||
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
|
||||
SELECT 586, m.menu_id, 10150
|
||||
FROM sys_menu m
|
||||
WHERE m.tenant_id = 10150
|
||||
AND m.deleted = 0
|
||||
AND (m.path IN ('/approval', '/approval/todo', '/approval/instance') OR m.authority LIKE 'approval:instance:%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
|
||||
);
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.gxwebsoft.approval.constant;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 审批域常量:状态、节点、动作、单据类型、单号前缀与附件规则。
|
||||
*/
|
||||
public final class ApprovalConstants {
|
||||
|
||||
private ApprovalConstants() {
|
||||
}
|
||||
|
||||
// 实例状态
|
||||
public static final String STATUS_DRAFT = "DRAFT";
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
public static final String STATUS_WITHDRAWN = "WITHDRAWN";
|
||||
public static final String STATUS_APPROVED = "APPROVED";
|
||||
public static final String STATUS_ARCHIVED = "ARCHIVED";
|
||||
|
||||
/** 发起人可编辑、可提交的状态。 */
|
||||
public static final Set<String> EDITABLE_STATUSES =
|
||||
Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList(
|
||||
STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN)));
|
||||
|
||||
// 任务状态
|
||||
public static final String TASK_PENDING = "PENDING";
|
||||
public static final String TASK_DONE = "DONE";
|
||||
public static final String TASK_CANCELLED = "CANCELLED";
|
||||
|
||||
// 节点类型 / 任务类型
|
||||
public static final String NODE_APPROVE = "APPROVE";
|
||||
public static final String NODE_COUNTERSIGN = "COUNTERSIGN";
|
||||
public static final String NODE_DISPATCH = "DISPATCH";
|
||||
public static final String NODE_CONFIRM = "CONFIRM";
|
||||
public static final String NODE_ARCHIVE = "ARCHIVE";
|
||||
|
||||
// 动作
|
||||
public static final String ACTION_SUBMIT = "SUBMIT";
|
||||
public static final String ACTION_RESUBMIT = "RESUBMIT";
|
||||
public static final String ACTION_APPROVE = "APPROVE";
|
||||
public static final String ACTION_REJECT = "REJECT";
|
||||
public static final String ACTION_WITHDRAW = "WITHDRAW";
|
||||
public static final String ACTION_DISPATCH = "DISPATCH";
|
||||
public static final String ACTION_CONFIRM = "CONFIRM";
|
||||
public static final String ACTION_ARCHIVE = "ARCHIVE";
|
||||
public static final String ACTION_ASSIGN = "ASSIGN";
|
||||
|
||||
// 单据类型 / 流程编码
|
||||
public static final String DOC_COMPLETION = "completion";
|
||||
public static final String DOC_COORDINATION = "coordination";
|
||||
public static final String FLOW_COMPLETION = "completion";
|
||||
public static final String FLOW_COORDINATION_INTERNAL = "coordination_internal";
|
||||
|
||||
// 单号前缀
|
||||
public static final String NO_PREFIX_COMPLETION = "WG";
|
||||
public static final String NO_PREFIX_COORDINATION = "XT";
|
||||
|
||||
// 附件分类
|
||||
public static final String CATEGORY_SIGNED = "SIGNED";
|
||||
public static final String CATEGORY_OTHER = "OTHER";
|
||||
|
||||
// 角色编码
|
||||
public static final String ROLE_ARCHIVIST = "approval_archivist";
|
||||
public static final String ROLE_SUPER_ADMIN = "superAdmin";
|
||||
|
||||
// 附件规则
|
||||
public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
|
||||
public static final int MAX_ATTACHMENT_COUNT = 9;
|
||||
public static final List<String> ATTACHMENT_EXTENSIONS =
|
||||
Collections.unmodifiableList(Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx"));
|
||||
|
||||
/** 扩展名与允许的 content-type 对应关系(服务端双校验)。 */
|
||||
public static final Map<String, List<String>> ATTACHMENT_CONTENT_TYPES;
|
||||
|
||||
static {
|
||||
Map<String, List<String>> types = new LinkedHashMap<>();
|
||||
types.put("jpg", Arrays.asList("image/jpeg", "image/jpg"));
|
||||
types.put("jpeg", Arrays.asList("image/jpeg", "image/jpg"));
|
||||
types.put("png", Collections.singletonList("image/png"));
|
||||
types.put("pdf", Collections.singletonList("application/pdf"));
|
||||
types.put("doc", Collections.singletonList("application/msword"));
|
||||
types.put("docx", Collections.singletonList(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
|
||||
types.put("xls", Collections.singletonList("application/vnd.ms-excel"));
|
||||
types.put("xlsx", Collections.singletonList(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
ATTACHMENT_CONTENT_TYPES = Collections.unmodifiableMap(types);
|
||||
}
|
||||
|
||||
private static final Map<String, String> STATUS_NAMES;
|
||||
private static final Map<String, String> DOC_TYPE_NAMES;
|
||||
private static final Map<String, String> ACTION_NAMES;
|
||||
private static final Map<String, String> CATEGORY_NAMES;
|
||||
|
||||
static {
|
||||
Map<String, String> status = new LinkedHashMap<>();
|
||||
status.put(STATUS_DRAFT, "未提交");
|
||||
status.put(STATUS_PENDING, "审批中");
|
||||
status.put(STATUS_REJECTED, "已驳回");
|
||||
status.put(STATUS_WITHDRAWN, "已撤回");
|
||||
status.put(STATUS_APPROVED, "审批完成");
|
||||
status.put(STATUS_ARCHIVED, "已归档");
|
||||
STATUS_NAMES = Collections.unmodifiableMap(status);
|
||||
|
||||
Map<String, String> docType = new LinkedHashMap<>();
|
||||
docType.put(DOC_COMPLETION, "业务完成确认单");
|
||||
docType.put(DOC_COORDINATION, "内部协调单");
|
||||
DOC_TYPE_NAMES = Collections.unmodifiableMap(docType);
|
||||
|
||||
Map<String, String> action = new LinkedHashMap<>();
|
||||
action.put(ACTION_SUBMIT, "提交");
|
||||
action.put(ACTION_RESUBMIT, "重新提交");
|
||||
action.put(ACTION_APPROVE, "通过");
|
||||
action.put(ACTION_REJECT, "驳回");
|
||||
action.put(ACTION_WITHDRAW, "撤回");
|
||||
action.put(ACTION_DISPATCH, "派单");
|
||||
action.put(ACTION_CONFIRM, "存档确认");
|
||||
action.put(ACTION_ARCHIVE, "归档");
|
||||
action.put(ACTION_ASSIGN, "改派");
|
||||
ACTION_NAMES = Collections.unmodifiableMap(action);
|
||||
|
||||
Map<String, String> category = new LinkedHashMap<>();
|
||||
category.put(CATEGORY_SIGNED, "签字件");
|
||||
category.put(CATEGORY_OTHER, "其他");
|
||||
CATEGORY_NAMES = Collections.unmodifiableMap(category);
|
||||
}
|
||||
|
||||
/** 业务完成确认单流程定义(version=1)。 */
|
||||
public static final String COMPLETION_NODES_JSON = "["
|
||||
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
|
||||
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
|
||||
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
|
||||
+ "]";
|
||||
|
||||
/** 内部协调单流程定义(version=1,表单与派单由后续迭代启用)。 */
|
||||
public static final String COORDINATION_NODES_JSON = "["
|
||||
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
|
||||
+ "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"},"
|
||||
+ "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgMembers\",\"name\":\"派单\"},"
|
||||
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"},"
|
||||
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
|
||||
+ "]";
|
||||
|
||||
public static String statusName(String status) {
|
||||
return STATUS_NAMES.getOrDefault(status, status);
|
||||
}
|
||||
|
||||
public static String docTypeName(String docType) {
|
||||
return DOC_TYPE_NAMES.getOrDefault(docType, docType);
|
||||
}
|
||||
|
||||
public static String actionName(String action) {
|
||||
return ACTION_NAMES.getOrDefault(action, action);
|
||||
}
|
||||
|
||||
public static String categoryName(String category) {
|
||||
return CATEGORY_NAMES.getOrDefault(category, category);
|
||||
}
|
||||
|
||||
public static String departmentLeaderRoleCode(Integer organizationId) {
|
||||
return organizationId == null ? null : "dept_leader_" + organizationId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.service.ApprovalAttachmentService;
|
||||
import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
* 审批附件接口:走审批域端点,不要求文件管理权限。
|
||||
*/
|
||||
@Api(tags = "审批附件")
|
||||
@RestController
|
||||
@RequestMapping("/api/approval/attachment")
|
||||
public class ApprovalAttachmentController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ApprovalAttachmentService attachmentService;
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||
@ApiOperation("上传附件")
|
||||
@PostMapping("/upload")
|
||||
public ApiResult<ApprovalAttachmentVO> upload(@RequestParam("instanceId") Long instanceId,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "category", required = false) String category) {
|
||||
return success(attachmentService.upload(instanceId, file, category, getLoginUser()));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||
@ApiOperation("逻辑删除附件")
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResult<?> delete(@PathVariable("id") Long id) {
|
||||
attachmentService.delete(id, getLoginUser());
|
||||
return success("删除成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.service.ApprovalDirectoryService;
|
||||
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批目录接口:组织与启用用户。
|
||||
*/
|
||||
@Api(tags = "审批目录")
|
||||
@RestController
|
||||
@RequestMapping("/api/approval/directory")
|
||||
public class ApprovalDirectoryController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ApprovalDirectoryService directoryService;
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||
@ApiOperation("启用组织")
|
||||
@GetMapping("/organizations")
|
||||
public ApiResult<List<ApprovalOrganizationVO>> organizations() {
|
||||
return success(directoryService.listOrganizations());
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||
@ApiOperation("启用用户搜索")
|
||||
@GetMapping("/users")
|
||||
public ApiResult<List<ApprovalUserVO>> users(
|
||||
@RequestParam(value = "keywords", required = false) String keywords,
|
||||
@RequestParam(value = "organizationId", required = false) Integer organizationId) {
|
||||
return success(directoryService.searchUsers(keywords, organizationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
* 审批实例接口(员工端与 PC 共用)。
|
||||
*/
|
||||
@Api(tags = "审批实例")
|
||||
@RestController
|
||||
@RequestMapping("/api/approval")
|
||||
public class ApprovalInstanceController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ApprovalInstanceService instanceService;
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||
@ApiOperation("新建或保存业务完成确认单草稿")
|
||||
@PostMapping("/instance/completion")
|
||||
public ApiResult<Long> saveCompletion(@RequestBody ApprovalCompletionParam param) {
|
||||
User user = getLoginUser();
|
||||
return success("保存成功", instanceService.saveCompletion(param, user));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||
@ApiOperation("提交或重提")
|
||||
@PostMapping("/instance/{id}/submit")
|
||||
public ApiResult<ApprovalInstanceVO> submit(@PathVariable("id") Long id) {
|
||||
User user = getLoginUser();
|
||||
String warning = instanceService.submit(id, user);
|
||||
return success(warning == null ? "提交成功" : warning, instanceService.detail(id, user));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||
@ApiOperation("单据详情")
|
||||
@GetMapping("/instance/{id}")
|
||||
public ApiResult<ApprovalInstanceVO> detail(@PathVariable("id") Long id) {
|
||||
return success(instanceService.detail(id, getLoginUser()));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||
@ApiOperation("我发起的")
|
||||
@GetMapping("/mine/page")
|
||||
public ApiResult<PageResult<ApprovalItemVO>> mine(ApprovalMineParam param) {
|
||||
return success(instanceService.mine(param, getLoginUser()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审批附件绑定(逻辑删除)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批附件")
|
||||
@TableName("approval_attachment")
|
||||
public class ApprovalAttachment implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private Integer fileId;
|
||||
|
||||
@ApiModelProperty("分类:SIGNED 签字件 / OTHER 其他")
|
||||
private String category;
|
||||
|
||||
private Integer uploaderId;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
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.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 业务完成确认单。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "业务完成确认单")
|
||||
@TableName("approval_completion_form")
|
||||
public class ApprovalCompletionForm implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String implementOrg;
|
||||
|
||||
private BigDecimal fee;
|
||||
|
||||
private String serviceContent;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 内部协调单(一期仅内部流程)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "内部协调单")
|
||||
@TableName("approval_coordination_form")
|
||||
public class ApprovalCoordinationForm implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private Integer acceptOrgId;
|
||||
|
||||
private String acceptOrgName;
|
||||
|
||||
private String title;
|
||||
|
||||
private String content;
|
||||
|
||||
private String remark;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审批流程定义版本。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批流程定义版本")
|
||||
@TableName("approval_flow_definition")
|
||||
public class ApprovalFlowDefinition implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@ApiModelProperty("租户id")
|
||||
private Integer tenantId;
|
||||
|
||||
@ApiModelProperty("流程编码")
|
||||
private String flowCode;
|
||||
|
||||
@ApiModelProperty("流程版本")
|
||||
private Integer version;
|
||||
|
||||
@ApiModelProperty("流程名称")
|
||||
private String name;
|
||||
|
||||
@ApiModelProperty("节点JSON")
|
||||
private String nodesJson;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审批实例。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批实例")
|
||||
@TableName("approval_instance")
|
||||
public class ApprovalInstance implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
@ApiModelProperty("单号,草稿为空")
|
||||
private String bizNo;
|
||||
|
||||
private String flowCode;
|
||||
|
||||
private Long definitionId;
|
||||
|
||||
private Integer definitionVersion;
|
||||
|
||||
@ApiModelProperty("单据类型:completion/coordination")
|
||||
private String docType;
|
||||
|
||||
@ApiModelProperty("业务表主键")
|
||||
private Long bizId;
|
||||
|
||||
private String status;
|
||||
|
||||
@ApiModelProperty("重提轮次")
|
||||
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 Integer dispatchTargetId;
|
||||
|
||||
private String dispatchTargetName;
|
||||
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
private LocalDateTime approveTime;
|
||||
|
||||
private LocalDateTime archiveTime;
|
||||
|
||||
private Integer archivistId;
|
||||
|
||||
private String archivistName;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 审批单号序列(租户 + 前缀 + 日期)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批单号序列")
|
||||
@TableName("approval_no_sequence")
|
||||
public class ApprovalNoSequence implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private String prefix;
|
||||
|
||||
private LocalDate seqDate;
|
||||
|
||||
private Integer currentSeq;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 审批处理记录(只增不改)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批处理记录")
|
||||
@TableName("approval_record")
|
||||
public class ApprovalRecord implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String nodeKey;
|
||||
|
||||
private Integer operatorId;
|
||||
|
||||
private String operatorName;
|
||||
|
||||
private String action;
|
||||
|
||||
private String opinion;
|
||||
|
||||
private Integer round;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.gxwebsoft.approval.entity;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 审批任务。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "审批任务")
|
||||
@TableName("approval_task")
|
||||
public class ApprovalTask implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Integer tenantId;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private String nodeKey;
|
||||
|
||||
private String nodeType;
|
||||
|
||||
private Integer round;
|
||||
|
||||
private String taskType;
|
||||
|
||||
private Integer assigneeId;
|
||||
|
||||
private String assigneeName;
|
||||
|
||||
private String status;
|
||||
|
||||
private LocalDateTime handleTime;
|
||||
|
||||
private String opinion;
|
||||
|
||||
private Integer dispatchTargetId;
|
||||
|
||||
private String dispatchTargetName;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalAttachment;
|
||||
|
||||
/**
|
||||
* 审批附件Mapper。
|
||||
*/
|
||||
public interface ApprovalAttachmentMapper extends BaseMapper<ApprovalAttachment> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
|
||||
|
||||
/**
|
||||
* 业务完成确认单Mapper。
|
||||
*/
|
||||
public interface ApprovalCompletionFormMapper extends BaseMapper<ApprovalCompletionForm> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalCoordinationForm;
|
||||
|
||||
/**
|
||||
* 内部协调单Mapper。
|
||||
*/
|
||||
public interface ApprovalCoordinationFormMapper extends BaseMapper<ApprovalCoordinationForm> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
|
||||
|
||||
/**
|
||||
* 审批流程定义Mapper。
|
||||
*/
|
||||
public interface ApprovalFlowDefinitionMapper extends BaseMapper<ApprovalFlowDefinition> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批实例Mapper。
|
||||
*/
|
||||
public interface ApprovalInstanceMapper extends BaseMapper<ApprovalInstance> {
|
||||
|
||||
/**
|
||||
* 对实例行加锁(必须在事务内调用)。
|
||||
*/
|
||||
ApprovalInstance selectByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 判断当前用户是否可查看实例。
|
||||
*/
|
||||
int countVisible(@Param("id") Long id,
|
||||
@Param("userId") Integer userId,
|
||||
@Param("allVisible") Integer allVisible);
|
||||
|
||||
/**
|
||||
* 我发起的列表。
|
||||
*/
|
||||
List<ApprovalItemRow> selectMinePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||
@Param("param") ApprovalMineParam param,
|
||||
@Param("userId") Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.gxwebsoft.approval.entity.ApprovalNoSequence;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 审批单号序列Mapper:事务内行锁取号。
|
||||
*/
|
||||
public interface ApprovalNoSequenceMapper {
|
||||
|
||||
/**
|
||||
* 确保当天序列行存在;唯一键冲突时静默忽略。
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO approval_no_sequence (prefix, seq_date, current_seq) "
|
||||
+ "VALUES (#{prefix}, CURDATE(), 0)")
|
||||
int insertTodayIfAbsent(@Param("prefix") String prefix);
|
||||
|
||||
/**
|
||||
* 锁定当天序列行。
|
||||
*/
|
||||
@Select("SELECT id, prefix, seq_date, current_seq FROM approval_no_sequence "
|
||||
+ "WHERE prefix = #{prefix} AND seq_date = CURDATE() FOR UPDATE")
|
||||
ApprovalNoSequence selectTodayForUpdate(@Param("prefix") String prefix);
|
||||
|
||||
/**
|
||||
* 序号自增。
|
||||
*/
|
||||
@Update("UPDATE approval_no_sequence SET current_seq = current_seq + 1 WHERE id = #{id}")
|
||||
int increment(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalRecord;
|
||||
|
||||
/**
|
||||
* 审批处理记录Mapper。
|
||||
*/
|
||||
public interface ApprovalRecordMapper extends BaseMapper<ApprovalRecord> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||
|
||||
/**
|
||||
* 审批任务Mapper。
|
||||
*/
|
||||
public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalInstanceMapper">
|
||||
|
||||
<select id="selectByIdForUpdate" resultType="com.gxwebsoft.approval.entity.ApprovalInstance">
|
||||
SELECT *
|
||||
FROM approval_instance
|
||||
WHERE id = #{id}
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
<select id="countVisible" resultType="int">
|
||||
SELECT COUNT(1)
|
||||
FROM approval_instance i
|
||||
WHERE i.id = #{id}
|
||||
AND (
|
||||
i.reporter_id = #{userId}
|
||||
OR (i.status != 'DRAFT' AND (
|
||||
#{allVisible} = 1
|
||||
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
|
||||
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
|
||||
OR i.dispatch_target_id = #{userId}
|
||||
))
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectMinePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||
SELECT i.id,
|
||||
i.biz_no,
|
||||
i.doc_type,
|
||||
i.status,
|
||||
i.reporter_id,
|
||||
i.reporter_name,
|
||||
i.report_org_name,
|
||||
i.accept_org_name,
|
||||
i.archive_time,
|
||||
i.current_node_key,
|
||||
i.definition_id,
|
||||
i.submit_time,
|
||||
COALESCE(f.name, co.title) AS title
|
||||
FROM approval_instance i
|
||||
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||
WHERE i.reporter_id = #{userId}
|
||||
<if test="param.status != null and param.status != ''">
|
||||
AND i.status = #{param.status}
|
||||
</if>
|
||||
<if test="param.docType != null and param.docType != ''">
|
||||
AND i.doc_type = #{param.docType}
|
||||
</if>
|
||||
<if test="param.keywords != null and param.keywords != ''">
|
||||
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||
OR co.title LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||
</if>
|
||||
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.gxwebsoft.approval.param;
|
||||
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 业务完成确认单草稿参数;id 为审批实例id,更新草稿时传入。
|
||||
*/
|
||||
@Data
|
||||
public class ApprovalCompletionParam implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("审批实例id(更新草稿时传入)")
|
||||
private Long id;
|
||||
|
||||
@ApiModelProperty("名称")
|
||||
private String name;
|
||||
|
||||
@ApiModelProperty("实施单位")
|
||||
private String implementOrg;
|
||||
|
||||
@ApiModelProperty("费用(元)")
|
||||
private BigDecimal fee;
|
||||
|
||||
@ApiModelProperty("服务内容")
|
||||
private String serviceContent;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.gxwebsoft.approval.param;
|
||||
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 我发起的查询参数。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ApprovalMineParam extends BaseParam {
|
||||
|
||||
@ApiModelProperty("实例状态")
|
||||
private String status;
|
||||
|
||||
@ApiModelProperty("单据类型")
|
||||
private String docType;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.entity.ApprovalAttachment;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||
import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
||||
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 审批附件服务:上传、逻辑删除、详情拼装。
|
||||
*
|
||||
* <p>文件落盘与 sys_file_record 复用既有文件基础设施,附件行写 master 库。
|
||||
* 上传不开启跨库事务:先落文件记录再绑定附件,失败时最多留下孤儿文件(允许)。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalAttachmentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApprovalAttachmentService.class);
|
||||
|
||||
@Resource
|
||||
private ApprovalInstanceMapper instanceMapper;
|
||||
@Resource
|
||||
private ApprovalAttachmentMapper attachmentMapper;
|
||||
@Resource
|
||||
private ApprovalFileService fileService;
|
||||
@Resource
|
||||
private ApprovalDirectoryService directoryService;
|
||||
@Resource
|
||||
private ConfigProperties config;
|
||||
|
||||
/**
|
||||
* 详情附件列表:过滤逻辑删除,并从文件记录补齐名称/地址/大小。
|
||||
*/
|
||||
public List<ApprovalAttachmentVO> listVos(Long instanceId) {
|
||||
List<ApprovalAttachment> attachments = attachmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ApprovalAttachment>()
|
||||
.eq(ApprovalAttachment::getInstanceId, instanceId)
|
||||
.orderByAsc(ApprovalAttachment::getId));
|
||||
if (attachments.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Integer> fileIds = attachments.stream().map(ApprovalAttachment::getFileId).collect(Collectors.toList());
|
||||
Map<Integer, FileRecord> files = fileService.listByIds(fileIds).stream()
|
||||
.collect(Collectors.toMap(FileRecord::getId, Function.identity(), (a, b) -> a));
|
||||
List<Integer> uploaderIds = attachments.stream().map(ApprovalAttachment::getUploaderId)
|
||||
.distinct().collect(Collectors.toList());
|
||||
Map<Integer, String> uploaders = directoryService.listUsersByIds(uploaderIds).stream()
|
||||
.collect(Collectors.toMap(ApprovalUserVO::getUserId, this::displayName, (a, b) -> a));
|
||||
List<ApprovalAttachmentVO> result = new ArrayList<>();
|
||||
for (ApprovalAttachment attachment : attachments) {
|
||||
FileRecord file = files.get(attachment.getFileId());
|
||||
if (file == null) {
|
||||
// 文件记录缺失视为不可用附件,不向前端暴露悬空链接
|
||||
continue;
|
||||
}
|
||||
result.add(toVO(attachment, file, uploaders.get(attachment.getUploaderId())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public int count(Long instanceId) {
|
||||
Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper<ApprovalAttachment>()
|
||||
.eq(ApprovalAttachment::getInstanceId, instanceId));
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件:仅发起人且实例可编辑时可上传。
|
||||
*/
|
||||
public ApprovalAttachmentVO upload(Long instanceId, MultipartFile file, String category, User user) {
|
||||
ApprovalInstance instance = instanceMapper.selectById(instanceId);
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
checkEditable(instance, user);
|
||||
|
||||
String normalizedCategory = StrUtil.isBlank(category) ? ApprovalConstants.CATEGORY_OTHER : category.trim();
|
||||
if (!ApprovalConstants.CATEGORY_SIGNED.equals(normalizedCategory)
|
||||
&& !ApprovalConstants.CATEGORY_OTHER.equals(normalizedCategory)) {
|
||||
throw new BusinessException("附件分类不正确");
|
||||
}
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("附件内容为空");
|
||||
}
|
||||
if (count(instanceId) >= ApprovalConstants.MAX_ATTACHMENT_COUNT) {
|
||||
throw new BusinessException("每单最多上传" + ApprovalConstants.MAX_ATTACHMENT_COUNT + "张附件");
|
||||
}
|
||||
if (file.getSize() > ApprovalConstants.MAX_ATTACHMENT_SIZE) {
|
||||
throw new BusinessException("单个附件不能超过20MB");
|
||||
}
|
||||
String extension = extensionOf(file.getOriginalFilename());
|
||||
checkExtensionAndContentType(extension, file.getContentType());
|
||||
|
||||
File stored;
|
||||
String path;
|
||||
try {
|
||||
String base = config.getUploadPath();
|
||||
String dir = (base.endsWith("/") ? base : base + "/") + "approval/";
|
||||
stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName()));
|
||||
path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("审批附件落盘失败", e);
|
||||
throw new BusinessException("附件上传失败");
|
||||
}
|
||||
|
||||
FileRecord fileRecord = new FileRecord();
|
||||
fileRecord.setCreateUserId(user.getUserId());
|
||||
fileRecord.setName(StrUtil.isBlank(file.getOriginalFilename()) ? stored.getName() : file.getOriginalFilename());
|
||||
fileRecord.setLength(stored.length());
|
||||
fileRecord.setPath(path);
|
||||
fileRecord.setContentType(normalizeContentType(file.getContentType()));
|
||||
fileService.save(fileRecord);
|
||||
|
||||
ApprovalAttachment attachment = new ApprovalAttachment();
|
||||
attachment.setInstanceId(instanceId);
|
||||
attachment.setFileId(fileRecord.getId());
|
||||
attachment.setCategory(normalizedCategory);
|
||||
attachment.setUploaderId(user.getUserId());
|
||||
attachment.setDeleted(0);
|
||||
attachment.setCreateTime(LocalDateTime.now());
|
||||
attachmentMapper.insert(attachment);
|
||||
|
||||
return toVO(attachment, fileRecord, displayName(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除附件,底层文件记录不删除。
|
||||
*/
|
||||
public void delete(Long attachmentId, User user) {
|
||||
ApprovalAttachment attachment = attachmentMapper.selectById(attachmentId);
|
||||
if (attachment == null) {
|
||||
throw new BusinessException("附件不存在");
|
||||
}
|
||||
ApprovalInstance instance = instanceMapper.selectById(attachment.getInstanceId());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
checkEditable(instance, user);
|
||||
attachmentMapper.deleteById(attachmentId);
|
||||
}
|
||||
|
||||
private void checkEditable(ApprovalInstance instance, User user) {
|
||||
if (user == null || !instance.getReporterId().equals(user.getUserId())
|
||||
|| !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||
throw new BusinessException("当前状态不可修改");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkExtensionAndContentType(String extension, String contentType) {
|
||||
if (extension == null || !ApprovalConstants.ATTACHMENT_EXTENSIONS.contains(extension)) {
|
||||
throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
|
||||
}
|
||||
List<String> allowed = ApprovalConstants.ATTACHMENT_CONTENT_TYPES.get(extension);
|
||||
String normalized = normalizeContentType(contentType);
|
||||
if (normalized == null || allowed == null || !allowed.contains(normalized)) {
|
||||
throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
|
||||
}
|
||||
}
|
||||
|
||||
private String extensionOf(String filename) {
|
||||
if (filename == null) {
|
||||
return null;
|
||||
}
|
||||
int index = filename.lastIndexOf('.');
|
||||
if (index < 0 || index == filename.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
return filename.substring(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
private String normalizeContentType(String contentType) {
|
||||
if (contentType == null) {
|
||||
return null;
|
||||
}
|
||||
int index = contentType.indexOf(';');
|
||||
String value = index >= 0 ? contentType.substring(0, index) : contentType;
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private ApprovalAttachmentVO toVO(ApprovalAttachment attachment, FileRecord file, String uploaderName) {
|
||||
ApprovalAttachmentVO vo = new ApprovalAttachmentVO();
|
||||
vo.setId(attachment.getId());
|
||||
vo.setFileId(attachment.getFileId());
|
||||
vo.setName(file.getName());
|
||||
vo.setUrl(urlOf(file));
|
||||
vo.setLength(file.getLength());
|
||||
vo.setCategory(attachment.getCategory());
|
||||
vo.setCategoryName(ApprovalConstants.categoryName(attachment.getCategory()));
|
||||
vo.setUploaderId(attachment.getUploaderId());
|
||||
vo.setUploaderName(uploaderName);
|
||||
vo.setCreateTime(attachment.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String urlOf(FileRecord file) {
|
||||
if (StrUtil.isNotBlank(file.getUrl())) {
|
||||
return file.getUrl();
|
||||
}
|
||||
return config.getServerUrl() + "/file" + (file.getPath() == null ? "" : file.getPath());
|
||||
}
|
||||
|
||||
private String displayName(User user) {
|
||||
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||
return user.getRealName();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private String displayName(ApprovalUserVO user) {
|
||||
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||
return user.getRealName();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||
import com.gxwebsoft.approval.support.ApprovalTenantContext;
|
||||
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批目录服务:组织、部门负责人/收文员解析、启用用户查询。
|
||||
*
|
||||
* <p>直接使用 gxtyzx_core 的专用连接,而不是 MyBatis 动态数据源代理:
|
||||
* 审批请求可能处在外层事务中,动态数据源在事务内无法切换库。
|
||||
* 所有 SQL 显式带 tenant_id。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalDirectoryService {
|
||||
|
||||
private static final RowMapper<ApprovalOrganizationVO> ORGANIZATION_MAPPER = (resultSet, rowNum) -> {
|
||||
ApprovalOrganizationVO vo = new ApprovalOrganizationVO();
|
||||
vo.setOrganizationId(resultSet.getInt("organization_id"));
|
||||
vo.setParentId(resultSet.getInt("parent_id"));
|
||||
vo.setOrganizationName(resultSet.getString("organization_name"));
|
||||
vo.setOrganizationFullName(resultSet.getString("organization_full_name"));
|
||||
vo.setOrganizationCode(resultSet.getString("organization_code"));
|
||||
int leaderId = resultSet.getInt("leader_id");
|
||||
vo.setLeaderId(resultSet.wasNull() ? null : leaderId);
|
||||
return vo;
|
||||
};
|
||||
|
||||
private static final RowMapper<ApprovalUserVO> USER_MAPPER = (resultSet, rowNum) -> {
|
||||
ApprovalUserVO vo = new ApprovalUserVO();
|
||||
vo.setUserId(resultSet.getInt("user_id"));
|
||||
vo.setUsername(resultSet.getString("username"));
|
||||
vo.setNickname(resultSet.getString("nickname"));
|
||||
vo.setRealName(resultSet.getString("real_name"));
|
||||
int organizationId = resultSet.getInt("organization_id");
|
||||
vo.setOrganizationId(resultSet.wasNull() ? null : organizationId);
|
||||
vo.setOrganizationName(resultSet.getString("organization_name"));
|
||||
return vo;
|
||||
};
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ApprovalDirectoryService(DataSource dataSource) {
|
||||
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||
this.jdbcTemplate = new JdbcTemplate(server);
|
||||
}
|
||||
|
||||
/** 启用组织。 */
|
||||
public List<ApprovalOrganizationVO> listOrganizations() {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT organization_id, parent_id, organization_name, organization_full_name, "
|
||||
+ "organization_code, leader_id FROM sys_organization "
|
||||
+ "WHERE tenant_id = ? AND deleted = 0 ORDER BY sort_number, organization_id",
|
||||
ORGANIZATION_MAPPER, ApprovalTenantContext.currentTenantId());
|
||||
}
|
||||
|
||||
/** 按id取启用组织;不存在返回 null。 */
|
||||
public ApprovalOrganizationVO getOrganization(Integer organizationId) {
|
||||
if (organizationId == null || organizationId <= 0) {
|
||||
return null;
|
||||
}
|
||||
List<ApprovalOrganizationVO> list = jdbcTemplate.query(
|
||||
"SELECT organization_id, parent_id, organization_name, organization_full_name, "
|
||||
+ "organization_code, leader_id FROM sys_organization "
|
||||
+ "WHERE organization_id = ? AND tenant_id = ? AND deleted = 0",
|
||||
ORGANIZATION_MAPPER, organizationId, ApprovalTenantContext.currentTenantId());
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/** 角色下全部启用成员;角色不存在或成员为空时返回空列表。 */
|
||||
public List<ApprovalUserVO> listRoleMembers(String roleCode) {
|
||||
if (roleCode == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
return jdbcTemplate.query(
|
||||
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||
+ "FROM sys_user u "
|
||||
+ "JOIN sys_user_role ur ON ur.user_id = u.user_id AND ur.tenant_id = ? "
|
||||
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
|
||||
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||
+ "WHERE r.role_code = ? AND r.deleted = 0 AND u.status = 0 AND u.deleted = 0 "
|
||||
+ "AND u.tenant_id = ? ORDER BY u.user_id",
|
||||
USER_MAPPER, tenantId, tenantId, tenantId, roleCode, tenantId);
|
||||
}
|
||||
|
||||
/** 部门负责人角色是否已配置至少一名启用成员。 */
|
||||
public boolean hasDepartmentLeader(Integer organizationId) {
|
||||
return organizationId != null && !listRoleMembers("dept_leader_" + organizationId).isEmpty();
|
||||
}
|
||||
|
||||
/** 启用用户搜索,最多 50 条。 */
|
||||
public List<ApprovalUserVO> searchUsers(String keywords, Integer organizationId) {
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||
+ "FROM sys_user u "
|
||||
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||
+ "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0");
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
args.add(tenantId);
|
||||
args.add(tenantId);
|
||||
if (organizationId != null) {
|
||||
sql.append(" AND u.organization_id = ?");
|
||||
args.add(organizationId);
|
||||
}
|
||||
if (keywords != null && !keywords.isEmpty()) {
|
||||
sql.append(" AND (u.username LIKE ? OR u.real_name LIKE ? OR u.nickname LIKE ?)");
|
||||
String like = "%" + keywords + "%";
|
||||
args.add(like);
|
||||
args.add(like);
|
||||
args.add(like);
|
||||
}
|
||||
sql.append(" ORDER BY u.user_id LIMIT 50");
|
||||
return jdbcTemplate.query(sql.toString(), USER_MAPPER, args.toArray());
|
||||
}
|
||||
|
||||
/** 部门启用成员。 */
|
||||
public List<ApprovalUserVO> listOrganizationMembers(Integer organizationId) {
|
||||
if (organizationId == null || organizationId <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
return jdbcTemplate.query(
|
||||
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||
+ "FROM sys_user u "
|
||||
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||
+ "WHERE u.organization_id = ? AND u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 "
|
||||
+ "ORDER BY u.user_id",
|
||||
USER_MAPPER, tenantId, organizationId, tenantId);
|
||||
}
|
||||
|
||||
/** 按id批量取启用用户。 */
|
||||
public List<ApprovalUserVO> listUsersByIds(List<Integer> userIds) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
|
||||
String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||
+ "FROM sys_user u "
|
||||
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||
+ "WHERE u.tenant_id = ? AND u.user_id IN (" + placeholders + ")";
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
args.add(tenantId);
|
||||
args.add(tenantId);
|
||||
args.addAll(userIds);
|
||||
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
|
||||
}
|
||||
|
||||
/** 是否持有任一角色(用于 allVisible 判断)。 */
|
||||
public boolean hasAnyRole(Integer userId, String... roleCodes) {
|
||||
if (userId == null || roleCodes == null || roleCodes.length == 0) {
|
||||
return false;
|
||||
}
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
String placeholders = String.join(",", Collections.nCopies(roleCodes.length, "?"));
|
||||
String sql = "SELECT COUNT(1) FROM sys_user_role ur "
|
||||
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
|
||||
+ "WHERE ur.user_id = ? AND ur.tenant_id = ? AND r.deleted = 0 AND r.role_code IN (" + placeholders + ")";
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
args.add(tenantId);
|
||||
args.add(userId);
|
||||
args.add(tenantId);
|
||||
Collections.addAll(args, roleCodes);
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args.toArray());
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.entity.ApprovalRecord;
|
||||
import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。
|
||||
*
|
||||
* <p>本类只写 master 库,必须在调用方的事务内执行;后续迭代在此扩展
|
||||
* 通过/驳回/撤回/存档/归档/派单的节点推进,不再改动已发布的方法签名。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalEngineService {
|
||||
|
||||
@Resource
|
||||
private ApprovalInstanceMapper instanceMapper;
|
||||
@Resource
|
||||
private ApprovalTaskMapper taskMapper;
|
||||
@Resource
|
||||
private ApprovalRecordMapper recordMapper;
|
||||
@Resource
|
||||
private ApprovalFlowService flowService;
|
||||
|
||||
/**
|
||||
* 提交/重提:激活流程首节点,为解析出的审批人生成或签任务。
|
||||
*
|
||||
* @return 生成的任务数;0 表示节点缺配,实例停留在当前节点等待管理员补配置
|
||||
*/
|
||||
public int startFlow(ApprovalInstance instance, List<ApprovalApprover> approvers,
|
||||
User operator, String action) {
|
||||
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
|
||||
if (nodes.isEmpty()) {
|
||||
throw new BusinessException("审批流程未配置节点");
|
||||
}
|
||||
return activateNode(instance, nodes.get(0), approvers, operator, action, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活指定节点:快照审批人并写处理记录。
|
||||
*/
|
||||
public int activateNode(ApprovalInstance instance, ApprovalNode node,
|
||||
List<ApprovalApprover> approvers, User operator,
|
||||
String action, String opinion) {
|
||||
instance.setCurrentNodeKey(node.getKey());
|
||||
instance.setCurrentNodeType(node.getType());
|
||||
instanceMapper.updateById(instance);
|
||||
addRecord(instance, null, node.getKey(), operator, action, opinion);
|
||||
return createTasks(instance, node, instance.getRound(), approvers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对实例行加锁,串行化节点推进。
|
||||
*/
|
||||
public ApprovalInstance lockInstance(Long instanceId) {
|
||||
ApprovalInstance instance = instanceMapper.selectByIdForUpdate(instanceId);
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消同一轮同一节点的其余待办(或签处理完成后调用)。
|
||||
*/
|
||||
public int cancelPendingTasks(Long instanceId, Integer round, Long exceptTaskId) {
|
||||
UpdateWrapper<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
|
||||
.eq("instance_id", instanceId)
|
||||
.eq("round", round)
|
||||
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||
.set("status", ApprovalConstants.TASK_CANCELLED);
|
||||
if (exceptTaskId != null) {
|
||||
wrapper.ne("id", exceptTaskId);
|
||||
}
|
||||
return taskMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加处理记录。
|
||||
*/
|
||||
public void addRecord(ApprovalInstance instance, Long taskId, String nodeKey,
|
||||
User operator, String action, String opinion) {
|
||||
ApprovalRecord record = new ApprovalRecord();
|
||||
record.setInstanceId(instance.getId());
|
||||
record.setTaskId(taskId);
|
||||
record.setNodeKey(nodeKey);
|
||||
record.setOperatorId(operator.getUserId());
|
||||
record.setOperatorName(displayName(operator));
|
||||
record.setAction(action);
|
||||
record.setOpinion(opinion);
|
||||
record.setRound(instance.getRound());
|
||||
record.setCreateTime(LocalDateTime.now());
|
||||
recordMapper.insert(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前节点之后的下一个节点;已是末节点返回 null。
|
||||
*/
|
||||
public ApprovalNode nextNode(ApprovalInstance instance, String currentNodeKey) {
|
||||
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
|
||||
for (int i = 0; i < nodes.size(); i++) {
|
||||
if (nodes.get(i).getKey().equals(currentNodeKey)) {
|
||||
return i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String nodeName(ApprovalInstance instance, String nodeKey) {
|
||||
return flowService.nodeName(instance.getDefinitionId(), nodeKey);
|
||||
}
|
||||
|
||||
private int createTasks(ApprovalInstance instance, ApprovalNode node, Integer round,
|
||||
List<ApprovalApprover> approvers) {
|
||||
if (approvers == null || approvers.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int created = 0;
|
||||
for (ApprovalApprover approver : approvers) {
|
||||
ApprovalTask task = new ApprovalTask();
|
||||
task.setInstanceId(instance.getId());
|
||||
task.setNodeKey(node.getKey());
|
||||
task.setNodeType(node.getType());
|
||||
task.setRound(round);
|
||||
task.setTaskType(node.getType());
|
||||
task.setAssigneeId(approver.getUserId());
|
||||
task.setAssigneeName(approver.getUserName());
|
||||
task.setStatus(ApprovalConstants.TASK_PENDING);
|
||||
task.setCreateTime(LocalDateTime.now());
|
||||
taskMapper.insert(task);
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private String displayName(User user) {
|
||||
if (user.getRealName() != null && !user.getRealName().isEmpty()) {
|
||||
return user.getRealName();
|
||||
}
|
||||
if (user.getNickname() != null && !user.getNickname().isEmpty()) {
|
||||
return user.getNickname();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||
import com.gxwebsoft.approval.support.ApprovalTenantContext;
|
||||
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批域文件记录读写:直接操作 gxtyzx_core.sys_file_record。
|
||||
*
|
||||
* <p>使用 server 库专用连接,保证在 master 事务内仍能读写核心库。
|
||||
* 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalFileService {
|
||||
|
||||
private static final RowMapper<FileRecord> FILE_MAPPER = (resultSet, rowNum) -> {
|
||||
FileRecord record = new FileRecord();
|
||||
record.setId(resultSet.getInt("id"));
|
||||
record.setName(resultSet.getString("name"));
|
||||
record.setPath(resultSet.getString("path"));
|
||||
record.setLength(resultSet.getLong("length"));
|
||||
record.setContentType(resultSet.getString("content_type"));
|
||||
record.setCreateUserId(resultSet.getInt("create_user_id"));
|
||||
record.setDeleted(resultSet.getInt("deleted"));
|
||||
return record;
|
||||
};
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ApprovalFileService(DataSource dataSource) {
|
||||
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||
this.jdbcTemplate = new JdbcTemplate(server);
|
||||
}
|
||||
|
||||
public FileRecord save(FileRecord fileRecord) {
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO sys_file_record "
|
||||
+ "(name, path, length, content_type, create_user_id, deleted, tenant_id) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setString(1, fileRecord.getName());
|
||||
statement.setString(2, fileRecord.getPath());
|
||||
statement.setLong(3, fileRecord.getLength() == null ? 0L : fileRecord.getLength());
|
||||
statement.setString(4, fileRecord.getContentType());
|
||||
statement.setInt(5, fileRecord.getCreateUserId());
|
||||
statement.setInt(6, tenantId);
|
||||
return statement;
|
||||
}, keyHolder);
|
||||
Number key = keyHolder.getKey();
|
||||
fileRecord.setId(key == null ? null : key.intValue());
|
||||
return fileRecord;
|
||||
}
|
||||
|
||||
public FileRecord getById(Integer id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
List<FileRecord> records = jdbcTemplate.query(
|
||||
"SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record "
|
||||
+ "WHERE id = ? AND tenant_id = ? AND deleted = 0",
|
||||
FILE_MAPPER, id, ApprovalTenantContext.currentTenantId());
|
||||
return records.isEmpty() ? null : records.get(0);
|
||||
}
|
||||
|
||||
public List<FileRecord> listByIds(Collection<Integer> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(ApprovalTenantContext.currentTenantId());
|
||||
args.addAll(ids);
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record "
|
||||
+ "WHERE tenant_id = ? AND deleted = 0 AND id IN (" + placeholders + ")",
|
||||
FILE_MAPPER, args.toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalFlowDefinitionMapper;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程定义服务:按租户幂等发布 version=1,并解析节点 JSON。
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalFlowService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class);
|
||||
private static final int INITIAL_VERSION = 1;
|
||||
|
||||
@Resource
|
||||
private ApprovalFlowDefinitionMapper flowDefinitionMapper;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 获取租户下的流程定义;不存在时创建 version=1。
|
||||
*
|
||||
* <p>并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新查询。</p>
|
||||
*/
|
||||
public ApprovalFlowDefinition getOrCreate(String flowCode) {
|
||||
ApprovalFlowDefinition definition = find(flowCode);
|
||||
if (definition != null) {
|
||||
return definition;
|
||||
}
|
||||
ApprovalFlowDefinition created = new ApprovalFlowDefinition();
|
||||
created.setFlowCode(flowCode);
|
||||
created.setVersion(INITIAL_VERSION);
|
||||
created.setName(flowName(flowCode));
|
||||
created.setNodesJson(flowNodesJson(flowCode));
|
||||
try {
|
||||
flowDefinitionMapper.insert(created);
|
||||
return created;
|
||||
} catch (DuplicateKeyException e) {
|
||||
log.info("审批流程定义已被并发创建,重新读取: {}", flowCode);
|
||||
definition = find(flowCode);
|
||||
if (definition == null) {
|
||||
throw new BusinessException("审批流程定义初始化失败");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
||||
public ApprovalFlowDefinition getById(Long definitionId) {
|
||||
ApprovalFlowDefinition definition = flowDefinitionMapper.selectById(definitionId);
|
||||
if (definition == null) {
|
||||
throw new BusinessException("审批流程定义不存在");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
public List<ApprovalNode> parseNodes(Long definitionId) {
|
||||
ApprovalFlowDefinition definition = getById(definitionId);
|
||||
try {
|
||||
List<ApprovalNode> nodes = objectMapper.readValue(definition.getNodesJson(),
|
||||
objectMapper.getTypeFactory().constructCollectionType(List.class, ApprovalNode.class));
|
||||
return nodes == null ? Collections.emptyList() : nodes;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("审批流程定义格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
public ApprovalNode findNode(Long definitionId, String nodeKey) {
|
||||
if (nodeKey == null) {
|
||||
return null;
|
||||
}
|
||||
return parseNodes(definitionId).stream()
|
||||
.filter(node -> nodeKey.equals(node.getKey()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public String nodeName(Long definitionId, String nodeKey) {
|
||||
ApprovalNode node = findNode(definitionId, nodeKey);
|
||||
return node == null ? null : node.getName();
|
||||
}
|
||||
|
||||
private ApprovalFlowDefinition find(String flowCode) {
|
||||
return flowDefinitionMapper.selectOne(new LambdaQueryWrapper<ApprovalFlowDefinition>()
|
||||
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
|
||||
.eq(ApprovalFlowDefinition::getVersion, INITIAL_VERSION)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private String flowName(String flowCode) {
|
||||
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
|
||||
return "业务完成确认单";
|
||||
}
|
||||
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
|
||||
return "内部协调单";
|
||||
}
|
||||
throw new BusinessException("未知的审批流程: " + flowCode);
|
||||
}
|
||||
|
||||
private String flowNodesJson(String flowCode) {
|
||||
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
|
||||
return ApprovalConstants.COMPLETION_NODES_JSON;
|
||||
}
|
||||
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
|
||||
return ApprovalConstants.COORDINATION_NODES_JSON;
|
||||
}
|
||||
throw new BusinessException("未知的审批流程: " + flowCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
|
||||
import com.gxwebsoft.approval.entity.ApprovalCoordinationForm;
|
||||
import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
|
||||
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||
import com.gxwebsoft.approval.entity.ApprovalRecord;
|
||||
import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalCompletionFormMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalCoordinationFormMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.approval.vo.ApprovalConfirmVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalMyTaskVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalPermissionsVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalRecordVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.Menu;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 业务完成确认单草稿、提交与详情服务。
|
||||
*
|
||||
* <p>跨库约束:服务方法本身不开启事务;需要写 master 的片段放进
|
||||
* TransactionTemplate,任何 @DS("server") 的目录查询都在事务外完成,
|
||||
* 避免动态数据源在事务内无法切换。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalInstanceService {
|
||||
|
||||
private static final BigDecimal MAX_FEE = new BigDecimal("99999999.99");
|
||||
private static final String PERMISSION_ASSIGN = "approval:instance:assign";
|
||||
|
||||
@Resource
|
||||
private ApprovalInstanceMapper instanceMapper;
|
||||
@Resource
|
||||
private ApprovalCompletionFormMapper completionFormMapper;
|
||||
@Resource
|
||||
private ApprovalCoordinationFormMapper coordinationFormMapper;
|
||||
@Resource
|
||||
private ApprovalRecordMapper recordMapper;
|
||||
@Resource
|
||||
private ApprovalTaskMapper taskMapper;
|
||||
@Resource
|
||||
private ApprovalFlowService flowService;
|
||||
@Resource
|
||||
private ApprovalNoService noService;
|
||||
@Resource
|
||||
private ApprovalEngineService engineService;
|
||||
@Resource
|
||||
private ApprovalAttachmentService attachmentService;
|
||||
@Resource
|
||||
private ApprovalDirectoryService directoryService;
|
||||
@Resource
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* 新建或更新业务完成确认单草稿,返回实例id。
|
||||
*/
|
||||
public Long saveCompletion(ApprovalCompletionParam param, User user) {
|
||||
validateCompletion(param);
|
||||
Integer organizationId = user.getOrganizationId();
|
||||
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
|
||||
String organizationName = organization == null ? null : organization.getOrganizationName();
|
||||
|
||||
if (param.getId() != null) {
|
||||
return inTransaction(() -> updateDraft(param, user, organizationId, organizationName));
|
||||
}
|
||||
ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COMPLETION);
|
||||
return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交或重提,返回节点缺配提示;正常提交返回 null。
|
||||
*/
|
||||
public String submit(Long instanceId, User user) {
|
||||
ApprovalInstance instance = instanceMapper.selectById(instanceId);
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
if (!instance.getReporterId().equals(user.getUserId())) {
|
||||
throw new BusinessException("无权操作该单据");
|
||||
}
|
||||
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||
throw new BusinessException(submitBlockedMessage(instance.getStatus()));
|
||||
}
|
||||
if (completionForm(instanceId) == null) {
|
||||
throw new BusinessException("单据内容不存在");
|
||||
}
|
||||
|
||||
Integer organizationId = user.getOrganizationId();
|
||||
if (organizationId == null || organizationId <= 0) {
|
||||
throw new BusinessException("未归属部门,无法提交");
|
||||
}
|
||||
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
|
||||
if (organization == null) {
|
||||
throw new BusinessException("未归属部门,无法提交");
|
||||
}
|
||||
List<ApprovalUserVO> leaders = directoryService.listRoleMembers(
|
||||
ApprovalConstants.departmentLeaderRoleCode(organizationId));
|
||||
if (leaders.isEmpty()) {
|
||||
throw new BusinessException("报送部门负责人角色缺配");
|
||||
}
|
||||
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
|
||||
throw new BusinessException("收文员角色缺配");
|
||||
}
|
||||
if (attachmentService.count(instanceId) == 0) {
|
||||
throw new BusinessException("请至少上传一张附件");
|
||||
}
|
||||
|
||||
List<ApprovalApprover> approvers = leaders.stream()
|
||||
.map(this::toApprover)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return inTransaction(() -> doSubmit(instanceId, user, organization, approvers));
|
||||
}
|
||||
|
||||
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
|
||||
List<ApprovalApprover> approvers) {
|
||||
ApprovalInstance locked = engineService.lockInstance(instanceId);
|
||||
if (!locked.getReporterId().equals(user.getUserId())
|
||||
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
|
||||
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||
}
|
||||
boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
|
||||
locked.setReportOrgId(organization.getOrganizationId());
|
||||
locked.setReportOrgName(organization.getOrganizationName());
|
||||
if (firstSubmit) {
|
||||
locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION));
|
||||
}
|
||||
locked.setStatus(ApprovalConstants.STATUS_PENDING);
|
||||
locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1);
|
||||
if (locked.getSubmitTime() == null) {
|
||||
locked.setSubmitTime(LocalDateTime.now());
|
||||
}
|
||||
int tasks = engineService.startFlow(locked, approvers, user,
|
||||
firstSubmit ? ApprovalConstants.ACTION_SUBMIT : ApprovalConstants.ACTION_RESUBMIT);
|
||||
if (tasks == 0) {
|
||||
return "当前节点缺少审批人,请联系管理员补配置";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情;草稿仅发起人可见,其余按任务/记录/派单目标或 allVisible 角色判断。
|
||||
*/
|
||||
public ApprovalInstanceVO detail(Long instanceId, User user) {
|
||||
ApprovalInstance instance = instanceMapper.selectById(instanceId);
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
int allVisible = directoryService.hasAnyRole(user.getUserId(),
|
||||
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
|
||||
if (instanceMapper.countVisible(instanceId, user.getUserId(), allVisible) == 0) {
|
||||
throw new BusinessException("无权查看该单据");
|
||||
}
|
||||
|
||||
ApprovalInstanceVO vo = baseVO(instance);
|
||||
ApprovalCompletionForm completion = completionForm(instanceId);
|
||||
if (completion != null) {
|
||||
vo.setName(completion.getName());
|
||||
vo.setImplementOrg(completion.getImplementOrg());
|
||||
vo.setFee(completion.getFee());
|
||||
vo.setServiceContent(completion.getServiceContent());
|
||||
}
|
||||
ApprovalCoordinationForm coordination = coordinationForm(instanceId);
|
||||
if (coordination != null) {
|
||||
vo.setAcceptOrgId(coordination.getAcceptOrgId());
|
||||
vo.setAcceptOrgName(coordination.getAcceptOrgName());
|
||||
vo.setTitle(coordination.getTitle());
|
||||
vo.setContent(coordination.getContent());
|
||||
vo.setRemark(coordination.getRemark());
|
||||
}
|
||||
vo.setAttachments(attachmentService.listVos(instanceId));
|
||||
vo.setRecords(records(instance));
|
||||
vo.setConfirms(confirms(instance));
|
||||
ApprovalMyTaskVO myTask = myTask(instance, user.getUserId());
|
||||
vo.setMyTask(myTask);
|
||||
vo.setPermissions(permissions(instance, user, myTask));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我发起的列表:按提交时间倒序,草稿排最后。
|
||||
*/
|
||||
public PageResult<ApprovalItemVO> mine(ApprovalMineParam param, User user) {
|
||||
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
|
||||
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||
List<ApprovalItemRow> rows = instanceMapper.selectMinePage(page, param, user.getUserId());
|
||||
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
|
||||
List<ApprovalItemVO> list = new ArrayList<>();
|
||||
for (ApprovalItemRow row : rows) {
|
||||
ApprovalItemVO item = new ApprovalItemVO();
|
||||
item.setId(row.getId());
|
||||
item.setBizNo(row.getBizNo());
|
||||
item.setDocType(row.getDocType());
|
||||
item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
|
||||
item.setStatus(row.getStatus());
|
||||
item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
|
||||
item.setTitle(row.getTitle());
|
||||
item.setSubmitTime(row.getSubmitTime());
|
||||
item.setReporterId(row.getReporterId());
|
||||
item.setReporterName(row.getReporterName());
|
||||
item.setReportOrgName(row.getReportOrgName());
|
||||
item.setAcceptOrgName(row.getAcceptOrgName());
|
||||
item.setArchiveTime(row.getArchiveTime());
|
||||
item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
|
||||
list.add(item);
|
||||
}
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition,
|
||||
Integer organizationId, String organizationName) {
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setFlowCode(ApprovalConstants.FLOW_COMPLETION);
|
||||
instance.setDefinitionId(definition.getId());
|
||||
instance.setDefinitionVersion(definition.getVersion());
|
||||
instance.setDocType(ApprovalConstants.DOC_COMPLETION);
|
||||
instance.setBizId(0L);
|
||||
instance.setStatus(ApprovalConstants.STATUS_DRAFT);
|
||||
instance.setRound(0);
|
||||
instance.setReporterId(user.getUserId());
|
||||
instance.setReporterName(displayName(user));
|
||||
instance.setReportOrgId(organizationId);
|
||||
instance.setReportOrgName(organizationName);
|
||||
instanceMapper.insert(instance);
|
||||
|
||||
ApprovalCompletionForm form = new ApprovalCompletionForm();
|
||||
form.setInstanceId(instance.getId());
|
||||
form.setName(param.getName().trim());
|
||||
form.setImplementOrg(param.getImplementOrg().trim());
|
||||
form.setFee(param.getFee());
|
||||
form.setServiceContent(param.getServiceContent().trim());
|
||||
completionFormMapper.insert(form);
|
||||
|
||||
instance.setBizId(form.getId());
|
||||
instanceMapper.updateById(instance);
|
||||
return instance.getId();
|
||||
}
|
||||
|
||||
private Long updateDraft(ApprovalCompletionParam param, User user,
|
||||
Integer organizationId, String organizationName) {
|
||||
ApprovalInstance instance = instanceMapper.selectById(param.getId());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
if (!instance.getReporterId().equals(user.getUserId())) {
|
||||
throw new BusinessException("无权操作该单据");
|
||||
}
|
||||
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||
throw new BusinessException("当前状态不可修改");
|
||||
}
|
||||
ApprovalCompletionForm form = completionForm(instance.getId());
|
||||
if (form == null) {
|
||||
throw new BusinessException("单据内容不存在");
|
||||
}
|
||||
form.setName(param.getName().trim());
|
||||
form.setImplementOrg(param.getImplementOrg().trim());
|
||||
form.setFee(param.getFee());
|
||||
form.setServiceContent(param.getServiceContent().trim());
|
||||
completionFormMapper.updateById(form);
|
||||
|
||||
instance.setReportOrgId(organizationId);
|
||||
instance.setReportOrgName(organizationName);
|
||||
instanceMapper.updateById(instance);
|
||||
return instance.getId();
|
||||
}
|
||||
|
||||
private ApprovalInstanceVO baseVO(ApprovalInstance instance) {
|
||||
ApprovalInstanceVO vo = new ApprovalInstanceVO();
|
||||
vo.setId(instance.getId());
|
||||
vo.setBizNo(instance.getBizNo());
|
||||
vo.setDocType(instance.getDocType());
|
||||
vo.setDocTypeName(ApprovalConstants.docTypeName(instance.getDocType()));
|
||||
vo.setFlowCode(instance.getFlowCode());
|
||||
vo.setStatus(instance.getStatus());
|
||||
vo.setStatusName(ApprovalConstants.statusName(instance.getStatus()));
|
||||
vo.setRound(instance.getRound());
|
||||
vo.setReporterId(instance.getReporterId());
|
||||
vo.setReporterName(instance.getReporterName());
|
||||
vo.setReportOrgId(instance.getReportOrgId());
|
||||
vo.setReportOrgName(instance.getReportOrgName());
|
||||
vo.setAcceptOrgId(instance.getAcceptOrgId());
|
||||
vo.setAcceptOrgName(instance.getAcceptOrgName());
|
||||
vo.setCurrentNodeKey(instance.getCurrentNodeKey());
|
||||
vo.setCurrentNodeType(instance.getCurrentNodeType());
|
||||
vo.setCurrentNodeName(flowService.nodeName(instance.getDefinitionId(), instance.getCurrentNodeKey()));
|
||||
vo.setSubmitTime(instance.getSubmitTime());
|
||||
vo.setApproveTime(instance.getApproveTime());
|
||||
vo.setArchiveTime(instance.getArchiveTime());
|
||||
vo.setArchivistId(instance.getArchivistId());
|
||||
vo.setArchivistName(instance.getArchivistName());
|
||||
vo.setDispatchTargetId(instance.getDispatchTargetId());
|
||||
vo.setDispatchTargetName(instance.getDispatchTargetName());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<ApprovalRecordVO> records(ApprovalInstance instance) {
|
||||
List<ApprovalRecord> records = recordMapper.selectList(new LambdaQueryWrapper<ApprovalRecord>()
|
||||
.eq(ApprovalRecord::getInstanceId, instance.getId())
|
||||
.orderByAsc(ApprovalRecord::getCreateTime)
|
||||
.orderByAsc(ApprovalRecord::getId));
|
||||
List<ApprovalRecordVO> result = new ArrayList<>();
|
||||
for (ApprovalRecord record : records) {
|
||||
ApprovalRecordVO vo = new ApprovalRecordVO();
|
||||
vo.setId(record.getId());
|
||||
vo.setTaskId(record.getTaskId());
|
||||
vo.setNodeKey(record.getNodeKey());
|
||||
vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), record.getNodeKey()));
|
||||
vo.setOperatorId(record.getOperatorId());
|
||||
vo.setOperatorName(record.getOperatorName());
|
||||
vo.setAction(record.getAction());
|
||||
vo.setActionName(ApprovalConstants.actionName(record.getAction()));
|
||||
vo.setOpinion(record.getOpinion());
|
||||
vo.setRound(record.getRound());
|
||||
vo.setCreateTime(record.getCreateTime());
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ApprovalConfirmVO> confirms(ApprovalInstance instance) {
|
||||
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
|
||||
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||
.eq(ApprovalTask::getNodeType, ApprovalConstants.NODE_CONFIRM)
|
||||
.eq(ApprovalTask::getRound, instance.getRound())
|
||||
.orderByAsc(ApprovalTask::getId));
|
||||
List<ApprovalConfirmVO> result = new ArrayList<>();
|
||||
for (ApprovalTask task : tasks) {
|
||||
ApprovalConfirmVO vo = new ApprovalConfirmVO();
|
||||
vo.setUserId(task.getAssigneeId());
|
||||
vo.setUserName(task.getAssigneeName());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setStatusName(taskStatusName(task.getStatus()));
|
||||
vo.setHandleTime(task.getHandleTime());
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApprovalMyTaskVO myTask(ApprovalInstance instance, Integer userId) {
|
||||
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
|
||||
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||
.eq(ApprovalTask::getAssigneeId, userId)
|
||||
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)
|
||||
.orderByDesc(ApprovalTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
if (tasks.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
ApprovalTask task = tasks.get(0);
|
||||
ApprovalMyTaskVO vo = new ApprovalMyTaskVO();
|
||||
vo.setId(task.getId());
|
||||
vo.setNodeKey(task.getNodeKey());
|
||||
vo.setNodeType(task.getNodeType());
|
||||
vo.setStatus(task.getStatus());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask) {
|
||||
ApprovalPermissionsVO permissions = new ApprovalPermissionsVO();
|
||||
boolean reporter = instance.getReporterId().equals(user.getUserId());
|
||||
boolean editable = reporter && ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus());
|
||||
permissions.setCanEdit(editable);
|
||||
permissions.setCanSubmit(editable);
|
||||
if (reporter && ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||
Integer done = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||
.eq(ApprovalTask::getRound, instance.getRound())
|
||||
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
|
||||
permissions.setCanWithdraw(done == null || done == 0);
|
||||
}
|
||||
if (myTask != null) {
|
||||
boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType())
|
||||
|| ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
|
||||
permissions.setCanApprove(approveNode);
|
||||
permissions.setCanReject(approveNode);
|
||||
permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()));
|
||||
permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType()));
|
||||
permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType()));
|
||||
permissions.setCanAssign(hasAuthority(user, PERMISSION_ASSIGN));
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private boolean hasAuthority(User user, String authority) {
|
||||
if (user.getAuthorities() == null) {
|
||||
return false;
|
||||
}
|
||||
return user.getAuthorities().stream()
|
||||
.map(Menu::getAuthority)
|
||||
.anyMatch(authority::equals);
|
||||
}
|
||||
|
||||
private ApprovalCompletionForm completionForm(Long instanceId) {
|
||||
List<ApprovalCompletionForm> forms = completionFormMapper.selectList(
|
||||
new LambdaQueryWrapper<ApprovalCompletionForm>()
|
||||
.eq(ApprovalCompletionForm::getInstanceId, instanceId)
|
||||
.last("LIMIT 1"));
|
||||
return forms.isEmpty() ? null : forms.get(0);
|
||||
}
|
||||
|
||||
private ApprovalCoordinationForm coordinationForm(Long instanceId) {
|
||||
List<ApprovalCoordinationForm> forms = coordinationFormMapper.selectList(
|
||||
new LambdaQueryWrapper<ApprovalCoordinationForm>()
|
||||
.eq(ApprovalCoordinationForm::getInstanceId, instanceId)
|
||||
.last("LIMIT 1"));
|
||||
return forms.isEmpty() ? null : forms.get(0);
|
||||
}
|
||||
|
||||
private void validateCompletion(ApprovalCompletionParam param) {
|
||||
if (param == null) {
|
||||
throw new BusinessException("参数不能为空");
|
||||
}
|
||||
String name = param.getName() == null ? "" : param.getName().trim();
|
||||
if (name.isEmpty()) {
|
||||
throw new BusinessException("名称不能为空");
|
||||
}
|
||||
if (name.length() > 100) {
|
||||
throw new BusinessException("名称不能超过100字");
|
||||
}
|
||||
String implementOrg = param.getImplementOrg() == null ? "" : param.getImplementOrg().trim();
|
||||
if (implementOrg.isEmpty()) {
|
||||
throw new BusinessException("实施单位不能为空");
|
||||
}
|
||||
if (implementOrg.length() > 100) {
|
||||
throw new BusinessException("实施单位不能超过100字");
|
||||
}
|
||||
BigDecimal fee = param.getFee();
|
||||
if (fee == null) {
|
||||
throw new BusinessException("费用不能为空");
|
||||
}
|
||||
if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 || fee.compareTo(MAX_FEE) > 0) {
|
||||
throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
|
||||
}
|
||||
String serviceContent = param.getServiceContent() == null ? "" : param.getServiceContent().trim();
|
||||
if (serviceContent.isEmpty()) {
|
||||
throw new BusinessException("服务内容不能为空");
|
||||
}
|
||||
if (serviceContent.length() > 2000) {
|
||||
throw new BusinessException("服务内容不能超过2000字");
|
||||
}
|
||||
}
|
||||
|
||||
private String submitBlockedMessage(String status) {
|
||||
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
|
||||
return "单据正在审批中,不能重复提交";
|
||||
}
|
||||
if (ApprovalConstants.STATUS_ARCHIVED.equals(status)) {
|
||||
return "单据已归档,不能修改";
|
||||
}
|
||||
return "当前状态不可提交";
|
||||
}
|
||||
|
||||
private ApprovalApprover toApprover(ApprovalUserVO user) {
|
||||
return new ApprovalApprover(user.getUserId(), userDisplayName(user));
|
||||
}
|
||||
|
||||
private String userDisplayName(ApprovalUserVO user) {
|
||||
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||
return user.getRealName();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private String displayName(User user) {
|
||||
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||
return user.getRealName();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
|
||||
if (definitionId == null || nodeKey == null) {
|
||||
return null;
|
||||
}
|
||||
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
|
||||
return nodes.stream()
|
||||
.filter(node -> nodeKey.equals(node.getKey()))
|
||||
.map(ApprovalNode::getName)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String taskStatusName(String status) {
|
||||
if (ApprovalConstants.TASK_PENDING.equals(status)) {
|
||||
return "待确认";
|
||||
}
|
||||
if (ApprovalConstants.TASK_DONE.equals(status)) {
|
||||
return "已确认";
|
||||
}
|
||||
if (ApprovalConstants.TASK_CANCELLED.equals(status)) {
|
||||
return "已取消";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
private <T> T inTransaction(Supplier<T> action) {
|
||||
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
||||
}
|
||||
}
|
||||
@@ -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 位流水。
|
||||
*
|
||||
* <p>调用方必须开启事务,锁会在事务提交/回滚时释放。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 审批域租户上下文。
|
||||
*
|
||||
* <p>gxtyzx_core 的查询使用独立连接(不受 master 事务绑定影响),
|
||||
* 因此需要显式拼 tenant_id;解析顺序与登录用户一致:
|
||||
* 登录用户 → tenantId 请求头 → 兜底 10150。</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ApprovalAttachmentVO> attachments;
|
||||
|
||||
private List<ApprovalRecordVO> records;
|
||||
|
||||
private List<ApprovalConfirmVO> confirms;
|
||||
|
||||
private ApprovalMyTaskVO myTask;
|
||||
|
||||
private ApprovalPermissionsVO permissions;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ApprovalTestData.TestUser> 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<Integer> 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<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant) {
|
||||
return grantDepartmentLeader(orgId, tenant, 1);
|
||||
}
|
||||
|
||||
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant, int memberCount) {
|
||||
ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人", tenant);
|
||||
List<ApprovalTestData.TestUser> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。
|
||||
*
|
||||
* <p>sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate,
|
||||
* 避免测试事务与动态数据源切换互相影响。</p>
|
||||
*/
|
||||
public class ApprovalTestData {
|
||||
|
||||
private static final AtomicInteger SEQ = new AtomicInteger();
|
||||
|
||||
private final JdbcTemplate master;
|
||||
private final JdbcTemplate server;
|
||||
|
||||
private final List<Integer> userIds = new ArrayList<>();
|
||||
private final List<Integer> roleIds = new ArrayList<>();
|
||||
private final List<Integer> organizationIds = new ArrayList<>();
|
||||
private final List<Long> 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> T queryMaster(String sql, Class<T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Menu> menus = Arrays.stream(authorities)
|
||||
.map(authority -> {
|
||||
Menu menu = new Menu();
|
||||
@@ -32,9 +46,12 @@ public final class TestAuthentication {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
User user = new User();
|
||||
user.setUserId(USER_ID);
|
||||
user.setUsername("admin");
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setUserId(userId);
|
||||
user.setUsername(username);
|
||||
user.setNickname(username);
|
||||
user.setRealName(username);
|
||||
user.setOrganizationId(organizationId);
|
||||
user.setTenantId(tenantId);
|
||||
user.setStatus(0);
|
||||
user.setAuthorities(menus);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user