feat: 二期审批引擎扩展与会签/明细/回填/附件分类共享层(#46)

- ApprovalDocumentType SPI + 注册表:新增单据不再改引擎
- ApprovalApproverResolver:会签组、orgLeaderByCode/orgDeputyByCode、租户岗位解析器
- 会签:任务带 group_key/group_name/group_seq,一人多组一次通过,只取消同组待办
- 回填:节点 writableFields、BACKFILL 动作、approval_field_change 留痕
- 附件:新增 VOUCHER 分类并按单据类型校验必传
- 详情:document.fields/lines/lineColumns/groupProgress + permissions.writableFields
- 列表/导出:title/search_text 落库、flowCode 筛选、明细行数/合计金额/会签进度列、明细导出
- sql/05-approval_phase2.sql(可重复执行)
This commit is contained in:
weicw
2026-10-31 06:13:45 +08:00
parent 5018463005
commit d5001c981c
59 changed files with 3230 additions and 661 deletions
+19
View File
@@ -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
);
+39
View File
@@ -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
);
+107
View File
@@ -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;
@@ -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_<orgCode> / dept_deputy_<orgCode>)。 */
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<String, String> docType = new LinkedHashMap<>();
docType.put(DOC_COMPLETION, "业务完成确认单");
docType.put(DOC_COORDINATION, "内部协调单");
docType.put(DOC_COORDINATION, "工作协调单");
DOC_TYPE_NAMES = Collections.unmodifiableMap(docType);
Map<String, String> action = new LinkedHashMap<>();
action.put(ACTION_SUBMIT, "提交");
action.put(ACTION_RESUBMIT, "重新提交");
action.put(ACTION_APPROVE, "通过");
action.put(ACTION_BACKFILL, "回填");
action.put(ACTION_REJECT, "驳回");
action.put(ACTION_WITHDRAW, "撤回");
action.put(ACTION_DISPATCH, "派单");
@@ -140,10 +191,15 @@ public final class ApprovalConstants {
Map<String, String> category = new LinkedHashMap<>();
category.put(CATEGORY_SIGNED, "签字件");
category.put(CATEGORY_VOUCHER, "凭证");
category.put(CATEGORY_OTHER, "其他");
CATEGORY_NAMES = Collections.unmodifiableMap(category);
}
/** 附件分类白名单(上传与提交预检共用)。 */
public static final List<String> 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\":\"报送部门负责人\"},"
@@ -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<FileRecord> 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<FileRecord> exportLines(@PathVariable("flowCode") String flowCode,
@RequestBody ApprovalQueryParam param) throws IOException {
ApprovalExportResult result = exportService.exportLines(flowCode, param, getLoginUser());
return success("导出成功,共" + result.getRowCount() + "条明细", result.getFile());
}
}
@@ -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 共用)。
*
* <p>草稿保存按 flow_code 路由到单据实现;新增单据只加一个 {@link ApprovalDocumentType},
* 不再加 Controller 方法。</p>
*/
@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。
*
* <p>body 为目标单据的 Param JSON(含 id 表示更新草稿)。</p>
*/
@PreAuthorize("hasAuthority('approval:instance:submit')")
@ApiOperation("新建或保存业务完成确认单草稿")
@PostMapping("/instance/completion")
public ApiResult<Long> saveCompletion(@RequestBody ApprovalCompletionParam param) {
@ApiOperation("新建或保存单据草稿")
@PostMapping("/instance/{flowCode}")
public ApiResult<Long> save(@PathVariable("flowCode") String flowCode,
@RequestBody Map<String, Object> 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<Long> 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<Long> saveCoordination(@RequestBody ApprovalCoordinationParam param) {
User user = getLoginUser();
return success("保存成功", instanceService.saveCoordination(param, user));
public ApiResult<Long> saveCoordination(
@RequestBody com.gxwebsoft.approval.param.ApprovalCoordinationParam param) {
return success("保存成功",
instanceService.save(ApprovalConstants.FLOW_COORDINATION_INTERNAL, param, getLoginUser()));
}
@PreAuthorize("hasAuthority('approval:instance:submit')")
@@ -68,11 +68,23 @@ public class ApprovalTaskController extends BaseController {
public ApiResult<ApprovalInstanceVO> approve(@PathVariable("id") Long id,
@RequestBody(required = false) ApprovalTaskActionParam param) {
User user = getLoginUser();
ApprovalTaskService.Result result = taskService.approve(id, param == null ? null : param.getOpinion(), user);
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<ApprovalInstanceVO> 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")
@@ -0,0 +1,133 @@
package com.gxwebsoft.approval.doc.completion;
import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.approval.document.AbstractApprovalDocumentType;
import com.gxwebsoft.approval.document.ApprovalDocumentParam;
import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.mapper.ApprovalCompletionFormMapper;
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
import com.gxwebsoft.approval.vo.ApprovalDetail;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.User;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.List;
/**
* 业务完成确认单(一期):报送部门负责人 → 报送人存档 → 归档。
*/
@Component
public class CompletionDocumentType extends AbstractApprovalDocumentType {
@Resource
private ApprovalCompletionFormMapper completionFormMapper;
private static final String NODES_JSON = "["
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
@Override
public String flowCode() {
return ApprovalConstants.FLOW_COMPLETION;
}
@Override
public String docType() {
return ApprovalConstants.DOC_COMPLETION;
}
@Override
public String flowName() {
return "业务完成确认单";
}
@Override
public String nodesJson() {
return NODES_JSON;
}
@Override
public String noPrefix() {
return ApprovalConstants.NO_PREFIX_COMPLETION;
}
@Override
public Class<? extends ApprovalDocumentParam> paramClass() {
return ApprovalCompletionParam.class;
}
@Override
public void validate(ApprovalDocumentParam param, User user) {
ApprovalCompletionParam completion = cast(param);
requireText("名称", completion.getName(), 100);
requireText("实施单位", completion.getImplementOrg(), 100);
BigDecimal fee = completion.getFee();
if (fee == null) {
throw new BusinessException("费用不能为空");
}
if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0
|| fee.compareTo(ApprovalConstants.MAX_FEE) > 0) {
throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
}
requireText("服务内容", completion.getServiceContent(), 2000);
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
ApprovalCompletionParam completion = cast(param);
ApprovalCompletionForm form = bizId == null ? null : completionFormMapper.selectById(bizId);
if (form == null) {
form = new ApprovalCompletionForm();
form.setInstanceId(instanceId);
}
form.setName(completion.getName().trim());
form.setImplementOrg(completion.getImplementOrg().trim());
form.setFee(completion.getFee());
form.setServiceContent(completion.getServiceContent().trim());
if (form.getId() == null) {
completionFormMapper.insert(form);
} else {
completionFormMapper.updateById(form);
}
return form.getId();
}
@Override
public void load(ApprovalInstance instance, ApprovalDetail detail) {
ApprovalCompletionForm form = findByInstance(instance.getId());
if (form == null) {
return;
}
detail.addField("name", "名称", form.getName());
detail.addField("implementOrg", "实施单位", form.getImplementOrg());
detail.addField("fee", "费用(元)", form.getFee());
detail.addField("serviceContent", "服务内容", form.getServiceContent());
}
@Override
public String title(ApprovalInstance instance) {
ApprovalCompletionForm form = findByInstance(instance.getId());
return form == null ? null : form.getName();
}
private ApprovalCompletionForm findByInstance(Long instanceId) {
List<ApprovalCompletionForm> forms = completionFormMapper.selectList(
new LambdaQueryWrapper<ApprovalCompletionForm>()
.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;
}
}
@@ -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;
/**
* 工作协调单(内部流程,一期):报送部门负责人 → 受理部门负责人 → 派单 →
* 存档(报送人 + 被派单人)→ 归档。
*
* <p>外部流程见 {@link CoordinationExternalDocumentType}:同一张表、同一份表单,
* 只多一个受理部门分管领导节点(ADR 0007)。</p>
*/
@Component
public class CoordinationDocumentType extends AbstractApprovalDocumentType {
static final String NODES_JSON_INTERNAL = "["
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"},"
+ "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgLeader\",\"name\":\"派单\"},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"},"
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
@Resource
private ApprovalCoordinationFormMapper coordinationFormMapper;
@Resource
private ApprovalDirectoryService directoryService;
@Resource
private ApprovalApproverResolver approverResolver;
@Override
public String flowCode() {
return ApprovalConstants.FLOW_COORDINATION_INTERNAL;
}
@Override
public String docType() {
return ApprovalConstants.DOC_COORDINATION;
}
@Override
public String flowName() {
return "内部协调单";
}
@Override
public String nodesJson() {
return NODES_JSON_INTERNAL;
}
/** 一期 v1 按部门全员派单、v2 改为受理部门负责人;版本不可变。 */
@Override
public int latestVersion() {
return 2;
}
@Override
public String noPrefix() {
return ApprovalConstants.NO_PREFIX_COORDINATION;
}
@Override
public Class<? extends ApprovalDocumentParam> paramClass() {
return ApprovalCoordinationParam.class;
}
@Override
public void validate(ApprovalDocumentParam param, User user) {
ApprovalCoordinationParam coordination = cast(param);
if (coordination.getAcceptOrgId() == null || coordination.getAcceptOrgId() <= 0) {
throw new BusinessException("请选择受理部门");
}
if (directoryService.getOrganization(coordination.getAcceptOrgId()) == null) {
throw new BusinessException("受理部门不存在或已停用");
}
requireText("标题", coordination.getTitle(), 100);
requireText("内容", coordination.getContent(), 2000);
optionalText("备注", coordination.getRemark(), 500);
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
ApprovalCoordinationParam coordination = cast(param);
ApprovalOrganizationVO accept = directoryService.getOrganization(coordination.getAcceptOrgId());
if (accept == null) {
throw new BusinessException("受理部门不存在或已停用");
}
ApprovalCoordinationForm form = bizId == null ? null : coordinationFormMapper.selectById(bizId);
if (form == null) {
form = new ApprovalCoordinationForm();
form.setInstanceId(instanceId);
}
form.setAcceptOrgId(accept.getOrganizationId());
form.setAcceptOrgName(accept.getOrganizationName());
form.setTitle(coordination.getTitle().trim());
form.setContent(coordination.getContent().trim());
form.setRemark(optionalText("备注", coordination.getRemark(), 500));
if (form.getId() == null) {
coordinationFormMapper.insert(form);
} else {
coordinationFormMapper.updateById(form);
}
return form.getId();
}
@Override
public void load(ApprovalInstance instance, ApprovalDetail detail) {
ApprovalCoordinationForm form = findByInstance(instance.getId());
if (form == null) {
return;
}
detail.addField("acceptOrgName", "受理部门", form.getAcceptOrgName());
detail.addField("title", "标题", form.getTitle());
detail.addField("content", "协调内容", form.getContent());
detail.addField("remark", "备注", form.getRemark());
}
@Override
public String title(ApprovalInstance instance) {
ApprovalCoordinationForm form = findByInstance(instance.getId());
return form == null ? null : form.getTitle();
}
private ApprovalCoordinationForm findByInstance(Long instanceId) {
List<ApprovalCoordinationForm> forms = coordinationFormMapper.selectList(
new LambdaQueryWrapper<ApprovalCoordinationForm>()
.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;
}
}
@@ -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);
}
}
@@ -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<ApprovalFieldSpec> 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<ApprovalFieldUpdate> applyWritableFields(ApprovalInstance instance, Map<String, String> 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;
}
}
@@ -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<java.util.Map<String, Object>> lines;
}
@@ -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 找到实现。
*
* <p>Spring 启动时收集全部 {@link ApprovalDocumentType};同一 flow_code 重复注册直接失败,
* 避免「改了实现却没生效」的静默错误。</p>
*/
@Component
public class ApprovalDocumentRegistry {
private final Map<String, ApprovalDocumentType> byFlowCode = new LinkedHashMap<>();
private final Map<String, ApprovalDocumentType> byDocType = new LinkedHashMap<>();
public ApprovalDocumentRegistry(List<ApprovalDocumentType> 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<ApprovalDocumentType> 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);
}
}
@@ -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 找到它。
*
* <p>引擎负责审批流转与公共留痕,表单字段的校验、读写、回填与展示由实现承担。
* 新增单据 = 新增一个实现 + 一张 DDL + 一个前端表单页,不改引擎。</p>
*/
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<ApprovalLineSpec> lineColumns() {
return null;
}
/** 可写字段元数据;无回填字段返回空。 */
default List<ApprovalFieldSpec> fieldSpecs() {
return Collections.emptyList();
}
/** 提交预检要求的附件分类;null 沿用「整单至少一张附件」。 */
default String requiredAttachmentCategory() {
return null;
}
/** 草稿参数类型。 */
Class<? extends ApprovalDocumentParam> paramClass();
/** 字段校验,失败抛 BusinessException。 */
void validate(ApprovalDocumentParam param, User user);
/**
* 写业务表:新建时 bizId 为 null,更新时传入已有 bizId。
*
* @return 业务表主键
*/
Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user);
/** 把业务字段与明细写入详情。 */
void load(ApprovalInstance instance, ApprovalDetail detail);
/** 明细行:每行以列 key 为键;无明细返回空列表。 */
default List<Map<String, Object>> 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<ApprovalFieldUpdate> applyWritableFields(ApprovalInstance instance, Map<String, String> fields,
User user, boolean requireComplete);
/** 撤回/驳回时清空本单需清空的字段(业务表不随引擎回滚)。 */
default void onRoundReset(ApprovalInstance instance, User user) {
}
}
@@ -0,0 +1,30 @@
package com.gxwebsoft.approval.document;
import lombok.Data;
/**
* 单据实现产生的一条改值记录;引擎负责补齐实例、任务、节点、操作人与轮次后落库。
*
* <p>实现负责判断旧值/新值;与实体 `ApprovalFieldChange` 区分开,避免同名混淆。</p>
*/
@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;
}
}
@@ -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 前调用。
*
* <p>服务端一律重算 `amount = round(quantity × unitPrice, 2)`(HALF_UP);客户端值与
* 重算值不一致即拒绝并指出行号,不静默改写。行号按提交顺序重排为 1..N。</p>
*/
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<Map<String, Object>> normalize(List<Map<String, Object>> rows,
List<ApprovalLineSpec> 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<Map<String, Object>> 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<String, Object> source = rows.get(index);
if (source == null) {
throw new BusinessException("第" + lineNo + "行明细为空");
}
Map<String, Object> 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<String, Object> 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<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) {
return null;
}
BigDecimal total = BigDecimal.ZERO;
boolean hasAmount = false;
for (Map<String, Object> 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<String, Object> 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());
}
}
@@ -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)。
*
* <p>记录「谁在第几轮把哪个字段从什么值改成了什么值」;每次回填按字段写一行。</p>
*/
@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;
}
@@ -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("重提轮次")
@@ -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;
@@ -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<ApprovalFieldChange> {
}
@@ -27,6 +27,11 @@ public interface ApprovalInstanceMapper extends BaseMapper<ApprovalInstance> {
@Param("allVisible") Integer allVisible,
@Param("assignVisible") Integer assignVisible);
/**
* 协调单受理部门快照(提交预检用)。
*/
java.util.Map<String, Object> selectCoordinationAcceptOrg(@Param("instanceId") Long instanceId);
/**
* 我发起的列表。
*/
@@ -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 @@
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.flowCode != null and param.flowCode != ''">
AND i.flow_code = #{param.flowCode}
</if>
<if test="param.bizNo != null and param.bizNo != ''">
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
</if>
@@ -57,6 +60,8 @@
</if>
<if test="param.keywords != null and param.keywords != ''">
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}, '%'))
@@ -25,10 +25,18 @@
)
</select>
<select id="selectCoordinationAcceptOrg" resultType="java.util.Map">
SELECT accept_org_id
FROM approval_coordination_form
WHERE instance_id = #{instanceId}
LIMIT 1
</select>
<select id="selectMinePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
SELECT i.id,
i.biz_no,
i.doc_type,
i.flow_code,
i.status,
i.reporter_id,
i.reporter_name,
@@ -38,7 +46,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
@@ -49,10 +57,16 @@
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.flowCode != null and param.flowCode != ''">
AND i.flow_code = #{param.flowCode}
</if>
<if test="param.keywords != null and param.keywords != ''">
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 co.title LIKE CONCAT('%', #{param.keywords}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
</if>
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
</select>
@@ -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 @@
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.flowCode != null and param.flowCode != ''">
AND i.flow_code = #{param.flowCode}
</if>
<if test="param.bizNo != null and param.bizNo != ''">
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
</if>
@@ -50,6 +54,8 @@
</if>
<if test="param.keywords != null and param.keywords != ''">
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}, '%'))
@@ -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 @@
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.flowCode != null and param.flowCode != ''">
AND i.flow_code = #{param.flowCode}
</if>
<if test="param.keywords != null and param.keywords != ''">
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 @@
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.flowCode != null and param.flowCode != ''">
AND i.flow_code = #{param.flowCode}
</if>
<if test="param.keywords != null and param.keywords != ''">
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}, '%'))
@@ -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;
@@ -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,更新草稿时传入。
*
* <p>报送部门与报送人由服务端按登录态带出,不在参数中接收。</p>
*/
@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;
@@ -17,4 +17,7 @@ public class ApprovalMineParam extends BaseParam {
@ApiModelProperty("单据类型")
private String docType;
@ApiModelProperty("流程编码,如 settlement")
private String flowCode;
}
@@ -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;
@@ -4,9 +4,13 @@ import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Map;
/**
* 任务动作参数(通过/驳回)。
* 任务动作参数(通过 / 驳回 / 回填)。
*
* <p>`fields` 为节点声明可写字段的 key → 值;回填时只写字段,通过时写字段并推进
* (ADR 0006)。</p>
*/
@Data
public class ApprovalTaskActionParam implements Serializable {
@@ -14,4 +18,7 @@ public class ApprovalTaskActionParam implements Serializable {
@ApiModelProperty("处理意见;驳回必填")
private String opinion;
@ApiModelProperty("可写字段 key → 值")
private Map<String, String> fields;
}
@@ -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;
@@ -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;
/**
* 审批人解析:把节点定义中的解析器(含会签组)解析为审批人快照。
*
* <p>解析全部走 {@link ApprovalDirectoryService}(core 专用连接),调用方必须在事务外执行。
* 缺配时不抛异常,而是返回 {@link Resolution#getMissingMessage()},由调用方决定
* 「提交预检直接阻断」还是「节点激活时停留并提示管理员」。</p>
*/
@Service
public class ApprovalApproverResolver {
@Resource
private ApprovalDirectoryService directoryService;
/**
* 解析节点的全部审批人;会签节点按「组 × 启用成员」展开并附带组信息。
*
* <p>会签节点任一组缺配即返回缺配消息且 {@link Resolution#getApprovers()} 为空——
* 全有或全无,避免半激活(ADR 0003)。</p>
*/
public Resolution resolve(ApprovalInstance instance, ApprovalNode node) {
if (node == null) {
return Resolution.missing("审批节点未配置");
}
if (hasGroups(node)) {
return resolveGroups(instance, node);
}
List<ApprovalApprover> 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<ApprovalNode> 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<ApprovalApprover> 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<ApprovalApprover> 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<ApprovalApprover> 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<ApprovalApprover> departmentLeaderApprovers(Integer organizationId) {
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
String roleCode = organization == null
? null : ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode());
return roleCode == null ? Collections.emptyList() : roleApprovers(roleCode);
}
private List<ApprovalApprover> 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<ApprovalApprover> 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<ApprovalApprover> 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<Integer> confirmCandidateIds(ApprovalInstance instance) {
List<Integer> ids = new ArrayList<>();
if (instance.getReporterId() != null) {
ids.add(instance.getReporterId());
}
if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) {
ids.add(instance.getDispatchTargetId());
}
return ids;
}
private List<ApprovalApprover> roleApprovers(String roleCode) {
return toApprovers(directoryService.listRoleMembers(roleCode));
}
private List<ApprovalApprover> userApprovers(List<Integer> userIds) {
return toApprovers(directoryService.listActiveUsersByIds(userIds));
}
private List<ApprovalApprover> organizationApprovers(Integer organizationId) {
return toApprovers(directoryService.listOrganizationMembers(organizationId));
}
private List<ApprovalApprover> toApprovers(List<ApprovalUserVO> users) {
return users.stream()
.map(user -> new ApprovalApprover(user.getUserId(), displayName(user)))
.collect(Collectors.toList());
}
/** 审批人展示名:实名 → 昵称 → 账号。 */
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<ApprovalApprover> approvers;
private final String missingMessage;
private Resolution(List<ApprovalApprover> approvers, String missingMessage) {
this.approvers = approvers;
this.missingMessage = missingMessage;
}
public static Resolution resolved(List<ApprovalApprover> 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<ApprovalApprover> getApprovers() {
return approvers;
}
public String getMissingMessage() {
return missingMessage;
}
}
}
@@ -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<ApprovalAttachment>()
.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()) {
@@ -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<ApprovalOrganizationVO> 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<ApprovalUserVO> 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<Object> 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 判断)。 */
@@ -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;
/**
* 单据服务:所有单据共用的新建/更新草稿入口与提交预检。
*
* <p>跨库约束同 {@link ApprovalInstanceService}:组织解析在事务外完成,写 master 的
* 部分放 TransactionTemplate;字段校验与业务表读写委托给 {@link ApprovalDocumentType}。</p>
*/
@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)。
*
* <p>不通过即抛 BusinessException;消息指名缺的是哪一类附件或哪个组。</p>
*/
public void preflight(ApprovalInstance instance, List<ApprovalNode> 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<ApprovalGroupProgressVO> progress = new ArrayList<>();
for (ApprovalNode node : flowService.parseNodes(instance.getDefinitionId())) {
if (node.getGroups() == null || node.getGroups().isEmpty()) {
continue;
}
Map<String, List<ApprovalTask>> grouped =
engineService.tasksByGroup(instance.getId(), instance.getRound(), node.getKey());
for (List<ApprovalTask> 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<ApprovalGroupProgressVO.Member> 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> T inTransaction(Supplier<T> action) {
return new TransactionTemplate(transactionManager).execute(status -> action.get());
}
}
@@ -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;
/**
* 审批引擎起步:实例加锁、首节点激活、任务快照与处理记录。
* 审批引擎:实例加锁、节点激活、任务快照、会签分组与处理记录。
*
* <p>本类只写 master 库,必须在调用方的事务内执行;后续迭代在此扩展
* 通过/驳回/撤回/存档/归档/派单的节点推进,不再改动已发布的方法签名。</p>
* <p>本类只写 master 库,必须在调用方的事务内执行;审批人解析(core 库)
* 必须由调用方在事务外完成后传入。</p>
*/
@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<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
@@ -105,6 +110,88 @@ public class ApprovalEngineService {
return taskMapper.update(null, wrapper);
}
/**
* 取消同节点同轮同组的其余待办(会签节点组内或签)。
*
* <p>会签节点不能像或签那样取消整轮:那会让第一组通过就杀掉其余组的待办(ADR 0003)。</p>
*/
public int cancelPendingTasksInGroup(Long instanceId, Integer round, String nodeKey,
String groupKey, Long exceptTaskId) {
UpdateWrapper<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
.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<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
.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<String> pendingGroupKeys(Long instanceId, Integer round, String nodeKey) {
List<ApprovalTask> pending = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
.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<String, Boolean> 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<String, List<ApprovalTask>> tasksByGroup(Long instanceId, Integer round, String nodeKey) {
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
.eq(ApprovalTask::getInstanceId, instanceId)
.eq(ApprovalTask::getRound, round)
.eq(ApprovalTask::getNodeKey, nodeKey)
.orderByAsc(ApprovalTask::getGroupSeq)
.orderByAsc(ApprovalTask::getId));
Map<String, List<ApprovalTask>> 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<ApprovalApprover> distinctApprovers(List<ApprovalApprover> approvers) {
return approvers.stream().distinct().collect(Collectors.toList());
}
}
@@ -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,并写文件记录。
* 单据导出:跨单据总表与按单据类型的明细导出。
*
* <p>独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限;
* 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。</p>
* <p>总表保持「一行单据一行」,只多「明细行数」「合计金额」与「会签进度」三列(ADR 0004);
* 逐行数据另做按 flow_code 的明细导出,供对账与成本归集。导出内容不含附件。</p>
*/
@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<ApprovalLineSpec> 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<ApprovalExportRow> instances = exportMapper.selectExportRows(param, user.getUserId(), allVisible,
assignVisible);
if (instances.size() > MAX_EXPORT_ROWS) {
throw new BusinessException("请缩小筛选范围");
}
List<ExportLine> 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<String, Object> 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<String, Object> line;
private ExportLine(ApprovalExportRow instance, Map<String, Object> 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<ApprovalExportRow> 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<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
// 批量取实例,避免逐行往返(5000 行导出时 N+1 会拖到分钟级)
Map<Long, ApprovalInstance> instances = new HashMap<>();
List<Long> 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<String, int[]> lineCountCache = new HashMap<>();
Map<String, BigDecimal> amountCache = new HashMap<>();
Map<String, String> 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<ApprovalExportRow> rows, File target) throws IOException {
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
try {
SXSSFSheet sheet = workbook.createSheet("审批单据");
for (int i = 0; i < COLUMN_WIDTHS.length; i++) {
sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256);
private void fillLineSheet(SXSSFWorkbook workbook, ApprovalDocumentType type,
List<ApprovalLineSpec> columns, List<ExportLine> lines) {
SXSSFSheet sheet = workbook.createSheet(type.flowName());
List<String> 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<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
int rowNum = 1;
for (ApprovalExportRow row : rows) {
SXSSFRow dataRow = sheet.createRow(rowNum++);
String[] values = toValues(row, nodeCache);
for (int i = 0; i < values.length; i++) {
dataRow.createCell(i).setCellValue(values[i]);
}
}
try (OutputStream output = Files.newOutputStream(target.toPath())) {
workbook.write(output);
}
} finally {
workbook.dispose();
workbook.close();
}
}
/**
* 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、
* 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。
* 会签进度列:仅会签节点有组时填「已签 N/M」,其余为空。
*/
private String[] toValues(ApprovalExportRow row, Map<Long, List<ApprovalNode>> nodeCache) {
private String countersignProgress(ApprovalInstance instance) {
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
List<String> 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<com.gxwebsoft.approval.vo.ApprovalGroupProgressVO> 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<Long, List<ApprovalNode>> 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<Long, List<ApprovalNode>> 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;
}
@@ -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。
*
* <p>版本不可变:流程调整只新增版本行,已创建实例继续引用其 definition_id;
* {@link #getOrCreate(String)} 始终返回最新版本。</p>
* {@link #getOrCreate(String)} 始终返回最新版本。节点 JSON、流程名称与最新版本号
* 全部来自 {@link ApprovalDocumentType},新增单据不需要改本类。</p>
*/
@Service
public class ApprovalFlowService {
private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class);
private static final int INITIAL_VERSION = 1;
/** 各流程当前发布版本;调整节点时必须递增并保留旧版本行。 */
private static final Map<String, Integer> LATEST_VERSIONS;
static {
Map<String, Integer> versions = new LinkedHashMap<>();
versions.put(ApprovalConstants.FLOW_COMPLETION, 1);
versions.put(ApprovalConstants.FLOW_COORDINATION_INTERNAL, 2);
LATEST_VERSIONS = Collections.unmodifiableMap(versions);
}
@Resource
private ApprovalFlowDefinitionMapper flowDefinitionMapper;
@Resource
private ApprovalDocumentRegistry documentRegistry;
@Resource
private ObjectMapper objectMapper;
/**
@@ -50,7 +42,8 @@ public class ApprovalFlowService {
* <p>并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新读取最新版本。</p>
*/
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<ApprovalFlowDefinition>()
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
.orderByDesc(ApprovalFlowDefinition::getVersion)
.last("LIMIT 1"));
}
private int latestVersion(String flowCode) {
Integer version = LATEST_VERSIONS.get(flowCode);
return version == null ? INITIAL_VERSION : version;
}
private String flowName(String flowCode) {
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
return "业务完成确认单";
}
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
return "内部协调单";
}
throw new BusinessException("未知的审批流程: " + flowCode);
}
private String flowNodesJson(String flowCode) {
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
return ApprovalConstants.COMPLETION_NODES_JSON;
}
if (ApprovalConstants.FLOW_COORDINATION_INTERNAL.equals(flowCode)) {
return ApprovalConstants.COORDINATION_NODES_JSON;
}
throw new BusinessException("未知的审批流程: " + flowCode);
}
}
@@ -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;
/**
* 业务完成确认单草稿、提交与详情服务。
* 审批实例服务:草稿保存入口、提交预检、详情装配与列表查询。
*
* <p>跨库约束:服务方法本身不开启事务;需要写 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。
*
* <p>报送部门与报送人取登录态;受理部门必须是启用组织,保存时即校验。</p>
*/
public Long saveCoordination(ApprovalCoordinationParam param, User user) {
validateCoordination(param);
ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId());
if (acceptOrganization == null) {
throw new BusinessException("受理部门不存在或已停用");
}
Integer organizationId = user.getOrganizationId();
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
String organizationName = organization == null ? null : organization.getOrganizationName();
if (param.getId() != null) {
return inTransaction(() ->
updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization));
}
ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
return inTransaction(() ->
createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization));
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<ApprovalUserVO> 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<ApprovalApprover> 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<ApprovalApprover> 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<ApprovalNode> nodes = flowService.parseNodes(locked.getDefinitionId());
if (nodes.isEmpty()) {
throw new BusinessException("审批流程未配置节点");
}
List<ApprovalApprover> 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<ApprovalWritableFieldVO> writableFields(ApprovalInstance instance, ApprovalDocumentType type,
ApprovalMyTaskVO myTask) {
List<ApprovalWritableFieldVO> 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<String, com.gxwebsoft.approval.support.ApprovalFieldSpec> 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<ApprovalItemVO> 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<ApprovalItemVO> pendingPage(ApprovalTaskListParam param) {
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
@@ -340,13 +380,16 @@ public class ApprovalInstanceService {
private List<ApprovalItemVO> toItems(List<ApprovalItemRow> rows) {
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
Map<Long, String> flowNameCache = new HashMap<>();
List<ApprovalItemVO> list = new ArrayList<>();
for (ApprovalItemRow row : rows) {
ApprovalItemVO item = new ApprovalItemVO();
item.setId(row.getId());
item.setBizNo(row.getBizNo());
item.setDocType(row.getDocType());
item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
item.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<ApprovalFieldChangeVO> fieldChanges(ApprovalInstance instance) {
List<ApprovalFieldChange> changes = fieldChangeMapper.selectList(
new LambdaQueryWrapper<ApprovalFieldChange>()
.eq(ApprovalFieldChange::getInstanceId, instance.getId())
.orderByAsc(ApprovalFieldChange::getCreateTime)
.orderByAsc(ApprovalFieldChange::getId));
List<ApprovalFieldChangeVO> 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<ApprovalConfirmVO> confirms(ApprovalInstance instance) {
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
.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<ApprovalCompletionForm> forms = completionFormMapper.selectList(
new LambdaQueryWrapper<ApprovalCompletionForm>()
.eq(ApprovalCompletionForm::getInstanceId, instanceId)
.last("LIMIT 1"));
return forms.isEmpty() ? null : forms.get(0);
}
private ApprovalCoordinationForm coordinationForm(Long instanceId) {
List<ApprovalCoordinationForm> forms = coordinationFormMapper.selectList(
new LambdaQueryWrapper<ApprovalCoordinationForm>()
.eq(ApprovalCoordinationForm::getInstanceId, instanceId)
.last("LIMIT 1"));
return forms.isEmpty() ? null : forms.get(0);
}
private void validateCompletion(ApprovalCompletionParam param) {
if (param == null) {
throw new BusinessException("参数不能为空");
}
String name = param.getName() == null ? "" : param.getName().trim();
if (name.isEmpty()) {
throw new BusinessException("名称不能为空");
}
if (name.length() > 100) {
throw new BusinessException("名称不能超过100字");
}
String implementOrg = param.getImplementOrg() == null ? "" : param.getImplementOrg().trim();
if (implementOrg.isEmpty()) {
throw new BusinessException("实施单位不能为空");
}
if (implementOrg.length() > 100) {
throw new BusinessException("实施单位不能超过100字");
}
BigDecimal fee = param.getFee();
if (fee == null) {
throw new BusinessException("费用不能为空");
}
if (fee.scale() > 2 || fee.compareTo(BigDecimal.ZERO) < 0 || fee.compareTo(MAX_FEE) > 0) {
throw new BusinessException("费用必须为0到99999999.99之间的两位小数");
}
String serviceContent = param.getServiceContent() == null ? "" : param.getServiceContent().trim();
if (serviceContent.isEmpty()) {
throw new BusinessException("服务内容不能为空");
}
if (serviceContent.length() > 2000) {
throw new BusinessException("服务内容不能超过2000字");
}
}
private void validateCoordination(ApprovalCoordinationParam param) {
if (param == null) {
throw new BusinessException("参数不能为空");
}
if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) {
throw new BusinessException("请选择受理部门");
}
String title = param.getTitle() == null ? "" : param.getTitle().trim();
if (title.isEmpty()) {
throw new BusinessException("标题不能为空");
}
if (title.length() > 100) {
throw new BusinessException("标题不能超过100字");
}
String content = param.getContent() == null ? "" : param.getContent().trim();
if (content.isEmpty()) {
throw new BusinessException("内容不能为空");
}
if (content.length() > 2000) {
throw new BusinessException("内容不能超过2000字");
}
String remark = normalizeRemark(param.getRemark());
if (remark != null && remark.length() > 500) {
throw new BusinessException("备注不能超过500字");
}
}
private String submitBlockedMessage(String status) {
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
return "单据正在审批中,不能重复提交";
}
if (ApprovalConstants.STATUS_ARCHIVED.equals(status)) {
return "单据已归档,不能修改";
}
return "当前状态不可提交";
}
private ApprovalApprover toApprover(ApprovalUserVO user) {
return new ApprovalApprover(user.getUserId(), userDisplayName(user));
}
private String userDisplayName(ApprovalUserVO user) {
if (StrUtil.isNotBlank(user.getRealName())) {
return user.getRealName();
}
if (StrUtil.isNotBlank(user.getNickname())) {
return user.getNickname();
}
return user.getUsername();
}
private String displayName(User user) {
if (StrUtil.isNotBlank(user.getRealName())) {
return user.getRealName();
}
if (StrUtil.isNotBlank(user.getNickname())) {
return user.getNickname();
}
return user.getUsername();
}
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
if (definitionId == null || nodeKey == null) {
return null;
}
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
return nodes.stream()
.filter(node -> nodeKey.equals(node.getKey()))
.map(ApprovalNode::getName)
.findFirst()
.orElse(null);
}
private String taskStatusName(String status) {
if (ApprovalConstants.TASK_PENDING.equals(status)) {
return "待确认";
@@ -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<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
if (definitionId == null || nodeKey == null) {
return null;
}
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
return nodes.stream()
.filter(node -> nodeKey.equals(node.getKey()))
.map(ApprovalNode::getName)
.findFirst()
.orElse(null);
}
private <T> T inTransaction(Supplier<T> 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;
}
}
}
@@ -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;
/**
* 审批任务动作:通过、驳回、撤回与节点推进。
* 审批任务动作:通过、会签通过、回填、驳回、撤回与节点推进。
*
* <p>跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接),
* <p>跨库约束:审批人解析必须走 {@link ApprovalApproverResolver}(core 专用连接),
* 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用
* WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。</p>
*/
@Service
public class ApprovalTaskService {
private static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader";
private static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader";
private static final String RESOLVER_ARCHIVIST = "archivist";
private static final String RESOLVER_REPORTER = "reporter";
private static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget";
private static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers";
private static final int MAX_OPINION_LENGTH = 500;
@Resource
@@ -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 节点组内或签。
*
* <p>会签节点一人同属多组时,一次通过把他该节点本轮全部待办置完成;
* 只取消同组剩余待办,全部组完成后才推进(ADR 0003)。</p>
*/
public Result approve(Long taskId, String opinion, User user) {
public Result approve(Long taskId, String opinion, Map<String, String> 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<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user);
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
List<ApprovalApprover> 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<String, String> 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<ApprovalFieldUpdate> 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<ApprovalApprover> 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 {
/**
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
*
* <p>会签节点任一组驳回即整节点结束、round+1,本轮已完成的组任务保持 DONE 留痕。
* 单据侧需要清空的字段由 {@link ApprovalDocumentType#onRoundReset} 处理(ADR 0014)。</p>
*/
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 记录。
*
* <p>改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。</p>
*/
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<ApprovalUserVO> 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<ApprovalUserVO> 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<ApprovalApprover> 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<ApprovalApprover> approvers) {
// 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁
ApprovalNode next, ApprovalApproverResolver.Resolution resolution,
List<ApprovalFieldUpdate> 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<ApprovalFieldUpdate> 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<String> 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<ApprovalApprover> 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<ApprovalTask>()
.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<ApprovalInstance>()
.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<ApprovalInstance>()
.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<ApprovalApprover> approvers) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
@@ -397,8 +494,67 @@ public class ApprovalTaskService {
}
/**
* 派单前校验:任务存在、属于本人、是派单节点且仍待处理。
* 回填字段:委托单据实现校验并落库,返回改值记录(事务外执行,可能访问 core 库)。
*/
private List<ApprovalFieldUpdate> writeFields(ApprovalInstance instance, ApprovalDocumentType type,
ApprovalTask task, Map<String, String> 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<ApprovalFieldUpdate> 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<ApprovalFieldUpdate> changes) {
List<String> 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<ApprovalApprover> dispatchNextApprovers(ApprovalInstance instance, ApprovalNode next,
ApprovalUserVO target) {
if (next == null) {
return Collections.emptyList();
}
if (!RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) {
return resolveApprovers(instance, next);
if (!ApprovalConstants.RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) {
return approverResolver.resolve(instance, next).getApprovers();
}
List<Integer> 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<ApprovalTask>()
.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<ApprovalApprover> resolveApprovers(ApprovalInstance instance, ApprovalNode node) {
String resolver = node.getResolver();
if (StrUtil.isBlank(resolver)) {
return Collections.emptyList();
}
switch (resolver) {
case RESOLVER_REPORTER_ORG_LEADER:
return departmentLeaderApprovers(instance.getReportOrgId());
case RESOLVER_ACCEPT_ORG_LEADER:
return departmentLeaderApprovers(instance.getAcceptOrgId());
case RESOLVER_ARCHIVIST:
return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST);
case RESOLVER_REPORTER:
return userApprovers(Collections.singletonList(instance.getReporterId()));
case RESOLVER_REPORTER_AND_DISPATCH_TARGET:
return userApprovers(confirmCandidateIds(instance));
case RESOLVER_ACCEPT_ORG_MEMBERS:
return organizationApprovers(instance.getAcceptOrgId());
default:
return Collections.emptyList();
}
}
/**
* 部门负责人审批人:按部门 organization_code 解析角色;部门或编码缺失时返回空列表,
* 节点保持缺配状态,等待管理员补配置。
*/
private List<ApprovalApprover> departmentLeaderApprovers(Integer organizationId) {
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
String roleCode = organization == null
? null : ApprovalConstants.departmentLeaderRoleCode(organization.getOrganizationCode());
return roleCode == null ? Collections.emptyList() : roleApprovers(roleCode);
}
private List<Integer> confirmCandidateIds(ApprovalInstance instance) {
List<Integer> ids = new ArrayList<>();
if (instance.getReporterId() != null) {
ids.add(instance.getReporterId());
}
if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) {
ids.add(instance.getDispatchTargetId());
}
return ids;
}
private List<ApprovalApprover> roleApprovers(String roleCode) {
if (StrUtil.isBlank(roleCode)) {
return Collections.emptyList();
}
return toApprovers(directoryService.listRoleMembers(roleCode));
}
private List<ApprovalApprover> userApprovers(List<Integer> userIds) {
return toApprovers(directoryService.listActiveUsersByIds(userIds));
}
private List<ApprovalApprover> organizationApprovers(Integer organizationId) {
return toApprovers(directoryService.listOrganizationMembers(organizationId));
}
private List<ApprovalApprover> toApprovers(List<ApprovalUserVO> users) {
return users.stream()
.map(user -> new ApprovalApprover(user.getUserId(), displayName(user)))
.collect(Collectors.toList());
}
private String displayName(ApprovalUserVO user) {
if (StrUtil.isNotBlank(user.getRealName())) {
return user.getRealName();
}
if (StrUtil.isNotBlank(user.getNickname())) {
return user.getNickname();
}
return user.getUsername();
}
private String displayName(User user) {
if (StrUtil.isNotBlank(user.getRealName())) {
return user.getRealName();
@@ -1,16 +1,42 @@
package com.gxwebsoft.approval.support;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 节点激活时快照的审批人。
*
* <p>会签节点额外带上组标识、组显示名与展示顺序;非会签节点这三项为空。</p>
*/
@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;
}
}
@@ -3,11 +3,13 @@ package com.gxwebsoft.approval.support;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 导出查询数据库行:两类单据合并列,未命中的表单字段为空。
* 导出查询数据库行:跨单据总表一行单据一行。
*
* <p>标题与检索文本取 approval_instance 的落库列,单据差异不再靠 LEFT JOIN 各业务表
* 拼列(ADR 0007);明细行数与合计金额由导出服务按单据实现补算。</p>
*/
@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;
}
@@ -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;
}
}
@@ -25,4 +25,5 @@ public class ApprovalItemRow implements Serializable {
private LocalDateTime archiveTime;
private String currentNodeKey;
private Long definitionId;
private String flowCode;
}
@@ -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 提供,驱动服务端校验与前端列渲染。
*
* <p>列结构随表结构走,不放进流程定义 JSON——改列就是改表。</p>
*/
@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<Option> options;
public static ApprovalLineSpec text(String key, String label, boolean required, int maxLength) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_TEXT);
spec.setRequired(required);
spec.setMaxLength(maxLength);
return spec;
}
public static ApprovalLineSpec decimal(String key, String label, boolean required, int scale) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_DECIMAL);
spec.setRequired(required);
spec.setScale(scale);
return spec;
}
public static ApprovalLineSpec integer(String key, String label, boolean required) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_INT);
spec.setRequired(required);
return spec;
}
public static ApprovalLineSpec date(String key, String label, boolean required) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_DATE);
spec.setRequired(required);
return spec;
}
public static ApprovalLineSpec bool(String key, String label, boolean required) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_BOOL);
spec.setRequired(required);
return spec;
}
public static ApprovalLineSpec enumOf(String key, String label, boolean required, List<Option> options) {
ApprovalLineSpec spec = new ApprovalLineSpec();
spec.setKey(key);
spec.setLabel(label);
spec.setType(TYPE_ENUM);
spec.setRequired(required);
spec.setOptions(options);
return spec;
}
/** 标记为金额列:值由服务端按 quantity × unitPrice 重算。 */
public ApprovalLineSpec asMoney() {
this.money = true;
return this;
}
public static final String TYPE_TEXT = "TEXT";
public static final String TYPE_DECIMAL = "DECIMAL";
public static final String TYPE_INT = "INT";
public static final String TYPE_DATE = "DATE";
public static final String TYPE_BOOL = "BOOL";
public static final String TYPE_ENUM = "ENUM";
/** 金额精度:DECIMAL(12,2),0~99999999.99。 */
public static final BigDecimal MAX_MONEY = new BigDecimal("99999999.99");
/** 枚举列选项:`value` 为存储编码,`label` 为中文文案。 */
@Data
public static class Option implements Serializable {
private static final long serialVersionUID = 1L;
private String value;
private String label;
public Option() {
}
public Option(String value, String label) {
this.value = value;
this.label = label;
}
}
}
@@ -3,8 +3,13 @@ package com.gxwebsoft.approval.support;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
/**
* 流程定义中的节点描述(nodes_json 数组元素)。
*
* <p>{@code @JsonIgnoreProperties(ignoreUnknown = true)} 意味着配置里多写的字段会被静默忽略,
* 因此节点上新增任何流程配置都必须同步在这里加字段(ADR 0003 / ADR 0006 的坑)。</p>
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -21,4 +26,29 @@ public class ApprovalNode {
/** 节点显示名称。 */
private String name;
/** 会签组(仅 COUNTERSIGN);为空时按或签处理。 */
private List<ApprovalGroup> groups;
/** 可写字段(APPROVE/COUNTERSIGN/CONFIRM 可声明);为空表示不可回填。 */
private List<WritableField> writableFields;
/** 会签组定义:组标识、显示名、解析器与可选固定组织编码。 */
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApprovalGroup {
private String key;
private String name;
private String resolver;
/** 固定组织编码(orgLeaderByCode / orgDeputyByCode 使用)。 */
private String orgCode;
}
/** 节点可写字段声明;长度与唯一性由 ApprovalFieldSpec 承担。 */
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class WritableField {
private String field;
private boolean required;
}
}
@@ -18,6 +18,11 @@ public class ApprovalConfirmVO implements Serializable {
private String userName;
/** 双存档时区分存档方(ADR 0003 / 0012)。 */
private String groupKey;
private String groupName;
private String status;
private String statusName;
@@ -0,0 +1,42 @@
package com.gxwebsoft.approval.vo;
import com.gxwebsoft.approval.support.ApprovalLineSpec;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 单据业务内容:业务字段、明细行/列元数据、明细汇总与会签进度。
*
* <p>前端按 `fields` 通用渲染;明细类单据再按 `lineColumns` 渲染表格。</p>
*/
@Data
@ApiModel(description = "单据业务内容")
public class ApprovalDetail implements Serializable {
private static final long serialVersionUID = 1L;
/** 业务字段(有序)。 */
private List<ApprovalFieldVO> fields = new ArrayList<>();
/** 明细行:每行是以列 key 为键的 map。 */
private List<java.util.Map<String, Object>> lines = new ArrayList<>();
/** 明细列元数据;无明细为空。 */
private List<ApprovalLineSpec> lineColumns = new ArrayList<>();
/** 明细汇总;无明细为 null。 */
private ApprovalLineSummaryVO lineSummary;
/** 会签节点进度(当前轮),非会签单据为空。 */
private List<ApprovalGroupProgressVO> groupProgress = new ArrayList<>();
/** 字段改值历史(最新在后)。 */
private List<ApprovalFieldChangeVO> fieldChanges = new ArrayList<>();
public void addField(String key, String label, Object value) {
fields.add(new ApprovalFieldVO(key, label, value == null ? null : String.valueOf(value)));
}
}
@@ -0,0 +1,36 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 详情中的字段改值历史(ADR 0006)。
*/
@Data
@ApiModel(description = "字段改值历史")
public class ApprovalFieldChangeVO implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String nodeKey;
private String nodeName;
private String fieldKey;
private String fieldLabel;
private String oldValue;
private String newValue;
private String operatorName;
private Integer round;
private LocalDateTime createTime;
}
@@ -0,0 +1,31 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
/**
* 详情中的业务字段(有序,中文标签),前端通用渲染。
*/
@Data
@ApiModel(description = "审批业务字段")
public class ApprovalFieldVO implements Serializable {
private static final long serialVersionUID = 1L;
private String key;
private String label;
/** 已格式化好的展示值;空值为 null。 */
private String value;
public ApprovalFieldVO() {
}
public ApprovalFieldVO(String key, String label, String value) {
this.key = key;
this.label = label;
this.value = value;
}
}
@@ -0,0 +1,49 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 会签节点进度:按组展示已签 N/M(ADR 0003)。
*/
@Data
@ApiModel(description = "会签进度")
public class ApprovalGroupProgressVO implements Serializable {
private static final long serialVersionUID = 1L;
private String nodeKey;
private String nodeName;
private String groupKey;
private String groupName;
private Integer groupSeq;
/** 该组已完成人数。 */
private int signed;
/** 该组任务总人数。 */
private int total;
private List<Member> members;
/** 组内成员状态。 */
@Data
@ApiModel(description = "会签组成员")
public static class Member implements Serializable {
private static final long serialVersionUID = 1L;
private Integer userId;
private String userName;
private String status;
private String statusName;
}
}
@@ -91,5 +91,11 @@ public class ApprovalInstanceVO implements Serializable {
/** 实例当前全部待办任务,供有 assign 权限的 PC 用户按任务改派。 */
private List<ApprovalPendingTaskVO> pendingTasks;
/** 单据业务内容(业务字段、明细、会签进度、改值历史)。 */
private ApprovalDetail document;
/** 字段改值历史(ADR 0006)。 */
private List<ApprovalFieldChangeVO> fieldChanges;
private ApprovalPermissionsVO permissions;
}
@@ -43,4 +43,7 @@ public class ApprovalItemVO implements Serializable {
private String acceptOrgName;
private LocalDateTime archiveTime;
/** 流程编码(按流程类型筛选,ADR 0007)。 */
private String flowCode;
}
@@ -0,0 +1,20 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 明细汇总:行数(必有)与合计金额(金额类单据才填)。
*/
@Data
@ApiModel(description = "明细汇总")
public class ApprovalLineSummaryVO implements Serializable {
private static final long serialVersionUID = 1L;
private int lineCount;
private BigDecimal amountTotal;
}
@@ -19,5 +19,9 @@ public class ApprovalMyTaskVO implements Serializable {
private String nodeType;
private String groupKey;
private String groupName;
private String status;
}
@@ -21,6 +21,10 @@ public class ApprovalPendingTaskVO implements Serializable {
private String nodeType;
private String groupKey;
private String groupName;
private Integer assigneeId;
private String assigneeName;
@@ -30,4 +30,7 @@ public class ApprovalPermissionsVO implements Serializable {
private boolean canDispatch;
private boolean canAssign;
/** 当前用户在该节点可回填的字段(ADR 0006);非节点处理人为空。 */
private java.util.List<ApprovalWritableFieldVO> writableFields = new java.util.ArrayList<>();
}
@@ -0,0 +1,24 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
/**
* 当前用户在该节点可回填的字段(仅当他是该节点处理人时返回)。
*/
@Data
@ApiModel(description = "可写字段")
public class ApprovalWritableFieldVO implements Serializable {
private static final long serialVersionUID = 1L;
private String field;
private String label;
private boolean required;
/** 当前值。 */
private String value;
}
@@ -63,7 +63,8 @@ class ApprovalExportIT {
private static final String[] HEADERS = {
"单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
"服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
"服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人",
"明细行数", "合计金额", "会签进度"
};
@Autowired