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 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 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 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> lines(ApprovalInstance instance) { + return Collections.emptyList(); + } + + /** 明细行数(无明细返回 0),供跨单据总表导出与详情汇总。 */ + default int lineCount(ApprovalInstance instance) { + return lines(instance).size(); + } + + /** 明细合计金额(无金额列返回 null),查询与导出时求和,不落库。 */ + default java.math.BigDecimal lineAmountTotal(ApprovalInstance instance) { + return null; + } + + /** 读取一个业务字段的当前值(回填展示用)。 */ + default String readField(ApprovalInstance instance, String field) { + return null; + } + + /** 列表显示标题。 */ + String title(ApprovalInstance instance); + + /** 关键词检索文本(含车牌号、收据号等业务检索主语)。 */ + String searchText(ApprovalInstance instance); + + /** + * 回填节点声明的可写字段。 + * + * @param requireComplete true 表示「通过」动作,必填必须齐全;false 表示「回填」,允许缺项 + * @return 本次产生的改值记录 + */ + List applyWritableFields(ApprovalInstance instance, Map fields, + User user, boolean requireComplete); + + /** 撤回/驳回时清空本单需清空的字段(业务表不随引擎回滚)。 */ + default void onRoundReset(ApprovalInstance instance, User user) { + } +} diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalFieldUpdate.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalFieldUpdate.java new file mode 100644 index 0000000..51a043f --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalFieldUpdate.java @@ -0,0 +1,30 @@ +package com.gxwebsoft.approval.document; + +import lombok.Data; + +/** + * 单据实现产生的一条改值记录;引擎负责补齐实例、任务、节点、操作人与轮次后落库。 + * + *

实现负责判断旧值/新值;与实体 `ApprovalFieldChange` 区分开,避免同名混淆。

+ */ +@Data +public class ApprovalFieldUpdate { + + private String fieldKey; + + private String fieldLabel; + + private String oldValue; + + private String newValue; + + public ApprovalFieldUpdate() { + } + + public ApprovalFieldUpdate(String fieldKey, String fieldLabel, String oldValue, String newValue) { + this.fieldKey = fieldKey; + this.fieldLabel = fieldLabel; + this.oldValue = oldValue; + this.newValue = newValue; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/document/ApprovalLineSupport.java b/src/main/java/com/gxwebsoft/approval/document/ApprovalLineSupport.java new file mode 100644 index 0000000..eb2fbbe --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/document/ApprovalLineSupport.java @@ -0,0 +1,284 @@ +package com.gxwebsoft.approval.document; + +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.support.ApprovalLineSpec; +import com.gxwebsoft.common.core.exception.BusinessException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 明细行校验与金额重算(ADR 0004),供各单据实现类在 persist 前调用。 + * + *

服务端一律重算 `amount = round(quantity × unitPrice, 2)`(HALF_UP);客户端值与 + * 重算值不一致即拒绝并指出行号,不静默改写。行号按提交顺序重排为 1..N。

+ */ +public final class ApprovalLineSupport { + + /** 金额列参与重算时使用的数量列与单价列键。 */ + public static final String KEY_QUANTITY = "quantity"; + public static final String KEY_UNIT_PRICE = "unitPrice"; + public static final String KEY_AMOUNT = "amount"; + public static final String KEY_LINE_NO = "lineNo"; + + private ApprovalLineSupport() { + } + + /** + * 校验并归一化明细行。 + * + * @param rows 客户端提交的行(key → 原始值) + * @param columns 列元数据 + * @return 行号已重排、金额已重算的规范行 + */ + public static List> normalize(List> rows, + List columns) { + if (columns == null || columns.isEmpty()) { + throw new BusinessException("该单据没有明细列"); + } + if (rows == null || rows.isEmpty()) { + throw new BusinessException("请至少填写一行明细"); + } + if (rows.size() > ApprovalConstants.MAX_LINE_COUNT) { + throw new BusinessException("明细不能超过" + ApprovalConstants.MAX_LINE_COUNT + "行"); + } + List> normalized = new ArrayList<>(rows.size()); + boolean money = columns.stream().anyMatch(ApprovalLineSpec::isMoney); + for (int index = 0; index < rows.size(); index++) { + int lineNo = index + 1; + Map source = rows.get(index); + if (source == null) { + throw new BusinessException("第" + lineNo + "行明细为空"); + } + Map row = new LinkedHashMap<>(); + row.put(KEY_LINE_NO, lineNo); + for (ApprovalLineSpec column : columns) { + Object raw = firstNonNull(source, column.getKey(), snake(column.getKey())); + Object value = convert(column, raw, lineNo); + if (column.isRequired() && isBlank(value)) { + throw new BusinessException("第" + lineNo + "行" + column.getLabel() + "不能为空"); + } + row.put(column.getKey(), value); + } + if (money) { + recomputeAmount(row, lineNo); + } + normalized.add(row); + } + return normalized; + } + + /** + * 金额重算:客户端提交的金额必须与 quantity × unitPrice 一致,否则拒绝并指出行号。 + */ + private static void recomputeAmount(Map row, int lineNo) { + BigDecimal quantity = decimal(row.get(KEY_QUANTITY)); + BigDecimal unitPrice = decimal(row.get(KEY_UNIT_PRICE)); + if (quantity == null || unitPrice == null) { + throw new BusinessException("第" + lineNo + "行数量与单价不能为空"); + } + BigDecimal amount = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); + if (amount.compareTo(BigDecimal.ZERO) < 0 || amount.compareTo(ApprovalLineSpec.MAX_MONEY) > 0) { + throw new BusinessException("第" + lineNo + "行金额必须为0到99999999.99之间"); + } + Object clientAmount = row.get(KEY_AMOUNT); + if (!isBlank(clientAmount)) { + BigDecimal submitted = decimal(clientAmount); + if (submitted == null || submitted.compareTo(amount) != 0) { + throw new BusinessException("第" + lineNo + "行金额与数量×单价不一致,应为" + + amount.toPlainString()); + } + } + row.put(KEY_AMOUNT, amount); + } + + /** 合计金额:仅金额类单据有值。 */ + public static BigDecimal amountTotal(List> rows) { + if (rows == null || rows.isEmpty()) { + return null; + } + BigDecimal total = BigDecimal.ZERO; + boolean hasAmount = false; + for (Map row : rows) { + BigDecimal amount = decimal(row.get(KEY_AMOUNT)); + if (amount != null) { + hasAmount = true; + total = total.add(amount); + } + } + return hasAmount ? total.setScale(2, RoundingMode.HALF_UP) : null; + } + + /** + * 百分号金额区间校验:0~99999999.99 且最多两位小数。 + */ + public static BigDecimal money(String label, BigDecimal value, boolean required) { + if (value == null) { + if (required) { + throw new BusinessException(label + "不能为空"); + } + return null; + } + if (value.scale() > 2) { + throw new BusinessException(label + "最多保留两位小数"); + } + if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(ApprovalConstants.MAX_FEE) > 0) { + throw new BusinessException(label + "必须为0到99999999.99之间"); + } + return value; + } + + private static Object convert(ApprovalLineSpec column, Object raw, int lineNo) { + String type = column.getType() == null ? ApprovalLineSpec.TYPE_TEXT : column.getType(); + switch (type) { + case ApprovalLineSpec.TYPE_TEXT: + String text = raw == null ? null : String.valueOf(raw).trim(); + if (text != null && column.getMaxLength() != null && text.length() > column.getMaxLength()) { + throw new BusinessException("第" + lineNo + "行" + column.getLabel() + + "不能超过" + column.getMaxLength() + "字"); + } + return text == null || text.isEmpty() ? null : text; + case ApprovalLineSpec.TYPE_DECIMAL: + BigDecimal decimal = decimal(raw); + if (decimal != null) { + int scale = column.getScale() == null ? 2 : column.getScale(); + if (decimal.scale() > scale) { + throw new BusinessException("第" + lineNo + "行" + column.getLabel() + + "最多保留" + scale + "位小数"); + } + if (column.isMoney() || KEY_UNIT_PRICE.equals(column.getKey()) + || KEY_AMOUNT.equals(column.getKey())) { + if (decimal.compareTo(BigDecimal.ZERO) < 0 + || decimal.compareTo(ApprovalLineSpec.MAX_MONEY) > 0) { + throw new BusinessException("第" + lineNo + "行" + column.getLabel() + + "必须为0到99999999.99之间"); + } + } + } + return decimal; + case ApprovalLineSpec.TYPE_INT: + Integer integer = integer(raw); + return integer; + case ApprovalLineSpec.TYPE_DATE: + LocalDate date = date(raw); + return date; + case ApprovalLineSpec.TYPE_BOOL: + return bool(raw, lineNo, column.getLabel()); + case ApprovalLineSpec.TYPE_ENUM: + String code = raw == null ? null : String.valueOf(raw).trim(); + if (code != null && !code.isEmpty() && column.getOptions() != null + && column.getOptions().stream().noneMatch(option -> option.getValue().equals(code))) { + throw new BusinessException("第" + lineNo + "行" + column.getLabel() + "取值不正确"); + } + return code == null || code.isEmpty() ? null : code; + default: + return raw; + } + } + + private static Boolean bool(Object raw, int lineNo, String label) { + if (raw == null) { + return null; + } + if (raw instanceof Boolean) { + return (Boolean) raw; + } + String text = String.valueOf(raw).trim(); + if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text)) { + return Boolean.TRUE; + } + if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text)) { + return Boolean.FALSE; + } + throw new BusinessException("第" + lineNo + "行" + label + "取值不正确"); + } + + private static Integer integer(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof Number) { + return ((Number) raw).intValue(); + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return null; + } + try { + return Integer.valueOf(text); + } catch (NumberFormatException e) { + throw new BusinessException("行内数量必须为整数"); + } + } + + private static BigDecimal decimal(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof BigDecimal) { + return (BigDecimal) raw; + } + if (raw instanceof Number) { + return new BigDecimal(raw.toString()); + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return null; + } + try { + return new BigDecimal(text); + } catch (NumberFormatException e) { + throw new BusinessException("行内数值格式不正确"); + } + } + + private static LocalDate date(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof LocalDate) { + return (LocalDate) raw; + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDate.parse(text.length() > 10 ? text.substring(0, 10) : text); + } catch (DateTimeParseException e) { + throw new BusinessException("行内日期格式不正确"); + } + } + + private static Object firstNonNull(Map source, String... keys) { + for (String key : keys) { + if (source.containsKey(key) && source.get(key) != null) { + return source.get(key); + } + } + return null; + } + + /** 列 key 的蛇形兼容:前端偶尔传 line_no。 */ + private static String snake(String key) { + StringBuilder builder = new StringBuilder(); + for (char c : key.toCharArray()) { + if (Character.isUpperCase(c)) { + builder.append('_').append(Character.toLowerCase(c)); + } else { + builder.append(c); + } + } + return builder.toString(); + } + + private static boolean isBlank(Object value) { + return value == null || (value instanceof String && ((String) value).trim().isEmpty()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFieldChange.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFieldChange.java new file mode 100644 index 0000000..64b295c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFieldChange.java @@ -0,0 +1,51 @@ +package com.gxwebsoft.approval.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审批字段改值历史(只增不改,ADR 0006)。 + * + *

记录「谁在第几轮把哪个字段从什么值改成了什么值」;每次回填按字段写一行。

+ */ +@Data +@ApiModel(description = "审批字段改值历史") +@TableName("approval_field_change") +public class ApprovalFieldChange implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer tenantId; + + private Long instanceId; + + private Long taskId; + + private String nodeKey; + + private String fieldKey; + + @ApiModelProperty("字段显示名快照") + private String fieldLabel; + + private String oldValue; + + private String newValue; + + private Integer operatorId; + + private String operatorName; + + private Integer round; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java index ee9ff55..4054217 100644 --- a/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java @@ -39,6 +39,12 @@ public class ApprovalInstance implements Serializable { @ApiModelProperty("业务表主键") private Long bizId; + @ApiModelProperty("列表显示标题(单据实现计算并落库)") + private String title; + + @ApiModelProperty("关键词检索文本(单据实现计算并落库)") + private String searchText; + private String status; @ApiModelProperty("重提轮次") diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java index 81a34b8..5b5fb71 100644 --- a/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java +++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java @@ -43,6 +43,15 @@ public class ApprovalTask implements Serializable { private String opinion; + /** 会签组标识;非会签节点为空。 */ + private String groupKey; + + /** 会签组显示名(冗余存储,详情与导出不必解析节点 JSON)。 */ + private String groupName; + + /** 会签组展示顺序,从 1 开始;非会签节点为空。 */ + private Integer groupSeq; + private Integer dispatchTargetId; private String dispatchTargetName; diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFieldChangeMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFieldChangeMapper.java new file mode 100644 index 0000000..4785559 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFieldChangeMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.approval.entity.ApprovalFieldChange; + +/** + * 审批字段改值历史Mapper。 + */ +public interface ApprovalFieldChangeMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java index 3754eb8..092fe78 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java @@ -27,6 +27,11 @@ public interface ApprovalInstanceMapper extends BaseMapper { @Param("allVisible") Integer allVisible, @Param("assignVisible") Integer assignVisible); + /** + * 协调单受理部门快照(提交预检用)。 + */ + java.util.Map selectCoordinationAcceptOrg(@Param("instanceId") Long instanceId); + /** * 我发起的列表。 */ diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml index 985bafc..4fab8e7 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml @@ -7,6 +7,7 @@ SELECT i.id, i.biz_no, i.doc_type, + i.flow_code, i.status, i.reporter_name, i.report_org_name, @@ -17,11 +18,10 @@ i.approve_time, i.archive_time, i.archivist_name, - f.name, + i.title, f.implement_org, f.fee, f.service_content, - co.title, co.content, co.remark FROM approval_instance i @@ -40,6 +40,9 @@ AND i.doc_type = #{param.docType} + + AND i.flow_code = #{param.flowCode} + AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%') @@ -57,6 +60,8 @@ AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%') + OR i.title LIKE CONCAT('%', #{param.keywords}, '%') + OR i.search_text LIKE CONCAT('%', #{param.keywords}, '%') OR f.name LIKE CONCAT('%', #{param.keywords}, '%') OR co.title LIKE CONCAT('%', #{param.keywords}, '%') OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%')) diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml index 0e84228..7fca2ec 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -25,10 +25,18 @@ ) + + diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml index 6056a81..db01185 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml @@ -7,6 +7,7 @@ SELECT i.id, i.biz_no, i.doc_type, + i.flow_code, i.status, i.reporter_id, i.reporter_name, @@ -16,7 +17,7 @@ i.current_node_key, i.definition_id, i.submit_time, - COALESCE(f.name, co.title) AS title + i.title AS title FROM approval_instance i LEFT JOIN approval_completion_form f ON f.instance_id = i.id LEFT JOIN approval_coordination_form co ON co.instance_id = i.id @@ -33,6 +34,9 @@ AND i.doc_type = #{param.docType} + + AND i.flow_code = #{param.flowCode} + AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%') @@ -50,6 +54,8 @@ AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%') + OR i.title LIKE CONCAT('%', #{param.keywords}, '%') + OR i.search_text LIKE CONCAT('%', #{param.keywords}, '%') OR f.name LIKE CONCAT('%', #{param.keywords}, '%') OR co.title LIKE CONCAT('%', #{param.keywords}, '%') OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%')) diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml index 29b9dcc..3a631f0 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml @@ -6,6 +6,7 @@ SELECT i.id, i.biz_no, i.doc_type, + i.flow_code, i.status, i.reporter_id, i.reporter_name, @@ -15,7 +16,7 @@ i.current_node_key, i.definition_id, i.submit_time, - COALESCE(f.name, co.title) AS title + i.title AS title FROM approval_instance i LEFT JOIN approval_completion_form f ON f.instance_id = i.id LEFT JOIN approval_coordination_form co ON co.instance_id = i.id @@ -31,8 +32,13 @@ AND i.doc_type = #{param.docType} + + AND i.flow_code = #{param.flowCode} + AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%') + OR i.title LIKE CONCAT('%', #{param.keywords}, '%') + OR i.search_text LIKE CONCAT('%', #{param.keywords}, '%') OR f.name LIKE CONCAT('%', #{param.keywords}, '%') OR co.title LIKE CONCAT('%', #{param.keywords}, '%') OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%')) @@ -44,6 +50,7 @@ SELECT i.id, i.biz_no, i.doc_type, + i.flow_code, i.status, i.reporter_id, i.reporter_name, @@ -53,7 +60,7 @@ i.current_node_key, i.definition_id, i.submit_time, - COALESCE(f.name, co.title) AS title + i.title AS title FROM approval_instance i LEFT JOIN approval_completion_form f ON f.instance_id = i.id LEFT JOIN approval_coordination_form co ON co.instance_id = i.id @@ -67,8 +74,13 @@ AND i.doc_type = #{param.docType} + + AND i.flow_code = #{param.flowCode} + AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%') + OR i.title LIKE CONCAT('%', #{param.keywords}, '%') + OR i.search_text LIKE CONCAT('%', #{param.keywords}, '%') OR f.name LIKE CONCAT('%', #{param.keywords}, '%') OR co.title LIKE CONCAT('%', #{param.keywords}, '%') OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%')) diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java index e8151d4..85c52fa 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java @@ -1,21 +1,20 @@ package com.gxwebsoft.approval.param; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; import io.swagger.annotations.ApiModelProperty; import lombok.Data; +import lombok.EqualsAndHashCode; -import java.io.Serializable; import java.math.BigDecimal; /** * 业务完成确认单草稿参数;id 为审批实例id,更新草稿时传入。 */ @Data -public class ApprovalCompletionParam implements Serializable { +@EqualsAndHashCode(callSuper = true) +public class ApprovalCompletionParam extends ApprovalDocumentParam { private static final long serialVersionUID = 1L; - @ApiModelProperty("审批实例id(更新草稿时传入)") - private Long id; - @ApiModelProperty("名称") private String name; diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java index 5e1fd18..4a564e0 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java @@ -1,22 +1,20 @@ package com.gxwebsoft.approval.param; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; import io.swagger.annotations.ApiModelProperty; import lombok.Data; - -import java.io.Serializable; +import lombok.EqualsAndHashCode; /** - * 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。 + * 工作协调单草稿参数(内部/外部流程共用);id 为审批实例id,更新草稿时传入。 * *

报送部门与报送人由服务端按登录态带出,不在参数中接收。

*/ @Data -public class ApprovalCoordinationParam implements Serializable { +@EqualsAndHashCode(callSuper = true) +public class ApprovalCoordinationParam extends ApprovalDocumentParam { private static final long serialVersionUID = 1L; - @ApiModelProperty("审批实例id(更新草稿时传入)") - private Long id; - @ApiModelProperty("受理部门id,必须是启用组织") private Integer acceptOrgId; diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java index 05b67f5..ce4a285 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java @@ -17,4 +17,7 @@ public class ApprovalMineParam extends BaseParam { @ApiModelProperty("单据类型") private String docType; + + @ApiModelProperty("流程编码,如 settlement") + private String flowCode; } diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java index 9341519..2fcc5af 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java @@ -18,6 +18,9 @@ public class ApprovalQueryParam extends BaseParam { @ApiModelProperty("单据类型 completion/coordination") private String docType; + @ApiModelProperty("流程编码,如 coordination_external") + private String flowCode; + @ApiModelProperty("单号") private String bizNo; diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java index 5f64d3e..ea09430 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java @@ -4,9 +4,13 @@ import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.io.Serializable; +import java.util.Map; /** - * 任务动作参数(通过/驳回)。 + * 任务动作参数(通过 / 驳回 / 回填)。 + * + *

`fields` 为节点声明可写字段的 key → 值;回填时只写字段,通过时写字段并推进 + * (ADR 0006)。

*/ @Data public class ApprovalTaskActionParam implements Serializable { @@ -14,4 +18,7 @@ public class ApprovalTaskActionParam implements Serializable { @ApiModelProperty("处理意见;驳回必填") private String opinion; + + @ApiModelProperty("可写字段 key → 值") + private Map fields; } diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java index ceeae32..53aeb75 100644 --- a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java @@ -18,6 +18,9 @@ public class ApprovalTaskListParam extends BaseParam { @ApiModelProperty("单据类型") private String docType; + @ApiModelProperty("流程编码,如 settlement") + private String flowCode; + /** 查询范围:mine(默认,本人待办/已办)或 all(全租户待办,需 assign 权限)。 */ @ApiModelProperty("查询范围 mine/all") private String scope; diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java new file mode 100644 index 0000000..88ed4a4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java @@ -0,0 +1,305 @@ +package com.gxwebsoft.approval.service; + +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.support.ApprovalApprover; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; +import com.gxwebsoft.approval.vo.ApprovalUserVO; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 审批人解析:把节点定义中的解析器(含会签组)解析为审批人快照。 + * + *

解析全部走 {@link ApprovalDirectoryService}(core 专用连接),调用方必须在事务外执行。 + * 缺配时不抛异常,而是返回 {@link Resolution#getMissingMessage()},由调用方决定 + * 「提交预检直接阻断」还是「节点激活时停留并提示管理员」。

+ */ +@Service +public class ApprovalApproverResolver { + + @Resource + private ApprovalDirectoryService directoryService; + + /** + * 解析节点的全部审批人;会签节点按「组 × 启用成员」展开并附带组信息。 + * + *

会签节点任一组缺配即返回缺配消息且 {@link Resolution#getApprovers()} 为空—— + * 全有或全无,避免半激活(ADR 0003)。

+ */ + public Resolution resolve(ApprovalInstance instance, ApprovalNode node) { + if (node == null) { + return Resolution.missing("审批节点未配置"); + } + if (hasGroups(node)) { + return resolveGroups(instance, node); + } + List approvers = resolveSingle(instance, node.getResolver(), null); + if (approvers.isEmpty()) { + return Resolution.missing(missingMessage(instance, node.getResolver(), null, node.getName())); + } + return Resolution.resolved(approvers); + } + + /** + * 提交预检:逐节点解析全部审批人,靠后的节点也检查(ADR 0003)。 + * + * @return 缺配提示;全部可解析返回 null + */ + public String preflight(ApprovalInstance instance, List nodes) { + for (ApprovalNode node : nodes) { + Resolution resolution = resolve(instance, node); + if (!resolution.isResolved()) { + return resolution.getMissingMessage(); + } + } + return null; + } + + /** + * 按组声明解析:会签节点组间会签、组内或签;CONFIRM 节点用组表达「多组全确认才前进」。 + * 任一组缺配即整节点不激活。 + */ + private Resolution resolveGroups(ApprovalInstance instance, ApprovalNode node) { + List approvers = new ArrayList<>(); + int seq = 0; + for (ApprovalNode.ApprovalGroup group : node.getGroups()) { + seq++; + if (group == null || group.getResolver() == null || group.getResolver().trim().isEmpty()) { + return Resolution.missing(groupName(group, seq) + "未配置解析器"); + } + List members = resolveSingle(instance, group.getResolver(), group.getOrgCode()); + if (members.isEmpty()) { + return Resolution.missing(missingMessage(instance, group.getResolver(), group.getOrgCode(), + groupName(group, seq))); + } + String groupKey = group.getKey() == null || group.getKey().trim().isEmpty() + ? "group" + seq : group.getKey().trim(); + String name = groupName(group, seq); + for (ApprovalApprover member : members) { + approvers.add(new ApprovalApprover(member.getUserId(), member.getUserName(), + groupKey, name, seq)); + } + } + if (approvers.isEmpty()) { + return Resolution.missing(node.getName() + "未配置审批组"); + } + return Resolution.resolved(approvers); + } + + private boolean hasGroups(ApprovalNode node) { + return node.getGroups() != null && !node.getGroups().isEmpty(); + } + + private String groupName(ApprovalNode.ApprovalGroup group, int seq) { + if (group != null && group.getName() != null && !group.getName().trim().isEmpty()) { + return group.getName().trim(); + } + return "审批组" + seq; + } + + /** + * 单个解析器 → 审批人。[orgCode] 仅对按组织编码的解析器生效。 + */ + private List resolveSingle(ApprovalInstance instance, String resolver, + String orgCode) { + if (resolver == null) { + return Collections.emptyList(); + } + switch (resolver) { + case ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER: + return departmentLeaderApprovers(instance.getReportOrgId()); + case ApprovalConstants.RESOLVER_REPORTER_ORG_DEPUTY: + return departmentDeputyApprovers(instance.getReportOrgId()); + case ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER: + return departmentLeaderApprovers(instance.getAcceptOrgId()); + case ApprovalConstants.RESOLVER_ACCEPT_ORG_DEPUTY: + return departmentDeputyApprovers(instance.getAcceptOrgId()); + case ApprovalConstants.RESOLVER_ARCHIVIST: + case ApprovalConstants.RESOLVER_ROLE_ARCHIVIST: + return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST); + case ApprovalConstants.RESOLVER_REPORTER: + return userApprovers(Collections.singletonList(instance.getReporterId())); + case ApprovalConstants.RESOLVER_REPORTER_AND_DISPATCH_TARGET: + return userApprovers(confirmCandidateIds(instance)); + case ApprovalConstants.RESOLVER_ACCEPT_ORG_MEMBERS: + return organizationApprovers(instance.getAcceptOrgId()); + case ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE: + return codeLeaderApprovers(orgCode); + case ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE: + return codeDeputyApprovers(orgCode); + case ApprovalConstants.RESOLVER_ROLE_CASHIER: + return roleApprovers(ApprovalConstants.ROLE_CASHIER); + case ApprovalConstants.RESOLVER_ROLE_TYPIST: + return roleApprovers(ApprovalConstants.ROLE_TYPIST); + case ApprovalConstants.RESOLVER_ROLE_ISSUER: + return roleApprovers(ApprovalConstants.ROLE_ISSUER); + case ApprovalConstants.RESOLVER_ROLE_GENERAL_MANAGER: + return roleApprovers(ApprovalConstants.ROLE_GENERAL_MANAGER); + case ApprovalConstants.RESOLVER_ROLE_RELATED_DEPT: + return roleApprovers(ApprovalConstants.ROLE_RELATED_DEPT); + default: + return Collections.emptyList(); + } + } + + /** + * 缺配提示:按解析器给出可定位到岗位/部门的文案,供提交预检直接展示。 + */ + private String missingMessage(ApprovalInstance instance, String resolver, String orgCode, + String fallbackName) { + if (resolver == null) { + return fallbackName + "未配置审批人"; + } + switch (resolver) { + case ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER: + return "报送部门负责人角色缺配"; + case ApprovalConstants.RESOLVER_REPORTER_ORG_DEPUTY: + return "报送部门分管领导角色缺配"; + case ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER: + return "受理部门负责人角色缺配"; + case ApprovalConstants.RESOLVER_ACCEPT_ORG_DEPUTY: + return "受理部门分管领导角色缺配"; + case ApprovalConstants.RESOLVER_ARCHIVIST: + case ApprovalConstants.RESOLVER_ROLE_ARCHIVIST: + return "收文员角色缺配"; + case ApprovalConstants.RESOLVER_REPORTER: + return "报送人账号不可用"; + case ApprovalConstants.RESOLVER_REPORTER_AND_DISPATCH_TARGET: + return "存档确认人账号不可用"; + case ApprovalConstants.RESOLVER_ACCEPT_ORG_MEMBERS: + return "受理部门无可用审批参与人"; + case ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE: + return organizationLabel(orgCode) + "负责人角色缺配"; + case ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE: + return organizationLabel(orgCode) + "分管领导角色缺配"; + case ApprovalConstants.RESOLVER_ROLE_CASHIER: + return "出纳角色缺配"; + case ApprovalConstants.RESOLVER_ROLE_TYPIST: + return "录入员角色缺配"; + case ApprovalConstants.RESOLVER_ROLE_ISSUER: + return "发放人角色缺配"; + case ApprovalConstants.RESOLVER_ROLE_GENERAL_MANAGER: + return "总经理角色缺配"; + case ApprovalConstants.RESOLVER_ROLE_RELATED_DEPT: + return "相关部门角色缺配"; + default: + return (fallbackName == null ? "审批节点" : fallbackName) + "审批人缺配"; + } + } + + /** 组织编码 → 展示名;组织不存在时退回编码本身。 */ + private String organizationLabel(String orgCode) { + ApprovalOrganizationVO organization = directoryService.getOrganizationByCode(orgCode); + return organization == null ? (orgCode == null ? "指定部门" : orgCode) + : organization.getOrganizationName(); + } + + 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 departmentDeputyApprovers(Integer organizationId) { + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + String roleCode = organization == null + ? null : ApprovalConstants.departmentDeputyRoleCode(organization.getOrganizationCode()); + return roleCode == null ? Collections.emptyList() : roleApprovers(roleCode); + } + + private List codeLeaderApprovers(String orgCode) { + ApprovalOrganizationVO organization = directoryService.getOrganizationByCode(orgCode); + String roleCode = organization == null + ? null : ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode()); + return roleCode == null ? Collections.emptyList() : roleApprovers(roleCode); + } + + private List codeDeputyApprovers(String orgCode) { + ApprovalOrganizationVO organization = directoryService.getOrganizationByCode(orgCode); + String roleCode = organization == null + ? null : ApprovalConstants.departmentDeputyRoleCode(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) { + 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()); + } + + /** 审批人展示名:实名 → 昵称 → 账号。 */ + public String displayName(ApprovalUserVO user) { + if (user.getRealName() != null && !user.getRealName().isEmpty()) { + return user.getRealName(); + } + if (user.getNickname() != null && !user.getNickname().isEmpty()) { + return user.getNickname(); + } + return user.getUsername(); + } + + /** + * 解析结果:审批人快照或指名缺配提示。 + */ + public static final class Resolution { + private final List approvers; + private final String missingMessage; + + private Resolution(List approvers, String missingMessage) { + this.approvers = approvers; + this.missingMessage = missingMessage; + } + + public static Resolution resolved(List approvers) { + return new Resolution(approvers == null ? Collections.emptyList() : approvers, null); + } + + public static Resolution missing(String message) { + return new Resolution(Collections.emptyList(), message); + } + + public boolean isResolved() { + return missingMessage == null; + } + + public List getApprovers() { + return approvers; + } + + public String getMissingMessage() { + return missingMessage; + } + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java index 50d0e4d..4ffbbe9 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -86,6 +86,17 @@ public class ApprovalAttachmentService { return count == null ? 0 : count; } + /** 指定分类的附件数量(提交预检按用途校验必传,ADR 0005)。 */ + public int countCategory(Long instanceId, String category) { + if (StrUtil.isBlank(category)) { + return 0; + } + Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalAttachment::getInstanceId, instanceId) + .eq(ApprovalAttachment::getCategory, category)); + return count == null ? 0 : count; + } + /** * 上传附件:仅报送人且实例可编辑时可上传。 */ @@ -97,8 +108,7 @@ public class ApprovalAttachmentService { checkEditable(instance, user); String normalizedCategory = StrUtil.isBlank(category) ? ApprovalConstants.CATEGORY_OTHER : category.trim(); - if (!ApprovalConstants.CATEGORY_SIGNED.equals(normalizedCategory) - && !ApprovalConstants.CATEGORY_OTHER.equals(normalizedCategory)) { + if (!ApprovalConstants.ATTACHMENT_CATEGORIES.contains(normalizedCategory)) { throw new BusinessException("附件分类不正确"); } if (file == null || file.isEmpty()) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index 25acce8..13f9f4d 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -78,6 +78,20 @@ public class ApprovalDirectoryService { return list.isEmpty() ? null : list.get(0); } + /** 按组织编码取启用组织;编码为空或不存在时返回 null(节点定义固定编码用)。 */ + public ApprovalOrganizationVO getOrganizationByCode(String organizationCode) { + if (organizationCode == null || organizationCode.trim().isEmpty()) { + return null; + } + List list = jdbcTemplate.query( + "SELECT organization_id, parent_id, organization_name, organization_full_name, " + + "organization_code, leader_id FROM sys_organization " + + "WHERE organization_code = ? AND tenant_id = ? AND deleted = 0 " + + "ORDER BY organization_id LIMIT 1", + ORGANIZATION_MAPPER, organizationCode.trim(), ApprovalTenantContext.currentTenantId()); + return list.isEmpty() ? null : list.get(0); + } + /** 角色下全部启用成员;角色不存在或成员为空时返回空列表。 */ public List listRoleMembers(String roleCode) { if (roleCode == null) { @@ -210,7 +224,7 @@ public class ApprovalDirectoryService { + "JOIN sys_role pr ON pr.role_id = pur.role_id AND pr.tenant_id = ? AND pr.deleted = 0 " + "WHERE pur.user_id = u.user_id AND pur.tenant_id = ? AND (" + "pr.role_code = ? OR LEFT(pr.role_code, 12) = ? OR LEFT(pr.role_code, 12) = ? " - + "OR pr.role_code IN (?, ?, ?, ?, ?, ?)))"; + + "OR pr.role_code IN (?, ?, ?, ?, ?, ?, ?)))"; private static List participantArgs(Integer tenantId) { // 与 sql/04-approval_role_grants.sql 的授予矩阵保持一致(superAdmin 在 02 已获全部审批权限) @@ -220,7 +234,9 @@ public class ApprovalDirectoryService { ApprovalConstants.ROLE_DEPT_DEPUTY_PREFIX, ApprovalConstants.ROLE_ARCHIVIST, ApprovalConstants.ROLE_SUPER_ADMIN, - "approval_cashier", "approval_typist", "approval_issuer", "approval_general_manager"); + ApprovalConstants.ROLE_CASHIER, ApprovalConstants.ROLE_TYPIST, + ApprovalConstants.ROLE_ISSUER, ApprovalConstants.ROLE_GENERAL_MANAGER, + ApprovalConstants.ROLE_RELATED_DEPT); } /** 是否持有任一角色(用于 allVisible 判断)。 */ diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java new file mode 100644 index 0000000..16e769d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java @@ -0,0 +1,250 @@ +package com.gxwebsoft.approval.service; + +import cn.hutool.core.util.StrUtil; +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.entity.ApprovalFlowDefinition; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalTask; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalDetail; +import com.gxwebsoft.approval.vo.ApprovalGroupProgressVO; +import com.gxwebsoft.approval.vo.ApprovalOrganizationVO; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * 单据服务:所有单据共用的新建/更新草稿入口与提交预检。 + * + *

跨库约束同 {@link ApprovalInstanceService}:组织解析在事务外完成,写 master 的 + * 部分放 TransactionTemplate;字段校验与业务表读写委托给 {@link ApprovalDocumentType}。

+ */ +@Service +public class ApprovalDocumentService { + + /** 检索文本上限,避免超出 approval_instance.search_text 列宽。 */ + private static final int MAX_SEARCH_TEXT = 500; + + @Resource + private ApprovalDocumentRegistry documentRegistry; + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalFlowService flowService; + @Resource + private ApprovalDirectoryService directoryService; + @Resource + private ApprovalAttachmentService attachmentService; + @Resource + private ApprovalApproverResolver approverResolver; + @Resource + private ApprovalEngineService engineService; + @Resource + private PlatformTransactionManager transactionManager; + + /** + * 新建或更新草稿,返回实例 id。 + */ + public Long save(String flowCode, ApprovalDocumentParam param, User user) { + ApprovalDocumentType type = documentRegistry.require(flowCode); + if (param == null) { + throw new BusinessException("参数不能为空"); + } + type.validate(param, user); + Integer organizationId = user == null ? null : user.getOrganizationId(); + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + String organizationName = organization == null ? null : organization.getOrganizationName(); + + if (param.getId() != null) { + return inTransaction(() -> + updateDraft(type, param, user, organizationId, organizationName)); + } + ApprovalFlowDefinition definition = flowService.getOrCreate(flowCode); + return inTransaction(() -> + createDraft(type, param, user, definition, organizationId, organizationName)); + } + + /** + * 提交预检:按单据声明校验必传附件分类,再逐节点逐组解析审批人(ADR 0003/0005)。 + * + *

不通过即抛 BusinessException;消息指名缺的是哪一类附件或哪个组。

+ */ + public void preflight(ApprovalInstance instance, List nodes) { + ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode()); + String requiredCategory = type == null ? null : type.requiredAttachmentCategory(); + if (requiredCategory != null) { + if (attachmentService.countCategory(instance.getId(), requiredCategory) == 0) { + throw new BusinessException("请至少上传一张" + ApprovalConstants.categoryName(requiredCategory)); + } + } else if (attachmentService.count(instance.getId()) == 0) { + throw new BusinessException("请至少上传一张附件"); + } + String missing = approverResolver.preflight(instance, nodes); + if (missing != null) { + throw new BusinessException(missing); + } + if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) { + throw new BusinessException("收文员角色缺配"); + } + } + + /** 单据实现;未知流程返回 null。 */ + public ApprovalDocumentType documentType(ApprovalInstance instance) { + return documentRegistry.find(instance.getFlowCode()); + } + + /** + * 提交/重提前刷新列表标题与检索文本(业务表可能刚被编辑过)。 + */ + public void refreshIndex(ApprovalInstance instance) { + ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode()); + if (type == null) { + return; + } + instance.setTitle(type.title(instance)); + instance.setSearchText(truncate(type.searchText(instance))); + instanceMapper.updateById(instance); + } + + /** + * 按实例当前轮次装配会签进度(仅会签节点有组)。 + */ + public void fillGroupProgress(ApprovalInstance instance, ApprovalDetail detail) { + List progress = new ArrayList<>(); + for (ApprovalNode node : flowService.parseNodes(instance.getDefinitionId())) { + if (node.getGroups() == null || node.getGroups().isEmpty()) { + continue; + } + Map> grouped = + engineService.tasksByGroup(instance.getId(), instance.getRound(), node.getKey()); + for (List tasks : grouped.values()) { + if (tasks.isEmpty()) { + continue; + } + ApprovalTask first = tasks.get(0); + ApprovalGroupProgressVO vo = new ApprovalGroupProgressVO(); + vo.setNodeKey(node.getKey()); + vo.setNodeName(node.getName()); + vo.setGroupKey(first.getGroupKey()); + vo.setGroupName(first.getGroupName()); + vo.setGroupSeq(first.getGroupSeq()); + List members = new ArrayList<>(tasks.size()); + int signed = 0; + for (ApprovalTask task : tasks) { + ApprovalGroupProgressVO.Member member = new ApprovalGroupProgressVO.Member(); + member.setUserId(task.getAssigneeId()); + member.setUserName(task.getAssigneeName()); + member.setStatus(task.getStatus()); + member.setStatusName(groupTaskStatusName(task.getStatus())); + members.add(member); + if (ApprovalConstants.TASK_DONE.equals(task.getStatus())) { + signed++; + } + } + vo.setMembers(members); + vo.setSigned(signed); + vo.setTotal(members.size()); + progress.add(vo); + } + } + detail.setGroupProgress(progress); + } + + private String groupTaskStatusName(String status) { + if (ApprovalConstants.TASK_PENDING.equals(status)) { + return "待审批"; + } + if (ApprovalConstants.TASK_DONE.equals(status)) { + return "已通过"; + } + if (ApprovalConstants.TASK_CANCELLED.equals(status)) { + return "已取消"; + } + return status; + } + + private Long createDraft(ApprovalDocumentType type, ApprovalDocumentParam param, User user, + ApprovalFlowDefinition definition, Integer organizationId, + String organizationName) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setFlowCode(type.flowCode()); + instance.setDefinitionId(definition.getId()); + instance.setDefinitionVersion(definition.getVersion()); + instance.setDocType(type.docType()); + 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); + + Long bizId = type.persist(instance.getId(), null, param, user); + instance.setBizId(bizId == null ? 0L : bizId); + instance.setTitle(type.title(instance)); + instance.setSearchText(truncate(type.searchText(instance))); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private Long updateDraft(ApprovalDocumentType type, ApprovalDocumentParam param, User user, + Integer organizationId, String organizationName) { + ApprovalInstance instance = instanceMapper.selectById(param.getId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!type.flowCode().equals(instance.getFlowCode())) { + 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())); + } + Long bizId = type.persist(instance.getId(), instance.getBizId(), param, user); + if (bizId != null) { + instance.setBizId(bizId); + } + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instance.setTitle(type.title(instance)); + instance.setSearchText(truncate(type.searchText(instance))); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private String truncate(String text) { + if (text == null) { + return null; + } + return text.length() <= MAX_SEARCH_TEXT ? text : text.substring(0, MAX_SEARCH_TEXT); + } + + 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 T inTransaction(Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java index 7d9b789..3ba4e8f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -1,5 +1,6 @@ package com.gxwebsoft.approval.service; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.gxwebsoft.approval.constant.ApprovalConstants; import com.gxwebsoft.approval.entity.ApprovalInstance; @@ -16,13 +17,17 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** - * 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。 + * 审批引擎:实例加锁、节点激活、任务快照、会签分组与处理记录。 * - *

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

+ *

本类只写 master 库,必须在调用方的事务内执行;审批人解析(core 库) + * 必须由调用方在事务外完成后传入。

*/ @Service public class ApprovalEngineService { @@ -37,7 +42,7 @@ public class ApprovalEngineService { private ApprovalFlowService flowService; /** - * 提交/重提:激活流程首节点,为解析出的审批人生成或签任务。 + * 提交/重提:激活流程首节点,为解析出的审批人生成任务。 * * @return 生成的任务数;0 表示节点缺配,实例停留在当前节点等待管理员补配置 */ @@ -91,7 +96,7 @@ public class ApprovalEngineService { } /** - * 取消同一轮同一节点的其余待办(或签处理完成后调用)。 + * 取消同一轮同一节点的其余待办(或签/派单/归档处理完成后调用)。 */ public int cancelPendingTasks(Long instanceId, Integer round, Long exceptTaskId) { UpdateWrapper wrapper = new UpdateWrapper() @@ -105,6 +110,88 @@ public class ApprovalEngineService { return taskMapper.update(null, wrapper); } + /** + * 取消同节点同轮同组的其余待办(会签节点组内或签)。 + * + *

会签节点不能像或签那样取消整轮:那会让第一组通过就杀掉其余组的待办(ADR 0003)。

+ */ + public int cancelPendingTasksInGroup(Long instanceId, Integer round, String nodeKey, + String groupKey, Long exceptTaskId) { + UpdateWrapper wrapper = new UpdateWrapper() + .eq("instance_id", instanceId) + .eq("round", round) + .eq("node_key", nodeKey) + .eq("group_key", groupKey) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("status", ApprovalConstants.TASK_CANCELLED); + if (exceptTaskId != null) { + wrapper.ne("id", exceptTaskId); + } + return taskMapper.update(null, wrapper); + } + + /** + * 完成指定用户在本节点本轮的全部待办(一人同属多个会签组时一次点击生效)。 + * + * @return 实际更新的任务数 + */ + public int completeAssigneeTasks(Long instanceId, Integer round, String nodeKey, Integer assigneeId, + Long excludeTaskId, String opinion) { + UpdateWrapper wrapper = new UpdateWrapper() + .eq("instance_id", instanceId) + .eq("round", round) + .eq("node_key", nodeKey) + .eq("assignee_id", assigneeId) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("status", ApprovalConstants.TASK_DONE) + .set("handle_time", LocalDateTime.now()); + if (excludeTaskId != null) { + wrapper.ne("id", excludeTaskId); + } + if (opinion != null) { + wrapper.set("opinion", opinion); + } + return taskMapper.update(null, wrapper); + } + + /** + * 该节点该轮仍待处理的组标识集合(按 group_seq 顺序)。 + */ + public List pendingGroupKeys(Long instanceId, Integer round, String nodeKey) { + List pending = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instanceId) + .eq(ApprovalTask::getRound, round) + .eq(ApprovalTask::getNodeKey, nodeKey) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING) + .orderByAsc(ApprovalTask::getGroupSeq) + .orderByAsc(ApprovalTask::getId)); + Map ordered = new LinkedHashMap<>(); + for (ApprovalTask task : pending) { + if (task.getGroupKey() != null) { + ordered.putIfAbsent(task.getGroupKey(), Boolean.TRUE); + } + } + return new ArrayList<>(ordered.keySet()); + } + + /** + * 节点下非空的会签组(按 group_seq),供详情分组展示。 + */ + public Map> tasksByGroup(Long instanceId, Integer round, String nodeKey) { + List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instanceId) + .eq(ApprovalTask::getRound, round) + .eq(ApprovalTask::getNodeKey, nodeKey) + .orderByAsc(ApprovalTask::getGroupSeq) + .orderByAsc(ApprovalTask::getId)); + Map> grouped = new LinkedHashMap<>(); + for (ApprovalTask task : tasks) { + String key = task.getGroupKey() == null ? "" : task.getGroupKey(); + grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(task); + } + return grouped; + } + /** * 追加处理记录。 */ @@ -136,6 +223,10 @@ public class ApprovalEngineService { return null; } + public ApprovalNode findNode(ApprovalInstance instance, String nodeKey) { + return flowService.findNode(instance.getDefinitionId(), nodeKey); + } + public String nodeName(ApprovalInstance instance, String nodeKey) { return flowService.nodeName(instance.getDefinitionId(), nodeKey); } @@ -155,6 +246,10 @@ public class ApprovalEngineService { task.setTaskType(node.getType()); task.setAssigneeId(approver.getUserId()); task.setAssigneeName(approver.getUserName()); + // 非会签节点三列为空(ADR 0003) + task.setGroupKey(approver.getGroupKey()); + task.setGroupName(approver.getGroupName()); + task.setGroupSeq(approver.getGroupSeq()); task.setStatus(ApprovalConstants.TASK_PENDING); task.setCreateTime(LocalDateTime.now()); taskMapper.insert(task); @@ -172,4 +267,9 @@ public class ApprovalEngineService { } return user.getUsername(); } + + /** 便于文档实现者复用:去重并保留顺序。 */ + public static List distinctApprovers(List approvers) { + return approvers.stream().distinct().collect(Collectors.toList()); + } } diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java index 483965c..514a8cc 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java @@ -1,10 +1,16 @@ package com.gxwebsoft.approval.service; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.document.ApprovalDocumentRegistry; +import com.gxwebsoft.approval.document.ApprovalDocumentType; +import com.gxwebsoft.approval.entity.ApprovalInstance; import com.gxwebsoft.approval.mapper.ApprovalExportMapper; +import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.param.ApprovalQueryParam; import com.gxwebsoft.approval.support.ApprovalExportResult; import com.gxwebsoft.approval.support.ApprovalExportRow; +import com.gxwebsoft.approval.support.ApprovalLineSpec; import com.gxwebsoft.approval.support.ApprovalNode; import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.exception.BusinessException; @@ -25,15 +31,16 @@ import java.math.RoundingMode; import java.nio.file.Files; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** - * 单据导出:按列表同一套筛选与数据权限同步生成 xlsx,并写文件记录。 + * 单据导出:跨单据总表与按单据类型的明细导出。 * - *

独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限; - * 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。

+ *

总表保持「一行单据一行」,只多「明细行数」「合计金额」与「会签进度」三列(ADR 0004); + * 逐行数据另做按 flow_code 的明细导出,供对账与成本归集。导出内容不含附件。

*/ @Service public class ApprovalExportService { @@ -43,33 +50,35 @@ public class ApprovalExportService { private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** 一期 16 列顺序保持不变,二期三列追加在末尾(ADR 0004 / 0003)。 */ private static final String[] HEADERS = { "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)", - "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人" + "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人", + "明细行数", "合计金额", "会签进度" }; /** 列宽(字符数),长文本列加宽但不截断内容。 */ private static final int[] COLUMN_WIDTHS = { - 20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12 + 20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12, 10, 14, 18 }; @Resource private ApprovalExportMapper exportMapper; - + @Resource + private ApprovalInstanceMapper instanceMapper; + @Resource + private ApprovalDocumentRegistry documentRegistry; @Resource private ApprovalFlowService flowService; - @Resource private ApprovalDirectoryService directoryService; - @Resource private ApprovalFileService fileService; - @Resource private ConfigProperties config; /** - * 导出当前筛选结果。 + * 导出跨单据总表。 * * @throws BusinessException 结果超过 {@link #MAX_EXPORT_ROWS} 行时拒绝,不生成文件 */ @@ -83,6 +92,57 @@ public class ApprovalExportService { throw new BusinessException("请缩小筛选范围"); } String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx"; + return write(user, rows.size(), filename, sheet -> fillSummarySheet(sheet, rows)); + } + + /** + * 按单据类型的明细导出:明细列 + 单号/报送部门/状态,含逐行数据。 + */ + public ApprovalExportResult exportLines(String flowCode, ApprovalQueryParam param, User user) + throws IOException { + ApprovalDocumentType type = documentRegistry.require(flowCode); + List columns = type.lineColumns(); + if (columns == null || columns.isEmpty()) { + throw new BusinessException("该单据类型没有明细行"); + } + int allVisible = directoryService.hasAnyRole(user.getUserId(), + ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0; + int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0; + List instances = exportMapper.selectExportRows(param, user.getUserId(), allVisible, + assignVisible); + if (instances.size() > MAX_EXPORT_ROWS) { + throw new BusinessException("请缩小筛选范围"); + } + List lines = new ArrayList<>(); + for (ApprovalExportRow row : instances) { + if (!flowCode.equals(row.getFlowCode())) { + continue; + } + ApprovalInstance instance = instanceMapper.selectById(row.getId()); + if (instance == null) { + continue; + } + for (Map line : type.lines(instance)) { + lines.add(new ExportLine(row, line)); + } + } + String filename = "excel/审批单据明细-" + flowCode + "-" + System.currentTimeMillis() + ".xlsx"; + return write(user, lines.size(), filename, sheet -> fillLineSheet(sheet, type, columns, lines)); + } + + /** 明细导出的一行:所属单据 + 明细行。 */ + private static final class ExportLine { + private final ApprovalExportRow instance; + private final Map line; + + private ExportLine(ApprovalExportRow instance, Map line) { + this.instance = instance; + this.line = line; + } + } + + private ApprovalExportResult write(User user, int rowCount, String filename, + WorkbookFiller filler) throws IOException { String path = config.getUploadPath() + filename; File target = new File(path); try { @@ -90,9 +150,17 @@ public class ApprovalExportService { if (parent != null) { Files.createDirectories(parent.toPath()); } - writeWorkbook(rows, target); + SXSSFWorkbook workbook = new SXSSFWorkbook(100); + try { + filler.fill(workbook); + try (OutputStream output = Files.newOutputStream(target.toPath())) { + workbook.write(output); + } + } finally { + workbook.dispose(); + workbook.close(); + } } catch (IOException | RuntimeException e) { - // 生成失败时不留下半成品文件 Files.deleteIfExists(target.toPath()); throw e; } @@ -106,57 +174,125 @@ public class ApprovalExportService { record.setUrl(config.getServerUrl() + "/file/download/" + filename); record.setDownloadUrl(record.getUrl()); fileService.save(record); - return new ApprovalExportResult(record, rows.size()); + return new ApprovalExportResult(record, rowCount); } - private boolean hasAuthority(User user, String authority) { - if (user.getAuthorities() == null) { - return false; + private void fillSummarySheet(SXSSFWorkbook workbook, List rows) { + SXSSFSheet sheet = workbook.createSheet("审批单据"); + for (int i = 0; i < COLUMN_WIDTHS.length; i++) { + sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256); + } + SXSSFRow header = sheet.createRow(0); + for (int i = 0; i < HEADERS.length; i++) { + header.createCell(i).setCellValue(HEADERS[i]); + } + Map> nodeCache = new HashMap<>(); + // 批量取实例,避免逐行往返(5000 行导出时 N+1 会拖到分钟级) + Map instances = new HashMap<>(); + List instanceIds = new ArrayList<>(); + for (ApprovalExportRow row : rows) { + instanceIds.add(row.getId()); + } + if (!instanceIds.isEmpty()) { + for (ApprovalInstance instance : instanceMapper.selectBatchIds(instanceIds)) { + instances.put(instance.getId(), instance); + } + } + Map lineCountCache = new HashMap<>(); + Map amountCache = new HashMap<>(); + Map progressCache = new HashMap<>(); + int rowNum = 1; + for (ApprovalExportRow row : rows) { + ApprovalInstance instance = instances.get(row.getId()); + ApprovalDocumentType type = instance == null ? null + : documentRegistry.find(instance.getFlowCode()); + String cacheKey = type == null ? "" : type.flowCode() + ":" + row.getId(); + int lineCount = type == null ? 0 + : lineCountCache.computeIfAbsent(cacheKey, k -> new int[]{documentRegistry.lineCount(type, instance)})[0]; + BigDecimal amountTotal = type == null ? null : amountCache.computeIfAbsent(cacheKey, + k -> documentRegistry.lineAmountTotal(type, instance)); + String progress = instance == null ? "" : progressCache.computeIfAbsent(cacheKey, + k -> countersignProgress(instance)); + SXSSFRow dataRow = sheet.createRow(rowNum++); + String[] values = toValues(row, nodeCache, lineCount, amountTotal, progress); + for (int i = 0; i < values.length; i++) { + dataRow.createCell(i).setCellValue(values[i]); + } } - return user.getAuthorities().stream() - .map(Menu::getAuthority) - .anyMatch(authority::equals); } - private void writeWorkbook(List rows, File target) throws IOException { - SXSSFWorkbook workbook = new SXSSFWorkbook(100); - try { - SXSSFSheet sheet = workbook.createSheet("审批单据"); - for (int i = 0; i < COLUMN_WIDTHS.length; i++) { - sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256); + private void fillLineSheet(SXSSFWorkbook workbook, ApprovalDocumentType type, + List columns, List lines) { + SXSSFSheet sheet = workbook.createSheet(type.flowName()); + List headers = new ArrayList<>(); + headers.add("单号"); + headers.add("状态"); + headers.add("报送部门"); + headers.add("报送人"); + for (ApprovalLineSpec column : columns) { + headers.add(column.getLabel()); + } + for (int i = 0; i < headers.size(); i++) { + sheet.setColumnWidth(i, 18 * 256); + } + SXSSFRow header = sheet.createRow(0); + for (int i = 0; i < headers.size(); i++) { + header.createCell(i).setCellValue(headers.get(i)); + } + int rowNum = 1; + for (ExportLine exportLine : lines) { + SXSSFRow dataRow = sheet.createRow(rowNum++); + int col = 0; + dataRow.createCell(col++).setCellValue(text(exportLine.instance.getBizNo())); + dataRow.createCell(col++).setCellValue(text(ApprovalConstants.statusName(exportLine.instance.getStatus()))); + dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReportOrgName())); + dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReporterName())); + for (ApprovalLineSpec column : columns) { + dataRow.createCell(col++).setCellValue(lineValue(exportLine.line.get(column.getKey()))); } - SXSSFRow header = sheet.createRow(0); - for (int i = 0; i < HEADERS.length; i++) { - header.createCell(i).setCellValue(HEADERS[i]); - } - Map> nodeCache = new HashMap<>(); - int rowNum = 1; - for (ApprovalExportRow row : rows) { - SXSSFRow dataRow = sheet.createRow(rowNum++); - String[] values = toValues(row, nodeCache); - for (int i = 0; i < values.length; i++) { - dataRow.createCell(i).setCellValue(values[i]); - } - } - try (OutputStream output = Files.newOutputStream(target.toPath())) { - workbook.write(output); - } - } finally { - workbook.dispose(); - workbook.close(); } } /** - * 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、 - * 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。 + * 会签进度列:仅会签节点有组时填「已签 N/M」,其余为空。 */ - private String[] toValues(ApprovalExportRow row, Map> nodeCache) { + private String countersignProgress(ApprovalInstance instance) { + List nodes = flowService.parseNodes(instance.getDefinitionId()); + List parts = new ArrayList<>(); + for (ApprovalNode node : nodes) { + if (node.getGroups() == null || node.getGroups().isEmpty()) { + continue; + } + // 用详情装配同一套进度,避免导出与详情口径分叉 + com.gxwebsoft.approval.vo.ApprovalDetail detail = new com.gxwebsoft.approval.vo.ApprovalDetail(); + documentService.fillGroupProgress(instance, detail); + List progress = detail.getGroupProgress(); + int signedGroups = 0; + int totalGroups = 0; + for (com.gxwebsoft.approval.vo.ApprovalGroupProgressVO group : progress) { + if (!node.getKey().equals(group.getNodeKey())) { + continue; + } + totalGroups++; + if (group.getSigned() >= group.getTotal() && group.getTotal() > 0) { + signedGroups++; + } + } + if (totalGroups > 0) { + parts.add(node.getName() + " 已签 " + signedGroups + "/" + totalGroups); + } + } + return String.join(";", parts); + } + + private String[] toValues(ApprovalExportRow row, Map> nodeCache, + int lineCount, BigDecimal amountTotal, String progress) { boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType()); + String flowName = flowService.flowName(row.getDefinitionId(), row.getFlowCode()); return new String[]{ text(row.getBizNo()), - text(ApprovalConstants.docTypeName(row.getDocType())), - completion ? text(row.getName()) : text(row.getTitle()), + text(flowName == null ? ApprovalConstants.docTypeName(row.getDocType()) : flowName), + text(row.getTitle()), text(row.getReportOrgName()), text(row.getReporterName()), text(row.getAcceptOrgName()), @@ -169,10 +305,32 @@ public class ApprovalExportService { time(row.getSubmitTime()), time(row.getApproveTime()), time(row.getArchiveTime()), - text(row.getArchivistName()) + text(row.getArchivistName()), + lineCount > 0 ? String.valueOf(lineCount) : "", + amountTotal == null ? "" : fee(amountTotal), + text(progress) }; } + private String lineValue(Object value) { + if (value == null) { + return ""; + } + if (value instanceof BigDecimal) { + return ((BigDecimal) value).stripTrailingZeros().toPlainString(); + } + if (value instanceof LocalDateTime) { + return TIME_FORMAT.format((LocalDateTime) value); + } + if (value instanceof java.time.LocalDate) { + return value.toString(); + } + if (value instanceof Boolean) { + return (Boolean) value ? "是" : "否"; + } + return String.valueOf(value); + } + private String nodeName(Map> cache, Long definitionId, String nodeKey) { if (definitionId == null || nodeKey == null) { return null; @@ -196,4 +354,21 @@ public class ApprovalExportService { private String text(String value) { return value == null ? "" : value; } + + private boolean hasAuthority(User user, String authority) { + if (user.getAuthorities() == null) { + return false; + } + return user.getAuthorities().stream() + .map(Menu::getAuthority) + .anyMatch(authority::equals); + } + + /** 工作簿填充回调。 */ + private interface WorkbookFiller { + void fill(SXSSFWorkbook workbook); + } + + @Resource + private ApprovalDocumentService documentService; } diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java index 1428a58..5def125 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java @@ -2,7 +2,8 @@ package com.gxwebsoft.approval.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; -import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.document.ApprovalDocumentRegistry; +import com.gxwebsoft.approval.document.ApprovalDocumentType; import com.gxwebsoft.approval.entity.ApprovalFlowDefinition; import com.gxwebsoft.approval.mapper.ApprovalFlowDefinitionMapper; import com.gxwebsoft.approval.support.ApprovalNode; @@ -14,34 +15,25 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * 流程定义服务:按租户幂等发布各流程的最新版本,并解析节点 JSON。 * *

版本不可变:流程调整只新增版本行,已创建实例继续引用其 definition_id; - * {@link #getOrCreate(String)} 始终返回最新版本。

+ * {@link #getOrCreate(String)} 始终返回最新版本。节点 JSON、流程名称与最新版本号 + * 全部来自 {@link ApprovalDocumentType},新增单据不需要改本类。

*/ @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