merge: 一期线下审批(#18-#26)
This commit is contained in:
@@ -40,6 +40,13 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- spring-security-test: MockMvc 直接注入登录态 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- spring-boot-web -->
|
<!-- spring-boot-web -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- 一期线下审批公共表与业务表(可重复执行)
|
||||||
|
-- 目标库:db_gxtyzx(master)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_flow_definition (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
flow_code VARCHAR(40) NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
nodes_json JSON NOT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_flow_ver (tenant_id, flow_code, version)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义版本';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_instance (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
biz_no VARCHAR(32) DEFAULT NULL,
|
||||||
|
flow_code VARCHAR(40) NOT NULL,
|
||||||
|
definition_id BIGINT NOT NULL,
|
||||||
|
definition_version INT NOT NULL,
|
||||||
|
doc_type VARCHAR(40) NOT NULL,
|
||||||
|
biz_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
round INT NOT NULL DEFAULT 0,
|
||||||
|
reporter_id INT NOT NULL,
|
||||||
|
reporter_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
report_org_id INT DEFAULT NULL,
|
||||||
|
report_org_name VARCHAR(200) DEFAULT NULL,
|
||||||
|
accept_org_id INT DEFAULT NULL,
|
||||||
|
accept_org_name VARCHAR(200) DEFAULT NULL,
|
||||||
|
current_node_key VARCHAR(40) DEFAULT NULL,
|
||||||
|
current_node_type VARCHAR(20) DEFAULT NULL,
|
||||||
|
dispatch_target_id INT DEFAULT NULL,
|
||||||
|
dispatch_target_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
submit_time DATETIME DEFAULT NULL,
|
||||||
|
approve_time DATETIME DEFAULT NULL,
|
||||||
|
archive_time DATETIME DEFAULT NULL,
|
||||||
|
archivist_id INT DEFAULT NULL,
|
||||||
|
archivist_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_biz_no (tenant_id, biz_no),
|
||||||
|
KEY idx_status_submit (tenant_id, status, submit_time),
|
||||||
|
KEY idx_reporter (tenant_id, reporter_id),
|
||||||
|
KEY idx_report_org (tenant_id, report_org_id),
|
||||||
|
KEY idx_accept_org (tenant_id, accept_org_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_task (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
instance_id BIGINT NOT NULL,
|
||||||
|
node_key VARCHAR(40) NOT NULL,
|
||||||
|
node_type VARCHAR(20) NOT NULL,
|
||||||
|
round INT NOT NULL,
|
||||||
|
task_type VARCHAR(20) NOT NULL,
|
||||||
|
assignee_id INT NOT NULL,
|
||||||
|
assignee_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
handle_time DATETIME DEFAULT NULL,
|
||||||
|
opinion VARCHAR(500) DEFAULT NULL,
|
||||||
|
dispatch_target_id INT DEFAULT NULL,
|
||||||
|
dispatch_target_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_instance (instance_id),
|
||||||
|
KEY idx_assignee_status (tenant_id, assignee_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_record (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
instance_id BIGINT NOT NULL,
|
||||||
|
task_id BIGINT DEFAULT NULL,
|
||||||
|
node_key VARCHAR(40) DEFAULT NULL,
|
||||||
|
operator_id INT NOT NULL,
|
||||||
|
operator_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
action VARCHAR(20) NOT NULL,
|
||||||
|
opinion VARCHAR(500) DEFAULT NULL,
|
||||||
|
round INT NOT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_instance_time (instance_id, create_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批处理记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_attachment (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
instance_id BIGINT NOT NULL,
|
||||||
|
file_id INT NOT NULL,
|
||||||
|
category VARCHAR(20) NOT NULL,
|
||||||
|
uploader_id INT NOT NULL,
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_instance (instance_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批附件';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_no_sequence (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
prefix VARCHAR(8) NOT NULL,
|
||||||
|
seq_date DATE NOT NULL,
|
||||||
|
current_seq INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_seq (tenant_id, prefix, seq_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批单号序列';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_completion_form (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
instance_id BIGINT NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
implement_org VARCHAR(100) NOT NULL,
|
||||||
|
fee DECIMAL(12,2) NOT NULL,
|
||||||
|
service_content VARCHAR(2000) NOT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_instance (instance_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务完成确认单';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_coordination_form (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id INT NOT NULL DEFAULT 10150,
|
||||||
|
instance_id BIGINT NOT NULL,
|
||||||
|
accept_org_id INT NOT NULL,
|
||||||
|
accept_org_name VARCHAR(200) NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
content VARCHAR(2000) NOT NULL,
|
||||||
|
remark VARCHAR(500) DEFAULT NULL,
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_instance (instance_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协调单';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- 一期线下审批菜单与权限种子(可重复执行)
|
||||||
|
-- 目标库:gxtyzx_core(server)
|
||||||
|
|
||||||
|
-- 顶级菜单:审批管理
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT 0, '审批管理', '/approval', '', 18, 'approval:instance:list', 'AuditOutlined', 0, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
|
||||||
|
|
||||||
|
-- 子菜单:待办审批
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @parent, '待办审批', '/approval/todo', '/approval/todo', 1, 'approval:instance:list', '', 0, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/todo' AND menu_type = 0 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 子菜单:单据查询
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @parent, '单据查询', '/approval/instance', '/approval/instance', 2, 'approval:instance:list', '', 0, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
|
||||||
|
|
||||||
|
-- 按钮权限(挂在单据查询下)
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @instanceMenu, '提交单据', '', '', 1, 'approval:instance:submit', '', 1, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:submit' AND menu_type = 1 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @instanceMenu, '审批通过', '', '', 2, 'approval:instance:approve', '', 1, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:approve' AND menu_type = 1 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @instanceMenu, '归档', '', '', 3, 'approval:instance:archive', '', 1, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:archive' AND menu_type = 1 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @instanceMenu, '改派', '', '', 4, 'approval:instance:assign', '', 1, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:assign' AND menu_type = 1 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
|
||||||
|
SELECT @instanceMenu, '导出', '', '', 5, 'approval:instance:export', '', 1, 10150, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:export' AND menu_type = 1 AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 授权 superAdmin(role_id = 586, tenant 10150)
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
|
||||||
|
SELECT 586, m.menu_id, 10150
|
||||||
|
FROM sys_menu m
|
||||||
|
WHERE m.tenant_id = 10150
|
||||||
|
AND m.deleted = 0
|
||||||
|
AND (m.path IN ('/approval', '/approval/todo', '/approval/instance') OR m.authority LIKE 'approval:instance:%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
|
||||||
|
);
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
-- 审批组织与岗位角色种子(可重复执行)
|
||||||
|
-- 目标库:gxtyzx_core(server)
|
||||||
|
-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql
|
||||||
|
--
|
||||||
|
-- 覆盖 0630 九类线下审批涉及的部门与岗位:
|
||||||
|
-- 部门:场馆部、运营管理部、安全运保部、财务部(均为新建,不复用历史部门)
|
||||||
|
-- 部门岗位:dept_leader_<orgCode>(负责人)、dept_deputy_<orgCode>(分管领导)
|
||||||
|
-- 租户岗位:approval_archivist(收文员)、approval_general_manager(总经理/主要领导)、
|
||||||
|
-- approval_cashier(出纳)、approval_typist(录入员)、approval_issuer(发放人)
|
||||||
|
--
|
||||||
|
-- 只建部门与空角色,不写成员、不授予菜单权限;成员与授权由客户在后台分配。
|
||||||
|
-- 按 tenant_id=10150 执行;换租户时改下面的 @tenant_id。
|
||||||
|
|
||||||
|
SET @tenant_id := 10150;
|
||||||
|
SET @root_id := (
|
||||||
|
SELECT organization_id FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND organization_name = '广西体育中心' AND deleted = 0
|
||||||
|
ORDER BY organization_id LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 部门
|
||||||
|
-- 场馆部按种子编码新建;历史同名部门不更新、不删除。
|
||||||
|
INSERT INTO sys_organization
|
||||||
|
(parent_id, organization_name, organization_full_name, organization_code,
|
||||||
|
organization_type, park, phone, email, business, industry, image, about,
|
||||||
|
sort_number, tenant_id, deleted)
|
||||||
|
SELECT @root_id, '场馆部', '场馆部', 'APPROVAL_VENUE',
|
||||||
|
'部门', '', '', '', '', '', '', '', 100, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_VENUE'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_organization
|
||||||
|
(parent_id, organization_name, organization_full_name, organization_code,
|
||||||
|
organization_type, park, phone, email, business, industry, image, about,
|
||||||
|
sort_number, tenant_id, deleted)
|
||||||
|
SELECT @root_id, '运营管理部', '运营管理部', 'APPROVAL_OPS',
|
||||||
|
'部门', '', '', '', '', '', '', '', 110, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_OPS'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_organization
|
||||||
|
(parent_id, organization_name, organization_full_name, organization_code,
|
||||||
|
organization_type, park, phone, email, business, industry, image, about,
|
||||||
|
sort_number, tenant_id, deleted)
|
||||||
|
SELECT @root_id, '安全运保部', '安全运保部', 'APPROVAL_SAFETY',
|
||||||
|
'部门', '', '', '', '', '', '', '', 120, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_SAFETY'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_organization
|
||||||
|
(parent_id, organization_name, organization_full_name, organization_code,
|
||||||
|
organization_type, park, phone, email, business, industry, image, about,
|
||||||
|
sort_number, tenant_id, deleted)
|
||||||
|
SELECT @root_id, '财务部', '财务部', 'APPROVAL_FINANCE',
|
||||||
|
'部门', '', '', '', '', '', '', '', 130, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_FINANCE'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @venue_code := (
|
||||||
|
SELECT organization_code FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_VENUE'
|
||||||
|
ORDER BY organization_id LIMIT 1
|
||||||
|
);
|
||||||
|
SET @ops_code := (
|
||||||
|
SELECT organization_code FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_OPS'
|
||||||
|
ORDER BY organization_id LIMIT 1
|
||||||
|
);
|
||||||
|
SET @safety_code := (
|
||||||
|
SELECT organization_code FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_SAFETY'
|
||||||
|
ORDER BY organization_id LIMIT 1
|
||||||
|
);
|
||||||
|
SET @finance_code := (
|
||||||
|
SELECT organization_code FROM sys_organization
|
||||||
|
WHERE tenant_id = @tenant_id AND deleted = 0 AND organization_code = 'APPROVAL_FINANCE'
|
||||||
|
ORDER BY organization_id LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 部门负责人(或签)
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '场馆部负责人', CONCAT('dept_leader_', @venue_code), '审批种子:场馆部负责人,多人或签', 901, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @venue_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_leader_', @venue_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '运营管理部负责人', CONCAT('dept_leader_', @ops_code), '审批种子:运营管理部负责人,多人或签', 902, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @ops_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_leader_', @ops_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '安全运保部负责人', CONCAT('dept_leader_', @safety_code), '审批种子:安全运保部负责人,多人或签', 903, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @safety_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_leader_', @safety_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '财务部负责人', CONCAT('dept_leader_', @finance_code), '审批种子:财务部负责人,多人或签', 904, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @finance_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_leader_', @finance_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 部门分管领导(二期使用,或签)
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '场馆部分管领导', CONCAT('dept_deputy_', @venue_code), '审批种子:场馆部分管领导,多人或签', 911, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @venue_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_deputy_', @venue_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '运营管理部分管领导', CONCAT('dept_deputy_', @ops_code), '审批种子:运营管理部分管领导,多人或签', 912, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @ops_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_deputy_', @ops_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '安全运保部分管领导', CONCAT('dept_deputy_', @safety_code), '审批种子:安全运保部分管领导,多人或签', 913, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @safety_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_deputy_', @safety_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '财务部分管领导', CONCAT('dept_deputy_', @finance_code), '审批种子:财务部分管领导,多人或签', 914, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @finance_code IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = CONCAT('dept_deputy_', @finance_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 租户岗位(或签)
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '收文员', 'approval_archivist', '审批种子:归档节点,多人或签', 921, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = 'approval_archivist'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '总经理', 'approval_general_manager', '审批种子:总经理/主要领导节点,多人或签', 922, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = 'approval_general_manager'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '出纳', 'approval_cashier', '审批种子:收据申请单出纳节点,多人或签', 923, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = 'approval_cashier'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '录入员', 'approval_typist', '审批种子:车辆免收费登记存档节点,多人或签', 924, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = 'approval_typist'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
|
||||||
|
SELECT '发放人', 'approval_issuer', '审批种子:体验券领用发放节点,多人或签', 925, @tenant_id, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0
|
||||||
|
AND role_code = 'approval_issuer'
|
||||||
|
);
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.gxwebsoft.approval.constant;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批域常量:状态、节点、动作、单据类型、单号前缀与附件规则。
|
||||||
|
*/
|
||||||
|
public final class ApprovalConstants {
|
||||||
|
|
||||||
|
private ApprovalConstants() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实例状态
|
||||||
|
public static final String STATUS_DRAFT = "DRAFT";
|
||||||
|
public static final String STATUS_PENDING = "PENDING";
|
||||||
|
public static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
public static final String STATUS_WITHDRAWN = "WITHDRAWN";
|
||||||
|
public static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
public static final String STATUS_ARCHIVED = "ARCHIVED";
|
||||||
|
|
||||||
|
/** 报送人可编辑、可提交的状态。 */
|
||||||
|
public static final Set<String> EDITABLE_STATUSES =
|
||||||
|
Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList(
|
||||||
|
STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN)));
|
||||||
|
|
||||||
|
// 任务状态
|
||||||
|
public static final String TASK_PENDING = "PENDING";
|
||||||
|
public static final String TASK_DONE = "DONE";
|
||||||
|
public static final String TASK_CANCELLED = "CANCELLED";
|
||||||
|
|
||||||
|
// 节点类型 / 任务类型
|
||||||
|
public static final String NODE_APPROVE = "APPROVE";
|
||||||
|
public static final String NODE_COUNTERSIGN = "COUNTERSIGN";
|
||||||
|
public static final String NODE_DISPATCH = "DISPATCH";
|
||||||
|
public static final String NODE_CONFIRM = "CONFIRM";
|
||||||
|
public static final String NODE_ARCHIVE = "ARCHIVE";
|
||||||
|
|
||||||
|
// 动作
|
||||||
|
public static final String ACTION_SUBMIT = "SUBMIT";
|
||||||
|
public static final String ACTION_RESUBMIT = "RESUBMIT";
|
||||||
|
public static final String ACTION_APPROVE = "APPROVE";
|
||||||
|
public static final String ACTION_REJECT = "REJECT";
|
||||||
|
public static final String ACTION_WITHDRAW = "WITHDRAW";
|
||||||
|
public static final String ACTION_DISPATCH = "DISPATCH";
|
||||||
|
public static final String ACTION_CONFIRM = "CONFIRM";
|
||||||
|
public static final String ACTION_ARCHIVE = "ARCHIVE";
|
||||||
|
public static final String ACTION_ASSIGN = "ASSIGN";
|
||||||
|
|
||||||
|
// 单据类型 / 流程编码
|
||||||
|
public static final String DOC_COMPLETION = "completion";
|
||||||
|
public static final String DOC_COORDINATION = "coordination";
|
||||||
|
public static final String FLOW_COMPLETION = "completion";
|
||||||
|
public static final String FLOW_COORDINATION_INTERNAL = "coordination_internal";
|
||||||
|
|
||||||
|
// 单号前缀
|
||||||
|
public static final String NO_PREFIX_COMPLETION = "WG";
|
||||||
|
public static final String NO_PREFIX_COORDINATION = "XT";
|
||||||
|
|
||||||
|
// 附件分类
|
||||||
|
public static final String CATEGORY_SIGNED = "SIGNED";
|
||||||
|
public static final String CATEGORY_OTHER = "OTHER";
|
||||||
|
|
||||||
|
// 角色编码
|
||||||
|
public static final String ROLE_ARCHIVIST = "approval_archivist";
|
||||||
|
public static final String ROLE_SUPER_ADMIN = "superAdmin";
|
||||||
|
/** 普通消费用户角色:无部门且仅有这些角色时不算派单候选。 */
|
||||||
|
public static final String ROLE_USER = "user";
|
||||||
|
public static final String ROLE_GUEST = "guest";
|
||||||
|
|
||||||
|
// 权限串(与 spec 约定一致;菜单/按钮共用)
|
||||||
|
public static final String PERMISSION_LIST = "approval:instance:list";
|
||||||
|
public static final String PERMISSION_SUBMIT = "approval:instance:submit";
|
||||||
|
public static final String PERMISSION_APPROVE = "approval:instance:approve";
|
||||||
|
public static final String PERMISSION_ARCHIVE = "approval:instance:archive";
|
||||||
|
public static final String PERMISSION_ASSIGN = "approval:instance:assign";
|
||||||
|
public static final String PERMISSION_EXPORT = "approval:instance:export";
|
||||||
|
|
||||||
|
// 附件规则
|
||||||
|
public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
|
||||||
|
public static final int MAX_ATTACHMENT_COUNT = 9;
|
||||||
|
public static final List<String> ATTACHMENT_EXTENSIONS =
|
||||||
|
Collections.unmodifiableList(Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx"));
|
||||||
|
|
||||||
|
/** 扩展名与允许的 content-type 对应关系(服务端双校验)。 */
|
||||||
|
public static final Map<String, List<String>> ATTACHMENT_CONTENT_TYPES;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, List<String>> types = new LinkedHashMap<>();
|
||||||
|
types.put("jpg", Arrays.asList("image/jpeg", "image/jpg"));
|
||||||
|
types.put("jpeg", Arrays.asList("image/jpeg", "image/jpg"));
|
||||||
|
types.put("png", Collections.singletonList("image/png"));
|
||||||
|
types.put("pdf", Collections.singletonList("application/pdf"));
|
||||||
|
types.put("doc", Collections.singletonList("application/msword"));
|
||||||
|
types.put("docx", Collections.singletonList(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
|
||||||
|
types.put("xls", Collections.singletonList("application/vnd.ms-excel"));
|
||||||
|
types.put("xlsx", Collections.singletonList(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||||
|
ATTACHMENT_CONTENT_TYPES = Collections.unmodifiableMap(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, String> STATUS_NAMES;
|
||||||
|
private static final Map<String, String> DOC_TYPE_NAMES;
|
||||||
|
private static final Map<String, String> ACTION_NAMES;
|
||||||
|
private static final Map<String, String> CATEGORY_NAMES;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, String> status = new LinkedHashMap<>();
|
||||||
|
status.put(STATUS_DRAFT, "未提交");
|
||||||
|
status.put(STATUS_PENDING, "审批中");
|
||||||
|
status.put(STATUS_REJECTED, "已驳回");
|
||||||
|
status.put(STATUS_WITHDRAWN, "已撤回");
|
||||||
|
status.put(STATUS_APPROVED, "审批完成");
|
||||||
|
status.put(STATUS_ARCHIVED, "已归档");
|
||||||
|
STATUS_NAMES = Collections.unmodifiableMap(status);
|
||||||
|
|
||||||
|
Map<String, String> docType = new LinkedHashMap<>();
|
||||||
|
docType.put(DOC_COMPLETION, "业务完成确认单");
|
||||||
|
docType.put(DOC_COORDINATION, "内部协调单");
|
||||||
|
DOC_TYPE_NAMES = Collections.unmodifiableMap(docType);
|
||||||
|
|
||||||
|
Map<String, String> action = new LinkedHashMap<>();
|
||||||
|
action.put(ACTION_SUBMIT, "提交");
|
||||||
|
action.put(ACTION_RESUBMIT, "重新提交");
|
||||||
|
action.put(ACTION_APPROVE, "通过");
|
||||||
|
action.put(ACTION_REJECT, "驳回");
|
||||||
|
action.put(ACTION_WITHDRAW, "撤回");
|
||||||
|
action.put(ACTION_DISPATCH, "派单");
|
||||||
|
action.put(ACTION_CONFIRM, "存档确认");
|
||||||
|
action.put(ACTION_ARCHIVE, "归档");
|
||||||
|
action.put(ACTION_ASSIGN, "改派");
|
||||||
|
ACTION_NAMES = Collections.unmodifiableMap(action);
|
||||||
|
|
||||||
|
Map<String, String> category = new LinkedHashMap<>();
|
||||||
|
category.put(CATEGORY_SIGNED, "签字件");
|
||||||
|
category.put(CATEGORY_OTHER, "其他");
|
||||||
|
CATEGORY_NAMES = Collections.unmodifiableMap(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务完成确认单流程定义(version=1)。 */
|
||||||
|
public static final String COMPLETION_NODES_JSON = "["
|
||||||
|
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
|
||||||
|
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
|
||||||
|
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
|
||||||
|
+ "]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单流程定义(version=2)。
|
||||||
|
*
|
||||||
|
* <p>version=1 的派单节点按受理部门全员生成任务,导致普通成员也能派单且部门无成员时卡单;
|
||||||
|
* v2 改为受理部门负责人(或签)。版本不可变:已发布实例仍引用其定义版本。</p>
|
||||||
|
*/
|
||||||
|
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\":\"acceptOrgLeader\",\"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 writeBlockedMessage(String status) {
|
||||||
|
return STATUS_ARCHIVED.equals(status) ? "单据已归档,不能修改" : "当前状态不可修改";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门岗位角色标识:后缀取启用组织的 organization_code。
|
||||||
|
* 部门未配置编码时返回 null,由调用方阻断并提示。
|
||||||
|
*/
|
||||||
|
public static String departmentLeaderRoleCode(String organizationCode) {
|
||||||
|
return departmentRoleCode("dept_leader_", organizationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String departmentDeputyRoleCode(String organizationCode) {
|
||||||
|
return departmentRoleCode("dept_deputy_", organizationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String departmentRoleCode(String prefix, String organizationCode) {
|
||||||
|
if (organizationCode == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String code = organizationCode.trim();
|
||||||
|
return code.isEmpty() ? null : prefix + code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalAttachmentService;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批附件接口:走审批域端点,不要求文件管理权限。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批附件")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval/attachment")
|
||||||
|
public class ApprovalAttachmentController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalAttachmentService attachmentService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("上传附件")
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public ApiResult<ApprovalAttachmentVO> upload(@RequestParam("instanceId") Long instanceId,
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "category", required = false) String category) {
|
||||||
|
return success(attachmentService.upload(instanceId, file, category, getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("逻辑删除附件")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResult<?> delete(@PathVariable("id") Long id) {
|
||||||
|
attachmentService.delete(id, getLoginUser());
|
||||||
|
return success("删除成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalDirectoryService;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批目录接口:组织与启用用户。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批目录")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval/directory")
|
||||||
|
public class ApprovalDirectoryController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalDirectoryService directoryService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("启用组织")
|
||||||
|
@GetMapping("/organizations")
|
||||||
|
public ApiResult<List<ApprovalOrganizationVO>> organizations() {
|
||||||
|
return success(directoryService.listOrganizations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAnyAuthority('approval:instance:list', 'approval:instance:assign')")
|
||||||
|
@ApiOperation("启用用户搜索")
|
||||||
|
@GetMapping("/users")
|
||||||
|
public ApiResult<List<ApprovalUserVO>> users(
|
||||||
|
@RequestParam(value = "keywords", required = false) String keywords,
|
||||||
|
@RequestParam(value = "organizationId", required = false) Integer organizationId) {
|
||||||
|
return success(directoryService.searchUsers(keywords, organizationId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalExportService;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalExportResult;
|
||||||
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
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;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据导出接口:与单据查询共用筛选参数与数据权限。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批单据导出")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval")
|
||||||
|
public class ApprovalExportController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalExportService exportService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:export')")
|
||||||
|
@OperationLog(module = "审批管理", comments = "导出单据查询结果")
|
||||||
|
@ApiOperation("导出单据")
|
||||||
|
@PostMapping("/export")
|
||||||
|
public ApiResult<FileRecord> export(@RequestBody ApprovalQueryParam param) throws IOException {
|
||||||
|
ApprovalExportResult result = exportService.export(param, getLoginUser());
|
||||||
|
return success("导出成功,共" + result.getRowCount() + "条", result.getFile());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批实例接口(员工端与 PC 共用)。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批实例")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval")
|
||||||
|
public class ApprovalInstanceController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalInstanceService instanceService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("新建或保存业务完成确认单草稿")
|
||||||
|
@PostMapping("/instance/completion")
|
||||||
|
public ApiResult<Long> saveCompletion(@RequestBody ApprovalCompletionParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
return success("保存成功", instanceService.saveCompletion(param, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("新建或保存内部协调单草稿")
|
||||||
|
@PostMapping("/instance/coordination")
|
||||||
|
public ApiResult<Long> saveCoordination(@RequestBody ApprovalCoordinationParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
return success("保存成功", instanceService.saveCoordination(param, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("提交或重提")
|
||||||
|
@PostMapping("/instance/{id}/submit")
|
||||||
|
public ApiResult<ApprovalInstanceVO> submit(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
String warning = instanceService.submit(id, user);
|
||||||
|
return success(warning == null ? "提交成功" : warning, instanceService.detail(id, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("单据详情")
|
||||||
|
@GetMapping("/instance/{id}")
|
||||||
|
public ApiResult<ApprovalInstanceVO> detail(@PathVariable("id") Long id) {
|
||||||
|
return success(instanceService.detail(id, getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("我发起的")
|
||||||
|
@GetMapping("/mine/page")
|
||||||
|
public ApiResult<PageResult<ApprovalItemVO>> mine(ApprovalMineParam param) {
|
||||||
|
return success(instanceService.mine(param, getLoginUser()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalQueryService;
|
||||||
|
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 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.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据查询接口。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批单据查询")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval")
|
||||||
|
public class ApprovalQueryController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalQueryService queryService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("PC 单据查询分页")
|
||||||
|
@GetMapping("/instance/page")
|
||||||
|
public ApiResult<PageResult<ApprovalItemVO>> page(ApprovalQueryParam param) {
|
||||||
|
return success(queryService.page(param, getLoginUser()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||||
|
import com.gxwebsoft.approval.service.ApprovalTaskService;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
|
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;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批待办与任务动作接口(员工端与 PC 共用)。
|
||||||
|
*/
|
||||||
|
@Api(tags = "审批待办")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/approval")
|
||||||
|
public class ApprovalTaskController extends BaseController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalTaskService taskService;
|
||||||
|
@Resource
|
||||||
|
private ApprovalInstanceService instanceService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("我的待办")
|
||||||
|
@GetMapping("/todo/page")
|
||||||
|
public ApiResult<PageResult<ApprovalItemVO>> todo(ApprovalTaskListParam param) {
|
||||||
|
return success(instanceService.todo(param, getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("我的已办")
|
||||||
|
@GetMapping("/done/page")
|
||||||
|
public ApiResult<PageResult<ApprovalItemVO>> done(ApprovalTaskListParam param) {
|
||||||
|
return success(instanceService.done(param, getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||||
|
@ApiOperation("待办数量")
|
||||||
|
@GetMapping("/todo/count")
|
||||||
|
public ApiResult<Integer> todoCount() {
|
||||||
|
return success(taskService.todoCount(getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@ApiOperation("通过")
|
||||||
|
@PostMapping("/task/{id}/approve")
|
||||||
|
public ApiResult<ApprovalInstanceVO> approve(@PathVariable("id") Long id,
|
||||||
|
@RequestBody(required = false) ApprovalTaskActionParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.approve(id, param == null ? null : param.getOpinion(), user);
|
||||||
|
return success(result.getWarning() == null ? "审批成功" : result.getWarning(),
|
||||||
|
instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@ApiOperation("驳回")
|
||||||
|
@PostMapping("/task/{id}/reject")
|
||||||
|
public ApiResult<ApprovalInstanceVO> reject(@PathVariable("id") Long id,
|
||||||
|
@RequestBody(required = false) ApprovalTaskActionParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.reject(id, param == null ? null : param.getOpinion(), user);
|
||||||
|
return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@ApiOperation("存档确认")
|
||||||
|
@PostMapping("/task/{id}/confirm")
|
||||||
|
public ApiResult<ApprovalInstanceVO> confirm(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.confirm(id, user);
|
||||||
|
return success(result.getWarning() == null ? "存档成功" : result.getWarning(),
|
||||||
|
instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:archive')")
|
||||||
|
@ApiOperation("归档")
|
||||||
|
@PostMapping("/task/{id}/archive")
|
||||||
|
public ApiResult<ApprovalInstanceVO> archive(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.archive(id, user);
|
||||||
|
return success("归档成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("撤回")
|
||||||
|
@PostMapping("/instance/{id}/withdraw")
|
||||||
|
public ApiResult<ApprovalInstanceVO> withdraw(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.withdraw(id, user);
|
||||||
|
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@ApiOperation("派单候选池")
|
||||||
|
@GetMapping("/task/{id}/candidates")
|
||||||
|
public ApiResult<List<ApprovalUserVO>> candidates(@PathVariable("id") Long id) {
|
||||||
|
return success(taskService.candidates(id, getLoginUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@OperationLog(value = "派单审批待办", module = "审批管理")
|
||||||
|
@ApiOperation("派单")
|
||||||
|
@PostMapping("/task/{id}/dispatch")
|
||||||
|
public ApiResult<ApprovalInstanceVO> dispatch(@PathVariable("id") Long id,
|
||||||
|
@RequestBody(required = false) ApprovalTaskDispatchParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.dispatch(id, param, user);
|
||||||
|
return success(result.getWarning() == null ? "派单成功" : result.getWarning(),
|
||||||
|
instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||||
|
@OperationLog(value = "改派审批待办", module = "审批管理")
|
||||||
|
@ApiOperation("改派")
|
||||||
|
@PostMapping("/task/{id}/assign")
|
||||||
|
public ApiResult<ApprovalAssignVO> assign(@PathVariable("id") Long id,
|
||||||
|
@RequestBody(required = false) ApprovalTaskAssignParam param) {
|
||||||
|
return success("改派成功", taskService.assign(id, param, getLoginUser()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批附件绑定(逻辑删除)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批附件")
|
||||||
|
@TableName("approval_attachment")
|
||||||
|
public class ApprovalAttachment implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private Integer fileId;
|
||||||
|
|
||||||
|
@ApiModelProperty("分类:SIGNED 签字件 / OTHER 其他")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
private Integer uploaderId;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务完成确认单。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "业务完成确认单")
|
||||||
|
@TableName("approval_completion_form")
|
||||||
|
public class ApprovalCompletionForm implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String implementOrg;
|
||||||
|
|
||||||
|
private BigDecimal fee;
|
||||||
|
|
||||||
|
private String serviceContent;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单(一期仅内部流程)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "内部协调单")
|
||||||
|
@TableName("approval_coordination_form")
|
||||||
|
public class ApprovalCoordinationForm implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
private String acceptOrgName;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批流程定义版本。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批流程定义版本")
|
||||||
|
@TableName("approval_flow_definition")
|
||||||
|
public class ApprovalFlowDefinition implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ApiModelProperty("租户id")
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@ApiModelProperty("流程编码")
|
||||||
|
private String flowCode;
|
||||||
|
|
||||||
|
@ApiModelProperty("流程版本")
|
||||||
|
private Integer version;
|
||||||
|
|
||||||
|
@ApiModelProperty("流程名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@ApiModelProperty("节点JSON")
|
||||||
|
private String nodesJson;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批实例。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批实例")
|
||||||
|
@TableName("approval_instance")
|
||||||
|
public class ApprovalInstance implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@ApiModelProperty("单号,草稿为空")
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
private String flowCode;
|
||||||
|
|
||||||
|
private Long definitionId;
|
||||||
|
|
||||||
|
private Integer definitionVersion;
|
||||||
|
|
||||||
|
@ApiModelProperty("单据类型:completion/coordination")
|
||||||
|
private String docType;
|
||||||
|
|
||||||
|
@ApiModelProperty("业务表主键")
|
||||||
|
private Long bizId;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@ApiModelProperty("重提轮次")
|
||||||
|
private Integer round;
|
||||||
|
|
||||||
|
private Integer reporterId;
|
||||||
|
|
||||||
|
private String reporterName;
|
||||||
|
|
||||||
|
private Integer reportOrgId;
|
||||||
|
|
||||||
|
private String reportOrgName;
|
||||||
|
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
private String acceptOrgName;
|
||||||
|
|
||||||
|
private String currentNodeKey;
|
||||||
|
|
||||||
|
private String currentNodeType;
|
||||||
|
|
||||||
|
private Integer dispatchTargetId;
|
||||||
|
|
||||||
|
private String dispatchTargetName;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
private LocalDateTime approveTime;
|
||||||
|
|
||||||
|
private LocalDateTime archiveTime;
|
||||||
|
|
||||||
|
private Integer archivistId;
|
||||||
|
|
||||||
|
private String archivistName;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批单号序列(租户 + 前缀 + 日期)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批单号序列")
|
||||||
|
@TableName("approval_no_sequence")
|
||||||
|
public class ApprovalNoSequence implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private String prefix;
|
||||||
|
|
||||||
|
private LocalDate seqDate;
|
||||||
|
|
||||||
|
private Integer currentSeq;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批处理记录(只增不改)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批处理记录")
|
||||||
|
@TableName("approval_record")
|
||||||
|
public class ApprovalRecord implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
|
private Integer operatorId;
|
||||||
|
|
||||||
|
private String operatorName;
|
||||||
|
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
private String opinion;
|
||||||
|
|
||||||
|
private Integer round;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.gxwebsoft.approval.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批任务。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批任务")
|
||||||
|
@TableName("approval_task")
|
||||||
|
public class ApprovalTask implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
|
private String nodeType;
|
||||||
|
|
||||||
|
private Integer round;
|
||||||
|
|
||||||
|
private String taskType;
|
||||||
|
|
||||||
|
private Integer assigneeId;
|
||||||
|
|
||||||
|
private String assigneeName;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private LocalDateTime handleTime;
|
||||||
|
|
||||||
|
private String opinion;
|
||||||
|
|
||||||
|
private Integer dispatchTargetId;
|
||||||
|
|
||||||
|
private String dispatchTargetName;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalAttachment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批附件Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalAttachmentMapper extends BaseMapper<ApprovalAttachment> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务完成确认单Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalCompletionFormMapper extends BaseMapper<ApprovalCompletionForm> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalCoordinationForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalCoordinationFormMapper extends BaseMapper<ApprovalCoordinationForm> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalExportRow;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出查询Mapper:独立的非分页 SQL,绕过分页插件上限,最多取 5001 行。
|
||||||
|
*
|
||||||
|
* <p>筛选条件与数据权限必须与 {@code ApprovalQueryMapper.selectInstancePage} 保持一致。</p>
|
||||||
|
*/
|
||||||
|
public interface ApprovalExportMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按筛选条件取导出数据,最多 5001 行(超出即代表需要拒绝导出)。
|
||||||
|
*/
|
||||||
|
List<ApprovalExportRow> selectExportRows(@Param("param") ApprovalQueryParam param,
|
||||||
|
@Param("userId") Integer userId,
|
||||||
|
@Param("allVisible") Integer allVisible,
|
||||||
|
@Param("assignVisible") Integer assignVisible);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批流程定义Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalFlowDefinitionMapper extends BaseMapper<ApprovalFlowDefinition> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批实例Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalInstanceMapper extends BaseMapper<ApprovalInstance> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对实例行加锁(必须在事务内调用)。
|
||||||
|
*/
|
||||||
|
ApprovalInstance selectByIdForUpdate(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否可查看实例。
|
||||||
|
*/
|
||||||
|
int countVisible(@Param("id") Long id,
|
||||||
|
@Param("userId") Integer userId,
|
||||||
|
@Param("allVisible") Integer allVisible,
|
||||||
|
@Param("assignVisible") Integer assignVisible);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我发起的列表。
|
||||||
|
*/
|
||||||
|
List<ApprovalItemRow> selectMinePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||||
|
@Param("param") ApprovalMineParam param,
|
||||||
|
@Param("userId") Integer userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalNoSequence;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批单号序列Mapper:事务内行锁取号。
|
||||||
|
*/
|
||||||
|
public interface ApprovalNoSequenceMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保当天序列行存在;唯一键冲突时静默忽略。
|
||||||
|
*/
|
||||||
|
@Insert("INSERT IGNORE INTO approval_no_sequence (prefix, seq_date, current_seq) "
|
||||||
|
+ "VALUES (#{prefix}, CURDATE(), 0)")
|
||||||
|
int insertTodayIfAbsent(@Param("prefix") String prefix);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定当天序列行。
|
||||||
|
*/
|
||||||
|
@Select("SELECT id, prefix, seq_date, current_seq FROM approval_no_sequence "
|
||||||
|
+ "WHERE prefix = #{prefix} AND seq_date = CURDATE() FOR UPDATE")
|
||||||
|
ApprovalNoSequence selectTodayForUpdate(@Param("prefix") String prefix);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序号自增。
|
||||||
|
*/
|
||||||
|
@Update("UPDATE approval_no_sequence SET current_seq = current_seq + 1 WHERE id = #{id}")
|
||||||
|
int increment(@Param("id") Long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据查询Mapper:两类单据联表单,数据权限条件与详情一致。
|
||||||
|
*/
|
||||||
|
public interface ApprovalQueryMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单据查询分页,默认提交时间倒序、同秒 id 倒序。
|
||||||
|
*/
|
||||||
|
List<ApprovalItemRow> selectInstancePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||||
|
@Param("param") ApprovalQueryParam param,
|
||||||
|
@Param("userId") Integer userId,
|
||||||
|
@Param("allVisible") Integer allVisible,
|
||||||
|
@Param("assignVisible") Integer assignVisible);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.approval.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批处理记录Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalRecordMapper extends BaseMapper<ApprovalRecord> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,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.ApprovalTask;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批任务Mapper。
|
||||||
|
*/
|
||||||
|
public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办/已办列表:按本人任务状态取实例,天然按实例去重。
|
||||||
|
*/
|
||||||
|
List<ApprovalItemRow> selectTaskInstancePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||||
|
@Param("param") ApprovalTaskListParam param,
|
||||||
|
@Param("userId") Integer userId,
|
||||||
|
@Param("taskStatus") String taskStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全租户待办列表:任一 PENDING 任务对应的实例,按实例去重。
|
||||||
|
*/
|
||||||
|
List<ApprovalItemRow> selectPendingInstancePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||||
|
@Param("param") ApprovalTaskListParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本人待办实例数量(同一实例多任务只算一条)。
|
||||||
|
*/
|
||||||
|
Integer countPendingInstances(@Param("userId") Integer userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalExportMapper">
|
||||||
|
|
||||||
|
<!-- 导出:数据权限与筛选条件同 ApprovalQueryMapper.selectInstancePage;LIMIT 5001 由 SQL 固定,不经过分页插件 -->
|
||||||
|
<select id="selectExportRows" resultType="com.gxwebsoft.approval.support.ApprovalExportRow">
|
||||||
|
SELECT i.id,
|
||||||
|
i.biz_no,
|
||||||
|
i.doc_type,
|
||||||
|
i.status,
|
||||||
|
i.reporter_name,
|
||||||
|
i.report_org_name,
|
||||||
|
i.accept_org_name,
|
||||||
|
i.current_node_key,
|
||||||
|
i.definition_id,
|
||||||
|
i.submit_time,
|
||||||
|
i.approve_time,
|
||||||
|
i.archive_time,
|
||||||
|
i.archivist_name,
|
||||||
|
f.name,
|
||||||
|
f.implement_org,
|
||||||
|
f.fee,
|
||||||
|
f.service_content,
|
||||||
|
co.title,
|
||||||
|
co.content,
|
||||||
|
co.remark
|
||||||
|
FROM approval_instance i
|
||||||
|
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||||
|
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||||
|
WHERE (
|
||||||
|
i.reporter_id = #{userId}
|
||||||
|
OR (i.status != 'DRAFT' AND (
|
||||||
|
#{allVisible} = 1
|
||||||
|
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
|
||||||
|
OR i.dispatch_target_id = #{userId}
|
||||||
|
))
|
||||||
|
)
|
||||||
|
<if test="param.docType != null and param.docType != ''">
|
||||||
|
AND i.doc_type = #{param.docType}
|
||||||
|
</if>
|
||||||
|
<if test="param.bizNo != null and param.bizNo != ''">
|
||||||
|
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
|
||||||
|
</if>
|
||||||
|
<if test="param.reportOrgId != null">
|
||||||
|
AND i.report_org_id = #{param.reportOrgId}
|
||||||
|
</if>
|
||||||
|
<if test="param.acceptOrgId != null">
|
||||||
|
AND i.accept_org_id = #{param.acceptOrgId}
|
||||||
|
</if>
|
||||||
|
<if test="param.reporterId != null">
|
||||||
|
AND i.reporter_id = #{param.reporterId}
|
||||||
|
</if>
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND i.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
|
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
|
||||||
|
AND i.submit_time >= #{param.submitTimeStart}
|
||||||
|
</if>
|
||||||
|
<if test="param.submitTimeEnd != null and param.submitTimeEnd != ''">
|
||||||
|
AND i.submit_time <= #{param.submitTimeEnd}
|
||||||
|
</if>
|
||||||
|
<if test="param.archiveTimeStart != null and param.archiveTimeStart != ''">
|
||||||
|
AND i.archive_time >= #{param.archiveTimeStart}
|
||||||
|
</if>
|
||||||
|
<if test="param.archiveTimeEnd != null and param.archiveTimeEnd != ''">
|
||||||
|
AND i.archive_time <= #{param.archiveTimeEnd}
|
||||||
|
</if>
|
||||||
|
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||||
|
LIMIT 5001
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalInstanceMapper">
|
||||||
|
|
||||||
|
<select id="selectByIdForUpdate" resultType="com.gxwebsoft.approval.entity.ApprovalInstance">
|
||||||
|
SELECT *
|
||||||
|
FROM approval_instance
|
||||||
|
WHERE id = #{id}
|
||||||
|
FOR UPDATE
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countVisible" resultType="int">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM approval_instance i
|
||||||
|
WHERE i.id = #{id}
|
||||||
|
AND (
|
||||||
|
i.reporter_id = #{userId}
|
||||||
|
OR (i.status != 'DRAFT' AND (
|
||||||
|
#{allVisible} = 1
|
||||||
|
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
|
||||||
|
OR i.dispatch_target_id = #{userId}
|
||||||
|
))
|
||||||
|
)
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectMinePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||||
|
SELECT i.id,
|
||||||
|
i.biz_no,
|
||||||
|
i.doc_type,
|
||||||
|
i.status,
|
||||||
|
i.reporter_id,
|
||||||
|
i.reporter_name,
|
||||||
|
i.report_org_name,
|
||||||
|
COALESCE(i.accept_org_name, co.accept_org_name) AS accept_org_name,
|
||||||
|
i.archive_time,
|
||||||
|
i.current_node_key,
|
||||||
|
i.definition_id,
|
||||||
|
i.submit_time,
|
||||||
|
COALESCE(f.name, co.title) AS title
|
||||||
|
FROM approval_instance i
|
||||||
|
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||||
|
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||||
|
WHERE i.reporter_id = #{userId}
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND i.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
<if test="param.docType != null and param.docType != ''">
|
||||||
|
AND i.doc_type = #{param.docType}
|
||||||
|
</if>
|
||||||
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
|
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR co.title LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalQueryMapper">
|
||||||
|
|
||||||
|
<!-- PC 单据查询:数据权限条件与详情保持一致(草稿仅报送人可见) -->
|
||||||
|
<select id="selectInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||||
|
SELECT i.id,
|
||||||
|
i.biz_no,
|
||||||
|
i.doc_type,
|
||||||
|
i.status,
|
||||||
|
i.reporter_id,
|
||||||
|
i.reporter_name,
|
||||||
|
i.report_org_name,
|
||||||
|
i.accept_org_name,
|
||||||
|
i.archive_time,
|
||||||
|
i.current_node_key,
|
||||||
|
i.definition_id,
|
||||||
|
i.submit_time,
|
||||||
|
COALESCE(f.name, co.title) AS title
|
||||||
|
FROM approval_instance i
|
||||||
|
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||||
|
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||||
|
WHERE (
|
||||||
|
i.reporter_id = #{userId}
|
||||||
|
OR (i.status != 'DRAFT' AND (
|
||||||
|
#{allVisible} = 1
|
||||||
|
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
|
||||||
|
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
|
||||||
|
OR i.dispatch_target_id = #{userId}
|
||||||
|
))
|
||||||
|
)
|
||||||
|
<if test="param.docType != null and param.docType != ''">
|
||||||
|
AND i.doc_type = #{param.docType}
|
||||||
|
</if>
|
||||||
|
<if test="param.bizNo != null and param.bizNo != ''">
|
||||||
|
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
|
||||||
|
</if>
|
||||||
|
<if test="param.reportOrgId != null">
|
||||||
|
AND i.report_org_id = #{param.reportOrgId}
|
||||||
|
</if>
|
||||||
|
<if test="param.acceptOrgId != null">
|
||||||
|
AND i.accept_org_id = #{param.acceptOrgId}
|
||||||
|
</if>
|
||||||
|
<if test="param.reporterId != null">
|
||||||
|
AND i.reporter_id = #{param.reporterId}
|
||||||
|
</if>
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND i.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
|
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
|
||||||
|
AND i.submit_time >= #{param.submitTimeStart}
|
||||||
|
</if>
|
||||||
|
<if test="param.submitTimeEnd != null and param.submitTimeEnd != ''">
|
||||||
|
AND i.submit_time <= #{param.submitTimeEnd}
|
||||||
|
</if>
|
||||||
|
<if test="param.archiveTimeStart != null and param.archiveTimeStart != ''">
|
||||||
|
AND i.archive_time >= #{param.archiveTimeStart}
|
||||||
|
</if>
|
||||||
|
<if test="param.archiveTimeEnd != null and param.archiveTimeEnd != ''">
|
||||||
|
AND i.archive_time <= #{param.archiveTimeEnd}
|
||||||
|
</if>
|
||||||
|
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalTaskMapper">
|
||||||
|
|
||||||
|
<select id="selectTaskInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||||
|
SELECT i.id,
|
||||||
|
i.biz_no,
|
||||||
|
i.doc_type,
|
||||||
|
i.status,
|
||||||
|
i.reporter_id,
|
||||||
|
i.reporter_name,
|
||||||
|
i.report_org_name,
|
||||||
|
i.accept_org_name,
|
||||||
|
i.archive_time,
|
||||||
|
i.current_node_key,
|
||||||
|
i.definition_id,
|
||||||
|
i.submit_time,
|
||||||
|
COALESCE(f.name, co.title) AS title
|
||||||
|
FROM approval_instance i
|
||||||
|
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||||
|
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||||
|
WHERE i.id IN (
|
||||||
|
SELECT t.instance_id
|
||||||
|
FROM approval_task t
|
||||||
|
WHERE t.assignee_id = #{userId}
|
||||||
|
AND t.status = #{taskStatus}
|
||||||
|
)
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND i.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
<if test="param.docType != null and param.docType != ''">
|
||||||
|
AND i.doc_type = #{param.docType}
|
||||||
|
</if>
|
||||||
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
|
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectPendingInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||||
|
SELECT i.id,
|
||||||
|
i.biz_no,
|
||||||
|
i.doc_type,
|
||||||
|
i.status,
|
||||||
|
i.reporter_id,
|
||||||
|
i.reporter_name,
|
||||||
|
i.report_org_name,
|
||||||
|
i.accept_org_name,
|
||||||
|
i.archive_time,
|
||||||
|
i.current_node_key,
|
||||||
|
i.definition_id,
|
||||||
|
i.submit_time,
|
||||||
|
COALESCE(f.name, co.title) AS title
|
||||||
|
FROM approval_instance i
|
||||||
|
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||||
|
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM approval_task t
|
||||||
|
WHERE t.instance_id = i.id AND t.status = 'PENDING'
|
||||||
|
)
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND i.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
<if test="param.docType != null and param.docType != ''">
|
||||||
|
AND i.doc_type = #{param.docType}
|
||||||
|
</if>
|
||||||
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
|
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countPendingInstances" resultType="java.lang.Integer">
|
||||||
|
SELECT COUNT(DISTINCT instance_id)
|
||||||
|
FROM approval_task
|
||||||
|
WHERE assignee_id = #{userId}
|
||||||
|
AND status = 'PENDING'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务完成确认单草稿参数;id 为审批实例id,更新草稿时传入。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalCompletionParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("审批实例id(更新草稿时传入)")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ApiModelProperty("名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@ApiModelProperty("实施单位")
|
||||||
|
private String implementOrg;
|
||||||
|
|
||||||
|
@ApiModelProperty("费用(元)")
|
||||||
|
private BigDecimal fee;
|
||||||
|
|
||||||
|
@ApiModelProperty("服务内容")
|
||||||
|
private String serviceContent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。
|
||||||
|
*
|
||||||
|
* <p>报送部门与报送人由服务端按登录态带出,不在参数中接收。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalCoordinationParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("审批实例id(更新草稿时传入)")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ApiModelProperty("受理部门id,必须是启用组织")
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
@ApiModelProperty("标题")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@ApiModelProperty("内容")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@ApiModelProperty("备注")
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.web.BaseParam;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我发起的查询参数。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ApprovalMineParam extends BaseParam {
|
||||||
|
|
||||||
|
@ApiModelProperty("实例状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@ApiModelProperty("单据类型")
|
||||||
|
private String docType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.web.BaseParam;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据查询参数:两类单据统一检索,含归档单据。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@ApiModel(description = "审批单据查询参数")
|
||||||
|
public class ApprovalQueryParam extends BaseParam {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("单据类型 completion/coordination")
|
||||||
|
private String docType;
|
||||||
|
|
||||||
|
@ApiModelProperty("单号")
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
@ApiModelProperty("报送部门id")
|
||||||
|
private Integer reportOrgId;
|
||||||
|
|
||||||
|
@ApiModelProperty("受理部门id")
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
@ApiModelProperty("报送人id")
|
||||||
|
private Integer reporterId;
|
||||||
|
|
||||||
|
@ApiModelProperty("单据状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@ApiModelProperty("提交时间起,yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String submitTimeStart;
|
||||||
|
|
||||||
|
@ApiModelProperty("提交时间止,yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String submitTimeEnd;
|
||||||
|
|
||||||
|
@ApiModelProperty("归档时间起,yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String archiveTimeStart;
|
||||||
|
|
||||||
|
@ApiModelProperty("归档时间止,yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String archiveTimeEnd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务动作参数(通过/驳回)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalTaskActionParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("处理意见;驳回必填")
|
||||||
|
private String opinion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办改派参数。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalTaskAssignParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("改派对象用户id")
|
||||||
|
private Integer targetUserId;
|
||||||
|
|
||||||
|
@ApiModelProperty("改派原因;必填")
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单参数。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalTaskDispatchParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("被派单人用户id;必须来自候选池")
|
||||||
|
private Integer targetUserId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 ApprovalTaskListParam extends BaseParam {
|
||||||
|
|
||||||
|
@ApiModelProperty("实例状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@ApiModelProperty("单据类型")
|
||||||
|
private String docType;
|
||||||
|
|
||||||
|
/** 查询范围:mine(默认,本人待办/已办)或 all(全租户待办,需 assign 权限)。 */
|
||||||
|
@ApiModelProperty("查询范围 mine/all")
|
||||||
|
private String scope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalAttachment;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
||||||
|
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批附件服务:上传、逻辑删除、详情拼装。
|
||||||
|
*
|
||||||
|
* <p>文件落盘与 sys_file_record 复用既有文件基础设施,附件行写 master 库。
|
||||||
|
* 上传不开启跨库事务:先落文件记录再绑定附件,失败时最多留下孤儿文件(允许)。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalAttachmentService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApprovalAttachmentService.class);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalInstanceMapper instanceMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalAttachmentMapper attachmentMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalFileService fileService;
|
||||||
|
@Resource
|
||||||
|
private ApprovalDirectoryService directoryService;
|
||||||
|
@Resource
|
||||||
|
private ConfigProperties config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情附件列表:过滤逻辑删除,并从文件记录补齐名称/地址/大小。
|
||||||
|
*/
|
||||||
|
public List<ApprovalAttachmentVO> listVos(Long instanceId) {
|
||||||
|
List<ApprovalAttachment> attachments = attachmentMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ApprovalAttachment>()
|
||||||
|
.eq(ApprovalAttachment::getInstanceId, instanceId)
|
||||||
|
.orderByAsc(ApprovalAttachment::getId));
|
||||||
|
if (attachments.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<Integer> fileIds = attachments.stream().map(ApprovalAttachment::getFileId).collect(Collectors.toList());
|
||||||
|
Map<Integer, FileRecord> files = fileService.listByIds(fileIds).stream()
|
||||||
|
.collect(Collectors.toMap(FileRecord::getId, Function.identity(), (a, b) -> a));
|
||||||
|
List<Integer> uploaderIds = attachments.stream().map(ApprovalAttachment::getUploaderId)
|
||||||
|
.distinct().collect(Collectors.toList());
|
||||||
|
Map<Integer, String> uploaders = directoryService.listUsersByIds(uploaderIds).stream()
|
||||||
|
.collect(Collectors.toMap(ApprovalUserVO::getUserId, this::displayName, (a, b) -> a));
|
||||||
|
List<ApprovalAttachmentVO> result = new ArrayList<>();
|
||||||
|
for (ApprovalAttachment attachment : attachments) {
|
||||||
|
FileRecord file = files.get(attachment.getFileId());
|
||||||
|
if (file == null) {
|
||||||
|
// 文件记录缺失视为不可用附件,不向前端暴露悬空链接
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.add(toVO(attachment, file, uploaders.get(attachment.getUploaderId())));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int count(Long instanceId) {
|
||||||
|
Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper<ApprovalAttachment>()
|
||||||
|
.eq(ApprovalAttachment::getInstanceId, instanceId));
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传附件:仅报送人且实例可编辑时可上传。
|
||||||
|
*/
|
||||||
|
public ApprovalAttachmentVO upload(Long instanceId, MultipartFile file, String category, User user) {
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(instanceId);
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
checkEditable(instance, user);
|
||||||
|
|
||||||
|
String normalizedCategory = StrUtil.isBlank(category) ? ApprovalConstants.CATEGORY_OTHER : category.trim();
|
||||||
|
if (!ApprovalConstants.CATEGORY_SIGNED.equals(normalizedCategory)
|
||||||
|
&& !ApprovalConstants.CATEGORY_OTHER.equals(normalizedCategory)) {
|
||||||
|
throw new BusinessException("附件分类不正确");
|
||||||
|
}
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("附件内容为空");
|
||||||
|
}
|
||||||
|
if (count(instanceId) >= ApprovalConstants.MAX_ATTACHMENT_COUNT) {
|
||||||
|
throw new BusinessException("每单最多上传" + ApprovalConstants.MAX_ATTACHMENT_COUNT + "张附件");
|
||||||
|
}
|
||||||
|
if (file.getSize() > ApprovalConstants.MAX_ATTACHMENT_SIZE) {
|
||||||
|
throw new BusinessException("单个附件不能超过20MB");
|
||||||
|
}
|
||||||
|
String extension = extensionOf(file.getOriginalFilename());
|
||||||
|
checkExtensionAndContentType(extension, file.getContentType());
|
||||||
|
|
||||||
|
File stored;
|
||||||
|
String path;
|
||||||
|
try {
|
||||||
|
String base = config.getUploadPath();
|
||||||
|
String dir = (base.endsWith("/") ? base : base + "/") + "approval/";
|
||||||
|
stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName()));
|
||||||
|
path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("审批附件落盘失败", e);
|
||||||
|
throw new BusinessException("附件上传失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileRecord fileRecord = new FileRecord();
|
||||||
|
fileRecord.setCreateUserId(user.getUserId());
|
||||||
|
fileRecord.setName(StrUtil.isBlank(file.getOriginalFilename()) ? stored.getName() : file.getOriginalFilename());
|
||||||
|
fileRecord.setLength(stored.length());
|
||||||
|
fileRecord.setPath(path);
|
||||||
|
fileRecord.setContentType(normalizeContentType(file.getContentType()));
|
||||||
|
fileService.save(fileRecord);
|
||||||
|
|
||||||
|
ApprovalAttachment attachment = new ApprovalAttachment();
|
||||||
|
attachment.setInstanceId(instanceId);
|
||||||
|
attachment.setFileId(fileRecord.getId());
|
||||||
|
attachment.setCategory(normalizedCategory);
|
||||||
|
attachment.setUploaderId(user.getUserId());
|
||||||
|
attachment.setDeleted(0);
|
||||||
|
attachment.setCreateTime(LocalDateTime.now());
|
||||||
|
attachmentMapper.insert(attachment);
|
||||||
|
|
||||||
|
return toVO(attachment, fileRecord, displayName(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除附件,底层文件记录不删除。
|
||||||
|
*/
|
||||||
|
public void delete(Long attachmentId, User user) {
|
||||||
|
ApprovalAttachment attachment = attachmentMapper.selectById(attachmentId);
|
||||||
|
if (attachment == null) {
|
||||||
|
throw new BusinessException("附件不存在");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(attachment.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
checkEditable(instance, user);
|
||||||
|
attachmentMapper.deleteById(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkEditable(ApprovalInstance instance, User user) {
|
||||||
|
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
|
||||||
|
throw new BusinessException("无权操作该单据");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
|
throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkExtensionAndContentType(String extension, String contentType) {
|
||||||
|
if (extension == null || !ApprovalConstants.ATTACHMENT_EXTENSIONS.contains(extension)) {
|
||||||
|
throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
|
||||||
|
}
|
||||||
|
List<String> allowed = ApprovalConstants.ATTACHMENT_CONTENT_TYPES.get(extension);
|
||||||
|
String normalized = normalizeContentType(contentType);
|
||||||
|
if (normalized == null || allowed == null || !allowed.contains(normalized)) {
|
||||||
|
throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extensionOf(String filename) {
|
||||||
|
if (filename == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int index = filename.lastIndexOf('.');
|
||||||
|
if (index < 0 || index == filename.length() - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return filename.substring(index + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeContentType(String contentType) {
|
||||||
|
if (contentType == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int index = contentType.indexOf(';');
|
||||||
|
String value = index >= 0 ? contentType.substring(0, index) : contentType;
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalAttachmentVO toVO(ApprovalAttachment attachment, FileRecord file, String uploaderName) {
|
||||||
|
ApprovalAttachmentVO vo = new ApprovalAttachmentVO();
|
||||||
|
vo.setId(attachment.getId());
|
||||||
|
vo.setFileId(attachment.getFileId());
|
||||||
|
vo.setName(file.getName());
|
||||||
|
vo.setUrl(urlOf(file));
|
||||||
|
vo.setLength(file.getLength());
|
||||||
|
vo.setCategory(attachment.getCategory());
|
||||||
|
vo.setCategoryName(ApprovalConstants.categoryName(attachment.getCategory()));
|
||||||
|
vo.setUploaderId(attachment.getUploaderId());
|
||||||
|
vo.setUploaderName(uploaderName);
|
||||||
|
vo.setCreateTime(attachment.getCreateTime());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String urlOf(FileRecord file) {
|
||||||
|
if (StrUtil.isNotBlank(file.getUrl())) {
|
||||||
|
return file.getUrl();
|
||||||
|
}
|
||||||
|
return config.getServerUrl() + "/file" + (file.getPath() == null ? "" : file.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String displayName(User user) {
|
||||||
|
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String displayName(ApprovalUserVO user) {
|
||||||
|
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalTenantContext;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批目录服务:组织、部门负责人/收文员解析、启用用户查询。
|
||||||
|
*
|
||||||
|
* <p>直接使用 gxtyzx_core 的专用连接,而不是 MyBatis 动态数据源代理:
|
||||||
|
* 审批请求可能处在外层事务中,动态数据源在事务内无法切换库。
|
||||||
|
* 所有 SQL 显式带 tenant_id。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@DS("server")
|
||||||
|
public class ApprovalDirectoryService {
|
||||||
|
|
||||||
|
private static final RowMapper<ApprovalOrganizationVO> ORGANIZATION_MAPPER = (resultSet, rowNum) -> {
|
||||||
|
ApprovalOrganizationVO vo = new ApprovalOrganizationVO();
|
||||||
|
vo.setOrganizationId(resultSet.getInt("organization_id"));
|
||||||
|
vo.setParentId(resultSet.getInt("parent_id"));
|
||||||
|
vo.setOrganizationName(resultSet.getString("organization_name"));
|
||||||
|
vo.setOrganizationFullName(resultSet.getString("organization_full_name"));
|
||||||
|
vo.setOrganizationCode(resultSet.getString("organization_code"));
|
||||||
|
int leaderId = resultSet.getInt("leader_id");
|
||||||
|
vo.setLeaderId(resultSet.wasNull() ? null : leaderId);
|
||||||
|
return vo;
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final RowMapper<ApprovalUserVO> USER_MAPPER = (resultSet, rowNum) -> {
|
||||||
|
ApprovalUserVO vo = new ApprovalUserVO();
|
||||||
|
vo.setUserId(resultSet.getInt("user_id"));
|
||||||
|
vo.setUsername(resultSet.getString("username"));
|
||||||
|
vo.setNickname(resultSet.getString("nickname"));
|
||||||
|
vo.setRealName(resultSet.getString("real_name"));
|
||||||
|
int organizationId = resultSet.getInt("organization_id");
|
||||||
|
vo.setOrganizationId(resultSet.wasNull() ? null : organizationId);
|
||||||
|
vo.setOrganizationName(resultSet.getString("organization_name"));
|
||||||
|
return vo;
|
||||||
|
};
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ApprovalDirectoryService(DataSource dataSource) {
|
||||||
|
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||||
|
this.jdbcTemplate = new JdbcTemplate(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用组织。 */
|
||||||
|
public List<ApprovalOrganizationVO> listOrganizations() {
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT organization_id, parent_id, organization_name, organization_full_name, "
|
||||||
|
+ "organization_code, leader_id FROM sys_organization "
|
||||||
|
+ "WHERE tenant_id = ? AND deleted = 0 ORDER BY sort_number, organization_id",
|
||||||
|
ORGANIZATION_MAPPER, ApprovalTenantContext.currentTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按id取启用组织;不存在返回 null。 */
|
||||||
|
public ApprovalOrganizationVO getOrganization(Integer organizationId) {
|
||||||
|
if (organizationId == null || organizationId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<ApprovalOrganizationVO> list = jdbcTemplate.query(
|
||||||
|
"SELECT organization_id, parent_id, organization_name, organization_full_name, "
|
||||||
|
+ "organization_code, leader_id FROM sys_organization "
|
||||||
|
+ "WHERE organization_id = ? AND tenant_id = ? AND deleted = 0",
|
||||||
|
ORGANIZATION_MAPPER, organizationId, ApprovalTenantContext.currentTenantId());
|
||||||
|
return list.isEmpty() ? null : list.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色下全部启用成员;角色不存在或成员为空时返回空列表。 */
|
||||||
|
public List<ApprovalUserVO> listRoleMembers(String roleCode) {
|
||||||
|
if (roleCode == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||||
|
+ "FROM sys_user u "
|
||||||
|
+ "JOIN sys_user_role ur ON ur.user_id = u.user_id AND ur.tenant_id = ? "
|
||||||
|
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
|
||||||
|
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||||
|
+ "WHERE r.role_code = ? AND r.deleted = 0 AND u.status = 0 AND u.deleted = 0 "
|
||||||
|
+ "AND u.tenant_id = ? ORDER BY u.user_id",
|
||||||
|
USER_MAPPER, tenantId, tenantId, tenantId, roleCode, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用用户搜索,最多 50 条。 */
|
||||||
|
public List<ApprovalUserVO> searchUsers(String keywords, Integer organizationId) {
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||||
|
+ "FROM sys_user u "
|
||||||
|
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||||
|
+ "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0");
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
args.add(tenantId);
|
||||||
|
args.add(tenantId);
|
||||||
|
if (organizationId != null) {
|
||||||
|
sql.append(" AND u.organization_id = ?");
|
||||||
|
args.add(organizationId);
|
||||||
|
}
|
||||||
|
if (keywords != null && !keywords.isEmpty()) {
|
||||||
|
sql.append(" AND (u.username LIKE ? OR u.real_name LIKE ? OR u.nickname LIKE ?)");
|
||||||
|
String like = "%" + keywords + "%";
|
||||||
|
args.add(like);
|
||||||
|
args.add(like);
|
||||||
|
args.add(like);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY u.user_id LIMIT 50");
|
||||||
|
return jdbcTemplate.query(sql.toString(), USER_MAPPER, args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 部门启用成员。 */
|
||||||
|
public List<ApprovalUserVO> listOrganizationMembers(Integer organizationId) {
|
||||||
|
if (organizationId == null || organizationId <= 0) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||||
|
+ "FROM sys_user u "
|
||||||
|
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||||
|
+ "WHERE u.organization_id = ? AND u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 "
|
||||||
|
+ "ORDER BY u.user_id",
|
||||||
|
USER_MAPPER, tenantId, organizationId, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按id批量取启用用户。 */
|
||||||
|
public List<ApprovalUserVO> listUsersByIds(List<Integer> userIds) {
|
||||||
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
|
||||||
|
String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||||
|
+ "FROM sys_user u "
|
||||||
|
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||||
|
+ "WHERE u.tenant_id = ? AND u.user_id IN (" + placeholders + ")";
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
args.add(tenantId);
|
||||||
|
args.add(tenantId);
|
||||||
|
args.addAll(userIds);
|
||||||
|
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按id批量取启用用户;停用或已删除的用户不返回(节点缺配时不生成任务)。 */
|
||||||
|
public List<ApprovalUserVO> listActiveUsersByIds(List<Integer> userIds) {
|
||||||
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
|
||||||
|
String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
|
||||||
|
+ "FROM sys_user u "
|
||||||
|
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
|
||||||
|
+ "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 AND u.user_id IN (" + placeholders + ")";
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
args.add(tenantId);
|
||||||
|
args.add(tenantId);
|
||||||
|
args.addAll(userIds);
|
||||||
|
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单候选池:优先受理部门启用成员;部门无成员时回退为当前租户的非普通消费用户。
|
||||||
|
*
|
||||||
|
* <p>回退排除「未归属组织且角色仅为 user/guest 或无角色」的账号;
|
||||||
|
* 部门有成员时不混入回退候选。</p>
|
||||||
|
*/
|
||||||
|
public List<ApprovalUserVO> listDispatchCandidates(Integer acceptOrganizationId) {
|
||||||
|
List<ApprovalUserVO> members = listOrganizationMembers(acceptOrganizationId);
|
||||||
|
if (!members.isEmpty()) {
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
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.tenant_id = ? AND u.status = 0 AND u.deleted = 0 "
|
||||||
|
+ "AND NOT ((u.organization_id IS NULL OR u.organization_id = 0) AND NOT EXISTS ("
|
||||||
|
+ "SELECT 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 = u.user_id AND ur.tenant_id = ? AND r.deleted = 0 "
|
||||||
|
+ "AND r.role_code NOT IN (?, ?))) "
|
||||||
|
+ "ORDER BY u.user_id",
|
||||||
|
USER_MAPPER, tenantId, tenantId, tenantId, tenantId,
|
||||||
|
ApprovalConstants.ROLE_USER, ApprovalConstants.ROLE_GUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否持有任一角色(用于 allVisible 判断)。 */
|
||||||
|
public boolean hasAnyRole(Integer userId, String... roleCodes) {
|
||||||
|
if (userId == null || roleCodes == null || roleCodes.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
String placeholders = String.join(",", Collections.nCopies(roleCodes.length, "?"));
|
||||||
|
String sql = "SELECT COUNT(1) FROM sys_user_role ur "
|
||||||
|
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
|
||||||
|
+ "WHERE ur.user_id = ? AND ur.tenant_id = ? AND r.deleted = 0 AND r.role_code IN (" + placeholders + ")";
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
args.add(tenantId);
|
||||||
|
args.add(userId);
|
||||||
|
args.add(tenantId);
|
||||||
|
Collections.addAll(args, roleCodes);
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args.toArray());
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalInstance;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalRecord;
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。
|
||||||
|
*
|
||||||
|
* <p>本类只写 master 库,必须在调用方的事务内执行;后续迭代在此扩展
|
||||||
|
* 通过/驳回/撤回/存档/归档/派单的节点推进,不再改动已发布的方法签名。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalEngineService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalInstanceMapper instanceMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalTaskMapper taskMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalRecordMapper recordMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalFlowService flowService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交/重提:激活流程首节点,为解析出的审批人生成或签任务。
|
||||||
|
*
|
||||||
|
* @return 生成的任务数;0 表示节点缺配,实例停留在当前节点等待管理员补配置
|
||||||
|
*/
|
||||||
|
public int startFlow(ApprovalInstance instance, List<ApprovalApprover> approvers,
|
||||||
|
User operator, String action) {
|
||||||
|
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
throw new BusinessException("审批流程未配置节点");
|
||||||
|
}
|
||||||
|
return activateNode(instance, nodes.get(0), approvers, operator, action, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活指定节点:快照审批人并写处理记录。
|
||||||
|
*/
|
||||||
|
public int activateNode(ApprovalInstance instance, ApprovalNode node,
|
||||||
|
List<ApprovalApprover> approvers, User operator,
|
||||||
|
String action, String opinion) {
|
||||||
|
instance.setCurrentNodeKey(node.getKey());
|
||||||
|
instance.setCurrentNodeType(node.getType());
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
addRecord(instance, null, node.getKey(), operator, action, opinion);
|
||||||
|
return createTasks(instance, node, instance.getRound(), approvers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前节点处理完成后推进到下一节点:更新当前节点并快照生成任务。
|
||||||
|
*
|
||||||
|
* <p>与 {@link #activateNode} 的区别:处理记录已由调用方按任务补充 taskId,
|
||||||
|
* 因此这里只更新节点与任务,不重复写处理记录。</p>
|
||||||
|
*
|
||||||
|
* @return 生成的任务数;0 表示节点缺配
|
||||||
|
*/
|
||||||
|
public int moveToNode(ApprovalInstance instance, ApprovalNode node, Integer round,
|
||||||
|
List<ApprovalApprover> approvers) {
|
||||||
|
instance.setCurrentNodeKey(node.getKey());
|
||||||
|
instance.setCurrentNodeType(node.getType());
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
return createTasks(instance, node, round, approvers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对实例行加锁,串行化节点推进。
|
||||||
|
*/
|
||||||
|
public ApprovalInstance lockInstance(Long instanceId) {
|
||||||
|
ApprovalInstance instance = instanceMapper.selectByIdForUpdate(instanceId);
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消同一轮同一节点的其余待办(或签处理完成后调用)。
|
||||||
|
*/
|
||||||
|
public int cancelPendingTasks(Long instanceId, Integer round, Long exceptTaskId) {
|
||||||
|
UpdateWrapper<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
|
||||||
|
.eq("instance_id", instanceId)
|
||||||
|
.eq("round", round)
|
||||||
|
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||||
|
.set("status", ApprovalConstants.TASK_CANCELLED);
|
||||||
|
if (exceptTaskId != null) {
|
||||||
|
wrapper.ne("id", exceptTaskId);
|
||||||
|
}
|
||||||
|
return taskMapper.update(null, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加处理记录。
|
||||||
|
*/
|
||||||
|
public void addRecord(ApprovalInstance instance, Long taskId, String nodeKey,
|
||||||
|
User operator, String action, String opinion) {
|
||||||
|
ApprovalRecord record = new ApprovalRecord();
|
||||||
|
record.setInstanceId(instance.getId());
|
||||||
|
record.setTaskId(taskId);
|
||||||
|
record.setNodeKey(nodeKey);
|
||||||
|
record.setOperatorId(operator.getUserId());
|
||||||
|
record.setOperatorName(displayName(operator));
|
||||||
|
record.setAction(action);
|
||||||
|
record.setOpinion(opinion);
|
||||||
|
record.setRound(instance.getRound());
|
||||||
|
record.setCreateTime(LocalDateTime.now());
|
||||||
|
recordMapper.insert(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前节点之后的下一个节点;已是末节点返回 null。
|
||||||
|
*/
|
||||||
|
public ApprovalNode nextNode(ApprovalInstance instance, String currentNodeKey) {
|
||||||
|
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
|
||||||
|
for (int i = 0; i < nodes.size(); i++) {
|
||||||
|
if (nodes.get(i).getKey().equals(currentNodeKey)) {
|
||||||
|
return i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String nodeName(ApprovalInstance instance, String nodeKey) {
|
||||||
|
return flowService.nodeName(instance.getDefinitionId(), nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int createTasks(ApprovalInstance instance, ApprovalNode node, Integer round,
|
||||||
|
List<ApprovalApprover> approvers) {
|
||||||
|
if (approvers == null || approvers.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int created = 0;
|
||||||
|
for (ApprovalApprover approver : approvers) {
|
||||||
|
ApprovalTask task = new ApprovalTask();
|
||||||
|
task.setInstanceId(instance.getId());
|
||||||
|
task.setNodeKey(node.getKey());
|
||||||
|
task.setNodeType(node.getType());
|
||||||
|
task.setRound(round);
|
||||||
|
task.setTaskType(node.getType());
|
||||||
|
task.setAssigneeId(approver.getUserId());
|
||||||
|
task.setAssigneeName(approver.getUserName());
|
||||||
|
task.setStatus(ApprovalConstants.TASK_PENDING);
|
||||||
|
task.setCreateTime(LocalDateTime.now());
|
||||||
|
taskMapper.insert(task);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String displayName(User user) {
|
||||||
|
if (user.getRealName() != null && !user.getRealName().isEmpty()) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (user.getNickname() != null && !user.getNickname().isEmpty()) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalExportMapper;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalExportResult;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalExportRow;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||||
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||||
|
import com.gxwebsoft.common.system.entity.Menu;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFRow;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFSheet;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单据导出:按列表同一套筛选与数据权限同步生成 xlsx,并写文件记录。
|
||||||
|
*
|
||||||
|
* <p>独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限;
|
||||||
|
* 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalExportService {
|
||||||
|
|
||||||
|
/** 单次导出上限,超过即要求缩小筛选范围。 */
|
||||||
|
public static final int MAX_EXPORT_ROWS = 5000;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private static final String[] HEADERS = {
|
||||||
|
"单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
|
||||||
|
"服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 列宽(字符数),长文本列加宽但不截断内容。 */
|
||||||
|
private static final int[] COLUMN_WIDTHS = {
|
||||||
|
20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12
|
||||||
|
};
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalExportMapper exportMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalFlowService flowService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalDirectoryService directoryService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalFileService fileService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ConfigProperties config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出当前筛选结果。
|
||||||
|
*
|
||||||
|
* @throws BusinessException 结果超过 {@link #MAX_EXPORT_ROWS} 行时拒绝,不生成文件
|
||||||
|
*/
|
||||||
|
public ApprovalExportResult export(ApprovalQueryParam param, User user) throws IOException {
|
||||||
|
int allVisible = directoryService.hasAnyRole(user.getUserId(),
|
||||||
|
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
|
||||||
|
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
|
||||||
|
List<ApprovalExportRow> rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible,
|
||||||
|
assignVisible);
|
||||||
|
if (rows.size() > MAX_EXPORT_ROWS) {
|
||||||
|
throw new BusinessException("请缩小筛选范围");
|
||||||
|
}
|
||||||
|
String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx";
|
||||||
|
String path = config.getUploadPath() + filename;
|
||||||
|
File target = new File(path);
|
||||||
|
try {
|
||||||
|
File parent = target.getParentFile();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent.toPath());
|
||||||
|
}
|
||||||
|
writeWorkbook(rows, target);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
// 生成失败时不留下半成品文件
|
||||||
|
Files.deleteIfExists(target.toPath());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileRecord record = new FileRecord();
|
||||||
|
record.setName(filename);
|
||||||
|
record.setPath(path);
|
||||||
|
record.setLength(target.length());
|
||||||
|
record.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
record.setCreateUserId(user.getUserId());
|
||||||
|
record.setUrl(config.getServerUrl() + "/file/download/" + filename);
|
||||||
|
record.setDownloadUrl(record.getUrl());
|
||||||
|
fileService.save(record);
|
||||||
|
return new ApprovalExportResult(record, rows.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAuthority(User user, String authority) {
|
||||||
|
if (user.getAuthorities() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.getAuthorities().stream()
|
||||||
|
.map(Menu::getAuthority)
|
||||||
|
.anyMatch(authority::equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeWorkbook(List<ApprovalExportRow> rows, File target) throws IOException {
|
||||||
|
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
|
||||||
|
try {
|
||||||
|
SXSSFSheet sheet = workbook.createSheet("审批单据");
|
||||||
|
for (int i = 0; i < COLUMN_WIDTHS.length; i++) {
|
||||||
|
sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256);
|
||||||
|
}
|
||||||
|
SXSSFRow header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < HEADERS.length; i++) {
|
||||||
|
header.createCell(i).setCellValue(HEADERS[i]);
|
||||||
|
}
|
||||||
|
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
|
||||||
|
int rowNum = 1;
|
||||||
|
for (ApprovalExportRow row : rows) {
|
||||||
|
SXSSFRow dataRow = sheet.createRow(rowNum++);
|
||||||
|
String[] values = toValues(row, nodeCache);
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
dataRow.createCell(i).setCellValue(values[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream output = Files.newOutputStream(target.toPath())) {
|
||||||
|
workbook.write(output);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workbook.dispose();
|
||||||
|
workbook.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、
|
||||||
|
* 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。
|
||||||
|
*/
|
||||||
|
private String[] toValues(ApprovalExportRow row, Map<Long, List<ApprovalNode>> nodeCache) {
|
||||||
|
boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType());
|
||||||
|
return new String[]{
|
||||||
|
text(row.getBizNo()),
|
||||||
|
text(ApprovalConstants.docTypeName(row.getDocType())),
|
||||||
|
completion ? text(row.getName()) : text(row.getTitle()),
|
||||||
|
text(row.getReportOrgName()),
|
||||||
|
text(row.getReporterName()),
|
||||||
|
text(row.getAcceptOrgName()),
|
||||||
|
completion ? text(row.getImplementOrg()) : "",
|
||||||
|
completion ? fee(row.getFee()) : "",
|
||||||
|
completion ? text(row.getServiceContent()) : text(row.getContent()),
|
||||||
|
text(row.getRemark()),
|
||||||
|
text(ApprovalConstants.statusName(row.getStatus())),
|
||||||
|
text(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())),
|
||||||
|
time(row.getSubmitTime()),
|
||||||
|
time(row.getApproveTime()),
|
||||||
|
time(row.getArchiveTime()),
|
||||||
|
text(row.getArchivistName())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
|
||||||
|
if (definitionId == null || nodeKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
|
||||||
|
return nodes.stream()
|
||||||
|
.filter(node -> nodeKey.equals(node.getKey()))
|
||||||
|
.map(ApprovalNode::getName)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fee(BigDecimal fee) {
|
||||||
|
return fee == null ? "" : fee.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String time(LocalDateTime time) {
|
||||||
|
return time == null ? "" : TIME_FORMAT.format(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalTenantContext;
|
||||||
|
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批域文件记录读写:直接操作 gxtyzx_core.sys_file_record。
|
||||||
|
*
|
||||||
|
* <p>使用 server 库专用连接,保证在 master 事务内仍能读写核心库。
|
||||||
|
* 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@DS("server")
|
||||||
|
public class ApprovalFileService {
|
||||||
|
|
||||||
|
private static final RowMapper<FileRecord> FILE_MAPPER = (resultSet, rowNum) -> {
|
||||||
|
FileRecord record = new FileRecord();
|
||||||
|
record.setId(resultSet.getInt("id"));
|
||||||
|
record.setName(resultSet.getString("name"));
|
||||||
|
record.setPath(resultSet.getString("path"));
|
||||||
|
record.setLength(resultSet.getLong("length"));
|
||||||
|
record.setContentType(resultSet.getString("content_type"));
|
||||||
|
record.setCreateUserId(resultSet.getInt("create_user_id"));
|
||||||
|
record.setDeleted(resultSet.getInt("deleted"));
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ApprovalFileService(DataSource dataSource) {
|
||||||
|
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||||
|
this.jdbcTemplate = new JdbcTemplate(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileRecord save(FileRecord fileRecord) {
|
||||||
|
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(connection -> {
|
||||||
|
PreparedStatement statement = connection.prepareStatement(
|
||||||
|
"INSERT INTO sys_file_record "
|
||||||
|
+ "(name, path, length, content_type, create_user_id, deleted, tenant_id) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||||
|
Statement.RETURN_GENERATED_KEYS);
|
||||||
|
statement.setString(1, fileRecord.getName());
|
||||||
|
statement.setString(2, fileRecord.getPath());
|
||||||
|
statement.setLong(3, fileRecord.getLength() == null ? 0L : fileRecord.getLength());
|
||||||
|
statement.setString(4, fileRecord.getContentType());
|
||||||
|
statement.setInt(5, fileRecord.getCreateUserId());
|
||||||
|
statement.setInt(6, tenantId);
|
||||||
|
return statement;
|
||||||
|
}, keyHolder);
|
||||||
|
Number key = keyHolder.getKey();
|
||||||
|
fileRecord.setId(key == null ? null : key.intValue());
|
||||||
|
return fileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FileRecord> listByIds(Collection<Integer> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
args.add(ApprovalTenantContext.currentTenantId());
|
||||||
|
args.addAll(ids);
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record "
|
||||||
|
+ "WHERE tenant_id = ? AND deleted = 0 AND id IN (" + placeholders + ")",
|
||||||
|
FILE_MAPPER, args.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程定义服务:按租户幂等发布各流程的最新版本,并解析节点 JSON。
|
||||||
|
*
|
||||||
|
* <p>版本不可变:流程调整只新增版本行,已创建实例继续引用其 definition_id;
|
||||||
|
* {@link #getOrCreate(String)} 始终返回最新版本。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalFlowService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class);
|
||||||
|
private static final int INITIAL_VERSION = 1;
|
||||||
|
/** 各流程当前发布版本;调整节点时必须递增并保留旧版本行。 */
|
||||||
|
private static final Map<String, Integer> LATEST_VERSIONS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, Integer> versions = new LinkedHashMap<>();
|
||||||
|
versions.put(ApprovalConstants.FLOW_COMPLETION, 1);
|
||||||
|
versions.put(ApprovalConstants.FLOW_COORDINATION_INTERNAL, 2);
|
||||||
|
LATEST_VERSIONS = Collections.unmodifiableMap(versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalFlowDefinitionMapper flowDefinitionMapper;
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取租户下流程定义的最新版本;尚无该版本时发布新版。
|
||||||
|
*
|
||||||
|
* <p>并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新读取最新版本。</p>
|
||||||
|
*/
|
||||||
|
public ApprovalFlowDefinition getOrCreate(String flowCode) {
|
||||||
|
int latestVersion = latestVersion(flowCode);
|
||||||
|
ApprovalFlowDefinition definition = findLatest(flowCode);
|
||||||
|
if (definition != null && definition.getVersion() >= latestVersion) {
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
ApprovalFlowDefinition created = new ApprovalFlowDefinition();
|
||||||
|
created.setFlowCode(flowCode);
|
||||||
|
created.setVersion(latestVersion);
|
||||||
|
created.setName(flowName(flowCode));
|
||||||
|
created.setNodesJson(flowNodesJson(flowCode));
|
||||||
|
try {
|
||||||
|
flowDefinitionMapper.insert(created);
|
||||||
|
return created;
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
log.info("审批流程定义已被并发创建,重新读取: {}", flowCode);
|
||||||
|
definition = findLatest(flowCode);
|
||||||
|
if (definition == null || definition.getVersion() < latestVersion) {
|
||||||
|
throw new BusinessException("审批流程定义初始化失败");
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApprovalFlowDefinition getById(Long definitionId) {
|
||||||
|
ApprovalFlowDefinition definition = flowDefinitionMapper.selectById(definitionId);
|
||||||
|
if (definition == null) {
|
||||||
|
throw new BusinessException("审批流程定义不存在");
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ApprovalNode> parseNodes(Long definitionId) {
|
||||||
|
ApprovalFlowDefinition definition = getById(definitionId);
|
||||||
|
try {
|
||||||
|
List<ApprovalNode> nodes = objectMapper.readValue(definition.getNodesJson(),
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, ApprovalNode.class));
|
||||||
|
return nodes == null ? Collections.emptyList() : nodes;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("审批流程定义格式错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApprovalNode findNode(Long definitionId, String nodeKey) {
|
||||||
|
if (nodeKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseNodes(definitionId).stream()
|
||||||
|
.filter(node -> nodeKey.equals(node.getKey()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String nodeName(Long definitionId, String nodeKey) {
|
||||||
|
ApprovalNode node = findNode(definitionId, nodeKey);
|
||||||
|
return node == null ? null : node.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalFlowDefinition findLatest(String flowCode) {
|
||||||
|
return flowDefinitionMapper.selectOne(new LambdaQueryWrapper<ApprovalFlowDefinition>()
|
||||||
|
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
|
||||||
|
.orderByDesc(ApprovalFlowDefinition::getVersion)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int latestVersion(String flowCode) {
|
||||||
|
Integer version = LATEST_VERSIONS.get(flowCode);
|
||||||
|
return version == null ? INITIAL_VERSION : version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String flowName(String flowCode) {
|
||||||
|
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
|
||||||
|
return "业务完成确认单";
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
|
||||||
|
return "内部协调单";
|
||||||
|
}
|
||||||
|
throw new BusinessException("未知的审批流程: " + flowCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String flowNodesJson(String flowCode) {
|
||||||
|
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
|
||||||
|
return ApprovalConstants.COMPLETION_NODES_JSON;
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
|
||||||
|
return ApprovalConstants.COORDINATION_NODES_JSON;
|
||||||
|
}
|
||||||
|
throw new BusinessException("未知的审批流程: " + flowCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
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.ApprovalCoordinationParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||||
|
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.ApprovalPendingTaskVO;
|
||||||
|
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.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务完成确认单草稿、提交与详情服务。
|
||||||
|
*
|
||||||
|
* <p>跨库约束:服务方法本身不开启事务;需要写 master 的片段放进
|
||||||
|
* TransactionTemplate,任何 @DS("server") 的目录查询都在事务外完成,
|
||||||
|
* 避免动态数据源在事务内无法切换。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalInstanceService {
|
||||||
|
|
||||||
|
private static final BigDecimal MAX_FEE = new BigDecimal("99999999.99");
|
||||||
|
private static final String PERMISSION_ASSIGN = ApprovalConstants.PERMISSION_ASSIGN;
|
||||||
|
/** 待办范围:全租户待办(仅 assign 权限可用)。 */
|
||||||
|
private static final String SCOPE_ALL = "all";
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建或更新内部协调单草稿,返回实例id。
|
||||||
|
*
|
||||||
|
* <p>报送部门与报送人取登录态;受理部门必须是启用组织,保存时即校验。</p>
|
||||||
|
*/
|
||||||
|
public Long saveCoordination(ApprovalCoordinationParam param, User user) {
|
||||||
|
validateCoordination(param);
|
||||||
|
ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId());
|
||||||
|
if (acceptOrganization == null) {
|
||||||
|
throw new BusinessException("受理部门不存在或已停用");
|
||||||
|
}
|
||||||
|
Integer organizationId = user.getOrganizationId();
|
||||||
|
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
|
||||||
|
String organizationName = organization == null ? null : organization.getOrganizationName();
|
||||||
|
|
||||||
|
if (param.getId() != null) {
|
||||||
|
return inTransaction(() ->
|
||||||
|
updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization));
|
||||||
|
}
|
||||||
|
ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
|
||||||
|
return inTransaction(() ->
|
||||||
|
createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交或重提,返回节点缺配提示;正常提交返回 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()));
|
||||||
|
}
|
||||||
|
boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType());
|
||||||
|
ApprovalCoordinationForm coordinationForm = null;
|
||||||
|
if (coordination) {
|
||||||
|
coordinationForm = coordinationForm(instanceId);
|
||||||
|
if (coordinationForm == null) {
|
||||||
|
throw new BusinessException("单据内容不存在");
|
||||||
|
}
|
||||||
|
} else 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("未归属部门,无法提交");
|
||||||
|
}
|
||||||
|
String leaderRoleCode = ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode());
|
||||||
|
if (leaderRoleCode == null) {
|
||||||
|
throw new BusinessException("部门未配置编码,无法提交");
|
||||||
|
}
|
||||||
|
List<ApprovalUserVO> leaders = directoryService.listRoleMembers(leaderRoleCode);
|
||||||
|
if (leaders.isEmpty()) {
|
||||||
|
throw new BusinessException("报送部门负责人角色缺配");
|
||||||
|
}
|
||||||
|
ApprovalOrganizationVO acceptOrganization = null;
|
||||||
|
if (coordination) {
|
||||||
|
acceptOrganization = preflightAcceptOrganization(coordinationForm);
|
||||||
|
}
|
||||||
|
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
|
||||||
|
throw new BusinessException("收文员角色缺配");
|
||||||
|
}
|
||||||
|
if (attachmentService.count(instanceId) == 0) {
|
||||||
|
throw new BusinessException("请至少上传一张附件");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ApprovalApprover> approvers = leaders.stream()
|
||||||
|
.map(this::toApprover)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
ApprovalOrganizationVO acceptOrg = acceptOrganization;
|
||||||
|
return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协调单提交预检:受理部门负责人角色、受理部门停用。
|
||||||
|
*/
|
||||||
|
private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) {
|
||||||
|
Integer acceptOrgId = form.getAcceptOrgId();
|
||||||
|
if (acceptOrgId == null || acceptOrgId <= 0) {
|
||||||
|
throw new BusinessException("请选择受理部门");
|
||||||
|
}
|
||||||
|
ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId);
|
||||||
|
if (acceptOrganization == null) {
|
||||||
|
throw new BusinessException("受理部门已停用");
|
||||||
|
}
|
||||||
|
String leaderRoleCode = ApprovalConstants.departmentLeaderRoleCode(acceptOrganization.getOrganizationCode());
|
||||||
|
if (leaderRoleCode == null) {
|
||||||
|
throw new BusinessException("受理部门未配置编码");
|
||||||
|
}
|
||||||
|
if (directoryService.listRoleMembers(leaderRoleCode).isEmpty()) {
|
||||||
|
throw new BusinessException("受理部门负责人角色缺配");
|
||||||
|
}
|
||||||
|
return acceptOrganization;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
|
||||||
|
ApprovalOrganizationVO acceptOrganization, List<ApprovalApprover> approvers) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(instanceId);
|
||||||
|
if (!locked.getReporterId().equals(user.getUserId())
|
||||||
|
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
|
||||||
|
locked.setReportOrgId(organization.getOrganizationId());
|
||||||
|
locked.setReportOrgName(organization.getOrganizationName());
|
||||||
|
if (acceptOrganization != null) {
|
||||||
|
locked.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
locked.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
}
|
||||||
|
if (firstSubmit) {
|
||||||
|
boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType());
|
||||||
|
locked.setBizNo(noService.nextNo(coordination
|
||||||
|
? ApprovalConstants.NO_PREFIX_COORDINATION
|
||||||
|
: 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;
|
||||||
|
int assignVisible = hasAuthority(user, PERMISSION_ASSIGN)
|
||||||
|
&& ApprovalConstants.STATUS_PENDING.equals(instance.getStatus()) ? 1 : 0;
|
||||||
|
if (instanceMapper.countVisible(instanceId, user.getUserId(), allVisible, assignVisible) == 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.setPendingTasks(pendingTasks(instance));
|
||||||
|
vo.setPermissions(permissions(instance, user, myTask));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我发起的列表:按提交时间倒序,草稿排最后。
|
||||||
|
*/
|
||||||
|
public PageResult<ApprovalItemVO> mine(ApprovalMineParam param, User user) {
|
||||||
|
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||||
|
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
|
||||||
|
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||||
|
List<ApprovalItemRow> rows = instanceMapper.selectMinePage(page, param, user.getUserId());
|
||||||
|
return new PageResult<>(toItems(rows), page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的待办:本人 PENDING 任务对应的实例,按实例去重。
|
||||||
|
*
|
||||||
|
* <p>PC 传 {@code scope=all} 时返回全租户待办,仅 assign 权限可用;
|
||||||
|
* 员工端不传 scope,行为不变。</p>
|
||||||
|
*/
|
||||||
|
public PageResult<ApprovalItemVO> todo(ApprovalTaskListParam param, User user) {
|
||||||
|
if (SCOPE_ALL.equals(param.getScope())) {
|
||||||
|
if (!hasAuthority(user, PERMISSION_ASSIGN)) {
|
||||||
|
throw new AccessDeniedException("无权限查看全部待办");
|
||||||
|
}
|
||||||
|
return pendingPage(param);
|
||||||
|
}
|
||||||
|
return taskPage(param, user, ApprovalConstants.TASK_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的已办:本人 DONE 任务对应的实例,按实例去重。
|
||||||
|
*/
|
||||||
|
public PageResult<ApprovalItemVO> done(ApprovalTaskListParam param, User user) {
|
||||||
|
return taskPage(param, user, ApprovalConstants.TASK_DONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全租户待办:任一 PENDING 任务对应的实例,按实例去重。
|
||||||
|
*/
|
||||||
|
private PageResult<ApprovalItemVO> pendingPage(ApprovalTaskListParam param) {
|
||||||
|
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||||
|
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
|
||||||
|
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||||
|
List<ApprovalItemRow> rows = taskMapper.selectPendingInstancePage(page, param);
|
||||||
|
return new PageResult<>(toItems(rows), page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageResult<ApprovalItemVO> taskPage(ApprovalTaskListParam param, User user, String taskStatus) {
|
||||||
|
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||||
|
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
|
||||||
|
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||||
|
List<ApprovalItemRow> rows = taskMapper.selectTaskInstancePage(page, param, user.getUserId(), taskStatus);
|
||||||
|
return new PageResult<>(toItems(rows), page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalItemVO> toItems(List<ApprovalItemRow> rows) {
|
||||||
|
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
|
||||||
|
List<ApprovalItemVO> list = new ArrayList<>();
|
||||||
|
for (ApprovalItemRow row : rows) {
|
||||||
|
ApprovalItemVO item = new ApprovalItemVO();
|
||||||
|
item.setId(row.getId());
|
||||||
|
item.setBizNo(row.getBizNo());
|
||||||
|
item.setDocType(row.getDocType());
|
||||||
|
item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
|
||||||
|
item.setStatus(row.getStatus());
|
||||||
|
item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
|
||||||
|
item.setTitle(row.getTitle());
|
||||||
|
item.setSubmitTime(row.getSubmitTime());
|
||||||
|
item.setReporterId(row.getReporterId());
|
||||||
|
item.setReporterName(row.getReporterName());
|
||||||
|
item.setReportOrgName(row.getReportOrgName());
|
||||||
|
item.setAcceptOrgName(row.getAcceptOrgName());
|
||||||
|
item.setArchiveTime(row.getArchiveTime());
|
||||||
|
item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
|
||||||
|
list.add(item);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
|
||||||
|
}
|
||||||
|
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 Long createCoordinationDraft(ApprovalCoordinationParam param, User user,
|
||||||
|
ApprovalFlowDefinition definition,
|
||||||
|
Integer organizationId, String organizationName,
|
||||||
|
ApprovalOrganizationVO acceptOrganization) {
|
||||||
|
ApprovalInstance instance = new ApprovalInstance();
|
||||||
|
instance.setFlowCode(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
|
||||||
|
instance.setDefinitionId(definition.getId());
|
||||||
|
instance.setDefinitionVersion(definition.getVersion());
|
||||||
|
instance.setDocType(ApprovalConstants.DOC_COORDINATION);
|
||||||
|
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);
|
||||||
|
|
||||||
|
ApprovalCoordinationForm form = new ApprovalCoordinationForm();
|
||||||
|
form.setInstanceId(instance.getId());
|
||||||
|
form.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
form.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
form.setTitle(param.getTitle().trim());
|
||||||
|
form.setContent(param.getContent().trim());
|
||||||
|
form.setRemark(normalizeRemark(param.getRemark()));
|
||||||
|
coordinationFormMapper.insert(form);
|
||||||
|
|
||||||
|
instance.setBizId(form.getId());
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
return instance.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long updateCoordinationDraft(ApprovalCoordinationParam param, User user,
|
||||||
|
Integer organizationId, String organizationName,
|
||||||
|
ApprovalOrganizationVO acceptOrganization) {
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(param.getId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType())) {
|
||||||
|
throw new BusinessException("单据类型不匹配");
|
||||||
|
}
|
||||||
|
if (!instance.getReporterId().equals(user.getUserId())) {
|
||||||
|
throw new BusinessException("无权操作该单据");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
|
throw new BusinessException("当前状态不可修改");
|
||||||
|
}
|
||||||
|
ApprovalCoordinationForm form = coordinationForm(instance.getId());
|
||||||
|
if (form == null) {
|
||||||
|
throw new BusinessException("单据内容不存在");
|
||||||
|
}
|
||||||
|
form.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
form.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
form.setTitle(param.getTitle().trim());
|
||||||
|
form.setContent(param.getContent().trim());
|
||||||
|
form.setRemark(normalizeRemark(param.getRemark()));
|
||||||
|
coordinationFormMapper.updateById(form);
|
||||||
|
|
||||||
|
instance.setReportOrgId(organizationId);
|
||||||
|
instance.setReportOrgName(organizationName);
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
return instance.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRemark(String remark) {
|
||||||
|
if (remark == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = remark.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalInstanceVO baseVO(ApprovalInstance instance) {
|
||||||
|
ApprovalInstanceVO vo = new ApprovalInstanceVO();
|
||||||
|
vo.setId(instance.getId());
|
||||||
|
vo.setBizNo(instance.getBizNo());
|
||||||
|
vo.setDocType(instance.getDocType());
|
||||||
|
vo.setDocTypeName(ApprovalConstants.docTypeName(instance.getDocType()));
|
||||||
|
vo.setFlowCode(instance.getFlowCode());
|
||||||
|
vo.setStatus(instance.getStatus());
|
||||||
|
vo.setStatusName(ApprovalConstants.statusName(instance.getStatus()));
|
||||||
|
vo.setRound(instance.getRound());
|
||||||
|
vo.setReporterId(instance.getReporterId());
|
||||||
|
vo.setReporterName(instance.getReporterName());
|
||||||
|
vo.setReportOrgId(instance.getReportOrgId());
|
||||||
|
vo.setReportOrgName(instance.getReportOrgName());
|
||||||
|
vo.setAcceptOrgId(instance.getAcceptOrgId());
|
||||||
|
vo.setAcceptOrgName(instance.getAcceptOrgName());
|
||||||
|
vo.setCurrentNodeKey(instance.getCurrentNodeKey());
|
||||||
|
vo.setCurrentNodeType(instance.getCurrentNodeType());
|
||||||
|
vo.setCurrentNodeName(flowService.nodeName(instance.getDefinitionId(), instance.getCurrentNodeKey()));
|
||||||
|
vo.setSubmitTime(instance.getSubmitTime());
|
||||||
|
vo.setApproveTime(instance.getApproveTime());
|
||||||
|
vo.setArchiveTime(instance.getArchiveTime());
|
||||||
|
vo.setArchivistId(instance.getArchivistId());
|
||||||
|
vo.setArchivistName(instance.getArchivistName());
|
||||||
|
vo.setDispatchTargetId(instance.getDispatchTargetId());
|
||||||
|
vo.setDispatchTargetName(instance.getDispatchTargetName());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalRecordVO> records(ApprovalInstance instance) {
|
||||||
|
List<ApprovalRecord> records = recordMapper.selectList(new LambdaQueryWrapper<ApprovalRecord>()
|
||||||
|
.eq(ApprovalRecord::getInstanceId, instance.getId())
|
||||||
|
.orderByAsc(ApprovalRecord::getCreateTime)
|
||||||
|
.orderByAsc(ApprovalRecord::getId));
|
||||||
|
List<ApprovalRecordVO> result = new ArrayList<>();
|
||||||
|
for (ApprovalRecord record : records) {
|
||||||
|
ApprovalRecordVO vo = new ApprovalRecordVO();
|
||||||
|
vo.setId(record.getId());
|
||||||
|
vo.setTaskId(record.getTaskId());
|
||||||
|
vo.setNodeKey(record.getNodeKey());
|
||||||
|
vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), record.getNodeKey()));
|
||||||
|
vo.setOperatorId(record.getOperatorId());
|
||||||
|
vo.setOperatorName(record.getOperatorName());
|
||||||
|
vo.setAction(record.getAction());
|
||||||
|
vo.setActionName(ApprovalConstants.actionName(record.getAction()));
|
||||||
|
vo.setOpinion(record.getOpinion());
|
||||||
|
vo.setRound(record.getRound());
|
||||||
|
vo.setCreateTime(record.getCreateTime());
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalConfirmVO> confirms(ApprovalInstance instance) {
|
||||||
|
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
|
.eq(ApprovalTask::getNodeType, ApprovalConstants.NODE_CONFIRM)
|
||||||
|
.eq(ApprovalTask::getRound, instance.getRound())
|
||||||
|
.orderByAsc(ApprovalTask::getId));
|
||||||
|
List<ApprovalConfirmVO> result = new ArrayList<>();
|
||||||
|
for (ApprovalTask task : tasks) {
|
||||||
|
ApprovalConfirmVO vo = new ApprovalConfirmVO();
|
||||||
|
vo.setUserId(task.getAssigneeId());
|
||||||
|
vo.setUserName(task.getAssigneeName());
|
||||||
|
vo.setStatus(task.getStatus());
|
||||||
|
vo.setStatusName(taskStatusName(task.getStatus()));
|
||||||
|
vo.setHandleTime(task.getHandleTime());
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalMyTaskVO myTask(ApprovalInstance instance, Integer userId) {
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
|
.eq(ApprovalTask::getAssigneeId, userId)
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)
|
||||||
|
.orderByDesc(ApprovalTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (tasks.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ApprovalTask task = tasks.get(0);
|
||||||
|
ApprovalMyTaskVO vo = new ApprovalMyTaskVO();
|
||||||
|
vo.setId(task.getId());
|
||||||
|
vo.setNodeKey(task.getNodeKey());
|
||||||
|
vo.setNodeType(task.getNodeType());
|
||||||
|
vo.setStatus(task.getStatus());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalPendingTaskVO> pendingTasks(ApprovalInstance instance) {
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)
|
||||||
|
.orderByAsc(ApprovalTask::getId));
|
||||||
|
List<ApprovalPendingTaskVO> result = new ArrayList<>(tasks.size());
|
||||||
|
for (ApprovalTask task : tasks) {
|
||||||
|
ApprovalPendingTaskVO vo = new ApprovalPendingTaskVO();
|
||||||
|
vo.setId(task.getId());
|
||||||
|
vo.setNodeKey(task.getNodeKey());
|
||||||
|
vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), task.getNodeKey()));
|
||||||
|
vo.setNodeType(task.getNodeType());
|
||||||
|
vo.setAssigneeId(task.getAssigneeId());
|
||||||
|
vo.setAssigneeName(task.getAssigneeName());
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask) {
|
||||||
|
ApprovalPermissionsVO permissions = new ApprovalPermissionsVO();
|
||||||
|
boolean reporter = instance.getReporterId().equals(user.getUserId());
|
||||||
|
boolean editable = reporter && ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus());
|
||||||
|
permissions.setCanEdit(editable);
|
||||||
|
permissions.setCanSubmit(editable);
|
||||||
|
if (reporter && ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
Integer done = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
|
.eq(ApprovalTask::getRound, instance.getRound())
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
|
||||||
|
permissions.setCanWithdraw(done == null || done == 0);
|
||||||
|
}
|
||||||
|
permissions.setCanAssign(hasAuthority(user, PERMISSION_ASSIGN)
|
||||||
|
&& ApprovalConstants.STATUS_PENDING.equals(instance.getStatus()));
|
||||||
|
if (myTask != null) {
|
||||||
|
boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType());
|
||||||
|
boolean rejectableNode = approveNode || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
|
||||||
|
permissions.setCanApprove(approveNode);
|
||||||
|
permissions.setCanReject(rejectableNode);
|
||||||
|
permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()));
|
||||||
|
permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType()));
|
||||||
|
permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType()));
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAuthority(User user, String authority) {
|
||||||
|
if (user.getAuthorities() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.getAuthorities().stream()
|
||||||
|
.map(Menu::getAuthority)
|
||||||
|
.anyMatch(authority::equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalCompletionForm completionForm(Long instanceId) {
|
||||||
|
List<ApprovalCompletionForm> forms = completionFormMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ApprovalCompletionForm>()
|
||||||
|
.eq(ApprovalCompletionForm::getInstanceId, instanceId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
return forms.isEmpty() ? null : forms.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalCoordinationForm coordinationForm(Long instanceId) {
|
||||||
|
List<ApprovalCoordinationForm> forms = coordinationFormMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ApprovalCoordinationForm>()
|
||||||
|
.eq(ApprovalCoordinationForm::getInstanceId, instanceId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
return forms.isEmpty() ? null : forms.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCompletion(ApprovalCompletionParam param) {
|
||||||
|
if (param == null) {
|
||||||
|
throw new BusinessException("参数不能为空");
|
||||||
|
}
|
||||||
|
String name = param.getName() == null ? "" : param.getName().trim();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
throw new BusinessException("名称不能为空");
|
||||||
|
}
|
||||||
|
if (name.length() > 100) {
|
||||||
|
throw new BusinessException("名称不能超过100字");
|
||||||
|
}
|
||||||
|
String implementOrg = param.getImplementOrg() == null ? "" : param.getImplementOrg().trim();
|
||||||
|
if (implementOrg.isEmpty()) {
|
||||||
|
throw new BusinessException("实施单位不能为空");
|
||||||
|
}
|
||||||
|
if (implementOrg.length() > 100) {
|
||||||
|
throw new BusinessException("实施单位不能超过100字");
|
||||||
|
}
|
||||||
|
BigDecimal fee = param.getFee();
|
||||||
|
if (fee == null) {
|
||||||
|
throw new BusinessException("费用不能为空");
|
||||||
|
}
|
||||||
|
if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 || fee.compareTo(MAX_FEE) > 0) {
|
||||||
|
throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
|
||||||
|
}
|
||||||
|
String serviceContent = param.getServiceContent() == null ? "" : param.getServiceContent().trim();
|
||||||
|
if (serviceContent.isEmpty()) {
|
||||||
|
throw new BusinessException("服务内容不能为空");
|
||||||
|
}
|
||||||
|
if (serviceContent.length() > 2000) {
|
||||||
|
throw new BusinessException("服务内容不能超过2000字");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCoordination(ApprovalCoordinationParam param) {
|
||||||
|
if (param == null) {
|
||||||
|
throw new BusinessException("参数不能为空");
|
||||||
|
}
|
||||||
|
if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) {
|
||||||
|
throw new BusinessException("请选择受理部门");
|
||||||
|
}
|
||||||
|
String title = param.getTitle() == null ? "" : param.getTitle().trim();
|
||||||
|
if (title.isEmpty()) {
|
||||||
|
throw new BusinessException("标题不能为空");
|
||||||
|
}
|
||||||
|
if (title.length() > 100) {
|
||||||
|
throw new BusinessException("标题不能超过100字");
|
||||||
|
}
|
||||||
|
String content = param.getContent() == null ? "" : param.getContent().trim();
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
throw new BusinessException("内容不能为空");
|
||||||
|
}
|
||||||
|
if (content.length() > 2000) {
|
||||||
|
throw new BusinessException("内容不能超过2000字");
|
||||||
|
}
|
||||||
|
String remark = normalizeRemark(param.getRemark());
|
||||||
|
if (remark != null && remark.length() > 500) {
|
||||||
|
throw new BusinessException("备注不能超过500字");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String submitBlockedMessage(String status) {
|
||||||
|
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
|
||||||
|
return "单据正在审批中,不能重复提交";
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(status)) {
|
||||||
|
return "单据已归档,不能修改";
|
||||||
|
}
|
||||||
|
return "当前状态不可提交";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalApprover toApprover(ApprovalUserVO user) {
|
||||||
|
return new ApprovalApprover(user.getUserId(), userDisplayName(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String userDisplayName(ApprovalUserVO user) {
|
||||||
|
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String displayName(User user) {
|
||||||
|
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
|
||||||
|
if (definitionId == null || nodeKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
|
||||||
|
return nodes.stream()
|
||||||
|
.filter(node -> nodeKey.equals(node.getKey()))
|
||||||
|
.map(ApprovalNode::getName)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String taskStatusName(String status) {
|
||||||
|
if (ApprovalConstants.TASK_PENDING.equals(status)) {
|
||||||
|
return "待确认";
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.TASK_DONE.equals(status)) {
|
||||||
|
return "已确认";
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.TASK_CANCELLED.equals(status)) {
|
||||||
|
return "已取消";
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T inTransaction(Supplier<T> action) {
|
||||||
|
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.approval.entity.ApprovalNoSequence;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalNoSequenceMapper;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批单号服务:事务内行锁取号,格式 前缀 + yyyyMMdd + - + 至少 4 位流水。
|
||||||
|
*
|
||||||
|
* <p>调用方必须开启事务,锁会在事务提交/回滚时释放。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalNoService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalNoSequenceMapper noSequenceMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成当天单号;同租户+前缀+日期每日重置,超过 9999 自然进位。
|
||||||
|
*/
|
||||||
|
public String nextNo(String prefix) {
|
||||||
|
noSequenceMapper.insertTodayIfAbsent(prefix);
|
||||||
|
ApprovalNoSequence sequence = noSequenceMapper.selectTodayForUpdate(prefix);
|
||||||
|
if (sequence == null) {
|
||||||
|
throw new BusinessException("单号序列初始化失败");
|
||||||
|
}
|
||||||
|
noSequenceMapper.increment(sequence.getId());
|
||||||
|
long current = sequence.getCurrentSeq() == null ? 1 : sequence.getCurrentSeq() + 1L;
|
||||||
|
return prefix + sequence.getSeqDate().format(DATE_FORMAT) + "-" + String.format("%04d", current);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalQueryMapper;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||||
|
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 javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据查询服务:两类单据统一分页检索,列表与详情共用同一套数据权限条件。
|
||||||
|
*
|
||||||
|
* <p>跨库约束:allVisible 角色判定走 core 专用连接,本方法不开启事务。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalQueryService {
|
||||||
|
|
||||||
|
private static final long DEFAULT_LIMIT = 10L;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalQueryMapper queryMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalFlowService flowService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalDirectoryService directoryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单据查询分页,默认提交时间倒序、同秒按 id 倒序。
|
||||||
|
*/
|
||||||
|
public PageResult<ApprovalItemVO> page(ApprovalQueryParam param, User user) {
|
||||||
|
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||||
|
long size = param.getLimit() == null ? DEFAULT_LIMIT : Math.max(1, param.getLimit());
|
||||||
|
int allVisible = directoryService.hasAnyRole(user.getUserId(),
|
||||||
|
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
|
||||||
|
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
|
||||||
|
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||||
|
List<ApprovalItemRow> rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible,
|
||||||
|
assignVisible);
|
||||||
|
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
|
||||||
|
List<ApprovalItemVO> list = new ArrayList<>(rows.size());
|
||||||
|
for (ApprovalItemRow row : rows) {
|
||||||
|
list.add(toItem(row, nodeCache));
|
||||||
|
}
|
||||||
|
return new PageResult<>(list, page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalItemVO toItem(ApprovalItemRow row, Map<Long, List<ApprovalNode>> nodeCache) {
|
||||||
|
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()));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAuthority(User user, String authority) {
|
||||||
|
if (user.getAuthorities() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.getAuthorities().stream()
|
||||||
|
.map(Menu::getAuthority)
|
||||||
|
.anyMatch(authority::equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
|
||||||
|
if (definitionId == null || nodeKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
|
||||||
|
return nodes.stream()
|
||||||
|
.filter(node -> nodeKey.equals(node.getKey()))
|
||||||
|
.map(ApprovalNode::getName)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
package com.gxwebsoft.approval.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
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.ApprovalTask;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||||
|
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||||
|
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批任务动作:通过、驳回、撤回与节点推进。
|
||||||
|
*
|
||||||
|
* <p>跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接),
|
||||||
|
* 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用
|
||||||
|
* WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApprovalTaskService {
|
||||||
|
|
||||||
|
private static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader";
|
||||||
|
private static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader";
|
||||||
|
private static final String RESOLVER_ARCHIVIST = "archivist";
|
||||||
|
private static final String RESOLVER_REPORTER = "reporter";
|
||||||
|
private static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget";
|
||||||
|
private static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers";
|
||||||
|
private static final int MAX_OPINION_LENGTH = 500;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ApprovalInstanceMapper instanceMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalTaskMapper taskMapper;
|
||||||
|
@Resource
|
||||||
|
private ApprovalEngineService engineService;
|
||||||
|
@Resource
|
||||||
|
private ApprovalDirectoryService directoryService;
|
||||||
|
@Resource
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过:APPROVE 节点或签,处理后取消同节点同轮其余待办并推进下一节点。
|
||||||
|
*/
|
||||||
|
public Result approve(Long taskId, String opinion, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持通过");
|
||||||
|
}
|
||||||
|
String normalized = normalizeOpinion(opinion);
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
|
||||||
|
List<ApprovalApprover> approvers =
|
||||||
|
next == null ? Collections.emptyList() : resolveApprovers(instance, next);
|
||||||
|
|
||||||
|
String warning = inTransaction(() -> doApprove(taskId, task, normalized, user, next, approvers));
|
||||||
|
return new Result(instance.getId(), warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成,
|
||||||
|
* 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。
|
||||||
|
*/
|
||||||
|
public Result confirm(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持存档确认");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureNotArchived(instance);
|
||||||
|
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
|
||||||
|
List<ApprovalApprover> approvers =
|
||||||
|
next == null ? Collections.emptyList() : resolveApprovers(instance, next);
|
||||||
|
|
||||||
|
String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers));
|
||||||
|
return new Result(instance.getId(), warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档:ARCHIVE 任务或签,处理后取消同节点其余待办,实例进入只读终态。
|
||||||
|
*/
|
||||||
|
public Result archive(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_ARCHIVE.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持归档");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureNotArchived(instance);
|
||||||
|
inTransaction(() -> doArchive(taskId, task, user));
|
||||||
|
return new Result(instance.getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
|
||||||
|
*/
|
||||||
|
public Result reject(Long taskId, String opinion, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
boolean rejectable = ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())
|
||||||
|
|| ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType());
|
||||||
|
if (!rejectable) {
|
||||||
|
throw new BusinessException("当前任务不支持驳回");
|
||||||
|
}
|
||||||
|
String required = requireOpinion(opinion);
|
||||||
|
inTransaction(() -> doReject(taskId, task, required, user));
|
||||||
|
return new Result(task.getInstanceId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回:仅报送人,且本轮没有任何任务被处理。
|
||||||
|
*/
|
||||||
|
public Result withdraw(Long instanceId, User user) {
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(instanceId);
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
|
||||||
|
throw new BusinessException("无权操作该单据");
|
||||||
|
}
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据已归档,不能修改");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("当前状态不可撤回");
|
||||||
|
}
|
||||||
|
ensureNoHandledTask(instance);
|
||||||
|
inTransaction(() -> doWithdraw(instanceId, user));
|
||||||
|
return new Result(instanceId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。
|
||||||
|
*
|
||||||
|
* <p>改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。</p>
|
||||||
|
*/
|
||||||
|
public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) {
|
||||||
|
if (param == null || param.getTargetUserId() == null) {
|
||||||
|
throw new BusinessException("请选择改派对象");
|
||||||
|
}
|
||||||
|
String reason = requireAssignReason(param.getReason());
|
||||||
|
ApprovalTask task = requireAssignableTask(taskId);
|
||||||
|
// 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户)
|
||||||
|
List<ApprovalUserVO> targets =
|
||||||
|
directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId()));
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
throw new BusinessException("改派对象不存在或已停用");
|
||||||
|
}
|
||||||
|
ApprovalUserVO target = targets.get(0);
|
||||||
|
String targetName = displayName(target);
|
||||||
|
return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单候选池:仅派单节点、本人待办的任务可查询;优先受理部门启用成员,
|
||||||
|
* 部门无成员时回退为租户非普通消费用户。
|
||||||
|
*/
|
||||||
|
public List<ApprovalUserVO> candidates(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireDispatchTask(taskId, user);
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureTaskHandleable(task, instance);
|
||||||
|
return directoryService.listDispatchCandidates(instance.getAcceptOrgId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单:选定候选池中的 1 人即完成派单节点,激活存档节点并为报送人与被派单人生成两条
|
||||||
|
* 存档任务。目标校验与审批人解析在事务外完成(core 库),写 master 的部分在事务内。
|
||||||
|
*/
|
||||||
|
public Result dispatch(Long taskId, ApprovalTaskDispatchParam param, User user) {
|
||||||
|
if (param == null || param.getTargetUserId() == null) {
|
||||||
|
throw new BusinessException("请选择派单对象");
|
||||||
|
}
|
||||||
|
ApprovalTask task = requireDispatchTask(taskId, user);
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureNotArchived(instance);
|
||||||
|
ApprovalUserVO target = requireDispatchTarget(instance, param.getTargetUserId());
|
||||||
|
String targetName = displayName(target);
|
||||||
|
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
|
||||||
|
List<ApprovalApprover> approvers = dispatchNextApprovers(instance, next, target);
|
||||||
|
String warning = inTransaction(() ->
|
||||||
|
doDispatch(taskId, task, target, targetName, user, next, approvers));
|
||||||
|
return new Result(instance.getId(), warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer todoCount(User user) {
|
||||||
|
return taskMapper.countPendingInstances(user.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doApprove(Long taskId, ApprovalTask task, String opinion, User user,
|
||||||
|
ApprovalNode next, List<ApprovalApprover> approvers) {
|
||||||
|
// 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_APPROVE, opinion);
|
||||||
|
if (next == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
|
||||||
|
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doConfirm(Long taskId, ApprovalTask task, User user,
|
||||||
|
ApprovalNode next, List<ApprovalApprover> approvers) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureNotArchived(locked);
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_CONFIRM, null);
|
||||||
|
Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, locked.getId())
|
||||||
|
.eq(ApprovalTask::getRound, locked.getRound())
|
||||||
|
.eq(ApprovalTask::getNodeKey, task.getNodeKey())
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING));
|
||||||
|
if (remaining != null && remaining > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (next == null) {
|
||||||
|
return "当前节点缺少审批人,请联系管理员补配置";
|
||||||
|
}
|
||||||
|
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
|
||||||
|
if (created == 0) {
|
||||||
|
return "当前节点缺少审批人,请联系管理员补配置";
|
||||||
|
}
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||||
|
.set("status", ApprovalConstants.STATUS_APPROVED)
|
||||||
|
.set("approve_time", LocalDateTime.now()));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doArchive(Long taskId, ApprovalTask task, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureNotArchived(locked);
|
||||||
|
if (!ApprovalConstants.STATUS_APPROVED.equals(locked.getStatus())) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
ensureTaskCurrent(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_ARCHIVE, null);
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_APPROVED)
|
||||||
|
.set("status", ApprovalConstants.STATUS_ARCHIVED)
|
||||||
|
.set("archive_time", LocalDateTime.now())
|
||||||
|
.set("archivist_id", user.getUserId())
|
||||||
|
.set("archivist_name", displayName(user)));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
Integer round = locked.getRound();
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_REJECT, opinion);
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), round, null);
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||||
|
.set("status", ApprovalConstants.STATUS_REJECTED)
|
||||||
|
.set("round", round + 1));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doWithdraw(Long instanceId, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(instanceId);
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(locked.getStatus())) {
|
||||||
|
throw new BusinessException("当前状态不可撤回");
|
||||||
|
}
|
||||||
|
ensureNoHandledTask(locked);
|
||||||
|
Integer round = locked.getRound();
|
||||||
|
engineService.addRecord(locked, null, locked.getCurrentNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_WITHDRAW, null);
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), round, null);
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", instanceId)
|
||||||
|
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||||
|
.set("status", ApprovalConstants.STATUS_WITHDRAWN)
|
||||||
|
.set("round", round + 1));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。
|
||||||
|
*/
|
||||||
|
private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId,
|
||||||
|
String targetName, String reason, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
int rows = taskMapper.update(null, new UpdateWrapper<ApprovalTask>()
|
||||||
|
.eq("id", taskId)
|
||||||
|
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||||
|
.set("assignee_id", targetUserId)
|
||||||
|
.set("assignee_name", targetName));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_ASSIGN, reason);
|
||||||
|
ApprovalAssignVO vo = new ApprovalAssignVO();
|
||||||
|
vo.setInstanceId(locked.getId());
|
||||||
|
vo.setTaskId(taskId);
|
||||||
|
vo.setOriginalAssigneeId(task.getAssigneeId());
|
||||||
|
vo.setOriginalAssigneeName(task.getAssigneeName());
|
||||||
|
vo.setAssigneeId(targetUserId);
|
||||||
|
vo.setAssigneeName(targetName);
|
||||||
|
vo.setReason(reason);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事务内派单:锁实例后条件更新任务、快照派单对象、写派单记录并激活存档节点。
|
||||||
|
*/
|
||||||
|
private String doDispatch(Long taskId, ApprovalTask task, ApprovalUserVO target, String targetName,
|
||||||
|
User user, ApprovalNode next, List<ApprovalApprover> approvers) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureNotArchived(locked);
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
if (updateDispatchTask(taskId, target.getUserId(), targetName) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||||
|
.set("dispatch_target_id", target.getUserId())
|
||||||
|
.set("dispatch_target_name", targetName));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
locked.setDispatchTargetId(target.getUserId());
|
||||||
|
locked.setDispatchTargetName(targetName);
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_DISPATCH, null);
|
||||||
|
if (next == null) {
|
||||||
|
return "当前节点缺少审批人,请联系管理员补配置";
|
||||||
|
}
|
||||||
|
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
|
||||||
|
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单前校验:任务存在、属于本人、是派单节点且仍待处理。
|
||||||
|
*/
|
||||||
|
private ApprovalTask requireDispatchTask(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持派单");
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单目标必须来自候选池(受理部门启用成员,或部门无成员时的租户回退候选)。
|
||||||
|
*/
|
||||||
|
private ApprovalUserVO requireDispatchTarget(ApprovalInstance instance, Integer targetUserId) {
|
||||||
|
for (ApprovalUserVO candidate : directoryService.listDispatchCandidates(instance.getAcceptOrgId())) {
|
||||||
|
if (targetUserId.equals(candidate.getUserId())) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BusinessException("派单对象不在候选范围内");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档节点审批人:报送人 + 本次派单对象(实例快照尚未写入,需显式解析)。
|
||||||
|
*/
|
||||||
|
private List<ApprovalApprover> dispatchNextApprovers(ApprovalInstance instance, ApprovalNode next,
|
||||||
|
ApprovalUserVO target) {
|
||||||
|
if (next == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
if (!RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) {
|
||||||
|
return resolveApprovers(instance, next);
|
||||||
|
}
|
||||||
|
List<Integer> userIds = new ArrayList<>();
|
||||||
|
if (instance.getReporterId() != null) {
|
||||||
|
userIds.add(instance.getReporterId());
|
||||||
|
}
|
||||||
|
if (!userIds.contains(target.getUserId())) {
|
||||||
|
userIds.add(target.getUserId());
|
||||||
|
}
|
||||||
|
return userApprovers(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单任务条件更新:完成并快照派单对象。
|
||||||
|
*/
|
||||||
|
private int updateDispatchTask(Long taskId, Integer targetUserId, String targetName) {
|
||||||
|
return taskMapper.update(null, new UpdateWrapper<ApprovalTask>()
|
||||||
|
.eq("id", taskId)
|
||||||
|
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||||
|
.set("status", ApprovalConstants.TASK_DONE)
|
||||||
|
.set("handle_time", LocalDateTime.now())
|
||||||
|
.set("dispatch_target_id", targetUserId)
|
||||||
|
.set("dispatch_target_name", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派前的基本校验:任务存在、待处理且单据仍在审批中。
|
||||||
|
*/
|
||||||
|
private ApprovalTask requireAssignableTask(Long taskId) {
|
||||||
|
if (taskId == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
ApprovalTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据不在审批中,无法改派");
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
|
||||||
|
*/
|
||||||
|
private void ensureTaskHandleable(ApprovalTask task, ApprovalInstance instance) {
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
ensureTaskCurrent(task, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTaskCurrent(ApprovalTask task, ApprovalInstance instance) {
|
||||||
|
if (!task.getRound().equals(instance.getRound())
|
||||||
|
|| !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureNotArchived(ApprovalInstance instance) {
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据已归档,不能修改");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureNoHandledTask(ApprovalInstance instance) {
|
||||||
|
Integer handled = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
|
.eq(ApprovalTask::getRound, instance.getRound())
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
|
||||||
|
if (handled != null && handled > 0) {
|
||||||
|
throw new BusinessException("已有审批人处理,无法撤回");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int updatePendingTask(Long taskId, String status, String opinion) {
|
||||||
|
UpdateWrapper<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
|
||||||
|
.eq("id", taskId)
|
||||||
|
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||||
|
.set("status", status)
|
||||||
|
.set("handle_time", LocalDateTime.now());
|
||||||
|
if (opinion != null) {
|
||||||
|
wrapper.set("opinion", opinion);
|
||||||
|
}
|
||||||
|
return taskMapper.update(null, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalTask requireTask(Long taskId, User user) {
|
||||||
|
if (taskId == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
ApprovalTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
if (user == null || !user.getUserId().equals(task.getAssigneeId())) {
|
||||||
|
throw new BusinessException("无权处理该任务");
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事务外按节点解析器解析审批人;缺配时返回空列表,由调用方提示并停留当前节点。
|
||||||
|
*/
|
||||||
|
private List<ApprovalApprover> resolveApprovers(ApprovalInstance instance, ApprovalNode node) {
|
||||||
|
String resolver = node.getResolver();
|
||||||
|
if (StrUtil.isBlank(resolver)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
switch (resolver) {
|
||||||
|
case RESOLVER_REPORTER_ORG_LEADER:
|
||||||
|
return departmentLeaderApprovers(instance.getReportOrgId());
|
||||||
|
case RESOLVER_ACCEPT_ORG_LEADER:
|
||||||
|
return departmentLeaderApprovers(instance.getAcceptOrgId());
|
||||||
|
case RESOLVER_ARCHIVIST:
|
||||||
|
return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST);
|
||||||
|
case RESOLVER_REPORTER:
|
||||||
|
return userApprovers(Collections.singletonList(instance.getReporterId()));
|
||||||
|
case RESOLVER_REPORTER_AND_DISPATCH_TARGET:
|
||||||
|
return userApprovers(confirmCandidateIds(instance));
|
||||||
|
case RESOLVER_ACCEPT_ORG_MEMBERS:
|
||||||
|
return organizationApprovers(instance.getAcceptOrgId());
|
||||||
|
default:
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门负责人审批人:按部门 organization_code 解析角色;部门或编码缺失时返回空列表,
|
||||||
|
* 节点保持缺配状态,等待管理员补配置。
|
||||||
|
*/
|
||||||
|
private List<ApprovalApprover> departmentLeaderApprovers(Integer organizationId) {
|
||||||
|
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
|
||||||
|
String roleCode = organization == null
|
||||||
|
? null : ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode());
|
||||||
|
return roleCode == null ? Collections.emptyList() : roleApprovers(roleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> confirmCandidateIds(ApprovalInstance instance) {
|
||||||
|
List<Integer> ids = new ArrayList<>();
|
||||||
|
if (instance.getReporterId() != null) {
|
||||||
|
ids.add(instance.getReporterId());
|
||||||
|
}
|
||||||
|
if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) {
|
||||||
|
ids.add(instance.getDispatchTargetId());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalApprover> roleApprovers(String roleCode) {
|
||||||
|
if (StrUtil.isBlank(roleCode)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return toApprovers(directoryService.listRoleMembers(roleCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalApprover> userApprovers(List<Integer> userIds) {
|
||||||
|
return toApprovers(directoryService.listActiveUsersByIds(userIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalApprover> organizationApprovers(Integer organizationId) {
|
||||||
|
return toApprovers(directoryService.listOrganizationMembers(organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalApprover> toApprovers(List<ApprovalUserVO> users) {
|
||||||
|
return users.stream()
|
||||||
|
.map(user -> new ApprovalApprover(user.getUserId(), displayName(user)))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String displayName(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 normalizeOpinion(String opinion) {
|
||||||
|
if (opinion == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = opinion.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed.length() > MAX_OPINION_LENGTH) {
|
||||||
|
throw new BusinessException("意见不能超过500字");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireOpinion(String opinion) {
|
||||||
|
String normalized = normalizeOpinion(opinion);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new BusinessException("请填写驳回意见");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireAssignReason(String reason) {
|
||||||
|
String normalized = normalizeOpinion(reason);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new BusinessException("请填写改派原因");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T inTransaction(Supplier<T> action) {
|
||||||
|
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void inTransaction(Runnable action) {
|
||||||
|
new TransactionTemplate(transactionManager).execute(status -> {
|
||||||
|
action.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作结果:实例id + 需要透出的节点缺配提示。
|
||||||
|
*/
|
||||||
|
public static class Result {
|
||||||
|
private final Long instanceId;
|
||||||
|
private final String warning;
|
||||||
|
|
||||||
|
public Result(Long instanceId, String warning) {
|
||||||
|
this.instanceId = instanceId;
|
||||||
|
this.warning = warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getInstanceId() {
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarning() {
|
||||||
|
return warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点激活时快照的审批人。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApprovalApprover {
|
||||||
|
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
private String userName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.FileRecord;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出结果:返回给前端的文件记录 + 实际导出行数(用于提示与操作日志)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalExportResult {
|
||||||
|
|
||||||
|
private final FileRecord file;
|
||||||
|
|
||||||
|
private final int rowCount;
|
||||||
|
|
||||||
|
public ApprovalExportResult(FileRecord file, int rowCount) {
|
||||||
|
this.file = file;
|
||||||
|
this.rowCount = rowCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出查询数据库行:两类单据合并列,未命中的表单字段为空。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalExportRow implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String bizNo;
|
||||||
|
private String docType;
|
||||||
|
private String status;
|
||||||
|
private String reporterName;
|
||||||
|
private String reportOrgName;
|
||||||
|
private String acceptOrgName;
|
||||||
|
private String currentNodeKey;
|
||||||
|
private Long definitionId;
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
private LocalDateTime approveTime;
|
||||||
|
private LocalDateTime archiveTime;
|
||||||
|
private String archivistName;
|
||||||
|
|
||||||
|
/** 业务完成确认单:名称。 */
|
||||||
|
private String name;
|
||||||
|
/** 业务完成确认单:实施单位。 */
|
||||||
|
private String implementOrg;
|
||||||
|
/** 业务完成确认单:费用(元)。 */
|
||||||
|
private BigDecimal fee;
|
||||||
|
/** 业务完成确认单:服务内容。 */
|
||||||
|
private String serviceContent;
|
||||||
|
|
||||||
|
/** 内部协调单:标题。 */
|
||||||
|
private String title;
|
||||||
|
/** 内部协调单:协调内容。 */
|
||||||
|
private String content;
|
||||||
|
/** 内部协调单:备注。 */
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表查询数据库行,额外携带当前节点key与流程定义id供服务端翻译。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalItemRow implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String bizNo;
|
||||||
|
private String docType;
|
||||||
|
private String status;
|
||||||
|
private String title;
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
private Integer reporterId;
|
||||||
|
private String reporterName;
|
||||||
|
private String reportOrgName;
|
||||||
|
private String acceptOrgName;
|
||||||
|
private LocalDateTime archiveTime;
|
||||||
|
private String currentNodeKey;
|
||||||
|
private Long definitionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程定义中的节点描述(nodes_json 数组元素)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class ApprovalNode {
|
||||||
|
|
||||||
|
/** 节点标识,仅在同一流程内唯一。 */
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
/** 节点类型:APPROVE/COUNTERSIGN/DISPATCH/CONFIRM/ARCHIVE。 */
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
/** 审批人解析器标识,由 ApprovalDirectoryService 负责解析。 */
|
||||||
|
private String resolver;
|
||||||
|
|
||||||
|
/** 节点显示名称。 */
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.gxwebsoft.approval.support;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批域租户上下文。
|
||||||
|
*
|
||||||
|
* <p>gxtyzx_core 的查询使用独立连接(不受 master 事务绑定影响),
|
||||||
|
* 因此需要显式拼 tenant_id;解析顺序与登录用户一致:
|
||||||
|
* 登录用户 → tenantId 请求头 → 兜底 10150。</p>
|
||||||
|
*/
|
||||||
|
public final class ApprovalTenantContext {
|
||||||
|
|
||||||
|
public static final int DEFAULT_TENANT_ID = 10150;
|
||||||
|
|
||||||
|
private ApprovalTenantContext() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Integer currentTenantId() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.getPrincipal() instanceof User) {
|
||||||
|
Integer tenantId = ((User) authentication.getPrincipal()).getTenantId();
|
||||||
|
if (tenantId != null) {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attributes instanceof ServletRequestAttributes) {
|
||||||
|
HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest();
|
||||||
|
String header = request.getHeader("tenantId");
|
||||||
|
if (header != null && !header.isEmpty()) {
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(header);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// 非法租户头按兜底租户处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_TENANT_ID;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "改派结果")
|
||||||
|
public class ApprovalAssignVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private Integer originalAssigneeId;
|
||||||
|
|
||||||
|
private String originalAssigneeName;
|
||||||
|
|
||||||
|
private Integer assigneeId;
|
||||||
|
|
||||||
|
private String assigneeName;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情中的附件。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批附件")
|
||||||
|
public class ApprovalAttachmentVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer fileId;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
private Long length;
|
||||||
|
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
private Integer uploaderId;
|
||||||
|
|
||||||
|
private String uploaderName;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前轮的存档确认状态。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "存档确认状态")
|
||||||
|
public class ApprovalConfirmVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private String statusName;
|
||||||
|
|
||||||
|
private LocalDateTime handleTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批实例详情。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批实例详情")
|
||||||
|
public class ApprovalInstanceVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
private String docType;
|
||||||
|
|
||||||
|
private String docTypeName;
|
||||||
|
|
||||||
|
private String flowCode;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private String statusName;
|
||||||
|
|
||||||
|
private Integer round;
|
||||||
|
|
||||||
|
private Integer reporterId;
|
||||||
|
|
||||||
|
private String reporterName;
|
||||||
|
|
||||||
|
private Integer reportOrgId;
|
||||||
|
|
||||||
|
private String reportOrgName;
|
||||||
|
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
private String acceptOrgName;
|
||||||
|
|
||||||
|
private String currentNodeKey;
|
||||||
|
|
||||||
|
private String currentNodeType;
|
||||||
|
|
||||||
|
private String currentNodeName;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
private LocalDateTime approveTime;
|
||||||
|
|
||||||
|
private LocalDateTime archiveTime;
|
||||||
|
|
||||||
|
private Integer archivistId;
|
||||||
|
|
||||||
|
private String archivistName;
|
||||||
|
|
||||||
|
// 业务完成确认单字段
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String implementOrg;
|
||||||
|
|
||||||
|
private BigDecimal fee;
|
||||||
|
|
||||||
|
private String serviceContent;
|
||||||
|
|
||||||
|
// 内部协调单字段
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
private Integer dispatchTargetId;
|
||||||
|
|
||||||
|
private String dispatchTargetName;
|
||||||
|
|
||||||
|
private List<ApprovalAttachmentVO> attachments;
|
||||||
|
|
||||||
|
private List<ApprovalRecordVO> records;
|
||||||
|
|
||||||
|
private List<ApprovalConfirmVO> confirms;
|
||||||
|
|
||||||
|
private ApprovalMyTaskVO myTask;
|
||||||
|
|
||||||
|
/** 实例当前全部待办任务,供有 assign 权限的 PC 用户按任务改派。 */
|
||||||
|
private List<ApprovalPendingTaskVO> pendingTasks;
|
||||||
|
|
||||||
|
private ApprovalPermissionsVO permissions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我发起的列表行。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批单据列表行")
|
||||||
|
public class ApprovalItemVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
private String docType;
|
||||||
|
|
||||||
|
private String docTypeName;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private String statusName;
|
||||||
|
|
||||||
|
@ApiModelProperty("名称或标题")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private LocalDateTime submitTime;
|
||||||
|
|
||||||
|
private String currentNodeName;
|
||||||
|
|
||||||
|
private Integer reporterId;
|
||||||
|
|
||||||
|
private String reporterName;
|
||||||
|
|
||||||
|
private String reportOrgName;
|
||||||
|
|
||||||
|
private String acceptOrgName;
|
||||||
|
|
||||||
|
private LocalDateTime archiveTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前登录人在该实例上的待办任务。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "当前用户待办任务")
|
||||||
|
public class ApprovalMyTaskVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
|
private String nodeType;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用组织。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批目录组织")
|
||||||
|
public class ApprovalOrganizationVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
private Integer parentId;
|
||||||
|
|
||||||
|
private String organizationName;
|
||||||
|
|
||||||
|
private String organizationFullName;
|
||||||
|
|
||||||
|
private String organizationCode;
|
||||||
|
|
||||||
|
private Integer leaderId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实例上的待办任务(PC 改派按任务 id 操作)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "实例待办任务")
|
||||||
|
public class ApprovalPendingTaskVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
|
private String nodeName;
|
||||||
|
|
||||||
|
private String nodeType;
|
||||||
|
|
||||||
|
private Integer assigneeId;
|
||||||
|
|
||||||
|
private String assigneeName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情动作权限,前端据此显示按钮。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "单据动作权限")
|
||||||
|
public class ApprovalPermissionsVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private boolean canEdit;
|
||||||
|
|
||||||
|
private boolean canSubmit;
|
||||||
|
|
||||||
|
private boolean canWithdraw;
|
||||||
|
|
||||||
|
private boolean canApprove;
|
||||||
|
|
||||||
|
private boolean canReject;
|
||||||
|
|
||||||
|
private boolean canConfirm;
|
||||||
|
|
||||||
|
private boolean canArchive;
|
||||||
|
|
||||||
|
private boolean canDispatch;
|
||||||
|
|
||||||
|
private boolean canAssign;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情时间线记录。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批处理记录")
|
||||||
|
public class ApprovalRecordVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String nodeKey;
|
||||||
|
|
||||||
|
private String nodeName;
|
||||||
|
|
||||||
|
private Integer operatorId;
|
||||||
|
|
||||||
|
private String operatorName;
|
||||||
|
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
private String actionName;
|
||||||
|
|
||||||
|
private String opinion;
|
||||||
|
|
||||||
|
private Integer round;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批目录用户。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "审批目录用户")
|
||||||
|
public class ApprovalUserVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
private String organizationName;
|
||||||
|
}
|
||||||
@@ -89,9 +89,12 @@ public class OperationLogAspect {
|
|||||||
record.setUrl(request.getRequestURI());
|
record.setUrl(request.getRequestURI());
|
||||||
record.setRequestMethod(request.getMethod());
|
record.setRequestMethod(request.getMethod());
|
||||||
UserAgent ua = UserAgentUtil.parse(ServletUtil.getHeaderIgnoreCase(request, "User-Agent"));
|
UserAgent ua = UserAgentUtil.parse(ServletUtil.getHeaderIgnoreCase(request, "User-Agent"));
|
||||||
record.setOs(ua.getPlatform().toString());
|
// 非浏览器客户端可能不带 User-Agent,此时不解析终端信息
|
||||||
record.setDevice(ua.getOs().toString());
|
if (ua != null) {
|
||||||
record.setBrowser(ua.getBrowser().toString());
|
record.setOs(ua.getPlatform() == null ? null : ua.getPlatform().toString());
|
||||||
|
record.setDevice(ua.getOs() == null ? null : ua.getOs().toString());
|
||||||
|
record.setBrowser(ua.getBrowser() == null ? null : ua.getBrowser().toString());
|
||||||
|
}
|
||||||
record.setIp(ServletUtil.getClientIP(request));
|
record.setIp(ServletUtil.getClientIP(request));
|
||||||
}
|
}
|
||||||
// 记录异常信息
|
// 记录异常信息
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.gxwebsoft.common.system.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
|
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.param.RoleMemberParam;
|
||||||
|
import com.gxwebsoft.common.system.service.RoleMemberService;
|
||||||
|
import com.gxwebsoft.common.system.vo.RoleMemberVO;
|
||||||
|
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.GetMapping;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员控制器
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
@Api(tags = "角色成员")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/system/role-member")
|
||||||
|
public class RoleMemberController extends BaseController {
|
||||||
|
@Resource
|
||||||
|
private RoleMemberService roleMemberService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:role:update')")
|
||||||
|
@ApiOperation("分页查询角色成员")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<PageResult<RoleMemberVO>> page(RoleMemberParam param) {
|
||||||
|
return success(roleMemberService.pageMembers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:role:update')")
|
||||||
|
@OperationLog
|
||||||
|
@ApiOperation("添加角色成员")
|
||||||
|
@PostMapping()
|
||||||
|
public ApiResult<?> save(@RequestBody RoleMemberParam param) {
|
||||||
|
roleMemberService.addMember(param.getRoleId(), param.getUserId());
|
||||||
|
return success("添加成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('sys:role:update')")
|
||||||
|
@OperationLog
|
||||||
|
@ApiOperation("移除角色成员")
|
||||||
|
@DeleteMapping()
|
||||||
|
public ApiResult<?> remove(@RequestParam Integer roleId, @RequestParam Integer userId) {
|
||||||
|
roleMemberService.removeMember(roleId, userId);
|
||||||
|
return success("移除成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.gxwebsoft.common.system.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.gxwebsoft.common.system.vo.RoleMemberVO;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员Mapper
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
public interface RoleMemberMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询角色下的启用用户
|
||||||
|
*
|
||||||
|
* @param page 分页对象
|
||||||
|
* @param roleId 角色id
|
||||||
|
* @param keywords 账号/姓名关键词
|
||||||
|
* @return List<RoleMemberVO>
|
||||||
|
*/
|
||||||
|
List<RoleMemberVO> selectPageByRoleId(@Param("page") IPage<RoleMemberVO> page,
|
||||||
|
@Param("roleId") Integer roleId,
|
||||||
|
@Param("keywords") String keywords);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
|
||||||
|
<mapper namespace="com.gxwebsoft.common.system.mapper.RoleMemberMapper">
|
||||||
|
|
||||||
|
<!-- 角色下的启用用户 -->
|
||||||
|
<select id="selectPageByRoleId" resultType="com.gxwebsoft.common.system.vo.RoleMemberVO">
|
||||||
|
SELECT a.user_id,
|
||||||
|
a.username,
|
||||||
|
a.real_name,
|
||||||
|
a.nickname,
|
||||||
|
a.organization_id,
|
||||||
|
a.status,
|
||||||
|
c.organization_name
|
||||||
|
FROM sys_user_role b
|
||||||
|
INNER JOIN sys_user a ON a.user_id = b.user_id AND a.deleted = 0 AND a.status = 0
|
||||||
|
LEFT JOIN sys_organization c ON c.organization_id = a.organization_id AND c.deleted = 0
|
||||||
|
WHERE b.role_id = #{roleId}
|
||||||
|
<if test="keywords != null and keywords != ''">
|
||||||
|
AND (a.username LIKE CONCAT('%', #{keywords}, '%')
|
||||||
|
OR a.real_name LIKE CONCAT('%', #{keywords}, '%')
|
||||||
|
OR a.nickname LIKE CONCAT('%', #{keywords}, '%'))
|
||||||
|
</if>
|
||||||
|
ORDER BY a.user_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.common.system.param;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseParam;
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员查询/操作参数
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@ApiModel(description = "角色成员参数")
|
||||||
|
public class RoleMemberParam extends BaseParam {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("角色id")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer roleId;
|
||||||
|
|
||||||
|
@ApiModelProperty("用户id")
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Integer userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.gxwebsoft.common.system.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.param.RoleMemberParam;
|
||||||
|
import com.gxwebsoft.common.system.vo.RoleMemberVO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员Service
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
public interface RoleMemberService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询角色下的启用用户
|
||||||
|
*
|
||||||
|
* @param param 查询参数
|
||||||
|
* @return PageResult<RoleMemberVO>
|
||||||
|
*/
|
||||||
|
PageResult<RoleMemberVO> pageMembers(RoleMemberParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加角色成员
|
||||||
|
*
|
||||||
|
* @param roleId 角色id
|
||||||
|
* @param userId 用户id
|
||||||
|
*/
|
||||||
|
void addMember(Integer roleId, Integer userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除角色成员
|
||||||
|
*
|
||||||
|
* @param roleId 角色id
|
||||||
|
* @param userId 用户id
|
||||||
|
*/
|
||||||
|
void removeMember(Integer roleId, Integer userId);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.common.system.service.impl;
|
package com.gxwebsoft.common.system.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
@@ -21,6 +22,7 @@ import java.util.List;
|
|||||||
* @since 2021-08-30 11:21:01
|
* @since 2021-08-30 11:21:01
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@DS("server")
|
||||||
public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRecord> implements FileRecordService {
|
public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRecord> implements FileRecordService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.common.system.service.impl;
|
package com.gxwebsoft.common.system.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
@@ -19,6 +20,7 @@ import java.util.List;
|
|||||||
* @since 2018-12-24 16:10:02
|
* @since 2018-12-24 16:10:02
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@DS("server")
|
||||||
public class OperationRecordServiceImpl extends ServiceImpl<OperationRecordMapper, OperationRecord>
|
public class OperationRecordServiceImpl extends ServiceImpl<OperationRecordMapper, OperationRecord>
|
||||||
implements OperationRecordService {
|
implements OperationRecordService {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.gxwebsoft.common.system.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.Role;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.common.system.entity.UserRole;
|
||||||
|
import com.gxwebsoft.common.system.mapper.RoleMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.RoleMemberMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.UserMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
|
||||||
|
import com.gxwebsoft.common.system.param.RoleMemberParam;
|
||||||
|
import com.gxwebsoft.common.system.service.RoleMemberService;
|
||||||
|
import com.gxwebsoft.common.system.vo.RoleMemberVO;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员Service实现
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@DS("server")
|
||||||
|
public class RoleMemberServiceImpl implements RoleMemberService {
|
||||||
|
@Resource
|
||||||
|
private RoleMapper roleMapper;
|
||||||
|
@Resource
|
||||||
|
private UserMapper userMapper;
|
||||||
|
@Resource
|
||||||
|
private UserRoleMapper userRoleMapper;
|
||||||
|
@Resource
|
||||||
|
private RoleMemberMapper roleMemberMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageResult<RoleMemberVO> pageMembers(RoleMemberParam param) {
|
||||||
|
PageParam<RoleMemberVO, RoleMemberParam> page = new PageParam<>(param);
|
||||||
|
List<RoleMemberVO> list = roleMemberMapper.selectPageByRoleId(page,
|
||||||
|
param.getRoleId(), param.getKeywords());
|
||||||
|
return new PageResult<>(list, page.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
@Override
|
||||||
|
public void addMember(Integer roleId, Integer userId) {
|
||||||
|
requireIds(roleId, userId);
|
||||||
|
checkRoleExists(roleId);
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException("用户不存在");
|
||||||
|
}
|
||||||
|
if (user.getStatus() == null || user.getStatus() != 0) {
|
||||||
|
throw new BusinessException("该用户已停用,不能添加为角色成员");
|
||||||
|
}
|
||||||
|
if (userRoleMapper.selectCount(new QueryWrapper<UserRole>()
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("role_id", roleId)) > 0) {
|
||||||
|
throw new BusinessException("该用户已是角色成员");
|
||||||
|
}
|
||||||
|
if (userRoleMapper.insertBatch(userId, Collections.singletonList(roleId)) < 1) {
|
||||||
|
throw new BusinessException("添加成员失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
@Override
|
||||||
|
public void removeMember(Integer roleId, Integer userId) {
|
||||||
|
requireIds(roleId, userId);
|
||||||
|
checkRoleExists(roleId);
|
||||||
|
int removed = userRoleMapper.delete(new LambdaQueryWrapper<UserRole>()
|
||||||
|
.eq(UserRole::getUserId, userId)
|
||||||
|
.eq(UserRole::getRoleId, roleId));
|
||||||
|
if (removed < 1) {
|
||||||
|
throw new BusinessException("该用户不是该角色成员");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireIds(Integer roleId, Integer userId) {
|
||||||
|
if (roleId == null) {
|
||||||
|
throw new BusinessException("角色不能为空");
|
||||||
|
}
|
||||||
|
if (userId == null) {
|
||||||
|
throw new BusinessException("用户不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRoleExists(Integer roleId) {
|
||||||
|
Role role = roleMapper.selectById(roleId);
|
||||||
|
if (role == null) {
|
||||||
|
throw new BusinessException("角色不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
@Resource
|
@Resource
|
||||||
private RedisUtil redisUtil;
|
private RedisUtil redisUtil;
|
||||||
|
|
||||||
|
@DS("server")
|
||||||
@Override
|
@Override
|
||||||
public PageResult<User> pageRel(UserParam param) {
|
public PageResult<User> pageRel(UserParam param) {
|
||||||
PageParam<User, UserParam> page = new PageParam<>(param);
|
PageParam<User, UserParam> page = new PageParam<>(param);
|
||||||
@@ -142,6 +143,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DS("server")
|
||||||
@Transactional(rollbackFor = {Exception.class})
|
@Transactional(rollbackFor = {Exception.class})
|
||||||
@Override
|
@Override
|
||||||
public boolean updateUser(User user) {
|
public boolean updateUser(User user) {
|
||||||
@@ -161,10 +163,11 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
throw new BusinessException("邮箱已存在");
|
throw new BusinessException("邮箱已存在");
|
||||||
}
|
}
|
||||||
boolean result = baseMapper.updateById(user) > 0;
|
boolean result = baseMapper.updateById(user) > 0;
|
||||||
if (result && user.getRoles() != null && user.getRoles().size() > 0) {
|
// roles 非 null 即代表本次提交了角色数据:先清空旧关联,空列表表示清空全部角色
|
||||||
|
if (result && user.getRoles() != null) {
|
||||||
userRoleService.remove(new LambdaUpdateWrapper<UserRole>().eq(UserRole::getUserId, user.getUserId()));
|
userRoleService.remove(new LambdaUpdateWrapper<UserRole>().eq(UserRole::getUserId, user.getUserId()));
|
||||||
List<Integer> roleIds = user.getRoles().stream().map(Role::getRoleId).collect(Collectors.toList());
|
List<Integer> roleIds = user.getRoles().stream().map(Role::getRoleId).collect(Collectors.toList());
|
||||||
if (userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
|
if (!roleIds.isEmpty() && userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
|
||||||
throw new BusinessException("用户角色添加失败");
|
throw new BusinessException("用户角色添加失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.gxwebsoft.common.system.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色成员
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2026-10-30
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "角色成员")
|
||||||
|
public class RoleMemberVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("用户id")
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@ApiModelProperty("账号")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@ApiModelProperty("真实姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@ApiModelProperty("昵称")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@ApiModelProperty("所属组织id")
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
@ApiModelProperty("所属组织名称")
|
||||||
|
private String organizationName;
|
||||||
|
|
||||||
|
@ApiModelProperty("状态, 0正常, 1停用")
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
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 publishesSingleFlowDefinitionPerTenant() throws Exception {
|
||||||
|
createCompletion(reporter, organizationId, TENANT, "流程一");
|
||||||
|
createCompletion(reporter, organizationId, TENANT, "流程二");
|
||||||
|
|
||||||
|
Integer count = data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", TENANT);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
assertThat(data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", String.class, TENANT))
|
||||||
|
.contains("deptLeader").contains("confirm").contains("archive");
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 rejectsSubmitWhenDepartmentCodeMissing() throws Exception {
|
||||||
|
Integer noCodeOrg = data.createOrganization("无编码部门" + data.uniqueSuffix(), 136);
|
||||||
|
data.clearOrganizationCode(noCodeOrg);
|
||||||
|
ApprovalTestData.TestUser noCodeReporter =
|
||||||
|
data.createUser("无编码报送人" + data.uniqueSuffix(), noCodeOrg);
|
||||||
|
long instanceId = createCompletion(noCodeReporter, noCodeOrg, TENANT, "无编码单据");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
noCodeReporter, noCodeOrg, 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 {
|
||||||
|
int tenant = 10023;
|
||||||
|
Integer orgId = data.createOrganization("缺收文员部门" + data.uniqueSuffix(), 136, tenant);
|
||||||
|
ApprovalTestData.TestUser submitter =
|
||||||
|
data.createUser("缺收文员报送人" + data.uniqueSuffix(), orgId, tenant);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(orgId), "部门负责人", tenant);
|
||||||
|
ApprovalTestData.TestUser leader = data.createUser("缺收文员负责人" + data.uniqueSuffix(), orgId, tenant);
|
||||||
|
data.bindTenant(leader.userId, leaderRole.roleId, tenant);
|
||||||
|
long instanceId = createCompletion(submitter, orgId, tenant, "缺收文员");
|
||||||
|
uploadAttachment(instanceId, submitter, orgId, tenant, "image/png");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
submitter, orgId, tenant, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收文员角色缺配")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenAttachmentMissing() throws Exception {
|
||||||
|
grantDepartmentLeader(organizationId, TENANT);
|
||||||
|
grantArchivist(TENANT);
|
||||||
|
long instanceId = createCompletion(reporter, organizationId, TENANT, "无附件");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, TENANT, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitsAndStartsDepartmentLeaderCountersign() throws Exception {
|
||||||
|
List<ApprovalTestData.TestUser> leaders = grantDepartmentLeader(organizationId, TENANT, 2);
|
||||||
|
grantArchivist(TENANT);
|
||||||
|
long instanceId = createCompletion(reporter, organizationId, TENANT, "完工确认");
|
||||||
|
long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, TENANT, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批中"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
|
||||||
|
.andExpect(jsonPath("$.data.records[0].actionName").value("提交"));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)).isNotNull();
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("deptLeader");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("APPROVE");
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
assertThat(bizNo).matches("WG\\d{8}-\\d{4,}");
|
||||||
|
assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
|
||||||
|
|
||||||
|
List<Integer> assignees = data.master().queryForList(
|
||||||
|
"SELECT assignee_id FROM approval_task WHERE instance_id = ? AND status = 'PENDING' ORDER BY id",
|
||||||
|
Integer.class, instanceId);
|
||||||
|
assertThat(assignees).containsExactlyInAnyOrder(leaders.get(0).userId, leaders.get(1).userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'SUBMIT'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
|
||||||
|
// 提交后字段与附件均只读
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body("改名", "实施单位", "10.00", "服务内容", instanceId)))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "locked.png", "image/png", pngBytes());
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "OTHER")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
|
||||||
|
|
||||||
|
// 重复提交保持审批中
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, TENANT, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1));
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
|
||||||
|
assertThat(attachmentId).isPositive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allocatesUniqueDailySequenceByTenant() throws Exception {
|
||||||
|
grantDepartmentLeader(organizationId, TENANT);
|
||||||
|
grantArchivist(TENANT);
|
||||||
|
|
||||||
|
long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号一"));
|
||||||
|
long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号二"));
|
||||||
|
|
||||||
|
String firstNo = bizNo(first);
|
||||||
|
String secondNo = bizNo(second);
|
||||||
|
String date = todayFromDatabase();
|
||||||
|
assertThat(firstNo).matches("WG" + date + "-\\d{4,}");
|
||||||
|
assertThat(secondNo).matches("WG" + date + "-\\d{4,}");
|
||||||
|
int firstSeq = Integer.parseInt(firstNo.substring(firstNo.indexOf('-') + 1));
|
||||||
|
int secondSeq = Integer.parseInt(secondNo.substring(secondNo.indexOf('-') + 1));
|
||||||
|
assertThat(secondSeq).isEqualTo(firstSeq + 1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||||
|
TENANT, firstNo)).isEqualTo(1);
|
||||||
|
|
||||||
|
// 另一租户使用同一天同一前缀独立取号,互不冲突
|
||||||
|
int otherTenant = 10023;
|
||||||
|
Integer otherOrg = data.createOrganization("外租户部门" + data.uniqueSuffix(), 136, otherTenant);
|
||||||
|
ApprovalTestData.TestUser otherReporter = data.createUser("外租户报送人" + data.uniqueSuffix(), otherOrg, otherTenant);
|
||||||
|
ApprovalTestData.TestRole otherLeaderRole = data.createRole(data.leaderRoleCode(otherOrg), "外租户负责人", otherTenant);
|
||||||
|
ApprovalTestData.TestUser otherLeader = data.createUser("外租户负责人" + data.uniqueSuffix(), otherOrg, otherTenant);
|
||||||
|
data.bindTenant(otherLeader.userId, otherLeaderRole.roleId, otherTenant);
|
||||||
|
ApprovalTestData.TestRole otherArchivistRole = data.createRole("approval_archivist", "外租户收文员", otherTenant);
|
||||||
|
ApprovalTestData.TestUser otherArchivist = data.createUser("外租户收文员" + data.uniqueSuffix(), otherOrg, otherTenant);
|
||||||
|
data.bindTenant(otherArchivist.userId, otherArchivistRole.roleId, otherTenant);
|
||||||
|
|
||||||
|
long otherInstance = submitCompletionFor(createCompletion(otherReporter, otherOrg, otherTenant, "外租户单据"),
|
||||||
|
otherReporter, otherOrg, otherTenant);
|
||||||
|
String otherNo = bizNo(otherInstance);
|
||||||
|
assertThat(otherNo).matches("WG" + date + "-\\d{4,}");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||||
|
otherTenant, otherNo)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||||
|
TENANT, firstNo)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enforcesAttachmentRules() throws Exception {
|
||||||
|
long instanceId = createCompletion(reporter, organizationId, TENANT, "附件规则");
|
||||||
|
|
||||||
|
MockMultipartFile text = new MockMultipartFile("file", "evil.txt", "text/plain",
|
||||||
|
"x".getBytes(StandardCharsets.UTF_8));
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload").file(text)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
|
||||||
|
|
||||||
|
MockMultipartFile mismatch = new MockMultipartFile("file", "fake.jpg", "application/pdf", pngBytes());
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload").file(mismatch)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
|
||||||
|
|
||||||
|
MockMultipartFile tooLarge = new MockMultipartFile("file", "big.png", "image/png",
|
||||||
|
new byte[20 * 1024 * 1024 + 1]);
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload").file(tooLarge)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("20MB")));
|
||||||
|
|
||||||
|
for (int i = 0; i < 9; i++) {
|
||||||
|
uploadAttachment(instanceId, reporter, organizationId, "image/png");
|
||||||
|
}
|
||||||
|
MockMultipartFile tenth = new MockMultipartFile("file", "tenth.png", "image/png", pngBytes());
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload").file(tenth)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("9张")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletesAttachmentLogicallyAndKeepsFileRecord() throws Exception {
|
||||||
|
long instanceId = createCompletion(reporter, organizationId, TENANT, "删除附件");
|
||||||
|
long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
|
||||||
|
Integer fileId = data.queryMaster("SELECT file_id FROM approval_attachment WHERE id = ?", Integer.class, attachmentId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, TENANT, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT deleted FROM approval_attachment WHERE id = ?", Integer.class, attachmentId))
|
||||||
|
.isEqualTo(1);
|
||||||
|
assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND deleted = 0", fileId))
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mineListIsOrderedAndScoped() throws Exception {
|
||||||
|
grantDepartmentLeader(organizationId, TENANT);
|
||||||
|
grantArchivist(TENANT);
|
||||||
|
long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "先提交"));
|
||||||
|
long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "后提交"));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) second))
|
||||||
|
.andExpect(jsonPath("$.data.list[1].id").value((int) first))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].statusName").value("审批中"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("路人" + data.uniqueSuffix(), organizationId);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/mine/page"), outsider, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + first), outsider, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + first), archivist, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value((int) first));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void draftStaysInvisibleEvenToAllVisibleArchivist() throws Exception {
|
||||||
|
ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
|
||||||
|
long draft = createCompletion(reporter, organizationId, TENANT, "草稿保密");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + draft), archivist, organizationId, TENANT, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private void expectSaveFailure(String body, String expectedMessage) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createCompletion(ApprovalTestData.TestUser user, Integer org, int tenant, String name) throws Exception {
|
||||||
|
String body = body(name, "广西体育中心实施部", "123.45", "完成场馆设施维修与验收");
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), user, org, tenant, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode node = objectMapper.readTree(response);
|
||||||
|
long id = node.path("data").asLong();
|
||||||
|
data.trackInstance(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long submitCompletion(long instanceId) throws Exception {
|
||||||
|
return submitCompletionFor(instanceId, reporterFor(instanceId), organizationIdFor(instanceId), TENANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long submitCompletionFor(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant)
|
||||||
|
throws Exception {
|
||||||
|
uploadAttachment(instanceId, user, org, tenant, "image/png");
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, tenant, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String contentType) throws Exception {
|
||||||
|
return uploadAttachment(instanceId, user, org, TENANT, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant,
|
||||||
|
String contentType) throws Exception {
|
||||||
|
String filename = "sign." + extensionOf(contentType);
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", filename, contentType, pngBytes());
|
||||||
|
String response = mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", tenant)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
return objectMapper.readTree(response).path("data").path("id").asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extensionOf(String contentType) {
|
||||||
|
switch (contentType) {
|
||||||
|
case "image/png":
|
||||||
|
return "png";
|
||||||
|
case "image/jpeg":
|
||||||
|
return "jpg";
|
||||||
|
default:
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] pngBytes() {
|
||||||
|
return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, ApprovalTestData.TestUser user,
|
||||||
|
Integer org, int tenant, String... authorities) {
|
||||||
|
return builder.header("tenantId", tenant)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalTestData.TestUser reporterFor(long instanceId) {
|
||||||
|
Integer reporterId = data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId);
|
||||||
|
return new ApprovalTestData.TestUser(reporterId, "reporter-" + reporterId, "报送人");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer organizationIdFor(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant) {
|
||||||
|
return grantDepartmentLeader(orgId, tenant, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant, int memberCount) {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(orgId), "部门负责人", tenant);
|
||||||
|
List<ApprovalTestData.TestUser> members = new ArrayList<>();
|
||||||
|
for (int i = 0; i < memberCount; i++) {
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("负责人" + data.uniqueSuffix(), orgId, tenant);
|
||||||
|
data.bindTenant(member.userId, role.roleId, tenant);
|
||||||
|
members.add(member);
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApprovalTestData.TestUser grantArchivist(int tenant) {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员", tenant);
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("收文员" + data.uniqueSuffix(), organizationId, tenant);
|
||||||
|
data.bindTenant(member.userId, role.roleId, tenant);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body(String name, String implementOrg, String fee, String serviceContent) {
|
||||||
|
return body(name, implementOrg, fee, serviceContent, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body(String name, String implementOrg, String fee, String serviceContent, Long id) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
if (id != null) {
|
||||||
|
sb.append("\"id\":").append(id).append(',');
|
||||||
|
}
|
||||||
|
sb.append("\"name\":").append(quote(name)).append(',')
|
||||||
|
.append("\"implementOrg\":").append(quote(implementOrg)).append(',')
|
||||||
|
.append("\"fee\":").append(fee).append(',')
|
||||||
|
.append("\"serviceContent\":").append(quote(serviceContent))
|
||||||
|
.append('}');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String repeat(String value, int count) {
|
||||||
|
return value.repeat(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String instanceStatus(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bizNo(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String todayFromDatabase() {
|
||||||
|
return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
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 javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档确认与归档闭环集成测试:全员存档、部分存档、归档或签、归档后只读、
|
||||||
|
* 归档缺配阻断、收文员与 superAdmin 全租户可见。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalConfirmArchiveIT {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立租户:收文员解析按 (tenant_id, role_code) 全局匹配,使用专属租户可避免
|
||||||
|
* 与并行执行的其它审批测试类互相污染角色成员。
|
||||||
|
*/
|
||||||
|
private static final int TENANT = 10024;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
private static final String ARCHIVE = "approval:instance:archive";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser leader;
|
||||||
|
private ApprovalTestData.TestUser archivist;
|
||||||
|
private ApprovalTestData.TestUser secondArchivist;
|
||||||
|
private ApprovalTestData.TestUser superAdmin;
|
||||||
|
private ApprovalTestData.TestUser outsider;
|
||||||
|
private Integer organizationId;
|
||||||
|
private Integer archivistRoleId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
organizationId = data.createOrganization("归档部门" + data.uniqueSuffix(), 136, TENANT);
|
||||||
|
reporter = data.createUser("归档报送人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(organizationId), "部门负责人", TENANT);
|
||||||
|
leader = data.createUser("归档负责人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(leader.userId, leaderRole.roleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
|
||||||
|
archivistRoleId = archivistRole.roleId;
|
||||||
|
archivist = data.createUser("归档收文员" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
secondArchivist = data.createUser("归档收文员B" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(archivist.userId, archivistRoleId, TENANT);
|
||||||
|
data.bindTenant(secondArchivist.userId, archivistRoleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT);
|
||||||
|
superAdmin = data.createUser("归档超管" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT);
|
||||||
|
|
||||||
|
outsider = data.createUser("归档路人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmsOnlyAfterAllConfirmTasksDone() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("全员存档");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
long reporterTask = pendingTaskId(instanceId, reporter.userId);
|
||||||
|
|
||||||
|
// 存档节点缺配前先验证:CONFIRM 任务不能驳回,也不需要意见
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/reject"),
|
||||||
|
reporter, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"不同意\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持驳回")));
|
||||||
|
|
||||||
|
// 追加一名并存档人(协调单场景:报送人 + 派单对象)
|
||||||
|
ApprovalTestData.TestUser secondConfirmer = data.createUser("归档存档人B" + data.uniqueSuffix(), organizationId);
|
||||||
|
long secondTask = insertConfirmTask(instanceId, secondConfirmer);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), secondConfirmer, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.confirms.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].statusName").value("待确认"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
|
||||||
|
|
||||||
|
// 部分存档:仍等待其他人
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"), reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].status").value("DONE"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].statusName").value("已确认"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].handleTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"));
|
||||||
|
assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?",
|
||||||
|
Object.class, instanceId)).isNull();
|
||||||
|
|
||||||
|
// 全员存档完成 → APPROVED,并为全部收文员生成或签归档任务
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/confirm"),
|
||||||
|
secondConfirmer, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
|
||||||
|
.andExpect(jsonPath("$.data.approveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("归档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("DONE"));
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'ARCHIVE' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
|
||||||
|
instanceId)).isEqualTo(2);
|
||||||
|
// 存档记录不带意见
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archivesOnceAndCancelsOtherArchiveTasks() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档或签");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
|
||||||
|
long firstArchive = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
long secondArchive = pendingTaskId(instanceId, secondArchivist.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + firstArchive + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||||
|
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.archivistId").value(archivist.userId))
|
||||||
|
.andExpect(jsonPath("$.data.archivistName").value(archivist.username));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondArchive))
|
||||||
|
.isEqualTo("CANCELLED");
|
||||||
|
assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, firstArchive))
|
||||||
|
.isNotNull();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondArchive + "/archive"),
|
||||||
|
secondArchivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已归档")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEveryWriteAfterArchive() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档只读");
|
||||||
|
long attachmentId = data.queryMaster("SELECT id FROM approval_attachment WHERE instance_id = ? LIMIT 1",
|
||||||
|
Long.class, instanceId);
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
long confirmTask = pendingTaskId(instanceId, reporter.userId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canWithdraw").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canArchive").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||||
|
|
||||||
|
String archived = "单据已归档,不能修改";
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"id\":" + instanceId + ",\"name\":\"改归档单据\","
|
||||||
|
+ "\"implementOrg\":\"广西体育中心实施部\",\"fee\":1.00,"
|
||||||
|
+ "\"serviceContent\":\"尝试修改已归档单据\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("ARCHIVED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksWhenArchiveNodeHasNoApprover() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档缺配");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
|
||||||
|
// 提交后收文员角色被清空,存档完成时归档节点缺配
|
||||||
|
data.server().update("DELETE FROM sys_user_role WHERE role_id = ?", archivistRoleId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||||
|
reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("缺少审批人")))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("归档"))
|
||||||
|
.andExpect(jsonPath("$.data.approveTime").doesNotExist());
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("archive");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'ARCHIVE'", instanceId)).isZero();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letsArchivistAndSuperAdminViewAllTenantInstances() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("全租户可见");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
// 收文员与 superAdmin 非参与人也可见完整详情(含归档人/时间与附件)
|
||||||
|
ApprovalTestData.TestUser otherArchivist =
|
||||||
|
data.createUser("归档外聘收文员" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(otherArchivist.userId, archivistRoleId, TENANT);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), otherArchivist, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.archivistName").value(archivist.username))
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.records.length()").isNotEmpty());
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), superAdmin, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value((int) instanceId));
|
||||||
|
|
||||||
|
// 普通参与人之外的用户仍被拒绝
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
// 参与人(报送人)可见
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private long createAndSubmit(String name) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name)
|
||||||
|
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
|
||||||
|
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
|
||||||
|
reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void approveByLeader(long instanceId) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + "/approve"),
|
||||||
|
leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意办理\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirmReporterTask(long instanceId) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||||
|
reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("APPROVED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long insertConfirmTask(long instanceId, ApprovalTestData.TestUser assignee) {
|
||||||
|
Integer round = data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
|
||||||
|
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||||
|
+ "task_type, assignee_id, assignee_name, status) VALUES (?, ?, 'confirm', 'CONFIRM', ?, 'CONFIRM', ?, ?, 'PENDING')",
|
||||||
|
TENANT, instanceId, round, assignee.userId, assignee.realName);
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? AND assignee_id = ? "
|
||||||
|
+ "AND status = 'PENDING'", Long.class, instanceId, assignee.userId);
|
||||||
|
assertThat(taskId).as("并存档任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
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 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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单:草稿、字段校验、提交预检、部门快照与双节点审批的集成测试。
|
||||||
|
*
|
||||||
|
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
|
||||||
|
* 数据由 {@link ApprovalTestData#cleanup()} 清理。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalCoordinationIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private Integer reportOrgId;
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
reportOrgId = data.createOrganization("协调报送部门" + data.uniqueSuffix(), 136);
|
||||||
|
acceptOrgId = data.createOrganization("协调受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
reporter = data.createUser("协调报送人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsDraftWithServerSideReportFields() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "设备借用协调",
|
||||||
|
"借用体育馆音响设备一天", "活动结束后归还");
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("coordination");
|
||||||
|
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("coordination_internal");
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isEqualTo(reporter.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo(reporter.username);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isEqualTo(reportOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_name FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isNotBlank();
|
||||||
|
// 受理部门在提交时才写入实例快照
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(acceptOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("设备借用协调");
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("活动结束后归还");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.docType").value("coordination"))
|
||||||
|
.andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("未提交"))
|
||||||
|
.andExpect(jsonPath("$.data.title").value("设备借用协调"))
|
||||||
|
.andExpect(jsonPath("$.data.content").value("借用体育馆音响设备一天"))
|
||||||
|
.andExpect(jsonPath("$.data.remark").value("活动结束后归还"))
|
||||||
|
.andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").doesNotExist());
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].title").value("设备借用协调"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
|
||||||
|
|
||||||
|
// 流程定义按租户幂等发布最新版本,节点顺序固定;v1 保留不改,v2 派单归受理部门负责人
|
||||||
|
assertThat(data.queryMaster("SELECT MAX(version) FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal'", Integer.class, TENANT)).isEqualTo(2);
|
||||||
|
String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' "
|
||||||
|
+ "ORDER BY version DESC LIMIT 1", String.class, TENANT);
|
||||||
|
assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch")
|
||||||
|
.contains("confirm").contains("archive");
|
||||||
|
assertThat(nodes).contains("\"type\": \"DISPATCH\", \"resolver\": \"acceptOrgLeader\"");
|
||||||
|
// 实例固定引用创建时的定义版本(v2)
|
||||||
|
assertThat(data.queryMaster("SELECT definition_version FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
|
||||||
|
|
||||||
|
// 报送部门与报送人只取登录态,客户端伪造字段被忽略
|
||||||
|
ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\","
|
||||||
|
+ "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}";
|
||||||
|
String forgedResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||||
|
reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(forged))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long forgedId = objectMapper.readTree(forgedResponse).path("data").asLong();
|
||||||
|
data.trackInstance(forgedId);
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, forgedId)).isEqualTo(reporter.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, forgedId)).isEqualTo(reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatesDraftOnlyForOwnerInEditableState() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "原标题", "原内容", null);
|
||||||
|
Integer otherAcceptOrg = data.createOrganization("备用受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(otherAcceptOrg, "新标题", "新内容", "补充说明", instanceId)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data").value((int) instanceId));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("新标题");
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(otherAcceptOrg);
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("补充说明");
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("协调路人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), outsider, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(otherAcceptOrg, "越权标题", "内容", null, instanceId)))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
|
||||||
|
|
||||||
|
// 提交后不可再修改
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(otherAcceptOrg, 1);
|
||||||
|
grantArchivist();
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(acceptOrgId, "提交后改标题", "内容", null, instanceId)))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesCoordinationFields() throws Exception {
|
||||||
|
expectSaveFailure(body(null, "标题", "内容", null, null), "请选择受理部门");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "", "内容", null, null), "标题不能为空");
|
||||||
|
expectSaveFailure(body(acceptOrgId, repeat("标", 101), "内容", null, null), "标题不能超过100字");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", "", null, null), "内容不能为空");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", repeat("内", 2001), null, null), "内容不能超过2000字");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", "内容", repeat("备", 501), null), "备注不能超过500字");
|
||||||
|
|
||||||
|
Integer disabledOrg = data.createOrganization("停用受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
data.disableOrganization(disabledOrg);
|
||||||
|
expectSaveFailure(body(disabledOrg, "标题", "内容", null, null), "受理部门不存在或已停用");
|
||||||
|
|
||||||
|
// 备注为可选字段,留空保存成功
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "无备注", "内容", " ");
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenReportLeaderRoleMissing() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺报送负责人", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, 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 rejectsSubmitWhenAcceptLeaderRoleMissing() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺受理负责人", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门负责人角色缺配")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenAcceptOrganizationDisabled() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "受理部门停用", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
data.disableOrganization(acceptOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门已停用")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitsWithXtBizNoAndSnapshotsBothOrganizations() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "提交协调单", "协调内容", "备注内容");
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批中"))
|
||||||
|
.andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false));
|
||||||
|
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
assertThat(bizNo).matches("XT\\d{8}-\\d{4}");
|
||||||
|
assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
|
||||||
|
|
||||||
|
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("reportLeader");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("APPROVE");
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(reportOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(acceptOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_name FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId))
|
||||||
|
.isEqualTo(data.server().queryForObject(
|
||||||
|
"SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, acceptOrgId));
|
||||||
|
|
||||||
|
assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvesReportThenAcceptLeaderAndReachesDispatch() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
ApprovalTestData.TestUser acceptLeader = grantDepartmentLeader(acceptOrgId, 1).get(0);
|
||||||
|
grantArchivist();
|
||||||
|
ApprovalTestData.TestUser acceptMember = data.createUser("受理部门成员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "双节点审批", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), reportLeader, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
long reportTask = pendingTaskId(instanceId, reportLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reportTask + "/approve"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意协调\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("acceptLeader");
|
||||||
|
assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), acceptLeader, acceptOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||||
|
|
||||||
|
long acceptTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + acceptTask + "/approve"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"可以协调\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("派单"));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("dispatch");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("DISPATCH");
|
||||||
|
// 派单节点只分配给受理部门负责人,普通部门成员无派单待办
|
||||||
|
assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE'", instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask))
|
||||||
|
.isEqualTo("同意协调");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsSamePersonToHandleBothApprovalNodes() throws Exception {
|
||||||
|
ApprovalTestData.TestUser both = data.createUser("双部门负责人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
ApprovalTestData.TestRole reportRole = data.createRole(data.leaderRoleCode(reportOrgId), "部门负责人");
|
||||||
|
ApprovalTestData.TestRole acceptRole = data.createRole(data.leaderRoleCode(acceptOrgId), "部门负责人");
|
||||||
|
data.bind(both.userId, reportRole.roleId);
|
||||||
|
data.bind(both.userId, acceptRole.roleId);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "同人连做", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
long firstTask = pendingTaskId(instanceId, both.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), both, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"));
|
||||||
|
|
||||||
|
long secondTask = pendingTaskId(instanceId, both.userId);
|
||||||
|
assertThat(secondTask).isNotEqualTo(firstTask);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), both, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("派单"));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("dispatch");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE' AND operator_id = ?", instanceId, both.userId)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsThenResubmitsKeepingBizNo() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "驳回重提协调", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
|
||||||
|
long taskId = pendingTaskId(instanceId, reportLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\" \"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"协调范围不清\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("REJECTED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已驳回"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
assertThat(pendingTaskId(instanceId, reportLeader.userId)).isPositive();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'REJECT' AND opinion = '协调范围不清'", instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawsAndResubmitsBeforeAnyApproval() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "撤回协调", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hidesDraftFromOutsider() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "草稿保密", "内容", null);
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("协调外人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private long saveCoordination(ApprovalTestData.TestUser user, Integer reportOrg, Integer acceptOrg,
|
||||||
|
String title, String content, String remark) throws Exception {
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||||
|
user, reportOrg, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(acceptOrg, title, content, remark, null)))
|
||||||
|
.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 void expectSaveFailure(String body, String expectedMessage) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, 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 void submitCoordination(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int memberCount) {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(orgId), "部门负责人");
|
||||||
|
List<ApprovalTestData.TestUser> members = new ArrayList<>();
|
||||||
|
for (int i = 0; i < memberCount; i++) {
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("协调负责人" + data.uniqueSuffix(), orgId);
|
||||||
|
data.bind(member.userId, role.roleId);
|
||||||
|
members.add(member);
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantArchivist() {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员");
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("协调收文员" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
data.bind(member.userId, role.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> pendingTaskIds(long instanceId) {
|
||||||
|
return data.master().queryForList("SELECT assignee_id FROM approval_task "
|
||||||
|
+ "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body(Integer acceptOrgId, String title, String content, String remark, Long id) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
if (id != null) {
|
||||||
|
sb.append("\"id\":").append(id).append(',');
|
||||||
|
}
|
||||||
|
sb.append("\"acceptOrgId\":").append(acceptOrgId == null ? "null" : acceptOrgId).append(',')
|
||||||
|
.append("\"title\":").append(quote(title)).append(',')
|
||||||
|
.append("\"content\":").append(quote(content)).append(',')
|
||||||
|
.append("\"remark\":").append(remark == null ? "null" : quote(remark))
|
||||||
|
.append('}');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String repeat(String value, int count) {
|
||||||
|
return value.repeat(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String instanceStatus(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bizNo(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String todayFromDatabase() {
|
||||||
|
return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
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 javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派单闭环集成测试:候选池、回退过滤、派单并发、被派单人可见性、双方存档缺一不可、
|
||||||
|
* 归档与 PC 派单权限。
|
||||||
|
*
|
||||||
|
* <p>独立租户隔离角色与用户,避免与并行执行的其它审批 IT 互相污染。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalDispatchIT {
|
||||||
|
|
||||||
|
private static final int TENANT = 10025;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
private static final String ARCHIVE = "approval:instance:archive";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private Integer reportOrgId;
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser reportLeader;
|
||||||
|
private ApprovalTestData.TestUser acceptLeader;
|
||||||
|
private ApprovalTestData.TestUser acceptMember;
|
||||||
|
private ApprovalTestData.TestUser archivist;
|
||||||
|
private ApprovalTestData.TestUser outsider;
|
||||||
|
private ApprovalTestData.TestUser staffNoOrg;
|
||||||
|
private ApprovalTestData.TestUser consumerUser;
|
||||||
|
private ApprovalTestData.TestUser guestUser;
|
||||||
|
private ApprovalTestData.TestUser noRoleUser;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
reportOrgId = data.createOrganization("派单报送部门" + data.uniqueSuffix(), 136, TENANT);
|
||||||
|
acceptOrgId = data.createOrganization("派单受理部门" + data.uniqueSuffix(), 136, TENANT);
|
||||||
|
reporter = data.createUser("派单报送人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole reportLeaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(reportOrgId), "报送部门负责人", TENANT);
|
||||||
|
reportLeader = data.createUser("派单报送负责人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||||
|
data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole acceptLeaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(acceptOrgId), "受理部门负责人", TENANT);
|
||||||
|
acceptLeader = data.createUser("派单受理负责人" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||||
|
data.bindTenant(acceptLeader.userId, acceptLeaderRole.roleId, TENANT);
|
||||||
|
acceptMember = data.createUser("派单受理成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
|
||||||
|
archivist = data.createUser("派单收文员" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||||
|
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
|
||||||
|
|
||||||
|
outsider = data.createUser("派单路人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||||
|
|
||||||
|
// 回退候选:无部门但持有业务角色应保留,仅有 user/guest 角色或无角色的普通消费用户被排除
|
||||||
|
ApprovalTestData.TestRole staffRole = data.createRole("backoffice_staff", "业务管理员", TENANT);
|
||||||
|
staffNoOrg = data.createUser("派单无部门业务员" + data.uniqueSuffix(), null, TENANT);
|
||||||
|
data.bindTenant(staffNoOrg.userId, staffRole.roleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole userRole = data.createRole("user", "普通用户", TENANT);
|
||||||
|
consumerUser = data.createUser("派单消费者" + data.uniqueSuffix(), null, TENANT);
|
||||||
|
data.bindTenant(consumerUser.userId, userRole.roleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole guestRole = data.createRole("guest", "访客", TENANT);
|
||||||
|
guestUser = data.createUser("派单访客" + data.uniqueSuffix(), null, TENANT);
|
||||||
|
data.bindTenant(guestUser.userId, guestRole.roleId, TENANT);
|
||||||
|
|
||||||
|
// 无角色且无部门
|
||||||
|
noRoleUser = data.createUser("派单无角色" + data.uniqueSuffix(), null, TENANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyEnabledAcceptOrgMembers() throws Exception {
|
||||||
|
long instanceId = reachDispatch("部门候选池");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
// 派单节点只落在受理部门负责人(或签),普通部门成员不产生派单待办
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
|
||||||
|
|
||||||
|
// 部门内停用成员不进入候选池
|
||||||
|
ApprovalTestData.TestUser disabled =
|
||||||
|
data.createUser("派单停用成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||||
|
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", disabled.userId);
|
||||||
|
|
||||||
|
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data[0].userId").value(acceptLeader.userId))
|
||||||
|
.andExpect(jsonPath("$.data[0].realName").value(acceptLeader.realName))
|
||||||
|
.andExpect(jsonPath("$.data[0].username").value(acceptLeader.username))
|
||||||
|
.andExpect(jsonPath("$.data[0].organizationName").value(
|
||||||
|
data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, acceptOrgId)))
|
||||||
|
.andExpect(jsonPath("$.data[1].userId").value(acceptMember.userId))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 部门有成员时不得混入回退候选
|
||||||
|
JsonNode candidates = objectMapper.readTree(response).path("data");
|
||||||
|
for (JsonNode candidate : candidates) {
|
||||||
|
assertThat(candidate.path("userId").asInt())
|
||||||
|
.isNotEqualTo(staffNoOrg.userId)
|
||||||
|
.isNotEqualTo(disabled.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackToTenantUsersExcludingConsumers() throws Exception {
|
||||||
|
long instanceId = reachDispatch("回退候选池");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
// 受理部门成员全部调离后部门为空,派单待办仍在负责人手中
|
||||||
|
data.setUserOrganization(acceptLeader.userId, reportOrgId);
|
||||||
|
data.setUserOrganization(acceptMember.userId, reportOrgId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND node_type = 'DISPATCH' AND status = 'PENDING'",
|
||||||
|
instanceId, acceptLeader.userId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
|
||||||
|
|
||||||
|
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||||
|
acceptLeader, reportOrgId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
JsonNode candidates = objectMapper.readTree(response).path("data");
|
||||||
|
List<Integer> ids = new java.util.ArrayList<>();
|
||||||
|
candidates.forEach(node -> ids.add(node.path("userId").asInt()));
|
||||||
|
|
||||||
|
// 无部门但持有业务角色的账号保留(含调离受理部门后的成员)
|
||||||
|
assertThat(ids).contains(staffNoOrg.userId, reporter.userId, acceptLeader.userId, acceptMember.userId);
|
||||||
|
// 无部门且角色仅有 user/guest、或无角色的普通消费用户被排除
|
||||||
|
assertThat(ids).doesNotContain(consumerUser.userId, guestUser.userId, noRoleUser.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchesToSelectedCandidateAndCreatesBothConfirmTasks() throws Exception {
|
||||||
|
long instanceId = reachDispatch("派单存档");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||||
|
.andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId))
|
||||||
|
.andExpect(jsonPath("$.data.dispatchTargetName").value(acceptMember.realName))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||||
|
|
||||||
|
// 派单任务完成并快照派单对象;派单节点只有负责人的一条或签待办
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask))
|
||||||
|
.isEqualTo("DONE");
|
||||||
|
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_task WHERE id = ?",
|
||||||
|
Integer.class, dispatchTask)).isEqualTo(acceptMember.userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(acceptMember.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT dispatch_target_name FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo(acceptMember.realName);
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("confirm");
|
||||||
|
|
||||||
|
// 派单记录 + 报送人与被派单人的两条存档待办
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'DISPATCH' AND operator_id = ?", instanceId, acceptLeader.userId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING'", instanceId, acceptMember.userId)).isEqualTo(1);
|
||||||
|
// 被派单人不产生审批任务
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND node_type = 'APPROVE'", instanceId, acceptMember.userId)).isZero();
|
||||||
|
|
||||||
|
// 被派单人立即可见:详情、附件与待办
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), acceptMember, acceptOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId))
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), acceptMember, acceptOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/count"), acceptMember, acceptOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data").value(1));
|
||||||
|
|
||||||
|
// 重复派单返回「任务已处理」
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + acceptLeader.userId + "}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTargetOutsideCandidatePool() throws Exception {
|
||||||
|
long instanceId = reachDispatch("越界派单");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + reporter.userId + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("派单对象不在候选范围内")));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请选择派单对象")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void concurrentDispatchOnlySucceedsOnce() throws Exception {
|
||||||
|
long instanceId = reachDispatch("并发派单");
|
||||||
|
long leaderTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(2);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
AtomicInteger success = new AtomicInteger();
|
||||||
|
AtomicInteger conflict = new AtomicInteger();
|
||||||
|
try {
|
||||||
|
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
|
||||||
|
acceptMember.userId, success, conflict);
|
||||||
|
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
|
||||||
|
acceptLeader.userId, success, conflict);
|
||||||
|
start.countDown();
|
||||||
|
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(success.get()).isEqualTo(1);
|
||||||
|
assertThat(conflict.get()).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'DISPATCH'", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'DISPATCH' AND status = 'DONE'", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresBothConfirmsBeforeArchive() throws Exception {
|
||||||
|
long instanceId = reachDispatch("双方存档");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
dispatch(dispatchTask, acceptLeader, acceptOrgId, acceptMember.userId);
|
||||||
|
|
||||||
|
// 只有报送人存档:实例仍审批中
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||||
|
reporter, reportOrgId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].status").value("DONE"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"));
|
||||||
|
assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?",
|
||||||
|
Object.class, instanceId)).isNull();
|
||||||
|
|
||||||
|
// 被派单人存档后双方齐全 → 审批完成并生成归档任务
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptMember.userId) + "/confirm"),
|
||||||
|
acceptMember, acceptOrgId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
|
||||||
|
.andExpect(jsonPath("$.data.approveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'CONFIRM'", instanceId)).isEqualTo(2);
|
||||||
|
|
||||||
|
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, reportOrgId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||||
|
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.archivistId").value(archivist.userId))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false));
|
||||||
|
|
||||||
|
// 归档后只读
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/dispatch"),
|
||||||
|
archivist, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCandidatesAndDispatchForNonDispatchTaskOrOutsider() throws Exception {
|
||||||
|
long instanceId = reachDispatch("派单校验");
|
||||||
|
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
|
||||||
|
// 非派单节点任务
|
||||||
|
long reportTask = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'APPROVE' AND assignee_id = ? LIMIT 1", Long.class, instanceId, reportLeader.userId);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/task/" + reportTask + "/candidates"), reportLeader, reportOrgId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("当前任务不支持派单")));
|
||||||
|
|
||||||
|
// 他人任务
|
||||||
|
mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), reporter, reportOrgId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||||
|
|
||||||
|
// PC 端与员工端使用同一权限串:缺少 approve 一律 403
|
||||||
|
mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||||
|
acceptLeader, acceptOrgId, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||||
|
acceptLeader, acceptOrgId, LIST)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private void dispatchAsync(ExecutorService executor, CountDownLatch start, CountDownLatch done,
|
||||||
|
long taskId, ApprovalTestData.TestUser actor, Integer org,
|
||||||
|
int targetUserId, AtomicInteger success, AtomicInteger conflict) {
|
||||||
|
executor.submit(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
boolean ok = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"),
|
||||||
|
actor, org, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + targetUserId + "}"))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
|
||||||
|
.contains("\"code\":0");
|
||||||
|
if (ok) {
|
||||||
|
success.incrementAndGet();
|
||||||
|
} else {
|
||||||
|
conflict.incrementAndGet();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
conflict.incrementAndGet();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(long taskId, ApprovalTestData.TestUser actor, Integer org, int targetUserId)
|
||||||
|
throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"), actor, org, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + targetUserId + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并推进内部协调单到派单节点:提交 → 报送负责人通过 → 受理负责人通过。
|
||||||
|
*/
|
||||||
|
private long reachDispatch(String title) throws Exception {
|
||||||
|
String body = "{\"acceptOrgId\":" + acceptOrgId
|
||||||
|
+ ",\"title\":" + quote(title) + ",\"content\":\"协调内容\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||||
|
reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId) + "/approve"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意协调\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptLeader.userId) + "/approve"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意派单\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("派单"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canDispatch").value(true));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
reportOrgId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.support.ApprovalTestData;
|
||||||
|
import com.gxwebsoft.support.TestAuthentication;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellType;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单据导出:权限、5000 行上限、列头与内容、数据权限与操作日志。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalExportIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String EXPORT = "approval:instance:export";
|
||||||
|
private static final String URL = "/api/approval/export";
|
||||||
|
private static final int EXPORT_LIMIT = 5000;
|
||||||
|
/** 多值 INSERT 每批行数,避免超出占位符上限。 */
|
||||||
|
private static final int SEED_CHUNK = 200;
|
||||||
|
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private static final String[] HEADERS = {
|
||||||
|
"单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
|
||||||
|
"服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
|
||||||
|
};
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
@Autowired
|
||||||
|
private ConfigProperties config;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private Integer reportOrgId;
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
/** 本次用例创建的导出文件,结束时删除。 */
|
||||||
|
private final List<Path> exportedFiles = new ArrayList<>();
|
||||||
|
/** 本次用例批量种子的关键字,结束时按单号前缀清理。 */
|
||||||
|
private final List<String> batchMarks = new ArrayList<>();
|
||||||
|
/** 本次用例创建的用户id,结束时清理其操作日志。 */
|
||||||
|
private final List<Integer> testUserIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
String suffix = data.uniqueSuffix();
|
||||||
|
reportOrgId = data.createOrganization("报送部门" + suffix, 136);
|
||||||
|
acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
|
||||||
|
reporter = createUser("报送人" + suffix, reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
for (String mark : batchMarks) {
|
||||||
|
cleanBatch(mark);
|
||||||
|
}
|
||||||
|
batchMarks.clear();
|
||||||
|
for (Path path : exportedFiles) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 文件清理失败不影响断言结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exportedFiles.clear();
|
||||||
|
try {
|
||||||
|
// 操作日志异步写入,稍等后再按测试用户清理
|
||||||
|
Thread.sleep(300);
|
||||||
|
for (Integer userId : testUserIds) {
|
||||||
|
data.server().update("DELETE FROM sys_operation_record WHERE url = ? AND user_id = ?", URL, userId);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 共享库清理尽力而为
|
||||||
|
}
|
||||||
|
testUserIds.clear();
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWithoutExportAuthority() throws Exception {
|
||||||
|
mockMvc.perform(authed(post(URL), reporter, LIST)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWhenResultExceedsLimitAndWritesNoFile() throws Exception {
|
||||||
|
String mark = "超限" + data.uniqueSuffix();
|
||||||
|
seedCompletionBatch(mark, EXPORT_LIMIT + 1);
|
||||||
|
List<Path> before = listExportFiles();
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"keywords\":" + quote(mark) + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围")));
|
||||||
|
|
||||||
|
assertThat(listExportFiles()).isEqualTo(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportsExactlyLimitRowsWithFixedColumnsAndFullText() throws Exception {
|
||||||
|
String mark = "上限" + data.uniqueSuffix();
|
||||||
|
String specialName = mark + "特殊名称";
|
||||||
|
String longContent = repeat("场馆设施维修与验收", 200);
|
||||||
|
Map<String, Object> special = new LinkedHashMap<>();
|
||||||
|
special.put("name", specialName);
|
||||||
|
special.put("implementOrg", mark + "实施单位");
|
||||||
|
special.put("fee", "1234.56");
|
||||||
|
special.put("serviceContent", longContent);
|
||||||
|
LocalDateTime submitTime = LocalDateTime.now().withNano(0).minusDays(1);
|
||||||
|
LocalDateTime approveTime = submitTime.plusHours(2);
|
||||||
|
LocalDateTime archiveTime = submitTime.plusDays(1);
|
||||||
|
seedCompletionBatch(mark, EXPORT_LIMIT, special, submitTime, approveTime, archiveTime);
|
||||||
|
|
||||||
|
String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"keywords\":" + quote(mark) + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode node = objectMapper.readTree(response).path("data");
|
||||||
|
assertThat(node.path("id").asInt()).isPositive();
|
||||||
|
String name = node.path("name").asText();
|
||||||
|
assertThat(name).startsWith("excel/审批单据-").endsWith(".xlsx");
|
||||||
|
assertThat(node.path("url").asText()).contains("/file/download/excel/审批单据-");
|
||||||
|
File file = new File(node.path("path").asText());
|
||||||
|
assertThat(file).exists();
|
||||||
|
exportedFiles.add(file.toPath());
|
||||||
|
assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND name = ?",
|
||||||
|
node.path("id").asInt(), name)).isEqualTo(1);
|
||||||
|
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
assertThat(sheet.getLastRowNum() + 1).isEqualTo(EXPORT_LIMIT + 1);
|
||||||
|
Row header = sheet.getRow(0);
|
||||||
|
List<String> headerValues = new ArrayList<>();
|
||||||
|
for (int i = 0; i < HEADERS.length; i++) {
|
||||||
|
headerValues.add(cellText(header.getCell(i)));
|
||||||
|
}
|
||||||
|
assertThat(headerValues).containsExactly(HEADERS);
|
||||||
|
|
||||||
|
Row matched = null;
|
||||||
|
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
|
||||||
|
Row row = sheet.getRow(i);
|
||||||
|
if (specialName.equals(cellText(row.getCell(2)))) {
|
||||||
|
matched = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(matched).as("应导出特殊名称行").isNotNull();
|
||||||
|
assertThat(cellText(matched.getCell(0))).isEqualTo(mark + "-0");
|
||||||
|
assertThat(cellText(matched.getCell(1))).isEqualTo("业务完成确认单");
|
||||||
|
assertThat(cellText(matched.getCell(3))).isEqualTo(orgName(reportOrgId));
|
||||||
|
assertThat(cellText(matched.getCell(4))).isEqualTo(reporter.realName);
|
||||||
|
assertThat(cellText(matched.getCell(5))).isEqualTo(orgName(acceptOrgId));
|
||||||
|
assertThat(cellText(matched.getCell(6))).isEqualTo(mark + "实施单位");
|
||||||
|
assertThat(cellText(matched.getCell(7))).isEqualTo("1234.56");
|
||||||
|
assertThat(cellText(matched.getCell(8))).isEqualTo(longContent);
|
||||||
|
assertThat(cellText(matched.getCell(9))).isEmpty();
|
||||||
|
assertThat(cellText(matched.getCell(10))).isEqualTo("已归档");
|
||||||
|
assertThat(cellText(matched.getCell(11))).isEqualTo("报送部门负责人");
|
||||||
|
assertThat(cellText(matched.getCell(12))).isEqualTo(TIME_FORMAT.format(submitTime));
|
||||||
|
assertThat(cellText(matched.getCell(13))).isEqualTo(TIME_FORMAT.format(approveTime));
|
||||||
|
assertThat(cellText(matched.getCell(14))).isEqualTo(TIME_FORMAT.format(archiveTime));
|
||||||
|
assertThat(cellText(matched.getCell(15))).isEqualTo("收文人" + mark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appliesSameDataPermissionAsList() throws Exception {
|
||||||
|
String mark = "权限" + data.uniqueSuffix();
|
||||||
|
long draft = seedCompletion(mark + "草稿", "DRAFT", null);
|
||||||
|
long pending = seedCompletion(mark + "在途", "PENDING", LocalDateTime.now().minusHours(1));
|
||||||
|
ApprovalTestData.TestUser outsider = createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser assignee = createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser archivist = createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||||
|
+ "task_type, assignee_id, status) VALUES (?, ?, 'deptLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
|
||||||
|
TENANT, pending, assignee.userId);
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||||
|
data.bind(archivist.userId, archivistRole.roleId);
|
||||||
|
|
||||||
|
assertThat(exportBizNos(reporter, mark)).containsExactlyInAnyOrder(bizNoOf(draft), bizNoOf(pending));
|
||||||
|
assertThat(exportBizNos(assignee, mark)).containsExactly(bizNoOf(pending));
|
||||||
|
assertThat(exportBizNos(outsider, mark)).isEmpty();
|
||||||
|
assertThat(exportBizNos(archivist, mark)).containsExactly(bizNoOf(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesOperationLogWithFilterAndRowCount() throws Exception {
|
||||||
|
String mark = "日志" + data.uniqueSuffix();
|
||||||
|
seedCompletion(mark + "一", "PENDING", LocalDateTime.now().minusHours(2));
|
||||||
|
seedCompletion(mark + "二", "PENDING", LocalDateTime.now().minusHours(1));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"keywords\":" + quote(mark) + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条")));
|
||||||
|
|
||||||
|
Map<String, Object> record = awaitOperationRecord(mark);
|
||||||
|
assertThat(record).as("应记录导出操作日志").isNotNull();
|
||||||
|
assertThat(record.get("user_id")).isEqualTo(reporter.userId);
|
||||||
|
assertThat((String) record.get("params")).contains(mark);
|
||||||
|
assertThat((String) record.get("result")).contains("共2条");
|
||||||
|
assertThat(record.get("create_time")).isNotNull();
|
||||||
|
assertThat(((Number) record.get("spend_time")).longValue()).isNotNegative();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
|
||||||
|
ApprovalTestData.TestUser user = data.createUser(realName, organizationId);
|
||||||
|
testUserIds.add(user.userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> awaitOperationRecord(String paramsMark) throws InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
List<Map<String, Object>> rows = data.server().queryForList(
|
||||||
|
"SELECT user_id, params, result, spend_time, create_time FROM sys_operation_record "
|
||||||
|
+ "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
|
||||||
|
URL, "%" + paramsMark + "%");
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
Thread.sleep(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keywords) throws Exception {
|
||||||
|
String response = mockMvc.perform(authed(post(URL), user, LIST, EXPORT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"keywords\":" + quote(keywords) + "}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode node = objectMapper.readTree(response).path("data");
|
||||||
|
File file = new File(node.path("path").asText());
|
||||||
|
exportedFiles.add(file.toPath());
|
||||||
|
List<String> bizNos = new ArrayList<>();
|
||||||
|
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
|
||||||
|
bizNos.add(cellText(sheet.getRow(i).getCell(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bizNos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long seedCompletion(String name, String status, LocalDateTime submitTime) {
|
||||||
|
long definitionId = completionDefinitionId();
|
||||||
|
String bizNo = "WG" + data.uniqueSuffix();
|
||||||
|
data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, "
|
||||||
|
+ "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, "
|
||||||
|
+ "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time) "
|
||||||
|
+ "VALUES (?, ?, 'completion', ?, 1, 'completion', 0, ?, 1, ?, ?, ?, ?, ?, ?, 'deptLeader', 'APPROVE', ?)",
|
||||||
|
TENANT, bizNo, definitionId, status, reporter.userId, reporter.realName, reportOrgId,
|
||||||
|
orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
|
||||||
|
submitTime == null ? null : Timestamp.valueOf(submitTime));
|
||||||
|
Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||||
|
Long.class, TENANT, bizNo);
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
data.master().update("INSERT INTO approval_completion_form (tenant_id, instance_id, name, implement_org, "
|
||||||
|
+ "fee, service_content) VALUES (?, ?, ?, '广西体育中心实施部', 100.00, '完成场馆设施维修与验收')",
|
||||||
|
TENANT, instanceId, name);
|
||||||
|
Long formId = data.queryMaster("SELECT id FROM approval_completion_form WHERE instance_id = ?",
|
||||||
|
Long.class, instanceId);
|
||||||
|
data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bizNoOf(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量种子:count 条已归档确认单,单号前缀与名称都用 mark 标识,便于按前缀清理。
|
||||||
|
*
|
||||||
|
* <p>master 库连接未开启 rewriteBatchedStatements,逐条 batchUpdate 会产生上千次往返;
|
||||||
|
* 这里按 200 行一条多值 INSERT 落库,再一次性回填 biz_id。</p>
|
||||||
|
*/
|
||||||
|
private void seedCompletionBatch(String mark, int count) {
|
||||||
|
seedCompletionBatch(mark, count, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedCompletionBatch(String mark, int count, Map<String, Object> special,
|
||||||
|
LocalDateTime submitTime, LocalDateTime approveTime,
|
||||||
|
LocalDateTime archiveTime) {
|
||||||
|
batchMarks.add(mark);
|
||||||
|
long definitionId = completionDefinitionId();
|
||||||
|
String reportOrgName = orgName(reportOrgId);
|
||||||
|
String acceptOrgName = orgName(acceptOrgId);
|
||||||
|
Timestamp submit = Timestamp.valueOf(submitTime == null
|
||||||
|
? LocalDateTime.now().withNano(0).minusHours(1) : submitTime);
|
||||||
|
Timestamp approve = approveTime == null ? null : Timestamp.valueOf(approveTime);
|
||||||
|
Timestamp archive = archiveTime == null ? null : Timestamp.valueOf(archiveTime);
|
||||||
|
Integer archivistId = archive == null ? null : reporter.userId;
|
||||||
|
String archivistName = archive == null ? null : "收文人" + mark;
|
||||||
|
|
||||||
|
String instanceRow = "(?, ?, 'completion', ?, 1, 'completion', 0, 'ARCHIVED', 1, ?, ?, ?, ?, ?, ?, "
|
||||||
|
+ "'deptLeader', 'APPROVE', ?, ?, ?, ?, ?)";
|
||||||
|
for (int start = 0; start < count; start += SEED_CHUNK) {
|
||||||
|
int end = Math.min(start + SEED_CHUNK, count);
|
||||||
|
StringBuilder sql = new StringBuilder("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, "
|
||||||
|
+ "definition_id, definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, "
|
||||||
|
+ "report_org_id, report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, "
|
||||||
|
+ "submit_time, approve_time, archive_time, archivist_id, archivist_name) VALUES ");
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
for (int i = start; i < end; i++) {
|
||||||
|
if (i > start) {
|
||||||
|
sql.append(',');
|
||||||
|
}
|
||||||
|
sql.append(instanceRow);
|
||||||
|
java.util.Collections.addAll(args, TENANT, mark + "-" + i, definitionId, reporter.userId,
|
||||||
|
reporter.realName, reportOrgId, reportOrgName, acceptOrgId, acceptOrgName, submit,
|
||||||
|
approve, archive, archivistId, archivistName);
|
||||||
|
}
|
||||||
|
data.master().update(sql.toString(), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> ids = data.master().queryForList(
|
||||||
|
"SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no LIKE ? ORDER BY id",
|
||||||
|
Long.class, TENANT, mark + "-%");
|
||||||
|
assertThat(ids).hasSize(count);
|
||||||
|
|
||||||
|
for (int start = 0; start < ids.size(); start += SEED_CHUNK) {
|
||||||
|
int end = Math.min(start + SEED_CHUNK, ids.size());
|
||||||
|
StringBuilder sql = new StringBuilder("INSERT INTO approval_completion_form (tenant_id, instance_id, "
|
||||||
|
+ "name, implement_org, fee, service_content) VALUES ");
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
for (int i = start; i < end; i++) {
|
||||||
|
if (i > start) {
|
||||||
|
sql.append(',');
|
||||||
|
}
|
||||||
|
sql.append("(?, ?, ?, ?, ?, ?)");
|
||||||
|
boolean isSpecial = special != null && i == 0;
|
||||||
|
java.util.Collections.addAll(args, TENANT, ids.get(i),
|
||||||
|
isSpecial ? special.get("name") : mark + "-" + i,
|
||||||
|
isSpecial ? special.get("implementOrg") : "广西体育中心实施部",
|
||||||
|
new java.math.BigDecimal(isSpecial ? (String) special.get("fee") : "100.00"),
|
||||||
|
isSpecial ? special.get("serviceContent") : "完成场馆设施维修与验收");
|
||||||
|
}
|
||||||
|
data.master().update(sql.toString(), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
data.master().update("UPDATE approval_instance i JOIN approval_completion_form f "
|
||||||
|
+ "ON f.instance_id = i.id SET i.biz_id = f.id WHERE i.biz_no LIKE ?", mark + "-%");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanBatch(String mark) {
|
||||||
|
try {
|
||||||
|
String[] tables = {"approval_attachment", "approval_record", "approval_task",
|
||||||
|
"approval_completion_form", "approval_coordination_form"};
|
||||||
|
for (String table : tables) {
|
||||||
|
data.master().update("DELETE t FROM " + table + " t JOIN approval_instance i "
|
||||||
|
+ "ON t.instance_id = i.id WHERE i.biz_no LIKE ?", mark + "-%");
|
||||||
|
}
|
||||||
|
data.master().update("DELETE FROM approval_instance WHERE biz_no LIKE ?", mark + "-%");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 清理失败不阻断测试报告
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long completionDefinitionId() {
|
||||||
|
List<Long> ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||||
|
+ "AND flow_code = 'completion' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
|
||||||
|
+ "VALUES (?, 'completion', 1, '业务完成确认单', ?)",
|
||||||
|
TENANT, ApprovalConstants.COMPLETION_NODES_JSON);
|
||||||
|
ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||||
|
+ "AND flow_code = 'completion' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||||
|
}
|
||||||
|
return ids.isEmpty() ? 0L : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Path> listExportFiles() throws Exception {
|
||||||
|
Path dir = Paths.get(config.getUploadPath(), "excel");
|
||||||
|
if (!Files.isDirectory(dir)) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<Path> files = new ArrayList<>();
|
||||||
|
try (java.util.stream.Stream<Path> stream = Files.list(dir)) {
|
||||||
|
stream.filter(path -> path.getFileName().toString().startsWith("审批单据-"))
|
||||||
|
.forEach(files::add);
|
||||||
|
}
|
||||||
|
files.sort(Comparator.comparing(Path::toString));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cellText(Cell cell) {
|
||||||
|
if (cell == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (cell.getCellType() == CellType.NUMERIC) {
|
||||||
|
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros()
|
||||||
|
.toPlainString();
|
||||||
|
}
|
||||||
|
return cell.getStringCellValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String orgName(Integer organizationId) {
|
||||||
|
return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username,
|
||||||
|
reportOrgId, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String repeat(String unit, int times) {
|
||||||
|
StringBuilder builder = new StringBuilder(unit.length() * times);
|
||||||
|
for (int i = 0; i < times; i++) {
|
||||||
|
builder.append(unit);
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
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 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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 待办审批与改派的端到端行为。
|
||||||
|
*
|
||||||
|
* <p>覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、
|
||||||
|
* ASSIGN 处理记录、操作日志以及改派候选用户查询。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalPcTodoIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
private static final String ASSIGN = "approval:instance:assign";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser leader;
|
||||||
|
private ApprovalTestData.TestUser admin;
|
||||||
|
private Integer organizationId;
|
||||||
|
private final List<String> assignUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136);
|
||||||
|
reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(organizationId), "部门负责人");
|
||||||
|
leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(leader.userId, leaderRole.roleId);
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
|
||||||
|
ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(archivist.userId, archivistRole.roleId);
|
||||||
|
admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
for (String url : assignUrls) {
|
||||||
|
data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?",
|
||||||
|
admin.userId, url);
|
||||||
|
}
|
||||||
|
assignUrls.clear();
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派单据");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
String reason = "原处理人休假" + data.uniqueSuffix();
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.instanceId").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.taskId").value((int) taskId))
|
||||||
|
.andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId))
|
||||||
|
.andExpect(jsonPath("$.data.assigneeId").value(target.userId))
|
||||||
|
.andExpect(jsonPath("$.data.assigneeName").value(target.realName))
|
||||||
|
.andExpect(jsonPath("$.data.reason").value(reason));
|
||||||
|
|
||||||
|
// 任务仍为待处理,归属已换人
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(target.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo(target.realName);
|
||||||
|
|
||||||
|
// 处理记录留痕:改派人与原因
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason))
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
|
// 原处理人待办消失、新处理人待办出现
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
// 新处理人可正常处理改派后的任务
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||||
|
|
||||||
|
// 原处理人已无权处理该任务
|
||||||
|
long second = createAndSubmit("改派后原处理人");
|
||||||
|
long secondTask = pendingTaskId(second, leader.userId);
|
||||||
|
mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix()))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsAllTenantPendingTasksForAssignUserAndReassignsOthers() throws Exception {
|
||||||
|
String title = "全租户待办" + data.uniqueSuffix();
|
||||||
|
long instanceId = createAndSubmit(title);
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
|
||||||
|
// 有 assign 权限的 PC 用户通过 scope=all 看到他人待办
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all")
|
||||||
|
.param("keywords", title), admin, organizationId, LIST, ASSIGN))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||||
|
|
||||||
|
// 非处理人也能打开详情,并按任务 id 拿到待办任务与改派权限
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), admin, organizationId, LIST, ASSIGN))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canAssign").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.pendingTasks.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.pendingTasks[0].id").value((int) taskId))
|
||||||
|
.andExpect(jsonPath("$.data.pendingTasks[0].assigneeId").value(leader.userId))
|
||||||
|
.andExpect(jsonPath("$.data.pendingTasks[0].nodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
// 按待办任务 id 改派成功,待办转移到新处理人
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("全租户改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "全租户改派" + data.uniqueSuffix()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.assigneeId").value(target.userId));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsScopeAllWithoutAssignAuthority() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("全租户越权" + data.uniqueSuffix());
|
||||||
|
assertThat(instanceId).isPositive();
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all"), admin, organizationId, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignToUnknownOrInactiveUser() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派无效目标");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'",
|
||||||
|
instanceId)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresAssignReason() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派无原因");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " "))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignForHandledTask() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派已处理任务");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignWithoutAuthority() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("无权限改派");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"),
|
||||||
|
admin, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesOperationLogWithOriginalAndNewAssignee() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派操作日志");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
String reason = "操作日志原因" + data.uniqueSuffix();
|
||||||
|
String url = "/api/approval/task/" + taskId + "/assign";
|
||||||
|
assignUrls.add(url);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
JsonNode record = awaitOperationRecord(url, reason);
|
||||||
|
assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull();
|
||||||
|
assertThat(record.path("userId").asInt()).isEqualTo(admin.userId);
|
||||||
|
assertThat(record.path("description").asText()).contains("改派");
|
||||||
|
assertThat(record.path("params").asText()).contains(reason);
|
||||||
|
assertThat(record.path("result").asText())
|
||||||
|
.contains(leader.realName)
|
||||||
|
.contains(target.realName)
|
||||||
|
.contains(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyActiveUsersForAssignCandidates() throws Exception {
|
||||||
|
ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId);
|
||||||
|
ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||||
|
|
||||||
|
String body = mockMvc.perform(authed(get("/api/approval/directory/users")
|
||||||
|
.param("keywords", active.realName), admin, organizationId, ASSIGN))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode users = objectMapper.readTree(body).path("data");
|
||||||
|
assertThat(users.isArray()).isTrue();
|
||||||
|
assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId));
|
||||||
|
assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId));
|
||||||
|
JsonNode matched = null;
|
||||||
|
for (JsonNode user : users) {
|
||||||
|
if (user.path("userId").asInt() == active.userId) {
|
||||||
|
matched = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(matched).isNotNull();
|
||||||
|
assertThat(matched.path("realName").asText()).isEqualTo(active.realName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator,
|
||||||
|
String authority, int targetUserId, String reason)
|
||||||
|
throws Exception {
|
||||||
|
return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createAndSubmit(String name) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name)
|
||||||
|
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
|
||||||
|
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
|
||||||
|
reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志异步写入,最多等待 2 秒。 */
|
||||||
|
private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception {
|
||||||
|
long deadline = System.currentTimeMillis() + 2000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
List<java.util.Map<String, Object>> rows = data.server().queryForList(
|
||||||
|
"SELECT user_id AS userId, description, params, result FROM sys_operation_record "
|
||||||
|
+ "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
|
||||||
|
url, "%" + paramsMark + "%");
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
return objectMapper.valueToTree(rows.get(0));
|
||||||
|
}
|
||||||
|
Thread.sleep(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||||
|
import com.gxwebsoft.support.ApprovalTestData;
|
||||||
|
import com.gxwebsoft.support.TestAuthentication;
|
||||||
|
import org.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.sql.Timestamp;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
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.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 单据查询:筛选、关键词、排序、归档检索与数据权限的集成测试。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
@Transactional
|
||||||
|
class ApprovalQueryIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private Integer reportOrgId;
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
String suffix = data.uniqueSuffix();
|
||||||
|
reportOrgId = data.createOrganization("报送部门" + suffix, 136);
|
||||||
|
acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
|
||||||
|
reporter = data.createUser("报送人" + suffix, reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filtersByTypeKeywordOrganizationReporterAndStatus() throws Exception {
|
||||||
|
long submitted = submitCompletion("场馆维修实施");
|
||||||
|
long coordination = seedCoordination("场馆维修协调", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||||
|
long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
|
||||||
|
|
||||||
|
// 单据类型 + 关键词命中实施单位
|
||||||
|
assertThat(queryIds("?docType=completion&keywords=实施部")).containsExactly(submitted);
|
||||||
|
// 关键词命中协调单标题
|
||||||
|
assertThat(queryIds("?docType=coordination&keywords=维修协调")).containsExactly(coordination);
|
||||||
|
// 关键词命中单号
|
||||||
|
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
|
||||||
|
assertThat(queryIds("?keywords=" + bizNo)).containsExactly(submitted);
|
||||||
|
// 报送部门:两类单据都按报送部门过滤,其它部门不命中
|
||||||
|
assertThat(queryIds("?reportOrgId=" + reportOrgId))
|
||||||
|
.containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||||
|
assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
|
||||||
|
// 受理部门
|
||||||
|
assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
|
||||||
|
// 报送人
|
||||||
|
assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||||
|
// 状态
|
||||||
|
assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
|
||||||
|
assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
|
||||||
|
// 总数与分页总数一致
|
||||||
|
assertThat(queryCount("?keywords=实施部")).isEqualTo(1);
|
||||||
|
// 单号精确筛选
|
||||||
|
assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
|
||||||
|
// 提交时间范围
|
||||||
|
assertThat(queryIds("?submitTimeStart=" + format(LocalDateTime.now().minusHours(2))
|
||||||
|
+ "&submitTimeEnd=" + format(LocalDateTime.now().plusMinutes(1))))
|
||||||
|
.containsExactlyInAnyOrder(submitted, coordination);
|
||||||
|
assertThat(queryIds("?submitTimeEnd=" + format(LocalDateTime.now().minusDays(1)))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordersBySubmitTimeDescThenIdDescAndKeepsPaginationStable() throws Exception {
|
||||||
|
LocalDateTime sameSecond = LocalDateTime.now().withNano(0).minusMinutes(10);
|
||||||
|
long first = seedCoordination("同秒一", "PENDING", sameSecond, null);
|
||||||
|
long second = seedCoordination("同秒二", "PENDING", sameSecond, null);
|
||||||
|
long third = seedCoordination("同秒三", "PENDING", sameSecond, null);
|
||||||
|
long older = seedCoordination("更早", "PENDING", sameSecond.minusDays(1), null);
|
||||||
|
long draft = createCompletionDraft("无提交时间草稿");
|
||||||
|
|
||||||
|
List<Long> page1 = queryIds("?limit=2&page=1");
|
||||||
|
List<Long> page2 = queryIds("?limit=2&page=2");
|
||||||
|
List<Long> page3 = queryIds("?limit=2&page=3");
|
||||||
|
|
||||||
|
assertThat(page1).containsExactly(third, second);
|
||||||
|
assertThat(page2).containsExactly(first, older);
|
||||||
|
assertThat(page3).containsExactly(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void includesArchivedInstancesAndFiltersByArchiveTime() throws Exception {
|
||||||
|
LocalDateTime archivedAt = LocalDateTime.now().withNano(0).minusDays(3);
|
||||||
|
long archived = seedCoordination("已归档协调单", "ARCHIVED", archivedAt.minusDays(1), archivedAt);
|
||||||
|
long pending = seedCoordination("在途协调单", "PENDING", archivedAt.minusDays(1), null);
|
||||||
|
|
||||||
|
assertThat(queryIds("?status=ARCHIVED")).containsExactly(archived);
|
||||||
|
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.minusHours(1))
|
||||||
|
+ "&archiveTimeEnd=" + format(archivedAt.plusHours(1)))).containsExactly(archived);
|
||||||
|
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.plusDays(1)))).isEmpty();
|
||||||
|
assertThat(queryIds("")).containsExactlyInAnyOrder(archived, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limitsListByDataPermission() throws Exception {
|
||||||
|
long draft = createCompletionDraft("仅报送人可见草稿");
|
||||||
|
long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser assignee = data.createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser operator = data.createUser("记录操作人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser dispatched = data.createUser("被派单人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
|
||||||
|
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||||
|
+ "task_type, assignee_id, status) VALUES (?, ?, 'reportLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
|
||||||
|
TENANT, pending, assignee.userId);
|
||||||
|
data.master().update("INSERT INTO approval_record (tenant_id, instance_id, operator_id, action, round) "
|
||||||
|
+ "VALUES (?, ?, ?, 'SUBMIT', 1)", TENANT, pending, operator.userId);
|
||||||
|
data.master().update("UPDATE approval_instance SET dispatch_target_id = ?, dispatch_target_name = ? WHERE id = ?",
|
||||||
|
dispatched.userId, dispatched.realName, pending);
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||||
|
data.bind(archivist.userId, archivistRole.roleId);
|
||||||
|
|
||||||
|
// 报送人可见自己的草稿与在途单据
|
||||||
|
assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
|
||||||
|
// 参与人:有任务、有处理记录、被派单
|
||||||
|
assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
|
||||||
|
assertThat(queryIdsAs(operator, "")).containsExactly(pending);
|
||||||
|
assertThat(queryIdsAs(dispatched, "")).containsExactly(pending);
|
||||||
|
// 无关人员看不到他人单据
|
||||||
|
assertThat(queryIdsAs(outsider, "")).isEmpty();
|
||||||
|
assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
|
||||||
|
// 收文员看全租户,但草稿仍只属于报送人
|
||||||
|
assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
|
||||||
|
assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDetailOfInstanceOutsideDataPermission() throws Exception {
|
||||||
|
long draft = createCompletionDraft("他人草稿");
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("越权人员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + draft), outsider, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + draft), reporter, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value((int) draft))
|
||||||
|
.andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsInstanceQueryUsersToSearchDirectoryUsers() throws Exception {
|
||||||
|
mockMvc.perform(authed(get("/api/approval/directory/users?keywords=" + reporter.username), reporter, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data[0].userId").value(reporter.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private List<Long> queryIds(String query) throws Exception {
|
||||||
|
return queryIdsAs(reporter, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> queryIdsAs(ApprovalTestData.TestUser user, String query) throws Exception {
|
||||||
|
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), user, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode node = objectMapper.readTree(response).path("data");
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
for (JsonNode item : node.path("list")) {
|
||||||
|
ids.add(item.path("id").asLong());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long queryCount(String query) throws Exception {
|
||||||
|
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), reporter, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
return objectMapper.readTree(response).path("data").path("count").asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createCompletionDraft(String name) throws Exception {
|
||||||
|
return createCompletionDraft(name, "广西体育中心实施部");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createCompletionDraft(String name, String implementOrg) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name) + ",\"implementOrg\":" + quote(implementOrg) + ","
|
||||||
|
+ "\"fee\":100.00,\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long id = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long submitCompletion(String name) throws Exception {
|
||||||
|
grantDepartmentLeader();
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = createCompletionDraft(name);
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接落库一条协调单实例:T23 的表单/提交流程尚未合入,查询侧只需完整数据行。
|
||||||
|
*/
|
||||||
|
private long seedCoordination(String title, String status, LocalDateTime submitTime, LocalDateTime archiveTime) {
|
||||||
|
String bizNo = "XT" + System.nanoTime();
|
||||||
|
data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, "
|
||||||
|
+ "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, "
|
||||||
|
+ "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time, "
|
||||||
|
+ "archive_time, archivist_id, archivist_name) "
|
||||||
|
+ "VALUES (?, ?, 'coordination_internal', ?, 1, 'coordination', 0, ?, 1, ?, ?, ?, ?, ?, ?, "
|
||||||
|
+ "'reportLeader', 'APPROVE', ?, ?, ?, ?)",
|
||||||
|
TENANT, bizNo, coordinationDefinitionId(), status, reporter.userId, reporter.realName,
|
||||||
|
reportOrgId, orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
|
||||||
|
Timestamp.valueOf(submitTime),
|
||||||
|
archiveTime == null ? null : Timestamp.valueOf(archiveTime),
|
||||||
|
archiveTime == null ? null : reporter.userId,
|
||||||
|
archiveTime == null ? null : reporter.realName);
|
||||||
|
Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||||
|
Long.class, TENANT, bizNo);
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
data.master().update("INSERT INTO approval_coordination_form (tenant_id, instance_id, accept_org_id, "
|
||||||
|
+ "accept_org_name, title, content) VALUES (?, ?, ?, ?, ?, '协调内容')",
|
||||||
|
TENANT, instanceId, acceptOrgId, orgName(acceptOrgId), title);
|
||||||
|
Long formId = data.queryMaster("SELECT id FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
Long.class, instanceId);
|
||||||
|
data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long coordinationDefinitionId() {
|
||||||
|
List<Long> ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||||
|
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
|
||||||
|
+ "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)",
|
||||||
|
TENANT, ApprovalConstants.COORDINATION_NODES_JSON);
|
||||||
|
ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||||
|
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||||
|
}
|
||||||
|
return ids.isEmpty() ? 0L : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String format(LocalDateTime time) {
|
||||||
|
return TIME_FORMAT.format(time.withNano(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String orgName(Integer organizationId) {
|
||||||
|
return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantDepartmentLeader() {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(reportOrgId), "部门负责人");
|
||||||
|
ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
data.bind(leader.userId, role.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantArchivist() {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||||
|
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
data.bind(archivist.userId, role.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username,
|
||||||
|
reportOrgId, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
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 javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。
|
||||||
|
*
|
||||||
|
* <p>本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新;
|
||||||
|
* 数据由 {@link ApprovalTestData#cleanup()} 清理。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalTaskIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser leader;
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
organizationId = data.createOrganization("任务部门" + data.uniqueSuffix(), 136);
|
||||||
|
reporter = data.createUser("任务报送人" + data.uniqueSuffix(), organizationId);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole(data.leaderRoleCode(organizationId), "部门负责人");
|
||||||
|
leader = data.createUser("任务负责人" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(leader.userId, leaderRole.roleId);
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
|
||||||
|
ApprovalTestData.TestUser archivist = data.createUser("任务收文员" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(archivist.userId, archivistRole.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvesTaskAndActivatesNextConfirmNode() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("通过测试");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意办理\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].userId").value(reporter.userId))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].status").value("PENDING"));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo("DONE");
|
||||||
|
assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo("同意办理");
|
||||||
|
assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, taskId))
|
||||||
|
.isNotNull();
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("confirm");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'CONFIRM' AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId))
|
||||||
|
.isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE' AND operator_id = ? AND opinion = '同意办理'", instanceId, leader.userId))
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
|
// 存档人能看到待确认任务与对应权限
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canApprove").value(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresRejectOpinionAndResubmitsWithSameBizNo() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("驳回重提");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\" \"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"材料不完整\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("REJECTED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已驳回"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2));
|
||||||
|
|
||||||
|
// 单据回到报送人:可修改、可重新提交
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
|
||||||
|
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
|
||||||
|
instanceId)).isZero();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'REJECT' AND opinion = '材料不完整'", instanceId)).isEqualTo(1);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||||
|
assertThat(pendingTaskId(instanceId, leader.userId)).isPositive();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvingOneCountersignTaskCancelsTheRest() throws Exception {
|
||||||
|
ApprovalTestData.TestUser second =
|
||||||
|
data.createUser("第二负责人" + data.uniqueSuffix(), organizationId);
|
||||||
|
Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
|
||||||
|
Integer.class, data.leaderRoleCode(organizationId), TENANT);
|
||||||
|
data.bind(second.userId, leaderRoleId);
|
||||||
|
|
||||||
|
long instanceId = createAndSubmit("或签取消");
|
||||||
|
long firstTask = pendingTaskId(instanceId, leader.userId);
|
||||||
|
long secondTask = pendingTaskId(instanceId, second.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondTask))
|
||||||
|
.isEqualTo("CANCELLED");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), second, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), second, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||||
|
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void concurrentApprovalOnlyHandlesTaskOnce() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("并发处理");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(2);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
AtomicInteger success = new AtomicInteger();
|
||||||
|
AtomicInteger conflict = new AtomicInteger();
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
executor.submit(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
int code = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
|
||||||
|
leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"并发\"}"))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
|
||||||
|
.contains("\"code\":0") ? 0 : 1;
|
||||||
|
if (code == 0) {
|
||||||
|
success.incrementAndGet();
|
||||||
|
} else {
|
||||||
|
conflict.incrementAndGet();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
conflict.incrementAndGet();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(success.get()).isEqualTo(1);
|
||||||
|
assertThat(conflict.get()).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'DONE'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsReporterToApproveOwnTask() throws Exception {
|
||||||
|
Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
|
||||||
|
Integer.class, data.leaderRoleCode(organizationId), TENANT);
|
||||||
|
data.bind(reporter.userId, leaderRoleId);
|
||||||
|
|
||||||
|
long instanceId = createAndSubmit("自审单据");
|
||||||
|
long taskId = pendingTaskId(instanceId, reporter.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
|
||||||
|
reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||||
|
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE' AND operator_id = ?", instanceId, reporter.userId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawsOnlyBeforeAnyTaskHandled() throws Exception {
|
||||||
|
long withdrawn = createAndSubmit("撤回单据");
|
||||||
|
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, withdrawn);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + withdrawn + "/withdraw"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
|
||||||
|
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
|
||||||
|
withdrawn)).isZero();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'WITHDRAW'",
|
||||||
|
withdrawn)).isEqualTo(1);
|
||||||
|
|
||||||
|
// 已有处理记录的实例不能撤回
|
||||||
|
long handled = createAndSubmit("已审批撤回");
|
||||||
|
long taskId = pendingTaskId(handled, leader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + handled + "/withdraw"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已有审批人处理,无法撤回")));
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, handled))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
|
||||||
|
// 非报送人不能撤回
|
||||||
|
long other = createAndSubmit("他人撤回");
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + other + "/withdraw"),
|
||||||
|
leader, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsTodoDoneAndTodoCountWithFilters() throws Exception {
|
||||||
|
long first = createAndSubmit("场馆维修A");
|
||||||
|
long second = createAndSubmit("场馆维修B");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, 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[0].bizNo").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].submitTime").isNotEmpty());
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page").param("keywords", "B"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) second));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page").param("status", "REJECTED"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", "A"), reporter, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) first));
|
||||||
|
|
||||||
|
long taskId = pendingTaskId(first, leader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data").value(1));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/done/page"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) first))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("存档"));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/done/page").param("keywords", "B"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("任务路人" + data.uniqueSuffix(), organizationId);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), outsider, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/done/page"), outsider, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private long createAndSubmit(String name) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name)
|
||||||
|
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
|
||||||
|
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
|
||||||
|
reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.gxwebsoft.support.CoreTestData;
|
||||||
|
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.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
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.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
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"})
|
||||||
|
class RoleMemberIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private CoreTestData data;
|
||||||
|
private String suffix;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new CoreTestData(dataSource);
|
||||||
|
suffix = String.valueOf(System.nanoTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addsMemberAndListsRoleMembers() throws Exception {
|
||||||
|
int roleId = data.createRole("dept_leader_" + suffix, "部门负责人" + suffix);
|
||||||
|
int organizationId = data.createOrganization("测试部门-" + suffix);
|
||||||
|
int userId = data.createUser("member_" + suffix, "张三", organizationId, 0);
|
||||||
|
|
||||||
|
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
|
||||||
|
assertThat(awaitOperationRecord("/api/system/role-member", String.valueOf(roleId))).isTrue();
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("page", "1")
|
||||||
|
.param("limit", "10"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].userId").value(userId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].username").value("member_" + suffix))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].realName").value("张三"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].organizationId").value(organizationId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].organizationName").value("测试部门-" + suffix))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].status").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removingLastMemberLeavesRoleEmpty() throws Exception {
|
||||||
|
int roleId = data.createRole("approval_archivist_" + suffix, "收文员" + suffix);
|
||||||
|
int userId = data.createUser("archivist_" + suffix, "李四", null, 0);
|
||||||
|
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
|
||||||
|
assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/system/role-member")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("userId", String.valueOf(userId)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
assertThat(data.roleIdsOf(userId)).isEmpty();
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("page", "1")
|
||||||
|
.param("limit", "10"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearsAllRolesWhenUserUpdateHasEmptyRoleList() throws Exception {
|
||||||
|
int roleId = data.createRole("dept_deputy_" + suffix, "部门副职" + suffix);
|
||||||
|
int userId = data.createUser("deputy_" + suffix, "王五", null, 0);
|
||||||
|
data.bind(userId, roleId);
|
||||||
|
assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/system/user")
|
||||||
|
.with(auth("sys:user:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"userId\":" + userId + ",\"nickname\":\"改名" + suffix + "\",\"roles\":[]}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
assertThat(data.roleIdsOf(userId)).isEmpty();
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("page", "1")
|
||||||
|
.param("limit", "10"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pagesRoleMembersAndHidesDisabledUsers() throws Exception {
|
||||||
|
int roleId = data.createRole("dept_leader_page_" + suffix, "分页部门负责人" + suffix);
|
||||||
|
int otherRoleId = data.createRole("other_role_" + suffix, "其他角色" + suffix);
|
||||||
|
int first = data.createUser("page_a_" + suffix, "甲", null, 0);
|
||||||
|
int second = data.createUser("page_b_" + suffix, "乙", null, 0);
|
||||||
|
int third = data.createUser("page_c_" + suffix, "丙", null, 0);
|
||||||
|
int disabled = data.createUser("page_disabled_" + suffix, "停用", null, 1);
|
||||||
|
int outsider = data.createUser("page_other_" + suffix, "外角色", null, 0);
|
||||||
|
for (int userId : new int[]{first, second, third, disabled}) {
|
||||||
|
data.bind(userId, roleId);
|
||||||
|
}
|
||||||
|
data.bind(outsider, otherRoleId);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("page", "1")
|
||||||
|
.param("limit", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.count").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("page", "2")
|
||||||
|
.param("limit", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.count").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(1));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", String.valueOf(roleId))
|
||||||
|
.param("keywords", "page_a_"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].userId").value(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateMemberWithChineseMessage() throws Exception {
|
||||||
|
int roleId = data.createRole("dept_leader_dup_" + suffix, "重复部门负责人" + suffix);
|
||||||
|
int userId = data.createUser("dup_" + suffix, "赵六", null, 0);
|
||||||
|
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
addMember(roleId, userId)
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已是角色成员")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWithoutRoleUpdateAuthority() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/system/role-member/page")
|
||||||
|
.with(auth("sys:role:list"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.param("roleId", "586"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.springframework.test.web.servlet.ResultActions addMember(int roleId, int userId) throws Exception {
|
||||||
|
return mockMvc.perform(post("/api/system/role-member")
|
||||||
|
.with(auth("sys:role:update"))
|
||||||
|
.header("tenantId", TestAuthentication.TENANT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"roleId\":" + roleId + ",\"userId\":" + userId + "}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestPostProcessor auth(String... authorities) {
|
||||||
|
return authentication(TestAuthentication.admin(authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作日志异步写入,最多等待 2 秒 */
|
||||||
|
private boolean awaitOperationRecord(String url, String paramsMark) throws InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + 2000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (data.countOperationRecords(url, paramsMark) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Thread.sleep(100);
|
||||||
|
}
|
||||||
|
return data.countOperationRecords(url, paramsMark) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.gxwebsoft.support;
|
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。
|
||||||
|
*
|
||||||
|
* <p>sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate,
|
||||||
|
* 避免测试事务与动态数据源切换互相影响。</p>
|
||||||
|
*/
|
||||||
|
public class ApprovalTestData {
|
||||||
|
|
||||||
|
private static final AtomicInteger SEQ = new AtomicInteger();
|
||||||
|
|
||||||
|
private final JdbcTemplate master;
|
||||||
|
private final JdbcTemplate server;
|
||||||
|
|
||||||
|
private final List<Integer> userIds = new ArrayList<>();
|
||||||
|
private final List<Integer> roleIds = new ArrayList<>();
|
||||||
|
private final List<Integer> organizationIds = new ArrayList<>();
|
||||||
|
private final List<Long> instanceIds = new ArrayList<>();
|
||||||
|
|
||||||
|
public ApprovalTestData(DataSource dataSource) {
|
||||||
|
this.master = new JdbcTemplate(dataSource);
|
||||||
|
DataSource serverDataSource = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||||
|
this.server = new JdbcTemplate(serverDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 唯一后缀,避免共享库中的账号/角色/组织重名。 */
|
||||||
|
public String uniqueSuffix() {
|
||||||
|
return System.nanoTime() + "-" + SEQ.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestUser createUser(String realName, Integer organizationId, int tenantId) {
|
||||||
|
String suffix = uniqueSuffix();
|
||||||
|
String username = "it_" + suffix;
|
||||||
|
Integer id = insert(server,
|
||||||
|
"INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
|
||||||
|
+ "VALUES (?, 'it', ?, ?, ?, 0, 0, ?)",
|
||||||
|
username, realName, realName, organizationId, tenantId);
|
||||||
|
userIds.add(id);
|
||||||
|
return new TestUser(id, username, realName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestUser createUser(String realName, Integer organizationId) {
|
||||||
|
return createUser(realName, organizationId, TestAuthentication.TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestRole createRole(String roleCode, String roleName, int tenantId) {
|
||||||
|
Integer id = insert(server,
|
||||||
|
"INSERT INTO sys_role (role_name, role_code, sort_number, deleted, tenant_id) VALUES (?, ?, 1, 0, ?)",
|
||||||
|
roleName, roleCode, tenantId);
|
||||||
|
roleIds.add(id);
|
||||||
|
return new TestRole(id, roleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestRole createRole(String roleCode, String roleName) {
|
||||||
|
return createRole(roleCode, roleName, TestAuthentication.TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bind(int userId, int roleId) {
|
||||||
|
insert(server,
|
||||||
|
"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
|
||||||
|
userId, roleId, TestAuthentication.TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bindTenant(int userId, int roleId, int tenantId) {
|
||||||
|
insert(server,
|
||||||
|
"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
|
||||||
|
userId, roleId, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer createOrganization(String name, int parentId, int tenantId) {
|
||||||
|
String code = "T_" + uniqueSuffix();
|
||||||
|
Integer id = insert(server,
|
||||||
|
"INSERT INTO sys_organization (organization_name, organization_code, parent_id, organization_type, deleted, tenant_id) "
|
||||||
|
+ "VALUES (?, ?, ?, 'dept', 0, ?)",
|
||||||
|
name, code, parentId, tenantId);
|
||||||
|
organizationIds.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer createOrganization(String name, int parentId) {
|
||||||
|
return createOrganization(name, parentId, TestAuthentication.TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String organizationCode(Integer organizationId) {
|
||||||
|
return server.queryForObject(
|
||||||
|
"SELECT organization_code FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String leaderRoleCode(Integer organizationId) {
|
||||||
|
return "dept_leader_" + organizationCode(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearOrganizationCode(int organizationId) {
|
||||||
|
server.update("UPDATE sys_organization SET organization_code = NULL WHERE organization_id = ?", organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disableOrganization(int organizationId) {
|
||||||
|
server.update("UPDATE sys_organization SET deleted = 1 WHERE organization_id = ?", organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserOrganization(int userId, Integer organizationId) {
|
||||||
|
server.update("UPDATE sys_user SET organization_id = ? WHERE user_id = ?", organizationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer countServer(String sql, Object... args) {
|
||||||
|
return server.queryForObject(sql, Integer.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer countMaster(String sql, Object... args) {
|
||||||
|
return master.queryForObject(sql, Integer.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T queryMaster(String sql, Class<T> type, Object... args) {
|
||||||
|
return master.queryForObject(sql, type, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JdbcTemplate master() {
|
||||||
|
return master;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JdbcTemplate server() {
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录实例 id,便于非事务用例结束后清理。 */
|
||||||
|
public void trackInstance(Long instanceId) {
|
||||||
|
if (instanceId != null) {
|
||||||
|
instanceIds.add(instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理本次用例创建的核心库数据与审批数据。 */
|
||||||
|
public void cleanup() {
|
||||||
|
try {
|
||||||
|
for (Long instanceId : instanceIds) {
|
||||||
|
deleteInstance(instanceId);
|
||||||
|
}
|
||||||
|
instanceIds.clear();
|
||||||
|
for (Integer userId : userIds) {
|
||||||
|
server.update("DELETE FROM sys_user_role WHERE user_id = ?", userId);
|
||||||
|
server.update("DELETE FROM sys_file_record WHERE create_user_id = ?", userId);
|
||||||
|
server.update("DELETE FROM sys_user WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
|
userIds.clear();
|
||||||
|
for (Integer roleId : roleIds) {
|
||||||
|
server.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId);
|
||||||
|
server.update("DELETE FROM sys_role WHERE role_id = ?", roleId);
|
||||||
|
}
|
||||||
|
roleIds.clear();
|
||||||
|
for (Integer organizationId : organizationIds) {
|
||||||
|
server.update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId);
|
||||||
|
}
|
||||||
|
organizationIds.clear();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 清理失败不阻断测试报告;残留行均由唯一后缀标识。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteInstance(Long instanceId) {
|
||||||
|
master.update("DELETE FROM approval_attachment WHERE instance_id = ?", instanceId);
|
||||||
|
master.update("DELETE FROM approval_record WHERE instance_id = ?", instanceId);
|
||||||
|
master.update("DELETE FROM approval_task WHERE instance_id = ?", instanceId);
|
||||||
|
master.update("DELETE FROM approval_completion_form WHERE instance_id = ?", instanceId);
|
||||||
|
master.update("DELETE FROM approval_coordination_form WHERE instance_id = ?", instanceId);
|
||||||
|
master.update("DELETE FROM approval_instance WHERE id = ?", instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer insert(JdbcTemplate jdbcTemplate, String sql, Object... args) {
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(connection -> {
|
||||||
|
PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
|
||||||
|
for (int i = 0; i < args.length; i++) {
|
||||||
|
statement.setObject(i + 1, args[i]);
|
||||||
|
}
|
||||||
|
return statement;
|
||||||
|
}, keyHolder);
|
||||||
|
Number key = keyHolder.getKey();
|
||||||
|
if (key == null) {
|
||||||
|
throw new IllegalStateException("未取得自增主键: " + sql);
|
||||||
|
}
|
||||||
|
return key.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TestUser {
|
||||||
|
public final int userId;
|
||||||
|
public final String username;
|
||||||
|
public final String realName;
|
||||||
|
|
||||||
|
public TestUser(int userId, String username, String realName) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.realName = realName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TestRole {
|
||||||
|
public final int roleId;
|
||||||
|
public final String roleCode;
|
||||||
|
|
||||||
|
TestRole(int roleId, String roleCode) {
|
||||||
|
this.roleId = roleId;
|
||||||
|
this.roleCode = roleCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gxtyzx_core 测试夹具:直接写 sys_user / sys_role / sys_user_role / sys_organization。
|
||||||
|
*
|
||||||
|
* <p>共享测试库不会被测试事务回滚,调用方必须在用例结束后执行 {@link #cleanup()}。</p>
|
||||||
|
*/
|
||||||
|
public final class CoreTestData {
|
||||||
|
|
||||||
|
private final JdbcTemplate core;
|
||||||
|
private final List<Integer> userIds = new ArrayList<>();
|
||||||
|
private final List<Integer> roleIds = new ArrayList<>();
|
||||||
|
private final List<Integer> organizationIds = new ArrayList<>();
|
||||||
|
private final List<String> operationParamMarks = new ArrayList<>();
|
||||||
|
|
||||||
|
public CoreTestData(DataSource dataSource) {
|
||||||
|
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
|
||||||
|
this.core = new JdbcTemplate(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建启用/停用用户,返回用户id。
|
||||||
|
*/
|
||||||
|
public int createUser(String username, String realName, Integer organizationId, int status) {
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
core.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(
|
||||||
|
"INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?, ?, 0, ?)", Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, username);
|
||||||
|
ps.setString(2, "test-password");
|
||||||
|
ps.setString(3, realName);
|
||||||
|
ps.setString(4, realName);
|
||||||
|
if (organizationId == null) {
|
||||||
|
ps.setNull(5, java.sql.Types.INTEGER);
|
||||||
|
} else {
|
||||||
|
ps.setInt(5, organizationId);
|
||||||
|
}
|
||||||
|
ps.setInt(6, status);
|
||||||
|
ps.setInt(7, TestAuthentication.TENANT_ID);
|
||||||
|
return ps;
|
||||||
|
}, keyHolder);
|
||||||
|
int userId = keyHolder.getKey().intValue();
|
||||||
|
userIds.add(userId);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建角色,返回角色id。
|
||||||
|
*/
|
||||||
|
public int createRole(String roleCode, String roleName) {
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
core.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(
|
||||||
|
"INSERT INTO sys_role (role_name, role_code, deleted, tenant_id) VALUES (?, ?, 0, ?)",
|
||||||
|
Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, roleName);
|
||||||
|
ps.setString(2, roleCode);
|
||||||
|
ps.setInt(3, TestAuthentication.TENANT_ID);
|
||||||
|
return ps;
|
||||||
|
}, keyHolder);
|
||||||
|
int roleId = keyHolder.getKey().intValue();
|
||||||
|
roleIds.add(roleId);
|
||||||
|
// 角色成员写操作的操作日志参数里含 roleId,结束时可据此清理
|
||||||
|
operationParamMarks.add(String.valueOf(roleId));
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建组织,返回组织id。
|
||||||
|
*/
|
||||||
|
public int createOrganization(String organizationName) {
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
core.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(
|
||||||
|
"INSERT INTO sys_organization (parent_id, organization_name, organization_type, deleted, tenant_id) "
|
||||||
|
+ "VALUES (0, ?, '', 0, ?)", Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, organizationName);
|
||||||
|
ps.setInt(2, TestAuthentication.TENANT_ID);
|
||||||
|
return ps;
|
||||||
|
}, keyHolder);
|
||||||
|
int organizationId = keyHolder.getKey().intValue();
|
||||||
|
organizationIds.add(organizationId);
|
||||||
|
return organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定用户与角色。
|
||||||
|
*/
|
||||||
|
public void bind(int userId, int roleId) {
|
||||||
|
core.update("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
|
||||||
|
userId, roleId, TestAuthentication.TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户当前的角色id。
|
||||||
|
*/
|
||||||
|
public List<Integer> roleIdsOf(int userId) {
|
||||||
|
return core.queryForList("SELECT role_id FROM sys_user_role WHERE user_id = ?", Integer.class, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计包含指定参数标记的操作日志条数。
|
||||||
|
*/
|
||||||
|
public int countOperationRecords(String url, String paramsMark) {
|
||||||
|
Integer count = core.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM sys_operation_record WHERE url = ? AND params LIKE ?",
|
||||||
|
Integer.class, url, "%" + paramsMark + "%");
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理本夹具创建的全部行。
|
||||||
|
*/
|
||||||
|
public void cleanup() {
|
||||||
|
if (!userIds.isEmpty() || !roleIds.isEmpty()) {
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
String userIn = placeholders(userIds.size());
|
||||||
|
String roleIn = placeholders(roleIds.size());
|
||||||
|
StringBuilder sql = new StringBuilder("DELETE FROM sys_user_role WHERE 1 = 0");
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
sql.append(" OR user_id IN (").append(userIn).append(')');
|
||||||
|
args.addAll(userIds);
|
||||||
|
}
|
||||||
|
if (!roleIds.isEmpty()) {
|
||||||
|
sql.append(" OR role_id IN (").append(roleIn).append(')');
|
||||||
|
args.addAll(roleIds);
|
||||||
|
}
|
||||||
|
core.update(sql.toString(), args.toArray());
|
||||||
|
}
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
core.update("DELETE FROM sys_user WHERE user_id IN (" + placeholders(userIds.size()) + ")", userIds.toArray());
|
||||||
|
}
|
||||||
|
if (!roleIds.isEmpty()) {
|
||||||
|
core.update("DELETE FROM sys_role WHERE role_id IN (" + placeholders(roleIds.size()) + ")", roleIds.toArray());
|
||||||
|
}
|
||||||
|
if (!organizationIds.isEmpty()) {
|
||||||
|
core.update("DELETE FROM sys_organization WHERE organization_id IN (" + placeholders(organizationIds.size()) + ")",
|
||||||
|
organizationIds.toArray());
|
||||||
|
}
|
||||||
|
if (!operationParamMarks.isEmpty()) {
|
||||||
|
for (String mark : operationParamMarks) {
|
||||||
|
core.update("DELETE FROM sys_operation_record WHERE params LIKE ?", "%" + mark + "%");
|
||||||
|
}
|
||||||
|
operationParamMarks.clear();
|
||||||
|
}
|
||||||
|
userIds.clear();
|
||||||
|
roleIds.clear();
|
||||||
|
organizationIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String placeholders(int size) {
|
||||||
|
return String.join(",", Collections.nCopies(size, "?"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,20 @@ public final class TestAuthentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Authentication admin(String... authorities) {
|
public static Authentication admin(String... authorities) {
|
||||||
|
return user(USER_ID, "admin", null, TENANT_ID, authorities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造指定用户/组织/租户的登录态,供审批流集成测试使用。
|
||||||
|
*
|
||||||
|
* @param userId 用户id
|
||||||
|
* @param username 账号(同时作为姓名)
|
||||||
|
* @param organizationId 所属部门id,可为 null
|
||||||
|
* @param tenantId 租户id
|
||||||
|
* @param authorities 权限串
|
||||||
|
*/
|
||||||
|
public static Authentication user(int userId, String username, Integer organizationId,
|
||||||
|
int tenantId, String... authorities) {
|
||||||
List<Menu> menus = Arrays.stream(authorities)
|
List<Menu> menus = Arrays.stream(authorities)
|
||||||
.map(authority -> {
|
.map(authority -> {
|
||||||
Menu menu = new Menu();
|
Menu menu = new Menu();
|
||||||
@@ -32,9 +46,12 @@ public final class TestAuthentication {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setUserId(USER_ID);
|
user.setUserId(userId);
|
||||||
user.setUsername("admin");
|
user.setUsername(username);
|
||||||
user.setTenantId(TENANT_ID);
|
user.setNickname(username);
|
||||||
|
user.setRealName(username);
|
||||||
|
user.setOrganizationId(organizationId);
|
||||||
|
user.setTenantId(tenantId);
|
||||||
user.setStatus(0);
|
user.setStatus(0);
|
||||||
user.setAuthorities(menus);
|
user.setAuthorities(menus);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user