From 4a1d02f040a03f01920a33cc24d705e9677c4138 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 20:18:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(approval):=20=E4=B8=9A=E5=8A=A1=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=A1=AE=E8=AE=A4=E5=8D=95=E8=8D=89=E7=A8=BF=E3=80=81?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E3=80=81=E6=8F=90=E4=BA=A4=E4=B8=8E=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=B5=B7=E6=AD=A5=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 + sql/approval.sql | 140 +++++ sql/approval_menu.sql | 77 +++ .../approval/constant/ApprovalConstants.java | 169 ++++++ .../ApprovalAttachmentController.java | 47 ++ .../ApprovalDirectoryController.java | 45 ++ .../ApprovalInstanceController.java | 65 +++ .../approval/entity/ApprovalAttachment.java | 41 ++ .../entity/ApprovalCompletionForm.java | 40 ++ .../entity/ApprovalCoordinationForm.java | 41 ++ .../entity/ApprovalFlowDefinition.java | 41 ++ .../approval/entity/ApprovalInstance.java | 80 +++ .../approval/entity/ApprovalNoSequence.java | 31 + .../approval/entity/ApprovalRecord.java | 43 ++ .../approval/entity/ApprovalTask.java | 53 ++ .../mapper/ApprovalAttachmentMapper.java | 10 + .../mapper/ApprovalCompletionFormMapper.java | 10 + .../ApprovalCoordinationFormMapper.java | 10 + .../mapper/ApprovalFlowDefinitionMapper.java | 10 + .../mapper/ApprovalInstanceMapper.java | 35 ++ .../mapper/ApprovalNoSequenceMapper.java | 33 ++ .../approval/mapper/ApprovalRecordMapper.java | 10 + .../approval/mapper/ApprovalTaskMapper.java | 10 + .../mapper/xml/ApprovalInstanceMapper.xml | 59 ++ .../param/ApprovalCompletionParam.java | 30 + .../approval/param/ApprovalMineParam.java | 20 + .../service/ApprovalAttachmentService.java | 243 ++++++++ .../service/ApprovalDirectoryService.java | 177 ++++++ .../service/ApprovalEngineService.java | 159 ++++++ .../approval/service/ApprovalFileService.java | 94 +++ .../approval/service/ApprovalFlowService.java | 121 ++++ .../service/ApprovalInstanceService.java | 540 ++++++++++++++++++ .../approval/service/ApprovalNoService.java | 37 ++ .../approval/support/ApprovalApprover.java | 16 + .../approval/support/ApprovalItemRow.java | 28 + .../approval/support/ApprovalNode.java | 24 + .../support/ApprovalTenantContext.java | 48 ++ .../approval/vo/ApprovalAttachmentVO.java | 36 ++ .../approval/vo/ApprovalConfirmVO.java | 26 + .../approval/vo/ApprovalInstanceVO.java | 92 +++ .../gxwebsoft/approval/vo/ApprovalItemVO.java | 46 ++ .../approval/vo/ApprovalMyTaskVO.java | 23 + .../approval/vo/ApprovalOrganizationVO.java | 27 + .../approval/vo/ApprovalPermissionsVO.java | 33 ++ .../approval/vo/ApprovalRecordVO.java | 38 ++ .../gxwebsoft/approval/vo/ApprovalUserVO.java | 27 + .../approval/ApprovalCompletionDraftIT.java | 519 +++++++++++++++++ .../gxwebsoft/support/ApprovalTestData.java | 203 +++++++ .../gxwebsoft/support/TestAuthentication.java | 23 +- 49 files changed, 3732 insertions(+), 3 deletions(-) create mode 100644 sql/approval.sql create mode 100644 sql/approval_menu.sql create mode 100644 src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java create mode 100644 src/test/java/com/gxwebsoft/support/ApprovalTestData.java diff --git a/pom.xml b/pom.xml index bc1296e..30b2cca 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + diff --git a/sql/approval.sql b/sql/approval.sql new file mode 100644 index 0000000..c87f202 --- /dev/null +++ b/sql/approval.sql @@ -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='协调单'; diff --git a/sql/approval_menu.sql b/sql/approval_menu.sql new file mode 100644 index 0000000..b92066c --- /dev/null +++ b/sql/approval_menu.sql @@ -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 + ); diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java new file mode 100644 index 0000000..3ea09af --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -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 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 ATTACHMENT_EXTENSIONS = + Collections.unmodifiableList(Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx")); + + /** 扩展名与允许的 content-type 对应关系(服务端双校验)。 */ + public static final Map> ATTACHMENT_CONTENT_TYPES; + + static { + Map> 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 STATUS_NAMES; + private static final Map DOC_TYPE_NAMES; + private static final Map ACTION_NAMES; + private static final Map CATEGORY_NAMES; + + static { + Map 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 docType = new LinkedHashMap<>(); + docType.put(DOC_COMPLETION, "业务完成确认单"); + docType.put(DOC_COORDINATION, "内部协调单"); + DOC_TYPE_NAMES = Collections.unmodifiableMap(docType); + + Map 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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java new file mode 100644 index 0000000..8893eeb --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java @@ -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 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("删除成功"); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java new file mode 100644 index 0000000..192eb34 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java @@ -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> organizations() { + return success(directoryService.listOrganizations()); + } + + @PreAuthorize("hasAuthority('approval:instance:assign')") + @ApiOperation("启用用户搜索") + @GetMapping("/users") + public ApiResult> users( + @RequestParam(value = "keywords", required = false) String keywords, + @RequestParam(value = "organizationId", required = false) Integer organizationId) { + return success(directoryService.searchUsers(keywords, organizationId)); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java new file mode 100644 index 0000000..586a594 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java @@ -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 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 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 detail(@PathVariable("id") Long id) { + return success(instanceService.detail(id, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("我发起的") + @GetMapping("/mine/page") + public ApiResult> mine(ApprovalMineParam param) { + return success(instanceService.mine(param, getLoginUser())); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java new file mode 100644 index 0000000..3aa1985 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java new file mode 100644 index 0000000..456873b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java new file mode 100644 index 0000000..121d497 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java new file mode 100644 index 0000000..fce235c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java new file mode 100644 index 0000000..ee9ff55 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java new file mode 100644 index 0000000..149b313 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java new file mode 100644 index 0000000..22d99a2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java new file mode 100644 index 0000000..81a34b8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java new file mode 100644 index 0000000..eeb5962 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java new file mode 100644 index 0000000..0019df0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java new file mode 100644 index 0000000..9c4c13f --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java new file mode 100644 index 0000000..2ee3873 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java new file mode 100644 index 0000000..0212f77 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java @@ -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 selectByIdForUpdate(@Param("id") Long id); + + /** + * 判断当前用户是否可查看实例。 + */ + int countVisible(@Param("id") Long id, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); + + /** + * 我发起的列表。 + */ + List selectMinePage(@Param("page") IPage page, + @Param("param") ApprovalMineParam param, + @Param("userId") Integer userId); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java new file mode 100644 index 0000000..999e44d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java @@ -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); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java new file mode 100644 index 0000000..34f58ed --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java new file mode 100644 index 0000000..9326221 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java @@ -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 { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml new file mode 100644 index 0000000..61cdb83 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java new file mode 100644 index 0000000..e8151d4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java new file mode 100644 index 0000000..05b67f5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java new file mode 100644 index 0000000..7a3dbfa --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -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; + +/** + * 审批附件服务:上传、逻辑删除、详情拼装。 + * + *

文件落盘与 sys_file_record 复用既有文件基础设施,附件行写 master 库。 + * 上传不开启跨库事务:先落文件记录再绑定附件,失败时最多留下孤儿文件(允许)。

+ */ +@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 listVos(Long instanceId) { + List attachments = attachmentMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalAttachment::getInstanceId, instanceId) + .orderByAsc(ApprovalAttachment::getId)); + if (attachments.isEmpty()) { + return new ArrayList<>(); + } + List fileIds = attachments.stream().map(ApprovalAttachment::getFileId).collect(Collectors.toList()); + Map files = fileService.listByIds(fileIds).stream() + .collect(Collectors.toMap(FileRecord::getId, Function.identity(), (a, b) -> a)); + List uploaderIds = attachments.stream().map(ApprovalAttachment::getUploaderId) + .distinct().collect(Collectors.toList()); + Map uploaders = directoryService.listUsersByIds(uploaderIds).stream() + .collect(Collectors.toMap(ApprovalUserVO::getUserId, this::displayName, (a, b) -> a)); + List 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() + .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 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(); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java new file mode 100644 index 0000000..0005653 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -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; + +/** + * 审批目录服务:组织、部门负责人/收文员解析、启用用户查询。 + * + *

直接使用 gxtyzx_core 的专用连接,而不是 MyBatis 动态数据源代理: + * 审批请求可能处在外层事务中,动态数据源在事务内无法切换库。 + * 所有 SQL 显式带 tenant_id。

+ */ +@Service +public class ApprovalDirectoryService { + + private static final RowMapper 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 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 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 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 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 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 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 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 listUsersByIds(List 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 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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java new file mode 100644 index 0000000..4711d23 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -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; + +/** + * 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。 + * + *

本类只写 master 库,必须在调用方的事务内执行;后续迭代在此扩展 + * 通过/驳回/撤回/存档/归档/派单的节点推进,不再改动已发布的方法签名。

+ */ +@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 approvers, + User operator, String action) { + List 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 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 wrapper = new UpdateWrapper() + .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 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 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(); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java new file mode 100644 index 0000000..c987404 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java @@ -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。 + * + *

使用 server 库专用连接,保证在 master 事务内仍能读写核心库。 + * 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。

+ */ +@Service +public class ApprovalFileService { + + private static final RowMapper 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 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 listByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + List 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()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java new file mode 100644 index 0000000..d618338 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java @@ -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。 + * + *

并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新查询。

+ */ + 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 parseNodes(Long definitionId) { + ApprovalFlowDefinition definition = getById(definitionId); + try { + List 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() + .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); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java new file mode 100644 index 0000000..7142dc9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -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; + +/** + * 业务完成确认单草稿、提交与详情服务。 + * + *

跨库约束:服务方法本身不开启事务;需要写 master 的片段放进 + * TransactionTemplate,任何 @DS("server") 的目录查询都在事务外完成, + * 避免动态数据源在事务内无法切换。

+ */ +@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 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 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 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 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 page = new Page<>(current, size); + List rows = instanceMapper.selectMinePage(page, param, user.getUserId()); + Map> nodeCache = new HashMap<>(); + List 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 records(ApprovalInstance instance) { + List records = recordMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalRecord::getInstanceId, instance.getId()) + .orderByAsc(ApprovalRecord::getCreateTime) + .orderByAsc(ApprovalRecord::getId)); + List 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 confirms(ApprovalInstance instance) { + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instance.getId()) + .eq(ApprovalTask::getNodeType, ApprovalConstants.NODE_CONFIRM) + .eq(ApprovalTask::getRound, instance.getRound()) + .orderByAsc(ApprovalTask::getId)); + List 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 tasks = taskMapper.selectList(new LambdaQueryWrapper() + .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() + .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 forms = completionFormMapper.selectList( + new LambdaQueryWrapper() + .eq(ApprovalCompletionForm::getInstanceId, instanceId) + .last("LIMIT 1")); + return forms.isEmpty() ? null : forms.get(0); + } + + private ApprovalCoordinationForm coordinationForm(Long instanceId) { + List forms = coordinationFormMapper.selectList( + new LambdaQueryWrapper() + .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> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List 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 inTransaction(Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java new file mode 100644 index 0000000..955954c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNoService.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.approval.service; + +import com.gxwebsoft.approval.entity.ApprovalNoSequence; +import com.gxwebsoft.approval.mapper.ApprovalNoSequenceMapper; +import com.gxwebsoft.common.core.exception.BusinessException; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.format.DateTimeFormatter; + +/** + * 审批单号服务:事务内行锁取号,格式 前缀 + yyyyMMdd + - + 至少 4 位流水。 + * + *

调用方必须开启事务,锁会在事务提交/回滚时释放。

+ */ +@Service +public class ApprovalNoService { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + @Resource + private ApprovalNoSequenceMapper noSequenceMapper; + + /** + * 生成当天单号;同租户+前缀+日期每日重置,超过 9999 自然进位。 + */ + public String nextNo(String prefix) { + noSequenceMapper.insertTodayIfAbsent(prefix); + ApprovalNoSequence sequence = noSequenceMapper.selectTodayForUpdate(prefix); + if (sequence == null) { + throw new BusinessException("单号序列初始化失败"); + } + noSequenceMapper.increment(sequence.getId()); + long current = sequence.getCurrentSeq() == null ? 1 : sequence.getCurrentSeq() + 1L; + return prefix + sequence.getSeqDate().format(DATE_FORMAT) + "-" + String.format("%04d", current); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java new file mode 100644 index 0000000..40e7537 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java @@ -0,0 +1,16 @@ +package com.gxwebsoft.approval.support; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 节点激活时快照的审批人。 + */ +@Data +@AllArgsConstructor +public class ApprovalApprover { + + private Integer userId; + + private String userName; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java new file mode 100644 index 0000000..c5a8bc0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.approval.support; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 列表查询数据库行,额外携带当前节点key与流程定义id供服务端翻译。 + */ +@Data +public class ApprovalItemRow implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String bizNo; + private String docType; + private String status; + private String title; + private LocalDateTime submitTime; + private Integer reporterId; + private String reporterName; + private String reportOrgName; + private String acceptOrgName; + private LocalDateTime archiveTime; + private String currentNodeKey; + private Long definitionId; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java new file mode 100644 index 0000000..968fac5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java @@ -0,0 +1,24 @@ +package com.gxwebsoft.approval.support; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 流程定义中的节点描述(nodes_json 数组元素)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ApprovalNode { + + /** 节点标识,仅在同一流程内唯一。 */ + private String key; + + /** 节点类型:APPROVE/COUNTERSIGN/DISPATCH/CONFIRM/ARCHIVE。 */ + private String type; + + /** 审批人解析器标识,由 ApprovalDirectoryService 负责解析。 */ + private String resolver; + + /** 节点显示名称。 */ + private String name; +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java new file mode 100644 index 0000000..6ce3fdc --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalTenantContext.java @@ -0,0 +1,48 @@ +package com.gxwebsoft.approval.support; + +import com.gxwebsoft.common.system.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * 审批域租户上下文。 + * + *

gxtyzx_core 的查询使用独立连接(不受 master 事务绑定影响), + * 因此需要显式拼 tenant_id;解析顺序与登录用户一致: + * 登录用户 → tenantId 请求头 → 兜底 10150。

+ */ +public final class ApprovalTenantContext { + + public static final int DEFAULT_TENANT_ID = 10150; + + private ApprovalTenantContext() { + } + + public static Integer currentTenantId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof User) { + Integer tenantId = ((User) authentication.getPrincipal()).getTenantId(); + if (tenantId != null) { + return tenantId; + } + } + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes) { + HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest(); + String header = request.getHeader("tenantId"); + if (header != null && !header.isEmpty()) { + try { + return Integer.valueOf(header); + } catch (NumberFormatException ignored) { + // 非法租户头按兜底租户处理 + } + } + } + return DEFAULT_TENANT_ID; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java new file mode 100644 index 0000000..3034537 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAttachmentVO.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 详情中的附件。 + */ +@Data +@ApiModel(description = "审批附件") +public class ApprovalAttachmentVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private Integer fileId; + + private String name; + + private String url; + + private Long length; + + private String category; + + private String categoryName; + + private Integer uploaderId; + + private String uploaderName; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java new file mode 100644 index 0000000..4409f5b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalConfirmVO.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 当前轮的存档确认状态。 + */ +@Data +@ApiModel(description = "存档确认状态") +public class ApprovalConfirmVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer userId; + + private String userName; + + private String status; + + private String statusName; + + private LocalDateTime handleTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java new file mode 100644 index 0000000..55235d2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalInstanceVO.java @@ -0,0 +1,92 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 审批实例详情。 + */ +@Data +@ApiModel(description = "审批实例详情") +public class ApprovalInstanceVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String bizNo; + + private String docType; + + private String docTypeName; + + private String flowCode; + + private String status; + + private String statusName; + + private Integer round; + + private Integer reporterId; + + private String reporterName; + + private Integer reportOrgId; + + private String reportOrgName; + + private Integer acceptOrgId; + + private String acceptOrgName; + + private String currentNodeKey; + + private String currentNodeType; + + private String currentNodeName; + + private LocalDateTime submitTime; + + private LocalDateTime approveTime; + + private LocalDateTime archiveTime; + + private Integer archivistId; + + private String archivistName; + + // 业务完成确认单字段 + private String name; + + private String implementOrg; + + private BigDecimal fee; + + private String serviceContent; + + // 内部协调单字段 + private String title; + + private String content; + + private String remark; + + private Integer dispatchTargetId; + + private String dispatchTargetName; + + private List attachments; + + private List records; + + private List confirms; + + private ApprovalMyTaskVO myTask; + + private ApprovalPermissionsVO permissions; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java new file mode 100644 index 0000000..4a58e90 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalItemVO.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 我发起的列表行。 + */ +@Data +@ApiModel(description = "审批单据列表行") +public class ApprovalItemVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String bizNo; + + private String docType; + + private String docTypeName; + + private String status; + + private String statusName; + + @ApiModelProperty("名称或标题") + private String title; + + private LocalDateTime submitTime; + + private String currentNodeName; + + private Integer reporterId; + + private String reporterName; + + private String reportOrgName; + + private String acceptOrgName; + + private LocalDateTime archiveTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java new file mode 100644 index 0000000..0ca7eef --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalMyTaskVO.java @@ -0,0 +1,23 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 当前登录人在该实例上的待办任务。 + */ +@Data +@ApiModel(description = "当前用户待办任务") +public class ApprovalMyTaskVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private String nodeKey; + + private String nodeType; + + private String status; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java new file mode 100644 index 0000000..a24b3a0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalOrganizationVO.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 启用组织。 + */ +@Data +@ApiModel(description = "审批目录组织") +public class ApprovalOrganizationVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer organizationId; + + private Integer parentId; + + private String organizationName; + + private String organizationFullName; + + private String organizationCode; + + private Integer leaderId; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java new file mode 100644 index 0000000..a37dfc8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalPermissionsVO.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 详情动作权限,前端据此显示按钮。 + */ +@Data +@ApiModel(description = "单据动作权限") +public class ApprovalPermissionsVO implements Serializable { + private static final long serialVersionUID = 1L; + + private boolean canEdit; + + private boolean canSubmit; + + private boolean canWithdraw; + + private boolean canApprove; + + private boolean canReject; + + private boolean canConfirm; + + private boolean canArchive; + + private boolean canDispatch; + + private boolean canAssign; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java new file mode 100644 index 0000000..99cf750 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalRecordVO.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 详情时间线记录。 + */ +@Data +@ApiModel(description = "审批处理记录") +public class ApprovalRecordVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + private Long taskId; + + private String nodeKey; + + private String nodeName; + + private Integer operatorId; + + private String operatorName; + + private String action; + + private String actionName; + + private String opinion; + + private Integer round; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java new file mode 100644 index 0000000..3046425 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalUserVO.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 审批目录用户。 + */ +@Data +@ApiModel(description = "审批目录用户") +public class ApprovalUserVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer userId; + + private String username; + + private String nickname; + + private String realName; + + private Integer organizationId; + + private String organizationName; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java new file mode 100644 index 0000000..953aa0e --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java @@ -0,0 +1,519 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 业务完成确认单:草稿、附件、提交预检与引擎起步的集成测试。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +@Transactional +class ApprovalCompletionDraftIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer organizationId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("测试部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("报送人" + data.uniqueSuffix(), organizationId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void createsDraftWithoutBizNoAndListsAsUnsubmitted() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "场馆维修"); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(organizationId); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.statusName").value("未提交")) + .andExpect(jsonPath("$.data.name").value("场馆维修")) + .andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.bizNo").doesNotExist()); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].statusName").value("未提交")) + .andExpect(jsonPath("$.data.list[0].title").value("场馆维修")) + .andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist()); + } + + @Test + void validatesCompletionFields() throws Exception { + expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空"); + expectSaveFailure(body(repeat("名", 101), "实施单位", "10.00", "服务内容"), "名称不能超过100字"); + expectSaveFailure(body("名称", "", "10.00", "服务内容"), "实施单位不能为空"); + expectSaveFailure(body("名称", "实施单位", "-1", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "100000000", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "1.234", "服务内容"), "费用必须为0到99999999.99之间的两位小数"); + expectSaveFailure(body("名称", "实施单位", "10.00", ""), "服务内容不能为空"); + expectSaveFailure(body("名称", "实施单位", "10.00", repeat("内", 2001)), "服务内容不能超过2000字"); + } + + @Test + void rejectsSubmitWhenReporterHasNoOrganization() throws Exception { + ApprovalTestData.TestUser noOrgReporter = data.createUser("无部门" + data.uniqueSuffix(), null); + long instanceId = createCompletion(noOrgReporter, null, TENANT, "无部门单据"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + noOrgReporter, null, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("未归属部门"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenDepartmentLeaderRoleMissing() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "缺负责人"); + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void rejectsSubmitWhenArchivistRoleMissing() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "缺收文员"); + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收文员角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void rejectsSubmitWhenAttachmentMissing() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "无附件"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + } + + @Test + void submitsAndStartsDepartmentLeaderCountersign() throws Exception { + List leaders = grantDepartmentLeader(organizationId, TENANT, 2); + grantArchivist(TENANT); + long instanceId = createCompletion(reporter, organizationId, TENANT, "完工确认"); + long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png"); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.statusName").value("审批中")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT")) + .andExpect(jsonPath("$.data.records[0].actionName").value("提交")); + + assertThat(instanceStatus(instanceId)).isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)).isEqualTo(1); + assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)).isNotNull(); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("deptLeader"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("APPROVE"); + String bizNo = bizNo(instanceId); + assertThat(bizNo).matches("WG\\d{8}-\\d{4,}"); + assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase()); + + List assignees = data.master().queryForList( + "SELECT assignee_id FROM approval_task WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", + Integer.class, instanceId); + assertThat(assignees).containsExactlyInAnyOrder(leaders.get(0).userId, leaders.get(1).userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'SUBMIT'", + instanceId)).isEqualTo(1); + + // 提交后字段与附件均只读 + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body("改名", "实施单位", "10.00", "服务内容", instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + + MockMultipartFile file = new MockMultipartFile("file", "locked.png", "image/png", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "OTHER") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + + // 重复提交保持审批中 + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, TENANT, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)); + assertThat(instanceStatus(instanceId)).isEqualTo("PENDING"); + assertThat(attachmentId).isPositive(); + } + + @Test + void allocatesUniqueDailySequenceByTenant() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + + long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号一")); + long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号二")); + + String firstNo = bizNo(first); + String secondNo = bizNo(second); + String date = todayFromDatabase(); + assertThat(firstNo).matches("WG" + date + "-\\d{4,}"); + assertThat(secondNo).matches("WG" + date + "-\\d{4,}"); + int firstSeq = Integer.parseInt(firstNo.substring(firstNo.indexOf('-') + 1)); + int secondSeq = Integer.parseInt(secondNo.substring(secondNo.indexOf('-') + 1)); + assertThat(secondSeq).isEqualTo(firstSeq + 1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + TENANT, firstNo)).isEqualTo(1); + + // 另一租户使用同一天同一前缀独立取号,互不冲突 + int otherTenant = 10023; + Integer otherOrg = data.createOrganization("外租户部门" + data.uniqueSuffix(), 136, otherTenant); + ApprovalTestData.TestUser otherReporter = data.createUser("外租户报送人" + data.uniqueSuffix(), otherOrg, otherTenant); + ApprovalTestData.TestRole otherLeaderRole = data.createRole("dept_leader_" + otherOrg, "外租户负责人", otherTenant); + ApprovalTestData.TestUser otherLeader = data.createUser("外租户负责人" + data.uniqueSuffix(), otherOrg, otherTenant); + data.bindTenant(otherLeader.userId, otherLeaderRole.roleId, otherTenant); + ApprovalTestData.TestRole otherArchivistRole = data.createRole("approval_archivist", "外租户收文员", otherTenant); + ApprovalTestData.TestUser otherArchivist = data.createUser("外租户收文员" + data.uniqueSuffix(), otherOrg, otherTenant); + data.bindTenant(otherArchivist.userId, otherArchivistRole.roleId, otherTenant); + + long otherInstance = submitCompletionFor(createCompletion(otherReporter, otherOrg, otherTenant, "外租户单据"), + otherReporter, otherOrg, otherTenant); + String otherNo = bizNo(otherInstance); + assertThat(otherNo).matches("WG" + date + "-\\d{4,}"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + otherTenant, otherNo)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?", + TENANT, firstNo)).isEqualTo(1); + } + + @Test + void enforcesAttachmentRules() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "附件规则"); + + MockMultipartFile text = new MockMultipartFile("file", "evil.txt", "text/plain", + "x".getBytes(StandardCharsets.UTF_8)); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(text) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持"))); + + MockMultipartFile mismatch = new MockMultipartFile("file", "fake.jpg", "application/pdf", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(mismatch) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持"))); + + MockMultipartFile tooLarge = new MockMultipartFile("file", "big.png", "image/png", + new byte[20 * 1024 * 1024 + 1]); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(tooLarge) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("20MB"))); + + for (int i = 0; i < 9; i++) { + uploadAttachment(instanceId, reporter, organizationId, "image/png"); + } + MockMultipartFile tenth = new MockMultipartFile("file", "tenth.png", "image/png", pngBytes()); + mockMvc.perform(multipart("/api/approval/attachment/upload").file(tenth) + .param("instanceId", String.valueOf(instanceId)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("9张"))); + } + + @Test + void deletesAttachmentLogicallyAndKeepsFileRecord() throws Exception { + long instanceId = createCompletion(reporter, organizationId, TENANT, "删除附件"); + long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png"); + Integer fileId = data.queryMaster("SELECT file_id FROM approval_attachment WHERE id = ?", Integer.class, attachmentId); + + mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, TENANT, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(data.queryMaster("SELECT deleted FROM approval_attachment WHERE id = ?", Integer.class, attachmentId)) + .isEqualTo(1); + assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND deleted = 0", fileId)) + .isEqualTo(1); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.data.attachments.length()").value(0)); + } + + @Test + void mineListIsOrderedAndScoped() throws Exception { + grantDepartmentLeader(organizationId, TENANT); + grantArchivist(TENANT); + long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "先提交")); + long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "后提交")); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(2)) + .andExpect(jsonPath("$.data.list[0].id").value((int) second)) + .andExpect(jsonPath("$.data.list[1].id").value((int) first)) + .andExpect(jsonPath("$.data.list[0].statusName").value("审批中")) + .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty()) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + ApprovalTestData.TestUser outsider = data.createUser("路人" + data.uniqueSuffix(), organizationId); + mockMvc.perform(authed(get("/api/approval/mine/page"), outsider, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.data.count").value(0)); + + mockMvc.perform(authed(get("/api/approval/instance/" + first), outsider, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + + ApprovalTestData.TestUser archivist = grantArchivist(TENANT); + mockMvc.perform(authed(get("/api/approval/instance/" + first), archivist, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) first)); + } + + @Test + void draftStaysInvisibleEvenToAllVisibleArchivist() throws Exception { + ApprovalTestData.TestUser archivist = grantArchivist(TENANT); + long draft = createCompletion(reporter, organizationId, TENANT, "草稿保密"); + + mockMvc.perform(authed(get("/api/approval/instance/" + draft), archivist, organizationId, TENANT, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + } + + // ---------- helpers ---------- + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage))); + } + + private long createCompletion(ApprovalTestData.TestUser user, Integer org, int tenant, String name) throws Exception { + String body = body(name, "广西体育中心实施部", "123.45", "完成场馆设施维修与验收"); + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), user, org, tenant, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response); + long id = node.path("data").asLong(); + data.trackInstance(id); + return id; + } + + private long submitCompletion(long instanceId) throws Exception { + return submitCompletionFor(instanceId, reporterFor(instanceId), organizationIdFor(instanceId), TENANT); + } + + private long submitCompletionFor(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant) + throws Exception { + uploadAttachment(instanceId, user, org, tenant, "image/png"); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, tenant, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, + String contentType) throws Exception { + return uploadAttachment(instanceId, user, org, TENANT, contentType); + } + + private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant, + String contentType) throws Exception { + String filename = "sign." + extensionOf(contentType); + MockMultipartFile file = new MockMultipartFile("file", filename, contentType, pngBytes()); + String response = mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", tenant) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return objectMapper.readTree(response).path("data").path("id").asLong(); + } + + private String extensionOf(String contentType) { + switch (contentType) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + default: + return "bin"; + } + } + + private byte[] pngBytes() { + return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, ApprovalTestData.TestUser user, + Integer org, int tenant, String... authorities) { + return builder.header("tenantId", tenant) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, authorities))); + } + + private ApprovalTestData.TestUser reporterFor(long instanceId) { + Integer reporterId = data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId); + return new ApprovalTestData.TestUser(reporterId, "reporter-" + reporterId, "报送人"); + } + + private Integer organizationIdFor(long instanceId) { + return data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId); + } + + private List grantDepartmentLeader(int orgId, int tenant) { + return grantDepartmentLeader(orgId, tenant, 1); + } + + private List grantDepartmentLeader(int orgId, int tenant, int memberCount) { + ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人", tenant); + List members = new ArrayList<>(); + for (int i = 0; i < memberCount; i++) { + ApprovalTestData.TestUser member = data.createUser("负责人" + data.uniqueSuffix(), orgId, tenant); + data.bindTenant(member.userId, role.roleId, tenant); + members.add(member); + } + return members; + } + + private ApprovalTestData.TestUser grantArchivist(int tenant) { + ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员", tenant); + ApprovalTestData.TestUser member = data.createUser("收文员" + data.uniqueSuffix(), organizationId, tenant); + data.bindTenant(member.userId, role.roleId, tenant); + return member; + } + + private String body(String name, String implementOrg, String fee, String serviceContent) { + return body(name, implementOrg, fee, serviceContent, null); + } + + private String body(String name, String implementOrg, String fee, String serviceContent, Long id) { + StringBuilder sb = new StringBuilder("{"); + if (id != null) { + sb.append("\"id\":").append(id).append(','); + } + sb.append("\"name\":").append(quote(name)).append(',') + .append("\"implementOrg\":").append(quote(implementOrg)).append(',') + .append("\"fee\":").append(fee).append(',') + .append("\"serviceContent\":").append(quote(serviceContent)) + .append('}'); + return sb.toString(); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String value, int count) { + return value.repeat(count); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String bizNo(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String todayFromDatabase() { + return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class); + } +} diff --git a/src/test/java/com/gxwebsoft/support/ApprovalTestData.java b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java new file mode 100644 index 0000000..913b843 --- /dev/null +++ b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java @@ -0,0 +1,203 @@ +package com.gxwebsoft.support; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。 + * + *

sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate, + * 避免测试事务与动态数据源切换互相影响。

+ */ +public class ApprovalTestData { + + private static final AtomicInteger SEQ = new AtomicInteger(); + + private final JdbcTemplate master; + private final JdbcTemplate server; + + private final List userIds = new ArrayList<>(); + private final List roleIds = new ArrayList<>(); + private final List organizationIds = new ArrayList<>(); + private final List instanceIds = new ArrayList<>(); + + public ApprovalTestData(DataSource dataSource) { + this.master = new JdbcTemplate(dataSource); + DataSource serverDataSource = ((DynamicRoutingDataSource) dataSource).getDataSource("server"); + this.server = new JdbcTemplate(serverDataSource); + } + + /** 唯一后缀,避免共享库中的账号/角色/组织重名。 */ + public String uniqueSuffix() { + return System.nanoTime() + "-" + SEQ.incrementAndGet(); + } + + public TestUser createUser(String realName, Integer organizationId, int tenantId) { + String suffix = uniqueSuffix(); + String username = "it_" + suffix; + Integer id = insert(server, + "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) " + + "VALUES (?, 'it', ?, ?, ?, 0, 0, ?)", + username, realName, realName, organizationId, tenantId); + userIds.add(id); + return new TestUser(id, username, realName); + } + + public TestUser createUser(String realName, Integer organizationId) { + return createUser(realName, organizationId, TestAuthentication.TENANT_ID); + } + + public TestRole createRole(String roleCode, String roleName, int tenantId) { + Integer id = insert(server, + "INSERT INTO sys_role (role_name, role_code, sort_number, deleted, tenant_id) VALUES (?, ?, 1, 0, ?)", + roleName, roleCode, tenantId); + roleIds.add(id); + return new TestRole(id, roleCode); + } + + public TestRole createRole(String roleCode, String roleName) { + return createRole(roleCode, roleName, TestAuthentication.TENANT_ID); + } + + public void bind(int userId, int roleId) { + insert(server, + "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)", + userId, roleId, TestAuthentication.TENANT_ID); + } + + public void bindTenant(int userId, int roleId, int tenantId) { + insert(server, + "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)", + userId, roleId, tenantId); + } + + public Integer createOrganization(String name, int parentId, int tenantId) { + Integer id = insert(server, + "INSERT INTO sys_organization (organization_name, parent_id, organization_type, deleted, tenant_id) " + + "VALUES (?, ?, 'dept', 0, ?)", + name, parentId, tenantId); + organizationIds.add(id); + return id; + } + + public Integer createOrganization(String name, int parentId) { + return createOrganization(name, parentId, TestAuthentication.TENANT_ID); + } + + public void disableOrganization(int organizationId) { + server.update("UPDATE sys_organization SET deleted = 1 WHERE organization_id = ?", organizationId); + } + + public void setUserOrganization(int userId, Integer organizationId) { + server.update("UPDATE sys_user SET organization_id = ? WHERE user_id = ?", organizationId, userId); + } + + public Integer countServer(String sql, Object... args) { + return server.queryForObject(sql, Integer.class, args); + } + + public Integer countMaster(String sql, Object... args) { + return master.queryForObject(sql, Integer.class, args); + } + + public T queryMaster(String sql, Class type, Object... args) { + return master.queryForObject(sql, type, args); + } + + public JdbcTemplate master() { + return master; + } + + public JdbcTemplate server() { + return server; + } + + /** 记录实例 id,便于非事务用例结束后清理。 */ + public void trackInstance(Long instanceId) { + if (instanceId != null) { + instanceIds.add(instanceId); + } + } + + /** 清理本次用例创建的核心库数据与审批数据。 */ + public void cleanup() { + try { + for (Long instanceId : instanceIds) { + deleteInstance(instanceId); + } + instanceIds.clear(); + for (Integer userId : userIds) { + server.update("DELETE FROM sys_user_role WHERE user_id = ?", userId); + server.update("DELETE FROM sys_file_record WHERE create_user_id = ?", userId); + server.update("DELETE FROM sys_user WHERE user_id = ?", userId); + } + userIds.clear(); + for (Integer roleId : roleIds) { + server.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId); + server.update("DELETE FROM sys_role WHERE role_id = ?", roleId); + } + roleIds.clear(); + for (Integer organizationId : organizationIds) { + server.update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId); + } + organizationIds.clear(); + } catch (Exception ignored) { + // 清理失败不阻断测试报告;残留行均由唯一后缀标识。 + } + } + + private void deleteInstance(Long instanceId) { + master.update("DELETE FROM approval_attachment WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_record WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_task WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_completion_form WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_coordination_form WHERE instance_id = ?", instanceId); + master.update("DELETE FROM approval_instance WHERE id = ?", instanceId); + } + + private Integer insert(JdbcTemplate jdbcTemplate, String sql, Object... args) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + for (int i = 0; i < args.length; i++) { + statement.setObject(i + 1, args[i]); + } + return statement; + }, keyHolder); + Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("未取得自增主键: " + sql); + } + return key.intValue(); + } + + public static class TestUser { + public final int userId; + public final String username; + public final String realName; + + public TestUser(int userId, String username, String realName) { + this.userId = userId; + this.username = username; + this.realName = realName; + } + } + + public static class TestRole { + public final int roleId; + public final String roleCode; + + TestRole(int roleId, String roleCode) { + this.roleId = roleId; + this.roleCode = roleCode; + } + } +} diff --git a/src/test/java/com/gxwebsoft/support/TestAuthentication.java b/src/test/java/com/gxwebsoft/support/TestAuthentication.java index 21a2051..cb7f05f 100644 --- a/src/test/java/com/gxwebsoft/support/TestAuthentication.java +++ b/src/test/java/com/gxwebsoft/support/TestAuthentication.java @@ -23,6 +23,20 @@ public final class TestAuthentication { } public static Authentication admin(String... authorities) { + return user(USER_ID, "admin", null, TENANT_ID, authorities); + } + + /** + * 构造指定用户/组织/租户的登录态,供审批流集成测试使用。 + * + * @param userId 用户id + * @param username 账号(同时作为姓名) + * @param organizationId 所属部门id,可为 null + * @param tenantId 租户id + * @param authorities 权限串 + */ + public static Authentication user(int userId, String username, Integer organizationId, + int tenantId, String... authorities) { List 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);