From d5001c981c14194de8033ea9127e0ddab3575678 Mon Sep 17 00:00:00 2001
From: weicw
Date: Sat, 31 Oct 2026 06:13:45 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=8C=E6=9C=9F=E5=AE=A1=E6=89=B9?=
=?UTF-8?q?=E5=BC=95=E6=93=8E=E6=89=A9=E5=B1=95=E4=B8=8E=E4=BC=9A=E7=AD=BE?=
=?UTF-8?q?/=E6=98=8E=E7=BB=86/=E5=9B=9E=E5=A1=AB/=E9=99=84=E4=BB=B6?=
=?UTF-8?q?=E5=88=86=E7=B1=BB=E5=85=B1=E4=BA=AB=E5=B1=82=EF=BC=88#46?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ApprovalDocumentType SPI + 注册表:新增单据不再改引擎
- ApprovalApproverResolver:会签组、orgLeaderByCode/orgDeputyByCode、租户岗位解析器
- 会签:任务带 group_key/group_name/group_seq,一人多组一次通过,只取消同组待办
- 回填:节点 writableFields、BACKFILL 动作、approval_field_change 留痕
- 附件:新增 VOUCHER 分类并按单据类型校验必传
- 详情:document.fields/lines/lineColumns/groupProgress + permissions.writableFields
- 列表/导出:title/search_text 落库、flowCode 筛选、明细行数/合计金额/会签进度列、明细导出
- sql/05-approval_phase2.sql(可重复执行)
---
sql/02-approval_menu.sql | 19 +
sql/04-approval_role_grants.sql | 39 ++
sql/05-approval_phase2.sql | 107 ++++
.../approval/constant/ApprovalConstants.java | 58 +-
.../controller/ApprovalExportController.java | 13 +-
.../ApprovalInstanceController.java | 53 +-
.../controller/ApprovalTaskController.java | 14 +-
.../completion/CompletionDocumentType.java | 133 ++++
.../CoordinationDocumentType.java | 154 +++++
.../CoordinationExternalDocumentType.java | 48 ++
.../AbstractApprovalDocumentType.java | 73 +++
.../document/ApprovalDocumentParam.java | 20 +
.../document/ApprovalDocumentRegistry.java | 76 +++
.../document/ApprovalDocumentType.java | 113 ++++
.../document/ApprovalFieldUpdate.java | 30 +
.../document/ApprovalLineSupport.java | 284 +++++++++
.../approval/entity/ApprovalFieldChange.java | 51 ++
.../approval/entity/ApprovalInstance.java | 6 +
.../approval/entity/ApprovalTask.java | 9 +
.../mapper/ApprovalFieldChangeMapper.java | 10 +
.../mapper/ApprovalInstanceMapper.java | 5 +
.../mapper/xml/ApprovalExportMapper.xml | 9 +-
.../mapper/xml/ApprovalInstanceMapper.xml | 18 +-
.../mapper/xml/ApprovalQueryMapper.xml | 8 +-
.../mapper/xml/ApprovalTaskMapper.xml | 16 +-
.../param/ApprovalCompletionParam.java | 9 +-
.../param/ApprovalCoordinationParam.java | 12 +-
.../approval/param/ApprovalMineParam.java | 3 +
.../approval/param/ApprovalQueryParam.java | 3 +
.../param/ApprovalTaskActionParam.java | 9 +-
.../approval/param/ApprovalTaskListParam.java | 3 +
.../service/ApprovalApproverResolver.java | 305 +++++++++
.../service/ApprovalAttachmentService.java | 14 +-
.../service/ApprovalDirectoryService.java | 20 +-
.../service/ApprovalDocumentService.java | 250 ++++++++
.../service/ApprovalEngineService.java | 110 +++-
.../service/ApprovalExportService.java | 275 ++++++--
.../approval/service/ApprovalFlowService.java | 63 +-
.../service/ApprovalInstanceService.java | 601 +++++++-----------
.../approval/service/ApprovalTaskService.java | 346 ++++++----
.../approval/support/ApprovalApprover.java | 30 +-
.../approval/support/ApprovalExportRow.java | 21 +-
.../approval/support/ApprovalFieldSpec.java | 60 ++
.../approval/support/ApprovalItemRow.java | 1 +
.../approval/support/ApprovalLineSpec.java | 130 ++++
.../approval/support/ApprovalNode.java | 30 +
.../approval/vo/ApprovalConfirmVO.java | 5 +
.../gxwebsoft/approval/vo/ApprovalDetail.java | 42 ++
.../approval/vo/ApprovalFieldChangeVO.java | 36 ++
.../approval/vo/ApprovalFieldVO.java | 31 +
.../approval/vo/ApprovalGroupProgressVO.java | 49 ++
.../approval/vo/ApprovalInstanceVO.java | 6 +
.../gxwebsoft/approval/vo/ApprovalItemVO.java | 3 +
.../approval/vo/ApprovalLineSummaryVO.java | 20 +
.../approval/vo/ApprovalMyTaskVO.java | 4 +
.../approval/vo/ApprovalPendingTaskVO.java | 4 +
.../approval/vo/ApprovalPermissionsVO.java | 3 +
.../approval/vo/ApprovalWritableFieldVO.java | 24 +
.../gxwebsoft/approval/ApprovalExportIT.java | 3 +-
59 files changed, 3230 insertions(+), 661 deletions(-)
create mode 100644 sql/05-approval_phase2.sql
create mode 100644 src/main/java/com/gxwebsoft/approval/doc/completion/CompletionDocumentType.java
create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationDocumentType.java
create mode 100644 src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationExternalDocumentType.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/AbstractApprovalDocumentType.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentRegistry.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/ApprovalFieldUpdate.java
create mode 100644 src/main/java/com/gxwebsoft/approval/document/ApprovalLineSupport.java
create mode 100644 src/main/java/com/gxwebsoft/approval/entity/ApprovalFieldChange.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalFieldChangeMapper.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java
create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalFieldSpec.java
create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalLineSpec.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalDetail.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalFieldChangeVO.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalFieldVO.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalGroupProgressVO.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalLineSummaryVO.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalWritableFieldVO.java
diff --git a/sql/02-approval_menu.sql b/sql/02-approval_menu.sql
index b92066c..9c6c397 100644
--- a/sql/02-approval_menu.sql
+++ b/sql/02-approval_menu.sql
@@ -75,3 +75,22 @@ WHERE m.tenant_id = 10150
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
);
+
+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);
+
+-- 价格表查看(ADR 0008):受众全体员工,独立权限点,配 /approval/price 只读页
+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/price', '/approval/price', 3, 'approval:price:view', '', 0, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/price' 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 @instanceMenu, '价格表查看', '', '', 6, 'approval:price:view', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:price:view' AND menu_type = 1 AND deleted = 0
+);
diff --git a/sql/04-approval_role_grants.sql b/sql/04-approval_role_grants.sql
index 8f8c463..d943caa 100644
--- a/sql/04-approval_role_grants.sql
+++ b/sql/04-approval_role_grants.sql
@@ -57,3 +57,42 @@ WHERE r.tenant_id = @tenant_id AND r.deleted = 0 AND r.role_code = 'approval_arc
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id AND rm.tenant_id = @tenant_id
);
+
+-- 4. 相关部门(体验券领用申请单固定角色,ADR 0013;待客户确认)
+INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
+SELECT '相关部门', 'approval_related_dept', '审批种子:体验券领用申请单「相关部门」节点,多人或签(ADR 0013)', 926, @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_related_dept'
+);
+
+INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
+SELECT r.role_id, m.menu_id, @tenant_id
+FROM sys_role r
+JOIN sys_menu m
+ ON m.tenant_id = @tenant_id AND m.deleted = 0
+ AND (m.path IN ('/approval', '/approval/todo', '/approval/instance')
+ OR m.authority IN ('approval:instance:list', 'approval:instance:submit', 'approval:instance:approve'))
+WHERE r.tenant_id = @tenant_id AND r.deleted = 0 AND r.role_code = 'approval_related_dept'
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu rm
+ WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id AND rm.tenant_id = @tenant_id
+ );
+
+-- 5. 价格表查看:全体员工可查当前生效与历史版本(ADR 0008)
+INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
+SELECT r.role_id, m.menu_id, @tenant_id
+FROM sys_role r
+JOIN sys_menu m
+ ON m.tenant_id = @tenant_id AND m.deleted = 0
+ AND (m.path = '/approval/price' OR m.authority = 'approval:price:view')
+WHERE r.tenant_id = @tenant_id AND r.deleted = 0
+ AND (r.role_code = 'approval_staff'
+ OR LEFT(r.role_code, 12) = 'dept_leader_'
+ OR LEFT(r.role_code, 12) = 'dept_deputy_'
+ OR r.role_code IN ('approval_archivist', 'approval_cashier', 'approval_typist',
+ 'approval_issuer', 'approval_general_manager', 'approval_related_dept'))
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu rm
+ WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id AND rm.tenant_id = @tenant_id
+ );
diff --git a/sql/05-approval_phase2.sql b/sql/05-approval_phase2.sql
new file mode 100644
index 0000000..06316da
--- /dev/null
+++ b/sql/05-approval_phase2.sql
@@ -0,0 +1,107 @@
+-- 二期审批引擎扩展(可重复执行)
+-- 目标库:db_gxtyzx(master)
+-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql
+-- -> 04-approval_role_grants.sql -> 05-approval_phase2.sql
+--
+-- 覆盖 ADR 0003(会签)、0004(明细行)、0006(回填)、0007(流程类型筛选)。
+-- MySQL 8 不支持 ADD COLUMN IF NOT EXISTS,因此用 information_schema 判断后动态执行。
+
+-- 老数据回填:一期实例没有 title/search_text(幂等,只补空值)
+UPDATE approval_instance i
+JOIN approval_completion_form f ON f.instance_id = i.id
+SET i.title = f.name,
+ i.search_text = CONCAT_WS(' ', f.name, f.implement_org, f.service_content)
+WHERE i.title IS NULL;
+
+UPDATE approval_instance i
+JOIN approval_coordination_form c ON c.instance_id = i.id
+SET i.title = c.title,
+ i.search_text = CONCAT_WS(' ', c.title, c.content, c.remark)
+WHERE i.title IS NULL;
+
+DROP PROCEDURE IF EXISTS approval_phase2_add_column;
+DROP PROCEDURE IF EXISTS approval_phase2_add_index;
+
+DELIMITER $$
+CREATE PROCEDURE approval_phase2_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64),
+ IN p_definition TEXT)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = p_table AND column_name = p_column
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE ', p_table, ' ADD COLUMN ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+
+CREATE PROCEDURE approval_phase2_add_index(IN p_table VARCHAR(64), IN p_index VARCHAR(64),
+ IN p_definition TEXT)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = DATABASE() AND table_name = p_table AND index_name = p_index
+ ) THEN
+ SET @ddl = CONCAT('ALTER TABLE ', p_table, ' ADD ', p_definition);
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+-- 会签任务分组:非会签节点三列为空(ADR 0003)
+CALL approval_phase2_add_column('approval_task', 'group_key',
+ 'group_key VARCHAR(40) DEFAULT NULL COMMENT ''会签组标识'' AFTER opinion');
+CALL approval_phase2_add_column('approval_task', 'group_name',
+ 'group_name VARCHAR(100) DEFAULT NULL COMMENT ''会签组显示名'' AFTER group_key');
+CALL approval_phase2_add_column('approval_task', 'group_seq',
+ 'group_seq INT DEFAULT NULL COMMENT ''会签组展示顺序'' AFTER group_name');
+
+-- 列表标题与检索文本:由单据实现计算落库,列表/检索不再按 doc_type 分支(ADR 0007)
+CALL approval_phase2_add_column('approval_instance', 'title',
+ 'title VARCHAR(200) DEFAULT NULL COMMENT ''列表显示标题'' AFTER biz_id');
+CALL approval_phase2_add_column('approval_instance', 'search_text',
+ 'search_text VARCHAR(500) DEFAULT NULL COMMENT ''关键词检索文本'' AFTER title');
+
+-- 回填唯一性检索用:同类单据按 flow_code 过滤
+CALL approval_phase2_add_index('approval_instance', 'idx_flow',
+ 'KEY idx_flow (tenant_id, flow_code, status)');
+
+-- 字段改值历史(ADR 0006)
+CREATE TABLE IF NOT EXISTS approval_field_change (
+ 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,
+ field_key VARCHAR(40) NOT NULL,
+ field_label VARCHAR(100) DEFAULT NULL,
+ old_value VARCHAR(500) DEFAULT NULL,
+ new_value VARCHAR(500) DEFAULT NULL,
+ operator_id INT NOT NULL,
+ operator_name VARCHAR(100) DEFAULT NULL,
+ round INT NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_instance (instance_id, create_time),
+ KEY idx_field (tenant_id, field_key, new_value)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批字段改值历史';
+
+-- 老数据回填:一期实例没有 title/search_text(幂等,只补空值)
+UPDATE approval_instance i
+JOIN approval_completion_form f ON f.instance_id = i.id
+SET i.title = f.name,
+ i.search_text = CONCAT_WS(' ', f.name, f.implement_org, f.service_content)
+WHERE i.title IS NULL;
+
+UPDATE approval_instance i
+JOIN approval_coordination_form c ON c.instance_id = i.id
+SET i.title = c.title,
+ i.search_text = CONCAT_WS(' ', c.title, c.content, c.remark)
+WHERE i.title IS NULL;
+
+DROP PROCEDURE IF EXISTS approval_phase2_add_column;
+DROP PROCEDURE IF EXISTS approval_phase2_add_index;
diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
index 1ce1734..a683ad4 100644
--- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
+++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
@@ -44,6 +44,7 @@ public final class ApprovalConstants {
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_BACKFILL = "BACKFILL";
public static final String ACTION_REJECT = "REJECT";
public static final String ACTION_WITHDRAW = "WITHDRAW";
public static final String ACTION_DISPATCH = "DISPATCH";
@@ -56,13 +57,27 @@ public final class ApprovalConstants {
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 FLOW_COORDINATION_EXTERNAL = "coordination_external";
+ public static final String FLOW_SETTLEMENT = "settlement";
+ public static final String FLOW_RECEIPT = "receipt";
+ public static final String FLOW_VEHICLE = "vehicle";
+ public static final String FLOW_VENUE = "venue";
+ public static final String FLOW_COUPON = "coupon";
+ public static final String FLOW_FACILITY = "facility";
// 单号前缀
public static final String NO_PREFIX_COMPLETION = "WG";
public static final String NO_PREFIX_COORDINATION = "XT";
+ public static final String NO_PREFIX_SETTLEMENT = "JS";
+ public static final String NO_PREFIX_RECEIPT = "SJ";
+ public static final String NO_PREFIX_VEHICLE = "CL";
+ public static final String NO_PREFIX_VENUE = "CD";
+ public static final String NO_PREFIX_COUPON = "TY";
+ public static final String NO_PREFIX_FACILITY = "JG";
// 附件分类
public static final String CATEGORY_SIGNED = "SIGNED";
+ public static final String CATEGORY_VOUCHER = "VOUCHER";
public static final String CATEGORY_OTHER = "OTHER";
// 角色编码
@@ -73,6 +88,16 @@ public final class ApprovalConstants {
/** 部门岗位角色前缀(dept_leader_ / dept_deputy_)。 */
public static final String ROLE_DEPT_LEADER_PREFIX = "dept_leader_";
public static final String ROLE_DEPT_DEPUTY_PREFIX = "dept_deputy_";
+ /** 租户岗位角色:出纳。 */
+ public static final String ROLE_CASHIER = "approval_cashier";
+ /** 租户岗位角色:录入员(运营管理部指定专人)。 */
+ public static final String ROLE_TYPIST = "approval_typist";
+ /** 租户岗位角色:发放人。 */
+ public static final String ROLE_ISSUER = "approval_issuer";
+ /** 租户岗位角色:总经理 / 主要领导 / 公司领导。 */
+ public static final String ROLE_GENERAL_MANAGER = "approval_general_manager";
+ /** 租户岗位角色:体验券「相关部门」固定角色(待客户确认,见 ADR 0013)。 */
+ public static final String ROLE_RELATED_DEPT = "approval_related_dept";
// 权限串(与 spec 约定一致;菜单/按钮共用)
public static final String PERMISSION_LIST = "approval:instance:list";
@@ -81,6 +106,31 @@ public final class ApprovalConstants {
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";
+ /** 价格表查看:受众全体员工,与「看审批单据」的列表权限分开(ADR 0008)。 */
+ public static final String PERMISSION_PRICE_VIEW = "approval:price:view";
+
+ /** 明细行上限(ADR 0004)。 */
+ public static final int MAX_LINE_COUNT = 100;
+ /** 金额上限,沿用一期 DECIMAL(12,2)。 */
+ public static final java.math.BigDecimal MAX_FEE = new java.math.BigDecimal("99999999.99");
+
+ /** 审批人解析器标识。 */
+ public static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader";
+ public static final String RESOLVER_REPORTER_ORG_DEPUTY = "reporterOrgDeputy";
+ public static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader";
+ public static final String RESOLVER_ACCEPT_ORG_DEPUTY = "acceptOrgDeputy";
+ public static final String RESOLVER_ARCHIVIST = "archivist";
+ public static final String RESOLVER_REPORTER = "reporter";
+ public static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget";
+ public static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers";
+ public static final String RESOLVER_ORG_LEADER_BY_CODE = "orgLeaderByCode";
+ public static final String RESOLVER_ORG_DEPUTY_BY_CODE = "orgDeputyByCode";
+ public static final String RESOLVER_ROLE_CASHIER = "roleCashier";
+ public static final String RESOLVER_ROLE_TYPIST = "roleTypist";
+ public static final String RESOLVER_ROLE_ISSUER = "roleIssuer";
+ public static final String RESOLVER_ROLE_GENERAL_MANAGER = "roleGeneralManager";
+ public static final String RESOLVER_ROLE_RELATED_DEPT = "roleRelatedDept";
+ public static final String RESOLVER_ROLE_ARCHIVIST = "roleArchivist";
// 附件规则
public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
@@ -123,13 +173,14 @@ public final class ApprovalConstants {
Map docType = new LinkedHashMap<>();
docType.put(DOC_COMPLETION, "业务完成确认单");
- docType.put(DOC_COORDINATION, "内部协调单");
+ docType.put(DOC_COORDINATION, "工作协调单");
DOC_TYPE_NAMES = Collections.unmodifiableMap(docType);
Map action = new LinkedHashMap<>();
action.put(ACTION_SUBMIT, "提交");
action.put(ACTION_RESUBMIT, "重新提交");
action.put(ACTION_APPROVE, "通过");
+ action.put(ACTION_BACKFILL, "回填");
action.put(ACTION_REJECT, "驳回");
action.put(ACTION_WITHDRAW, "撤回");
action.put(ACTION_DISPATCH, "派单");
@@ -140,10 +191,15 @@ public final class ApprovalConstants {
Map category = new LinkedHashMap<>();
category.put(CATEGORY_SIGNED, "签字件");
+ category.put(CATEGORY_VOUCHER, "凭证");
category.put(CATEGORY_OTHER, "其他");
CATEGORY_NAMES = Collections.unmodifiableMap(category);
}
+ /** 附件分类白名单(上传与提交预检共用)。 */
+ public static final List ATTACHMENT_CATEGORIES =
+ Collections.unmodifiableList(Arrays.asList(CATEGORY_SIGNED, CATEGORY_VOUCHER, CATEGORY_OTHER));
+
/** 业务完成确认单流程定义(version=1)。 */
public static final String COMPLETION_NODES_JSON = "["
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
index 89c94a4..8dbc4ac 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
@@ -10,6 +10,7 @@ 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -31,10 +32,20 @@ public class ApprovalExportController extends BaseController {
@PreAuthorize("hasAuthority('approval:instance:export')")
@OperationLog(module = "审批管理", comments = "导出单据查询结果")
- @ApiOperation("导出单据")
+ @ApiOperation("导出单据总表")
@PostMapping("/export")
public ApiResult export(@RequestBody ApprovalQueryParam param) throws IOException {
ApprovalExportResult result = exportService.export(param, getLoginUser());
return success("导出成功,共" + result.getRowCount() + "条", result.getFile());
}
+
+ @PreAuthorize("hasAuthority('approval:instance:export')")
+ @OperationLog(module = "审批管理", comments = "导出单据明细")
+ @ApiOperation("按单据类型导出明细(含逐行数据)")
+ @PostMapping("/export/{flowCode}/lines")
+ public ApiResult exportLines(@PathVariable("flowCode") String flowCode,
+ @RequestBody ApprovalQueryParam param) throws IOException {
+ ApprovalExportResult result = exportService.exportLines(flowCode, param, getLoginUser());
+ return success("导出成功,共" + result.getRowCount() + "条明细", result.getFile());
+ }
}
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
index cf3b993..56049e9 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
@@ -1,11 +1,15 @@
package com.gxwebsoft.approval.controller;
-import com.gxwebsoft.approval.param.ApprovalCompletionParam;
-import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.document.ApprovalDocumentParam;
+import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
+import com.gxwebsoft.approval.document.ApprovalDocumentType;
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.exception.BusinessException;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
@@ -21,9 +25,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
+import java.util.Map;
/**
* 审批实例接口(员工端与 PC 共用)。
+ *
+ * 草稿保存按 flow_code 路由到单据实现;新增单据只加一个 {@link ApprovalDocumentType},
+ * 不再加 Controller 方法。
*/
@Api(tags = "审批实例")
@RestController
@@ -32,21 +40,46 @@ public class ApprovalInstanceController extends BaseController {
@Resource
private ApprovalInstanceService instanceService;
+ @Resource
+ private ApprovalDocumentRegistry documentRegistry;
+ @Resource
+ private ObjectMapper objectMapper;
+ /**
+ * 新建或保存任意单据草稿,返回实例 id。
+ *
+ * body 为目标单据的 Param JSON(含 id 表示更新草稿)。
+ */
@PreAuthorize("hasAuthority('approval:instance:submit')")
- @ApiOperation("新建或保存业务完成确认单草稿")
- @PostMapping("/instance/completion")
- public ApiResult saveCompletion(@RequestBody ApprovalCompletionParam param) {
+ @ApiOperation("新建或保存单据草稿")
+ @PostMapping("/instance/{flowCode}")
+ public ApiResult save(@PathVariable("flowCode") String flowCode,
+ @RequestBody Map body) {
User user = getLoginUser();
- return success("保存成功", instanceService.saveCompletion(param, user));
+ ApprovalDocumentType type = documentRegistry.require(flowCode);
+ ApprovalDocumentParam param = objectMapper.convertValue(body, type.paramClass());
+ if (param == null) {
+ throw new BusinessException("参数不能为空");
+ }
+ return success("保存成功", instanceService.save(flowCode, param, user));
}
+ /** 一期端点保留:等价于 POST /instance/completion。 */
@PreAuthorize("hasAuthority('approval:instance:submit')")
- @ApiOperation("新建或保存内部协调单草稿")
+ @ApiOperation("新建或保存业务完成确认单草稿(一期)")
+ @PostMapping("/instance/completion")
+ public ApiResult saveCompletion(@RequestBody com.gxwebsoft.approval.param.ApprovalCompletionParam param) {
+ return success("保存成功", instanceService.save(ApprovalConstants.FLOW_COMPLETION, param, getLoginUser()));
+ }
+
+ /** 一期端点保留:等价于 POST /instance/coordination_internal。 */
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("新建或保存内部协调单草稿(一期)")
@PostMapping("/instance/coordination")
- public ApiResult saveCoordination(@RequestBody ApprovalCoordinationParam param) {
- User user = getLoginUser();
- return success("保存成功", instanceService.saveCoordination(param, user));
+ public ApiResult saveCoordination(
+ @RequestBody com.gxwebsoft.approval.param.ApprovalCoordinationParam param) {
+ return success("保存成功",
+ instanceService.save(ApprovalConstants.FLOW_COORDINATION_INTERNAL, param, getLoginUser()));
}
@PreAuthorize("hasAuthority('approval:instance:submit')")
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
index 8301b34..9190c13 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
@@ -68,11 +68,23 @@ public class ApprovalTaskController extends BaseController {
public ApiResult 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);
+ ApprovalTaskService.Result result = taskService.approve(id,
+ param == null ? null : param.getOpinion(),
+ param == null ? null : param.getFields(), user);
return success(result.getWarning() == null ? "审批成功" : result.getWarning(),
instanceService.detail(result.getInstanceId(), user));
}
+ @PreAuthorize("hasAuthority('approval:instance:approve')")
+ @ApiOperation("回填(只写字段,不推进节点)")
+ @PostMapping("/task/{id}/backfill")
+ public ApiResult backfill(@PathVariable("id") Long id,
+ @RequestBody(required = false) ApprovalTaskActionParam param) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.backfill(id, param == null ? null : param.getFields(), user);
+ return success("回填成功", instanceService.detail(result.getInstanceId(), user));
+ }
+
@PreAuthorize("hasAuthority('approval:instance:approve')")
@ApiOperation("驳回")
@PostMapping("/task/{id}/reject")
diff --git a/src/main/java/com/gxwebsoft/approval/doc/completion/CompletionDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/completion/CompletionDocumentType.java
new file mode 100644
index 0000000..cf54949
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/completion/CompletionDocumentType.java
@@ -0,0 +1,133 @@
+package com.gxwebsoft.approval.doc.completion;
+
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
+import com.gxwebsoft.approval.document.ApprovalDocumentParam;
+import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.mapper.ApprovalCompletionFormMapper;
+import com.gxwebsoft.approval.param.ApprovalCompletionParam;
+import com.gxwebsoft.approval.vo.ApprovalDetail;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.User;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 业务完成确认单(一期):报送部门负责人 → 报送人存档 → 归档。
+ */
+@Component
+public class CompletionDocumentType extends AbstractApprovalDocumentType {
+
+ @Resource
+ private ApprovalCompletionFormMapper completionFormMapper;
+
+ private static final String NODES_JSON = "["
+ + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ + "]";
+
+ @Override
+ public String flowCode() {
+ return ApprovalConstants.FLOW_COMPLETION;
+ }
+
+ @Override
+ public String docType() {
+ return ApprovalConstants.DOC_COMPLETION;
+ }
+
+ @Override
+ public String flowName() {
+ return "业务完成确认单";
+ }
+
+ @Override
+ public String nodesJson() {
+ return NODES_JSON;
+ }
+
+ @Override
+ public String noPrefix() {
+ return ApprovalConstants.NO_PREFIX_COMPLETION;
+ }
+
+ @Override
+ public Class extends ApprovalDocumentParam> paramClass() {
+ return ApprovalCompletionParam.class;
+ }
+
+ @Override
+ public void validate(ApprovalDocumentParam param, User user) {
+ ApprovalCompletionParam completion = cast(param);
+ requireText("名称", completion.getName(), 100);
+ requireText("实施单位", completion.getImplementOrg(), 100);
+ BigDecimal fee = completion.getFee();
+ if (fee == null) {
+ throw new BusinessException("费用不能为空");
+ }
+ if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0
+ || fee.compareTo(ApprovalConstants.MAX_FEE) > 0) {
+ throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
+ }
+ requireText("服务内容", completion.getServiceContent(), 2000);
+ }
+
+ @Override
+ public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
+ ApprovalCompletionParam completion = cast(param);
+ ApprovalCompletionForm form = bizId == null ? null : completionFormMapper.selectById(bizId);
+ if (form == null) {
+ form = new ApprovalCompletionForm();
+ form.setInstanceId(instanceId);
+ }
+ form.setName(completion.getName().trim());
+ form.setImplementOrg(completion.getImplementOrg().trim());
+ form.setFee(completion.getFee());
+ form.setServiceContent(completion.getServiceContent().trim());
+ if (form.getId() == null) {
+ completionFormMapper.insert(form);
+ } else {
+ completionFormMapper.updateById(form);
+ }
+ return form.getId();
+ }
+
+ @Override
+ public void load(ApprovalInstance instance, ApprovalDetail detail) {
+ ApprovalCompletionForm form = findByInstance(instance.getId());
+ if (form == null) {
+ return;
+ }
+ detail.addField("name", "名称", form.getName());
+ detail.addField("implementOrg", "实施单位", form.getImplementOrg());
+ detail.addField("fee", "费用(元)", form.getFee());
+ detail.addField("serviceContent", "服务内容", form.getServiceContent());
+ }
+
+ @Override
+ public String title(ApprovalInstance instance) {
+ ApprovalCompletionForm form = findByInstance(instance.getId());
+ return form == null ? null : form.getName();
+ }
+
+ private ApprovalCompletionForm findByInstance(Long instanceId) {
+ List forms = completionFormMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalCompletionForm::getInstanceId, instanceId)
+ .last("LIMIT 1"));
+ return forms.isEmpty() ? null : forms.get(0);
+ }
+
+ private ApprovalCompletionParam cast(ApprovalDocumentParam param) {
+ if (!(param instanceof ApprovalCompletionParam)) {
+ throw new BusinessException("单据类型不匹配");
+ }
+ return (ApprovalCompletionParam) param;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationDocumentType.java
new file mode 100644
index 0000000..82e8e17
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationDocumentType.java
@@ -0,0 +1,154 @@
+package com.gxwebsoft.approval.doc.coordination;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
+import com.gxwebsoft.approval.document.ApprovalDocumentParam;
+import com.gxwebsoft.approval.entity.ApprovalCoordinationForm;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.mapper.ApprovalCoordinationFormMapper;
+import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
+import com.gxwebsoft.approval.service.ApprovalApproverResolver;
+import com.gxwebsoft.approval.service.ApprovalDirectoryService;
+import com.gxwebsoft.approval.vo.ApprovalDetail;
+import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.User;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 工作协调单(内部流程,一期):报送部门负责人 → 受理部门负责人 → 派单 →
+ * 存档(报送人 + 被派单人)→ 归档。
+ *
+ * 外部流程见 {@link CoordinationExternalDocumentType}:同一张表、同一份表单,
+ * 只多一个受理部门分管领导节点(ADR 0007)。
+ */
+@Component
+public class CoordinationDocumentType extends AbstractApprovalDocumentType {
+
+ static final String NODES_JSON_INTERNAL = "["
+ + "{\"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\":\"归档\"}"
+ + "]";
+
+ @Resource
+ private ApprovalCoordinationFormMapper coordinationFormMapper;
+ @Resource
+ private ApprovalDirectoryService directoryService;
+ @Resource
+ private ApprovalApproverResolver approverResolver;
+
+ @Override
+ public String flowCode() {
+ return ApprovalConstants.FLOW_COORDINATION_INTERNAL;
+ }
+
+ @Override
+ public String docType() {
+ return ApprovalConstants.DOC_COORDINATION;
+ }
+
+ @Override
+ public String flowName() {
+ return "内部协调单";
+ }
+
+ @Override
+ public String nodesJson() {
+ return NODES_JSON_INTERNAL;
+ }
+
+ /** 一期 v1 按部门全员派单、v2 改为受理部门负责人;版本不可变。 */
+ @Override
+ public int latestVersion() {
+ return 2;
+ }
+
+ @Override
+ public String noPrefix() {
+ return ApprovalConstants.NO_PREFIX_COORDINATION;
+ }
+
+ @Override
+ public Class extends ApprovalDocumentParam> paramClass() {
+ return ApprovalCoordinationParam.class;
+ }
+
+ @Override
+ public void validate(ApprovalDocumentParam param, User user) {
+ ApprovalCoordinationParam coordination = cast(param);
+ if (coordination.getAcceptOrgId() == null || coordination.getAcceptOrgId() <= 0) {
+ throw new BusinessException("请选择受理部门");
+ }
+ if (directoryService.getOrganization(coordination.getAcceptOrgId()) == null) {
+ throw new BusinessException("受理部门不存在或已停用");
+ }
+ requireText("标题", coordination.getTitle(), 100);
+ requireText("内容", coordination.getContent(), 2000);
+ optionalText("备注", coordination.getRemark(), 500);
+ }
+
+ @Override
+ public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
+ ApprovalCoordinationParam coordination = cast(param);
+ ApprovalOrganizationVO accept = directoryService.getOrganization(coordination.getAcceptOrgId());
+ if (accept == null) {
+ throw new BusinessException("受理部门不存在或已停用");
+ }
+ ApprovalCoordinationForm form = bizId == null ? null : coordinationFormMapper.selectById(bizId);
+ if (form == null) {
+ form = new ApprovalCoordinationForm();
+ form.setInstanceId(instanceId);
+ }
+ form.setAcceptOrgId(accept.getOrganizationId());
+ form.setAcceptOrgName(accept.getOrganizationName());
+ form.setTitle(coordination.getTitle().trim());
+ form.setContent(coordination.getContent().trim());
+ form.setRemark(optionalText("备注", coordination.getRemark(), 500));
+ if (form.getId() == null) {
+ coordinationFormMapper.insert(form);
+ } else {
+ coordinationFormMapper.updateById(form);
+ }
+ return form.getId();
+ }
+
+ @Override
+ public void load(ApprovalInstance instance, ApprovalDetail detail) {
+ ApprovalCoordinationForm form = findByInstance(instance.getId());
+ if (form == null) {
+ return;
+ }
+ detail.addField("acceptOrgName", "受理部门", form.getAcceptOrgName());
+ detail.addField("title", "标题", form.getTitle());
+ detail.addField("content", "协调内容", form.getContent());
+ detail.addField("remark", "备注", form.getRemark());
+ }
+
+ @Override
+ public String title(ApprovalInstance instance) {
+ ApprovalCoordinationForm form = findByInstance(instance.getId());
+ return form == null ? null : form.getTitle();
+ }
+
+ private ApprovalCoordinationForm findByInstance(Long instanceId) {
+ List forms = coordinationFormMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalCoordinationForm::getInstanceId, instanceId)
+ .last("LIMIT 1"));
+ return forms.isEmpty() ? null : forms.get(0);
+ }
+
+ private ApprovalCoordinationParam cast(ApprovalDocumentParam param) {
+ if (!(param instanceof ApprovalCoordinationParam)) {
+ throw new BusinessException("单据类型不匹配");
+ }
+ return (ApprovalCoordinationParam) param;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationExternalDocumentType.java b/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationExternalDocumentType.java
new file mode 100644
index 0000000..b484183
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/doc/coordination/CoordinationExternalDocumentType.java
@@ -0,0 +1,48 @@
+package com.gxwebsoft.approval.doc.coordination;
+
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.vo.ApprovalDetail;
+import org.springframework.stereotype.Component;
+
+/**
+ * 工作协调单(外部流程):节点链在受理部门负责人之后多一个受理部门分管领导,
+ * 其余与内部流程完全一致;共用同一张业务表、同一套表单(ADR 0007)。
+ */
+@Component
+public class CoordinationExternalDocumentType extends CoordinationDocumentType {
+
+ private static final String NODES_JSON_EXTERNAL = "["
+ + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ + "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"},"
+ + "{\"key\":\"acceptDeputy\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgDeputy\",\"name\":\"分管领导\"},"
+ + "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgLeader\",\"name\":\"派单\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ + "]";
+
+ @Override
+ public String flowCode() {
+ return ApprovalConstants.FLOW_COORDINATION_EXTERNAL;
+ }
+
+ @Override
+ public String flowName() {
+ return "外部协调单";
+ }
+
+ @Override
+ public String nodesJson() {
+ return NODES_JSON_EXTERNAL;
+ }
+
+ @Override
+ public int latestVersion() {
+ return 1;
+ }
+
+ @Override
+ public void load(ApprovalInstance instance, ApprovalDetail detail) {
+ super.load(instance, detail);
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/document/AbstractApprovalDocumentType.java b/src/main/java/com/gxwebsoft/approval/document/AbstractApprovalDocumentType.java
new file mode 100644
index 0000000..b8790b6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/document/AbstractApprovalDocumentType.java
@@ -0,0 +1,73 @@
+package com.gxwebsoft.approval.document;
+
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.support.ApprovalFieldSpec;
+import com.gxwebsoft.approval.vo.ApprovalDetail;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.User;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单据 SPI 的空实现基类:只覆盖本单需要的钩子。
+ */
+public abstract class AbstractApprovalDocumentType implements ApprovalDocumentType {
+
+ @Override
+ public List fieldSpecs() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public String requiredAttachmentCategory() {
+ return null;
+ }
+
+ @Override
+ public void load(ApprovalInstance instance, ApprovalDetail detail) {
+ }
+
+ @Override
+ public String searchText(ApprovalInstance instance) {
+ return title(instance);
+ }
+
+ @Override
+ public List applyWritableFields(ApprovalInstance instance, Map fields,
+ User user, boolean requireComplete) {
+ if (fields != null && !fields.isEmpty()) {
+ throw new BusinessException("当前节点不支持回填");
+ }
+ return new ArrayList<>();
+ }
+
+ /** 必填文本校验,返回去掉首尾空格的值。 */
+ protected String requireText(String label, String value, int maxLength) {
+ String trimmed = value == null ? "" : value.trim();
+ if (trimmed.isEmpty()) {
+ throw new BusinessException(label + "不能为空");
+ }
+ if (trimmed.length() > maxLength) {
+ throw new BusinessException(label + "不能超过" + maxLength + "字");
+ }
+ return trimmed;
+ }
+
+ /** 选填文本:空白归一为 null。 */
+ protected String optionalText(String label, String value, int maxLength) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ if (trimmed.length() > maxLength) {
+ throw new BusinessException(label + "不能超过" + maxLength + "字");
+ }
+ return trimmed;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentParam.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentParam.java
new file mode 100644
index 0000000..b9df3f7
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentParam.java
@@ -0,0 +1,20 @@
+package com.gxwebsoft.approval.document;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 单据草稿参数基类:`id` 为审批实例id,更新草稿时传入。
+ */
+@Data
+public abstract class ApprovalDocumentParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("审批实例id(更新草稿时传入)")
+ private Long id;
+
+ /** 明细行(明细类单据使用):每行以列 key 为键;无明细单据忽略。 */
+ private java.util.List> lines;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentRegistry.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentRegistry.java
new file mode 100644
index 0000000..1949f40
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentRegistry.java
@@ -0,0 +1,76 @@
+package com.gxwebsoft.approval.document;
+
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单据类型注册表:按 flow_code / doc_type 找到实现。
+ *
+ * Spring 启动时收集全部 {@link ApprovalDocumentType};同一 flow_code 重复注册直接失败,
+ * 避免「改了实现却没生效」的静默错误。
+ */
+@Component
+public class ApprovalDocumentRegistry {
+
+ private final Map byFlowCode = new LinkedHashMap<>();
+ private final Map byDocType = new LinkedHashMap<>();
+
+ public ApprovalDocumentRegistry(List documentTypes) {
+ for (ApprovalDocumentType type : documentTypes) {
+ ApprovalDocumentType previous = byFlowCode.put(type.flowCode(), type);
+ if (previous != null) {
+ throw new IllegalStateException("审批流程编码重复注册: " + type.flowCode());
+ }
+ // doc_type 可被多个流程共用(内部/外部协调单),以第一个注册的为准
+ byDocType.putIfAbsent(type.docType(), type);
+ }
+ }
+
+ /** 按流程编码取实现;未知流程抛业务异常。 */
+ public ApprovalDocumentType require(String flowCode) {
+ ApprovalDocumentType type = byFlowCode.get(flowCode);
+ if (type == null) {
+ throw new BusinessException("未知的审批流程: " + flowCode);
+ }
+ return type;
+ }
+
+ public ApprovalDocumentType find(String flowCode) {
+ return flowCode == null ? null : byFlowCode.get(flowCode);
+ }
+
+ public ApprovalDocumentType findByDocType(String docType) {
+ return docType == null ? null : byDocType.get(docType);
+ }
+
+ public List all() {
+ return Collections.unmodifiableList(new ArrayList<>(byFlowCode.values()));
+ }
+
+ /** 明细行数;无实现返回 0。 */
+ public int lineCount(ApprovalDocumentType type, com.gxwebsoft.approval.entity.ApprovalInstance instance) {
+ return type == null ? 0 : type.lineCount(instance);
+ }
+
+ /** 明细合计金额;无实现返回 null。 */
+ public java.math.BigDecimal lineAmountTotal(ApprovalDocumentType type,
+ com.gxwebsoft.approval.entity.ApprovalInstance instance) {
+ return type == null ? null : type.lineAmountTotal(instance);
+ }
+
+ /** 列表/详情显示的流程名称;未知流程回退 doc_type 名称。 */
+ public String flowName(String flowCode, String docType) {
+ ApprovalDocumentType type = find(flowCode);
+ if (type != null) {
+ return type.flowName();
+ }
+ return ApprovalConstants.docTypeName(docType);
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
new file mode 100644
index 0000000..eb359a1
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalDocumentType.java
@@ -0,0 +1,113 @@
+package com.gxwebsoft.approval.document;
+
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.support.ApprovalFieldSpec;
+import com.gxwebsoft.approval.support.ApprovalLineSpec;
+import com.gxwebsoft.approval.vo.ApprovalDetail;
+import com.gxwebsoft.common.system.entity.User;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单据类型 SPI:每张单据一个实现,引擎按 flow_code 找到它。
+ *
+ * 引擎负责审批流转与公共留痕,表单字段的校验、读写、回填与展示由实现承担。
+ * 新增单据 = 新增一个实现 + 一张 DDL + 一个前端表单页,不改引擎。
+ */
+public interface ApprovalDocumentType {
+
+ /** 流程编码,如 settlement。 */
+ String flowCode();
+
+ /** 业务表族(doc_type),外部协调单与内部协调单同为 coordination。 */
+ String docType();
+
+ /** 流程名称,同时作为单据类型显示名(ADR 0007)。 */
+ String flowName();
+
+ /** 节点定义 JSON。 */
+ String nodesJson();
+
+ /**
+ * 本实现的当前发布版本号:调整节点时必须递增并保留旧版本行(ADR 0001)。
+ * 新增单据默认 1。
+ */
+ default int latestVersion() {
+ return 1;
+ }
+
+ /** 单号前缀,如 JS。 */
+ String noPrefix();
+
+ /** 明细列元数据;无明细返回 null。列结构随表结构走,不进流程定义 JSON(ADR 0004)。 */
+ default List lineColumns() {
+ return null;
+ }
+
+ /** 可写字段元数据;无回填字段返回空。 */
+ default List fieldSpecs() {
+ return Collections.emptyList();
+ }
+
+ /** 提交预检要求的附件分类;null 沿用「整单至少一张附件」。 */
+ default String requiredAttachmentCategory() {
+ return null;
+ }
+
+ /** 草稿参数类型。 */
+ Class extends ApprovalDocumentParam> paramClass();
+
+ /** 字段校验,失败抛 BusinessException。 */
+ void validate(ApprovalDocumentParam param, User user);
+
+ /**
+ * 写业务表:新建时 bizId 为 null,更新时传入已有 bizId。
+ *
+ * @return 业务表主键
+ */
+ Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user);
+
+ /** 把业务字段与明细写入详情。 */
+ void load(ApprovalInstance instance, ApprovalDetail detail);
+
+ /** 明细行:每行以列 key 为键;无明细返回空列表。 */
+ default List
*/
@Service
public class ApprovalFlowService {
private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class);
- private static final int INITIAL_VERSION = 1;
- /** 各流程当前发布版本;调整节点时必须递增并保留旧版本行。 */
- private static final Map LATEST_VERSIONS;
-
- static {
- Map 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 ApprovalDocumentRegistry documentRegistry;
+ @Resource
private ObjectMapper objectMapper;
/**
@@ -50,7 +42,8 @@ public class ApprovalFlowService {
* 并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新读取最新版本。
*/
public ApprovalFlowDefinition getOrCreate(String flowCode) {
- int latestVersion = latestVersion(flowCode);
+ ApprovalDocumentType type = documentRegistry.require(flowCode);
+ int latestVersion = type.latestVersion();
ApprovalFlowDefinition definition = findLatest(flowCode);
if (definition != null && definition.getVersion() >= latestVersion) {
return definition;
@@ -58,8 +51,8 @@ public class ApprovalFlowService {
ApprovalFlowDefinition created = new ApprovalFlowDefinition();
created.setFlowCode(flowCode);
created.setVersion(latestVersion);
- created.setName(flowName(flowCode));
- created.setNodesJson(flowNodesJson(flowCode));
+ created.setName(type.flowName());
+ created.setNodesJson(type.nodesJson());
try {
flowDefinitionMapper.insert(created);
return created;
@@ -107,35 +100,21 @@ public class ApprovalFlowService {
return node == null ? null : node.getName();
}
+ /** 流程定义名称(单据类型显示名,ADR 0007);定义缺失时回退实现名称。 */
+ public String flowName(Long definitionId, String flowCode) {
+ ApprovalFlowDefinition definition =
+ definitionId == null ? null : flowDefinitionMapper.selectById(definitionId);
+ if (definition != null && definition.getName() != null) {
+ return definition.getName();
+ }
+ ApprovalDocumentType type = documentRegistry.find(flowCode);
+ return type == null ? null : type.flowName();
+ }
+
private ApprovalFlowDefinition findLatest(String flowCode) {
return flowDefinitionMapper.selectOne(new LambdaQueryWrapper()
.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);
- }
}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
index 6fd8044..ca1469f 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
@@ -4,25 +4,26 @@ 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.document.ApprovalDocumentParam;
+import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
+import com.gxwebsoft.approval.document.ApprovalDocumentType;
+import com.gxwebsoft.approval.entity.ApprovalFieldChange;
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.ApprovalFieldChangeMapper;
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.ApprovalDetail;
+import com.gxwebsoft.approval.vo.ApprovalFieldChangeVO;
+import com.gxwebsoft.approval.vo.ApprovalFieldVO;
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
import com.gxwebsoft.approval.vo.ApprovalItemVO;
import com.gxwebsoft.approval.vo.ApprovalMyTaskVO;
@@ -30,7 +31,7 @@ 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.approval.vo.ApprovalWritableFieldVO;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Menu;
@@ -41,17 +42,15 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
-import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
-import java.util.stream.Collectors;
/**
- * 业务完成确认单草稿、提交与详情服务。
+ * 审批实例服务:草稿保存入口、提交预检、详情装配与列表查询。
*
* 跨库约束:服务方法本身不开启事务;需要写 master 的片段放进
* TransactionTemplate,任何 @DS("server") 的目录查询都在事务外完成,
@@ -60,7 +59,6 @@ import java.util.stream.Collectors;
@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";
@@ -68,64 +66,35 @@ public class ApprovalInstanceService {
@Resource
private ApprovalInstanceMapper instanceMapper;
@Resource
- private ApprovalCompletionFormMapper completionFormMapper;
- @Resource
- private ApprovalCoordinationFormMapper coordinationFormMapper;
- @Resource
private ApprovalRecordMapper recordMapper;
@Resource
private ApprovalTaskMapper taskMapper;
@Resource
+ private ApprovalFieldChangeMapper fieldChangeMapper;
+ @Resource
private ApprovalFlowService flowService;
@Resource
private ApprovalNoService noService;
@Resource
private ApprovalEngineService engineService;
@Resource
- private ApprovalAttachmentService attachmentService;
+ private ApprovalDocumentService documentService;
+ @Resource
+ private ApprovalDocumentRegistry documentRegistry;
@Resource
private ApprovalDirectoryService directoryService;
@Resource
+ private ApprovalAttachmentService attachmentService;
+ @Resource
+ private ApprovalApproverResolver approverResolver;
+ @Resource
private PlatformTransactionManager transactionManager;
/**
- * 新建或更新业务完成确认单草稿,返回实例id。
+ * 新建或更新任意单据草稿,返回实例 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。
- *
- *
报送部门与报送人取登录态;受理部门必须是启用组织,保存时即校验。
- */
- 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));
+ public Long save(String flowCode, ApprovalDocumentParam param, User user) {
+ return documentService.save(flowCode, param, user);
}
/**
@@ -142,15 +111,9 @@ public class ApprovalInstanceService {
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("单据内容不存在");
+ ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode());
+ if (type == null) {
+ throw new BusinessException("单据类型不支持");
}
Integer organizationId = user.getOrganizationId();
@@ -165,76 +128,81 @@ public class ApprovalInstanceService {
if (leaderRoleCode == null) {
throw new BusinessException("部门未配置编码,无法提交");
}
- List leaders = directoryService.listRoleMembers(leaderRoleCode);
- if (leaders.isEmpty()) {
+ if (directoryService.listRoleMembers(leaderRoleCode).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("请至少上传一张附件");
- }
+ // 协调单的受理部门在提交时快照到实例;外部流程的分管领导按受理部门解析
+ ApprovalOrganizationVO acceptOrganization = preflightAcceptOrganization(type, instance);
+ // 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005)
+ documentService.preflight(instance, flowService.parseNodes(instance.getDefinitionId()));
- List approvers = leaders.stream()
- .map(this::toApprover)
- .collect(Collectors.toList());
-
- ApprovalOrganizationVO acceptOrg = acceptOrganization;
- return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers));
+ return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrganization, type));
}
/**
- * 协调单提交预检:受理部门负责人角色、受理部门停用。
+ * 协调单提交预检:受理部门仍启用且负责人角色有成员;返回受理部门供提交时快照。
*/
- private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) {
- Integer acceptOrgId = form.getAcceptOrgId();
- if (acceptOrgId == null || acceptOrgId <= 0) {
- throw new BusinessException("请选择受理部门");
+ private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalDocumentType type,
+ ApprovalInstance instance) {
+ if (!ApprovalConstants.DOC_COORDINATION.equals(type.docType())) {
+ return null;
}
- ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId);
- if (acceptOrganization == null) {
+ ApprovalCoordinationHolder holder = coordinationAcceptOrg(instance.getId());
+ if (holder == null) {
+ throw new BusinessException("单据内容不存在");
+ }
+ ApprovalOrganizationVO organization = directoryService.getOrganization(holder.acceptOrgId);
+ if (organization == null) {
throw new BusinessException("受理部门已停用");
}
- String leaderRoleCode = ApprovalConstants.departmentLeaderRoleCode(acceptOrganization.getOrganizationCode());
+ String leaderRoleCode = ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode());
if (leaderRoleCode == null) {
throw new BusinessException("受理部门未配置编码");
}
if (directoryService.listRoleMembers(leaderRoleCode).isEmpty()) {
throw new BusinessException("受理部门负责人角色缺配");
}
- return acceptOrganization;
+ return organization;
+ }
+
+ /**
+ * 读协调单业务表的受理部门 id(仅协调单需要,走 engine 侧 master 查询)。
+ */
+ private ApprovalCoordinationHolder coordinationAcceptOrg(Long instanceId) {
+ var row = instanceMapper.selectCoordinationAcceptOrg(instanceId);
+ if (row == null || row.get("accept_org_id") == null) {
+ return null;
+ }
+ return new ApprovalCoordinationHolder(((Number) row.get("accept_org_id")).intValue());
}
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
- ApprovalOrganizationVO acceptOrganization, List approvers) {
+ ApprovalOrganizationVO acceptOrganization, ApprovalDocumentType type) {
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());
}
+ boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
if (firstSubmit) {
- boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType());
- locked.setBizNo(noService.nextNo(coordination
- ? ApprovalConstants.NO_PREFIX_COORDINATION
- : ApprovalConstants.NO_PREFIX_COMPLETION));
+ locked.setBizNo(noService.nextNo(type.noPrefix()));
}
locked.setStatus(ApprovalConstants.STATUS_PENDING);
locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1);
if (locked.getSubmitTime() == null) {
locked.setSubmitTime(LocalDateTime.now());
}
+ List nodes = flowService.parseNodes(locked.getDefinitionId());
+ if (nodes.isEmpty()) {
+ throw new BusinessException("审批流程未配置节点");
+ }
+ List approvers = approverResolver.resolve(locked, nodes.get(0)).getApprovers();
int tasks = engineService.startFlow(locked, approvers, user,
firstSubmit ? ApprovalConstants.ACTION_SUBMIT : ApprovalConstants.ACTION_RESUBMIT);
if (tasks == 0) {
@@ -260,33 +228,108 @@ public class ApprovalInstanceService {
}
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());
+ ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode());
+ ApprovalDetail document = new ApprovalDetail();
+ if (type != null) {
+ type.load(instance, document);
+ if (type.lineColumns() != null) {
+ document.setLineColumns(type.lineColumns());
+ document.setLineSummary(lineSummary(type, instance));
+ }
+ documentService.fillGroupProgress(instance, document);
+ applyLegacyFields(vo, document);
}
+ vo.setDocument(document);
vo.setAttachments(attachmentService.listVos(instanceId));
vo.setRecords(records(instance));
vo.setConfirms(confirms(instance));
+ vo.setFieldChanges(fieldChanges(instance));
ApprovalMyTaskVO myTask = myTask(instance, user.getUserId());
vo.setMyTask(myTask);
vo.setPendingTasks(pendingTasks(instance));
- vo.setPermissions(permissions(instance, user, myTask));
+ vo.setPermissions(permissions(instance, user, myTask, type));
return vo;
}
/**
- * 我发起的列表:按提交时间倒序,草稿排最后。
+ * 兼容一期的扁平字段:详情仍返回 name/implementOrg/fee/serviceContent/title/content/remark,
+ * 两端既有页面与用例不用改;新页面统一读 `document.fields`。
+ */
+ private void applyLegacyFields(ApprovalInstanceVO vo, ApprovalDetail document) {
+ for (ApprovalFieldVO field : document.getFields()) {
+ switch (field.getKey()) {
+ case "name":
+ vo.setName(field.getValue());
+ break;
+ case "implementOrg":
+ vo.setImplementOrg(field.getValue());
+ break;
+ case "fee":
+ vo.setFee(field.getValue() == null ? null : new java.math.BigDecimal(field.getValue()));
+ break;
+ case "serviceContent":
+ vo.setServiceContent(field.getValue());
+ break;
+ case "title":
+ vo.setTitle(field.getValue());
+ break;
+ case "content":
+ vo.setContent(field.getValue());
+ break;
+ case "remark":
+ vo.setRemark(field.getValue());
+ break;
+ case "acceptOrgName":
+ vo.setAcceptOrgName(field.getValue());
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ /**
+ * 明细汇总:行数 + 合计金额(金额由查询时 SUM,不落库)。
+ */
+ private com.gxwebsoft.approval.vo.ApprovalLineSummaryVO lineSummary(ApprovalDocumentType type,
+ ApprovalInstance instance) {
+ var summary = new com.gxwebsoft.approval.vo.ApprovalLineSummaryVO();
+ summary.setLineCount(type.lineCount(instance));
+ summary.setAmountTotal(type.lineAmountTotal(instance));
+ return summary;
+ }
+
+ /**
+ * 当前用户可回填字段:仅当他是当前节点处理人且该节点声明了可写字段时非空(ADR 0006)。
+ */
+ private List writableFields(ApprovalInstance instance, ApprovalDocumentType type,
+ ApprovalMyTaskVO myTask) {
+ List result = new ArrayList<>();
+ if (myTask == null || type == null) {
+ return result;
+ }
+ ApprovalNode node = flowService.findNode(instance.getDefinitionId(), myTask.getNodeKey());
+ if (node == null || node.getWritableFields() == null || node.getWritableFields().isEmpty()) {
+ return result;
+ }
+ Map specs = new HashMap<>();
+ for (var spec : type.fieldSpecs()) {
+ specs.put(spec.getKey(), spec);
+ }
+ for (ApprovalNode.WritableField writable : node.getWritableFields()) {
+ ApprovalWritableFieldVO vo = new ApprovalWritableFieldVO();
+ vo.setField(writable.getField());
+ var spec = specs.get(writable.getField());
+ vo.setLabel(spec == null ? writable.getField() : spec.getLabel());
+ vo.setRequired(writable.isRequired());
+ vo.setValue(type.readField(instance, writable.getField()));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ /**
+ * 我发起的列表。
*/
public PageResult mine(ApprovalMineParam param, User user) {
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
@@ -319,9 +362,6 @@ public class ApprovalInstanceService {
return taskPage(param, user, ApprovalConstants.TASK_DONE);
}
- /**
- * 全租户待办:任一 PENDING 任务对应的实例,按实例去重。
- */
private PageResult 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());
@@ -340,13 +380,16 @@ public class ApprovalInstanceService {
private List toItems(List rows) {
Map> nodeCache = new HashMap<>();
+ Map flowNameCache = new HashMap<>();
List list = new ArrayList<>();
for (ApprovalItemRow row : rows) {
ApprovalItemVO item = new ApprovalItemVO();
item.setId(row.getId());
item.setBizNo(row.getBizNo());
item.setDocType(row.getDocType());
- item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
+ item.setDocTypeName(flowNameCache.computeIfAbsent(row.getDefinitionId(),
+ id -> flowService.flowName(id, row.getFlowCode())));
+ item.setFlowCode(row.getFlowCode());
item.setStatus(row.getStatus());
item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
item.setTitle(row.getTitle());
@@ -362,142 +405,12 @@ public class ApprovalInstanceService {
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.setDocTypeName(flowService.flowName(instance.getDefinitionId(), instance.getFlowCode()));
vo.setFlowCode(instance.getFlowCode());
vo.setStatus(instance.getStatus());
vo.setStatusName(ApprovalConstants.statusName(instance.getStatus()));
@@ -545,6 +458,30 @@ public class ApprovalInstanceService {
return result;
}
+ private List fieldChanges(ApprovalInstance instance) {
+ List changes = fieldChangeMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalFieldChange::getInstanceId, instance.getId())
+ .orderByAsc(ApprovalFieldChange::getCreateTime)
+ .orderByAsc(ApprovalFieldChange::getId));
+ List result = new ArrayList<>(changes.size());
+ for (ApprovalFieldChange change : changes) {
+ ApprovalFieldChangeVO vo = new ApprovalFieldChangeVO();
+ vo.setId(change.getId());
+ vo.setNodeKey(change.getNodeKey());
+ vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), change.getNodeKey()));
+ vo.setFieldKey(change.getFieldKey());
+ vo.setFieldLabel(change.getFieldLabel());
+ vo.setOldValue(change.getOldValue());
+ vo.setNewValue(change.getNewValue());
+ vo.setOperatorName(change.getOperatorName());
+ vo.setRound(change.getRound());
+ vo.setCreateTime(change.getCreateTime());
+ result.add(vo);
+ }
+ return result;
+ }
+
private List confirms(ApprovalInstance instance) {
List tasks = taskMapper.selectList(new LambdaQueryWrapper()
.eq(ApprovalTask::getInstanceId, instance.getId())
@@ -556,6 +493,8 @@ public class ApprovalInstanceService {
ApprovalConfirmVO vo = new ApprovalConfirmVO();
vo.setUserId(task.getAssigneeId());
vo.setUserName(task.getAssigneeName());
+ vo.setGroupKey(task.getGroupKey());
+ vo.setGroupName(task.getGroupName());
vo.setStatus(task.getStatus());
vo.setStatusName(taskStatusName(task.getStatus()));
vo.setHandleTime(task.getHandleTime());
@@ -582,6 +521,8 @@ public class ApprovalInstanceService {
vo.setId(task.getId());
vo.setNodeKey(task.getNodeKey());
vo.setNodeType(task.getNodeType());
+ vo.setGroupKey(task.getGroupKey());
+ vo.setGroupName(task.getGroupName());
vo.setStatus(task.getStatus());
return vo;
}
@@ -601,6 +542,8 @@ public class ApprovalInstanceService {
vo.setNodeKey(task.getNodeKey());
vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), task.getNodeKey()));
vo.setNodeType(task.getNodeType());
+ vo.setGroupKey(task.getGroupKey());
+ vo.setGroupName(task.getGroupName());
vo.setAssigneeId(task.getAssigneeId());
vo.setAssigneeName(task.getAssigneeName());
result.add(vo);
@@ -608,7 +551,8 @@ public class ApprovalInstanceService {
return result;
}
- private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask) {
+ private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask,
+ ApprovalDocumentType type) {
ApprovalPermissionsVO permissions = new ApprovalPermissionsVO();
boolean reporter = instance.getReporterId().equals(user.getUserId());
boolean editable = reporter && ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus());
@@ -625,12 +569,17 @@ public class ApprovalInstanceService {
&& 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);
+ boolean countersignNode = ApprovalConstants.NODE_COUNTERSIGN.equals(myTask.getNodeType());
+ boolean rejectableNode = approveNode || countersignNode
+ || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
+ permissions.setCanApprove(approveNode || countersignNode);
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()));
+ permissions.setWritableFields(writableFields(instance, type, myTask));
+ } else {
+ permissions.setWritableFields(new ArrayList<>());
}
return permissions;
}
@@ -644,129 +593,6 @@ public class ApprovalInstanceService {
.anyMatch(authority::equals);
}
- private ApprovalCompletionForm completionForm(Long instanceId) {
- List forms = completionFormMapper.selectList(
- new LambdaQueryWrapper()
- .eq(ApprovalCompletionForm::getInstanceId, instanceId)
- .last("LIMIT 1"));
- return forms.isEmpty() ? null : forms.get(0);
- }
-
- private ApprovalCoordinationForm coordinationForm(Long instanceId) {
- List forms = coordinationFormMapper.selectList(
- new LambdaQueryWrapper()
- .eq(ApprovalCoordinationForm::getInstanceId, instanceId)
- .last("LIMIT 1"));
- return forms.isEmpty() ? null : forms.get(0);
- }
-
- private void validateCompletion(ApprovalCompletionParam param) {
- if (param == null) {
- throw new BusinessException("参数不能为空");
- }
- String name = param.getName() == null ? "" : param.getName().trim();
- if (name.isEmpty()) {
- throw new BusinessException("名称不能为空");
- }
- if (name.length() > 100) {
- throw new BusinessException("名称不能超过100字");
- }
- String implementOrg = param.getImplementOrg() == null ? "" : param.getImplementOrg().trim();
- if (implementOrg.isEmpty()) {
- throw new BusinessException("实施单位不能为空");
- }
- if (implementOrg.length() > 100) {
- throw new BusinessException("实施单位不能超过100字");
- }
- BigDecimal fee = param.getFee();
- if (fee == null) {
- throw new BusinessException("费用不能为空");
- }
- if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 || fee.compareTo(MAX_FEE) > 0) {
- throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
- }
- String serviceContent = param.getServiceContent() == null ? "" : param.getServiceContent().trim();
- if (serviceContent.isEmpty()) {
- throw new BusinessException("服务内容不能为空");
- }
- if (serviceContent.length() > 2000) {
- throw new BusinessException("服务内容不能超过2000字");
- }
- }
-
- private 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> cache, Long definitionId, String nodeKey) {
- if (definitionId == null || nodeKey == null) {
- return null;
- }
- List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
- return nodes.stream()
- .filter(node -> nodeKey.equals(node.getKey()))
- .map(ApprovalNode::getName)
- .findFirst()
- .orElse(null);
- }
-
private String taskStatusName(String status) {
if (ApprovalConstants.TASK_PENDING.equals(status)) {
return "待确认";
@@ -780,7 +606,38 @@ public class ApprovalInstanceService {
return status;
}
+ private String submitBlockedMessage(String status) {
+ if (ApprovalConstants.STATUS_PENDING.equals(status)) {
+ return "单据正在审批中,不能重复提交";
+ }
+ if (ApprovalConstants.STATUS_ARCHIVED.equals(status)) {
+ return "单据已归档,不能修改";
+ }
+ return "当前状态不可提交";
+ }
+
+ private String nodeName(Map> cache, Long definitionId, String nodeKey) {
+ if (definitionId == null || nodeKey == null) {
+ return null;
+ }
+ List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
+ return nodes.stream()
+ .filter(node -> nodeKey.equals(node.getKey()))
+ .map(ApprovalNode::getName)
+ .findFirst()
+ .orElse(null);
+ }
+
private T inTransaction(Supplier action) {
return new TransactionTemplate(transactionManager).execute(status -> action.get());
}
+
+ /** 协调单受理部门快照载体。 */
+ private static final class ApprovalCoordinationHolder {
+ private final Integer acceptOrgId;
+
+ private ApprovalCoordinationHolder(Integer acceptOrgId) {
+ this.acceptOrgId = acceptOrgId;
+ }
+ }
}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
index 0d97c9f..b7ad5bc 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
@@ -4,8 +4,14 @@ 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.document.ApprovalDocumentParam;
+import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
+import com.gxwebsoft.approval.document.ApprovalDocumentType;
+import com.gxwebsoft.approval.document.ApprovalFieldUpdate;
+import com.gxwebsoft.approval.entity.ApprovalFieldChange;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.entity.ApprovalTask;
+import com.gxwebsoft.approval.mapper.ApprovalFieldChangeMapper;
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
@@ -13,7 +19,6 @@ 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;
@@ -26,25 +31,19 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.function.Supplier;
-import java.util.stream.Collectors;
/**
- * 审批任务动作:通过、驳回、撤回与节点推进。
+ * 审批任务动作:通过、会签通过、回填、驳回、撤回与节点推进。
*
- * 跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接),
+ *
跨库约束:审批人解析必须走 {@link ApprovalApproverResolver}(core 专用连接),
* 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用
* WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。
*/
@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
@@ -52,18 +51,28 @@ public class ApprovalTaskService {
@Resource
private ApprovalTaskMapper taskMapper;
@Resource
+ private ApprovalFieldChangeMapper fieldChangeMapper;
+ @Resource
private ApprovalEngineService engineService;
@Resource
private ApprovalDirectoryService directoryService;
@Resource
+ private ApprovalApproverResolver approverResolver;
+ @Resource
+ private ApprovalDocumentRegistry documentRegistry;
+ @Resource
private PlatformTransactionManager transactionManager;
/**
- * 通过:APPROVE 节点或签,处理后取消同节点同轮其余待办并推进下一节点。
+ * 通过:APPROVE 节点或签、COUNTERSIGN 节点组内或签。
+ *
+ * 会签节点一人同属多组时,一次通过把他该节点本轮全部待办置完成;
+ * 只取消同组剩余待办,全部组完成后才推进(ADR 0003)。
*/
- public Result approve(Long taskId, String opinion, User user) {
+ public Result approve(Long taskId, String opinion, Map fields, User user) {
ApprovalTask task = requireTask(taskId, user);
- if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())) {
+ boolean countersign = ApprovalConstants.NODE_COUNTERSIGN.equals(task.getNodeType());
+ if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType()) && !countersign) {
throw new BusinessException("当前任务不支持通过");
}
String normalized = normalizeOpinion(opinion);
@@ -71,14 +80,52 @@ public class ApprovalTaskService {
if (instance == null) {
throw new BusinessException("单据不存在");
}
+ ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
+ // 回填字段在事务外校验(可能涉及唯一性查询与业务表读取)
+ List changes = writeFields(instance, documentType, task, fields, true, user);
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
- List approvers =
- next == null ? Collections.emptyList() : resolveApprovers(instance, next);
+ ApprovalApproverResolver.Resolution resolution = next == null
+ ? ApprovalApproverResolver.Resolution.resolved(Collections.emptyList())
+ : approverResolver.resolve(instance, next);
- String warning = inTransaction(() -> doApprove(taskId, task, normalized, user, next, approvers));
+ String warning = inTransaction(() -> countersign
+ ? doCountersignApprove(taskId, task, normalized, user, next, resolution, changes)
+ : doApprove(taskId, task, normalized, user, next, resolution, changes));
return new Result(instance.getId(), warning);
}
+ /**
+ * 回填:只写字段不推进,允许必填缺项;仅任务处理人本人、任务仍待办、实例仍审批中。
+ */
+ public Result backfill(Long taskId, Map fields, User user) {
+ ApprovalTask task = requireTask(taskId, user);
+ if (fields == null || fields.isEmpty()) {
+ 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("单据状态已变化,请刷新后重试");
+ }
+ ensureTaskCurrent(task, instance);
+ requireWritableNode(instance, task);
+ ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
+ List changes = writeFields(instance, documentType, task, fields, false, user);
+ if (changes.isEmpty()) {
+ return new Result(instance.getId(), null);
+ }
+ inTransaction(() -> {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureTaskHandleable(task, locked);
+ persistChanges(locked, task, changes, user);
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_BACKFILL, backfillSummary(changes));
+ });
+ return new Result(instance.getId(), null);
+ }
+
/**
* 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成,
* 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。
@@ -94,10 +141,11 @@ public class ApprovalTaskService {
}
ensureNotArchived(instance);
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
- List approvers =
- next == null ? Collections.emptyList() : resolveApprovers(instance, next);
+ ApprovalApproverResolver.Resolution resolution = next == null
+ ? ApprovalApproverResolver.Resolution.resolved(Collections.emptyList())
+ : approverResolver.resolve(instance, next);
- String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers));
+ String warning = inTransaction(() -> doConfirm(taskId, task, user, next, resolution));
return new Result(instance.getId(), warning);
}
@@ -120,16 +168,25 @@ public class ApprovalTaskService {
/**
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
+ *
+ * 会签节点任一组驳回即整节点结束、round+1,本轮已完成的组任务保持 DONE 留痕。
+ * 单据侧需要清空的字段由 {@link ApprovalDocumentType#onRoundReset} 处理(ADR 0014)。
*/
public Result reject(Long taskId, String opinion, User user) {
ApprovalTask task = requireTask(taskId, user);
boolean rejectable = ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())
+ || ApprovalConstants.NODE_COUNTERSIGN.equals(task.getNodeType())
|| ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType());
if (!rejectable) {
throw new BusinessException("当前任务不支持驳回");
}
String required = requireOpinion(opinion);
- inTransaction(() -> doReject(taskId, task, required, user));
+ ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
+ inTransaction(() -> doReject(taskId, task, required, user, documentType));
return new Result(task.getInstanceId(), null);
}
@@ -151,14 +208,13 @@ public class ApprovalTaskService {
throw new BusinessException("当前状态不可撤回");
}
ensureNoHandledTask(instance);
- inTransaction(() -> doWithdraw(instanceId, user));
+ ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
+ inTransaction(() -> doWithdraw(instanceId, user, documentType));
return new Result(instanceId, null);
}
/**
* 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。
- *
- * 改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。
*/
public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) {
if (param == null || param.getTargetUserId() == null) {
@@ -166,20 +222,18 @@ public class ApprovalTaskService {
}
String reason = requireAssignReason(param.getReason());
ApprovalTask task = requireAssignableTask(taskId);
- // 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户)
List targets =
directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId()));
if (targets.isEmpty()) {
throw new BusinessException("改派对象不存在或已停用");
}
ApprovalUserVO target = targets.get(0);
- String targetName = displayName(target);
+ String targetName = approverResolver.displayName(target);
return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
}
/**
- * 派单候选池:仅派单节点、本人待办的任务可查询;优先受理部门启用成员,
- * 部门无成员时回退为租户非普通消费用户。
+ * 派单候选池:仅派单节点、本人待办的任务可查询。
*/
public List candidates(Long taskId, User user) {
ApprovalTask task = requireDispatchTask(taskId, user);
@@ -192,8 +246,7 @@ public class ApprovalTaskService {
}
/**
- * 派单:选定候选池中的 1 人即完成派单节点,激活存档节点并为报送人与被派单人生成两条
- * 存档任务。目标校验与审批人解析在事务外完成(core 库),写 master 的部分在事务内。
+ * 派单:选定候选池中的 1 人即完成派单节点,激活存档节点并为存档人生成任务。
*/
public Result dispatch(Long taskId, ApprovalTaskDispatchParam param, User user) {
if (param == null || param.getTargetUserId() == null) {
@@ -206,7 +259,7 @@ public class ApprovalTaskService {
}
ensureNotArchived(instance);
ApprovalUserVO target = requireDispatchTarget(instance, param.getTargetUserId());
- String targetName = displayName(target);
+ String targetName = approverResolver.displayName(target);
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
List approvers = dispatchNextApprovers(instance, next, target);
String warning = inTransaction(() ->
@@ -219,25 +272,66 @@ public class ApprovalTaskService {
}
private String doApprove(Long taskId, ApprovalTask task, String opinion, User user,
- ApprovalNode next, List approvers) {
- // 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁
+ ApprovalNode next, ApprovalApproverResolver.Resolution resolution,
+ List changes) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureTaskHandleable(task, locked);
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
throw new BusinessException("任务已处理");
}
+ persistChanges(locked, task, changes, user);
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_APPROVE, opinion);
+ return advance(locked, next, resolution);
+ }
+
+ /**
+ * 会签通过:完成本人在该节点本轮的全部待办 → 只取消同组剩余 → 全部组完成才推进。
+ */
+ private String doCountersignApprove(Long taskId, ApprovalTask task, String opinion, User user,
+ ApprovalNode next, ApprovalApproverResolver.Resolution resolution,
+ List changes) {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureTaskHandleable(task, locked);
+ if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ persistChanges(locked, task, changes, user);
+ // 一人同属多组:一次点击完成他在本节点本轮的全部待办
+ engineService.completeAssigneeTasks(locked.getId(), locked.getRound(), task.getNodeKey(),
+ user.getUserId(), taskId, opinion);
+ if (task.getGroupKey() != null) {
+ engineService.cancelPendingTasksInGroup(locked.getId(), locked.getRound(), task.getNodeKey(),
+ task.getGroupKey(), null);
+ }
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_APPROVE, opinion);
+ List pendingGroups = engineService.pendingGroupKeys(locked.getId(), locked.getRound(),
+ task.getNodeKey());
+ if (!pendingGroups.isEmpty()) {
+ // 还有组未签:节点停在当前节点,不推进
+ return null;
+ }
+ return advance(locked, next, resolution);
+ }
+
+ /**
+ * 推进到下一节点;仓库内所有调用点共用同一套缺配提示。
+ */
+ private String advance(ApprovalInstance locked, ApprovalNode next,
+ ApprovalApproverResolver.Resolution resolution) {
if (next == null) {
return null;
}
- int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
+ // 缺配也要把当前节点推进到该节点(一期口径:停在缺配节点等管理员补人,不留在上一节点)
+ int created = engineService.moveToNode(locked, next, locked.getRound(),
+ resolution.isResolved() ? resolution.getApprovers() : java.util.Collections.emptyList());
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
}
private String doConfirm(Long taskId, ApprovalTask task, User user,
- ApprovalNode next, List approvers) {
+ ApprovalNode next, ApprovalApproverResolver.Resolution resolution) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureNotArchived(locked);
ensureTaskHandleable(task, locked);
@@ -246,6 +340,7 @@ public class ApprovalTaskService {
}
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_CONFIRM, null);
+ // 存档进度按「节点 + 轮次」聚合,同轮两个 CONFIRM 节点不会串进度(ADR 0003 修正)
Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper()
.eq(ApprovalTask::getInstanceId, locked.getId())
.eq(ApprovalTask::getRound, locked.getRound())
@@ -257,7 +352,8 @@ public class ApprovalTaskService {
if (next == null) {
return "当前节点缺少审批人,请联系管理员补配置";
}
- int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
+ int created = engineService.moveToNode(locked, next, locked.getRound(),
+ resolution.isResolved() ? resolution.getApprovers() : java.util.Collections.emptyList());
if (created == 0) {
return "当前节点缺少审批人,请联系管理员补配置";
}
@@ -297,7 +393,8 @@ public class ApprovalTaskService {
}
}
- private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
+ private void doReject(Long taskId, ApprovalTask task, String opinion, User user,
+ ApprovalDocumentType documentType) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureTaskHandleable(task, locked);
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
@@ -307,6 +404,9 @@ public class ApprovalTaskService {
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_REJECT, opinion);
engineService.cancelPendingTasks(locked.getId(), round, null);
+ if (documentType != null) {
+ documentType.onRoundReset(locked, user);
+ }
int rows = instanceMapper.update(null, new UpdateWrapper()
.eq("id", locked.getId())
.eq("status", ApprovalConstants.STATUS_PENDING)
@@ -317,7 +417,7 @@ public class ApprovalTaskService {
}
}
- private void doWithdraw(Long instanceId, User user) {
+ private void doWithdraw(Long instanceId, User user, ApprovalDocumentType documentType) {
ApprovalInstance locked = engineService.lockInstance(instanceId);
if (!ApprovalConstants.STATUS_PENDING.equals(locked.getStatus())) {
throw new BusinessException("当前状态不可撤回");
@@ -327,6 +427,9 @@ public class ApprovalTaskService {
engineService.addRecord(locked, null, locked.getCurrentNodeKey(), user,
ApprovalConstants.ACTION_WITHDRAW, null);
engineService.cancelPendingTasks(locked.getId(), round, null);
+ if (documentType != null) {
+ documentType.onRoundReset(locked, user);
+ }
int rows = instanceMapper.update(null, new UpdateWrapper()
.eq("id", instanceId)
.eq("status", ApprovalConstants.STATUS_PENDING)
@@ -337,9 +440,6 @@ public class ApprovalTaskService {
}
}
- /**
- * 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。
- */
private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId,
String targetName, String reason, User user) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
@@ -365,9 +465,6 @@ public class ApprovalTaskService {
return vo;
}
- /**
- * 事务内派单:锁实例后条件更新任务、快照派单对象、写派单记录并激活存档节点。
- */
private String doDispatch(Long taskId, ApprovalTask task, ApprovalUserVO target, String targetName,
User user, ApprovalNode next, List approvers) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
@@ -397,8 +494,67 @@ public class ApprovalTaskService {
}
/**
- * 派单前校验:任务存在、属于本人、是派单节点且仍待处理。
+ * 回填字段:委托单据实现校验并落库,返回改值记录(事务外执行,可能访问 core 库)。
*/
+ private List writeFields(ApprovalInstance instance, ApprovalDocumentType type,
+ ApprovalTask task, Map fields,
+ boolean requireComplete, User user) {
+ if (type == null || fields == null || fields.isEmpty()) {
+ return new ArrayList<>();
+ }
+ requireWritableNode(instance, task);
+ return type.applyWritableFields(instance, fields, user, requireComplete);
+ }
+
+ /**
+ * 节点必须声明了可写字段,否则拒绝回填。
+ */
+ private void requireWritableNode(ApprovalInstance instance, ApprovalTask task) {
+ ApprovalNode node = engineService.findNode(instance, task.getNodeKey());
+ if (node == null || node.getWritableFields() == null || node.getWritableFields().isEmpty()) {
+ throw new BusinessException("当前节点不支持回填");
+ }
+ }
+
+ /**
+ * 落改值历史;每次回填/通过按字段写一行。
+ */
+ private void persistChanges(ApprovalInstance instance, ApprovalTask task,
+ List changes, User user) {
+ if (changes == null || changes.isEmpty()) {
+ return;
+ }
+ for (ApprovalFieldUpdate change : changes) {
+ ApprovalFieldChange entity = new ApprovalFieldChange();
+ entity.setInstanceId(instance.getId());
+ entity.setTaskId(task.getId());
+ entity.setNodeKey(task.getNodeKey());
+ entity.setFieldKey(change.getFieldKey());
+ entity.setFieldLabel(change.getFieldLabel());
+ entity.setOldValue(change.getOldValue());
+ entity.setNewValue(change.getNewValue());
+ entity.setOperatorId(user.getUserId());
+ entity.setOperatorName(displayName(user));
+ entity.setRound(instance.getRound());
+ entity.setCreateTime(LocalDateTime.now());
+ fieldChangeMapper.insert(entity);
+ }
+ }
+
+ private String backfillSummary(List changes) {
+ List parts = new ArrayList<>();
+ for (ApprovalFieldUpdate change : changes) {
+ parts.add(change.getFieldLabel() + ":" + text(change.getOldValue()) + " → "
+ + text(change.getNewValue()));
+ }
+ String summary = String.join(";", parts);
+ return summary.length() > MAX_OPINION_LENGTH ? summary.substring(0, MAX_OPINION_LENGTH) : summary;
+ }
+
+ private String text(String value) {
+ return value == null || value.isEmpty() ? "空" : value;
+ }
+
private ApprovalTask requireDispatchTask(Long taskId, User user) {
ApprovalTask task = requireTask(taskId, user);
if (!ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType())) {
@@ -407,9 +563,6 @@ public class ApprovalTaskService {
return task;
}
- /**
- * 派单目标必须来自候选池(受理部门启用成员,或部门无成员时的租户回退候选)。
- */
private ApprovalUserVO requireDispatchTarget(ApprovalInstance instance, Integer targetUserId) {
for (ApprovalUserVO candidate : directoryService.listDispatchCandidates(instance.getAcceptOrgId())) {
if (targetUserId.equals(candidate.getUserId())) {
@@ -419,16 +572,13 @@ public class ApprovalTaskService {
throw new BusinessException("派单对象不在候选范围内");
}
- /**
- * 存档节点审批人:报送人 + 本次派单对象(实例快照尚未写入,需显式解析)。
- */
private List 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);
+ if (!ApprovalConstants.RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) {
+ return approverResolver.resolve(instance, next).getApprovers();
}
List userIds = new ArrayList<>();
if (instance.getReporterId() != null) {
@@ -437,12 +587,11 @@ public class ApprovalTaskService {
if (!userIds.contains(target.getUserId())) {
userIds.add(target.getUserId());
}
- return userApprovers(userIds);
+ return directoryService.listActiveUsersByIds(userIds).stream()
+ .map(user -> new ApprovalApprover(user.getUserId(), approverResolver.displayName(user)))
+ .collect(java.util.stream.Collectors.toList());
}
- /**
- * 派单任务条件更新:完成并快照派单对象。
- */
private int updateDispatchTask(Long taskId, Integer targetUserId, String targetName) {
return taskMapper.update(null, new UpdateWrapper()
.eq("id", taskId)
@@ -453,9 +602,6 @@ public class ApprovalTaskService {
.set("dispatch_target_name", targetName));
}
- /**
- * 改派前的基本校验:任务存在、待处理且单据仍在审批中。
- */
private ApprovalTask requireAssignableTask(Long taskId) {
if (taskId == null) {
throw new BusinessException("任务不存在");
@@ -477,9 +623,6 @@ public class ApprovalTaskService {
return task;
}
- /**
- * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
- */
private void ensureTaskHandleable(ApprovalTask task, ApprovalInstance instance) {
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
throw new BusinessException("单据状态已变化,请刷新后重试");
@@ -536,85 +679,6 @@ public class ApprovalTaskService {
return task;
}
- /**
- * 事务外按节点解析器解析审批人;缺配时返回空列表,由调用方提示并停留当前节点。
- */
- private List 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 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 confirmCandidateIds(ApprovalInstance instance) {
- List 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 roleApprovers(String roleCode) {
- if (StrUtil.isBlank(roleCode)) {
- return Collections.emptyList();
- }
- return toApprovers(directoryService.listRoleMembers(roleCode));
- }
-
- private List userApprovers(List userIds) {
- return toApprovers(directoryService.listActiveUsersByIds(userIds));
- }
-
- private List organizationApprovers(Integer organizationId) {
- return toApprovers(directoryService.listOrganizationMembers(organizationId));
- }
-
- private List toApprovers(List 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();
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java
index 40e7537..f58cb9c 100644
--- a/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalApprover.java
@@ -1,16 +1,42 @@
package com.gxwebsoft.approval.support;
-import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 节点激活时快照的审批人。
+ *
+ * 会签节点额外带上组标识、组显示名与展示顺序;非会签节点这三项为空。
*/
@Data
-@AllArgsConstructor
public class ApprovalApprover {
private Integer userId;
private String userName;
+
+ /** 会签组标识;非会签节点为空。 */
+ private String groupKey;
+
+ /** 会签组显示名;非会签节点为空。 */
+ private String groupName;
+
+ /** 会签组展示顺序,从 1 开始;非会签节点为空。 */
+ private Integer groupSeq;
+
+ public ApprovalApprover() {
+ }
+
+ public ApprovalApprover(Integer userId, String userName) {
+ this.userId = userId;
+ this.userName = userName;
+ }
+
+ public ApprovalApprover(Integer userId, String userName, String groupKey, String groupName,
+ Integer groupSeq) {
+ this.userId = userId;
+ this.userName = userName;
+ this.groupKey = groupKey;
+ this.groupName = groupName;
+ this.groupSeq = groupSeq;
+ }
}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
index 0ad1409..4a4c29f 100644
--- a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
@@ -3,11 +3,13 @@ package com.gxwebsoft.approval.support;
import lombok.Data;
import java.io.Serializable;
-import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
- * 导出查询数据库行:两类单据合并列,未命中的表单字段为空。
+ * 导出查询数据库行:跨单据总表一行单据一行。
+ *
+ * 标题与检索文本取 approval_instance 的落库列,单据差异不再靠 LEFT JOIN 各业务表
+ * 拼列(ADR 0007);明细行数与合计金额由导出服务按单据实现补算。
*/
@Data
public class ApprovalExportRow implements Serializable {
@@ -16,7 +18,9 @@ public class ApprovalExportRow implements Serializable {
private Long id;
private String bizNo;
private String docType;
+ private String flowCode;
private String status;
+ private String title;
private String reporterName;
private String reportOrgName;
private String acceptOrgName;
@@ -27,19 +31,14 @@ public class ApprovalExportRow implements Serializable {
private LocalDateTime archiveTime;
private String archivistName;
- /** 业务完成确认单:名称。 */
- private String name;
- /** 业务完成确认单:实施单位。 */
+ /** 业务完成确认单:实施单位(仅一期单据保留的固定列)。 */
private String implementOrg;
/** 业务完成确认单:费用(元)。 */
- private BigDecimal fee;
+ private java.math.BigDecimal fee;
/** 业务完成确认单:服务内容。 */
private String serviceContent;
-
- /** 内部协调单:标题。 */
- private String title;
- /** 内部协调单:协调内容。 */
+ /** 工作协调单:协调内容。 */
private String content;
- /** 内部协调单:备注。 */
+ /** 工作协调单:备注。 */
private String remark;
}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalFieldSpec.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalFieldSpec.java
new file mode 100644
index 0000000..cc6e627
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalFieldSpec.java
@@ -0,0 +1,60 @@
+package com.gxwebsoft.approval.support;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 可写字段元数据(ADR 0006):长度、格式、精度与唯一性由后端注册表承担,
+ * 流程 JSON 里只有「哪些字段可写、是否必填」的策略。
+ */
+@Data
+public class ApprovalFieldSpec implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 字段键(驼峰,与业务表实体一致)。 */
+ private String key;
+
+ /** 字段显示名。 */
+ private String label;
+
+ /** 最大长度;null 表示不限制。 */
+ private Integer maxLength;
+
+ /** 是否在同类单据内唯一(同租户、同 flow_code、实例未作废)。 */
+ private boolean unique;
+
+ /** 字段类型:TEXT / DECIMAL / DATE / INT。 */
+ private String type;
+
+ public static ApprovalFieldSpec text(String key, String label, int maxLength) {
+ ApprovalFieldSpec spec = new ApprovalFieldSpec();
+ spec.setKey(key);
+ spec.setLabel(label);
+ spec.setType(ApprovalLineSpec.TYPE_TEXT);
+ spec.setMaxLength(maxLength);
+ return spec;
+ }
+
+ public static ApprovalFieldSpec uniqueText(String key, String label, int maxLength) {
+ ApprovalFieldSpec spec = text(key, label, maxLength);
+ spec.setUnique(true);
+ return spec;
+ }
+
+ public static ApprovalFieldSpec decimal(String key, String label) {
+ ApprovalFieldSpec spec = new ApprovalFieldSpec();
+ spec.setKey(key);
+ spec.setLabel(label);
+ spec.setType(ApprovalLineSpec.TYPE_DECIMAL);
+ return spec;
+ }
+
+ public static ApprovalFieldSpec date(String key, String label) {
+ ApprovalFieldSpec spec = new ApprovalFieldSpec();
+ spec.setKey(key);
+ spec.setLabel(label);
+ spec.setType(ApprovalLineSpec.TYPE_DATE);
+ return spec;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java
index c5a8bc0..9762965 100644
--- a/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalItemRow.java
@@ -25,4 +25,5 @@ public class ApprovalItemRow implements Serializable {
private LocalDateTime archiveTime;
private String currentNodeKey;
private Long definitionId;
+ private String flowCode;
}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalLineSpec.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalLineSpec.java
new file mode 100644
index 0000000..f6241ed
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalLineSpec.java
@@ -0,0 +1,130 @@
+package com.gxwebsoft.approval.support;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 明细列元数据(ADR 0004):由后端注册表按 flow_code 提供,驱动服务端校验与前端列渲染。
+ *
+ * 列结构随表结构走,不放进流程定义 JSON——改列就是改表。
+ */
+@Data
+public class ApprovalLineSpec implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 列键(驼峰,与返回的明细行字段一致)。 */
+ private String key;
+
+ /** 列显示名。 */
+ private String label;
+
+ /** 列类型:TEXT / DECIMAL / INT / DATE / BOOL / ENUM。 */
+ private String type;
+
+ private boolean required;
+
+ /** 数值列小数位。 */
+ private Integer scale;
+
+ /** 文本列最大长度。 */
+ private Integer maxLength;
+
+ /** 是否参与金额(服务端按数量 × 单价重算)。 */
+ private boolean money;
+
+ /** ENUM 列的可选值。 */
+ private List