diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..28364abfe --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,26 @@ +# PIGX 后端 + +PIGX 后端承载平台业务规则与运行状态。本上下文中的工作流语言用于描述审批任务及其参与者关系。 + +## 工作流审批 + +**会签**: +同一审批节点为多名审批人分别生成独立待办,并以全部审批人同意作为节点通过条件。 + +**动态加签**: +在会签节点运行期间追加一名或多名独立审批人;新增审批人与原审批人共同遵守该节点的会签完成条件。 +_避免使用_:委托、转办 + +**原始审批人**: +会签节点进入运行状态时,根据流程定义确定并获得独立待办的审批人。 + +**加签审批人**: +会签节点运行期间通过动态加签加入并获得独立待办的审批人;不属于该节点的原始审批人。 + +**委托**: +当前审批人临时将自己的待办交给另一人处理,处理结果随后返回原审批人;委托不增加会签人数。 +_避免使用_:加签 + +**转办**: +当前审批人将自己的待办及处理权永久移交给另一人,原审批人不再处理该待办。 +_避免使用_:加签、委托 diff --git a/db/0pigxx_boot.sql b/db/0pigxx_boot.sql index 9e3fe24d5..df8a6fc33 100644 --- a/db/0pigxx_boot.sql +++ b/db/0pigxx_boot.sql @@ -1753,12 +1753,44 @@ CREATE TABLE `process_node_record_assign_user` ( `node_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ' 节点名称', `task_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '任务类型', `local_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '表单本地数据', + `node_record_id` bigint NOT NULL COMMENT '本轮节点执行记录id', + `assignment_origin` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'ORIGINAL' COMMENT '分配来源 ORIGINAL/ADD_SIGN', + `initial_user_id` bigint NOT NULL COMMENT '本份待办最初分配用户id', + `add_sign_record_id` bigint DEFAULT NULL COMMENT '动态加签批次记录id', `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '所属租户id', PRIMARY KEY (`id`) USING BTREE, - KEY `user_idx_id` (`id`) USING BTREE + UNIQUE KEY `uk_node_record_user` (`tenant_id`,`node_record_id`,`user_id`) USING BTREE, + KEY `user_idx_id` (`id`) USING BTREE, + KEY `idx_add_sign_record_id` (`add_sign_record_id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=597 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='流程节点记录-执行人'; +-- ---------------------------- +-- Table structure for process_task_add_sign_record +-- ---------------------------- +DROP TABLE IF EXISTS `process_task_add_sign_record`; +CREATE TABLE `process_task_add_sign_record` ( + `id` bigint NOT NULL COMMENT '主键id', + `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `flow_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '流程id', + `process_instance_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '流程实例id', + `node_record_id` bigint NOT NULL COMMENT '本轮节点执行记录id', + `node_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '节点id', + `source_task_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '发起加签的任务id', + `added_by_user_id` bigint NOT NULL COMMENT '加签发起人id', + `reason` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '加签原因', + `target_user_ids` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '本批次目标用户快照', + `participant_user_ids_before` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作前参与人快照', + `participant_user_ids_after` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作后参与人快照', + `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '所属租户id', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_add_sign_node_record` (`tenant_id`,`node_record_id`) USING BTREE, + KEY `idx_add_sign_process_instance` (`tenant_id`,`process_instance_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='流程动态加签批次记录'; + + -- ---------------------------- -- Table structure for process_starter -- ---------------------------- diff --git a/db/3pigxx_flow.sql b/db/3pigxx_flow.sql index 49358fceb..825cfc162 100644 --- a/db/3pigxx_flow.sql +++ b/db/3pigxx_flow.sql @@ -194,12 +194,44 @@ CREATE TABLE `process_node_record_assign_user` ( `node_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ' 节点名称', `task_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '任务类型', `local_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '表单本地数据', + `node_record_id` bigint NOT NULL COMMENT '本轮节点执行记录id', + `assignment_origin` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'ORIGINAL' COMMENT '分配来源 ORIGINAL/ADD_SIGN', + `initial_user_id` bigint NOT NULL COMMENT '本份待办最初分配用户id', + `add_sign_record_id` bigint DEFAULT NULL COMMENT '动态加签批次记录id', `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '所属租户id', PRIMARY KEY (`id`) USING BTREE, - KEY `user_idx_id` (`id`) USING BTREE + UNIQUE KEY `uk_node_record_user` (`tenant_id`,`node_record_id`,`user_id`) USING BTREE, + KEY `user_idx_id` (`id`) USING BTREE, + KEY `idx_add_sign_record_id` (`add_sign_record_id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=597 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='流程节点记录-执行人'; +-- ---------------------------- +-- Table structure for process_task_add_sign_record +-- ---------------------------- +DROP TABLE IF EXISTS `process_task_add_sign_record`; +CREATE TABLE `process_task_add_sign_record` ( + `id` bigint NOT NULL COMMENT '主键id', + `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标志', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `flow_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '流程id', + `process_instance_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '流程实例id', + `node_record_id` bigint NOT NULL COMMENT '本轮节点执行记录id', + `node_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '节点id', + `source_task_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '发起加签的任务id', + `added_by_user_id` bigint NOT NULL COMMENT '加签发起人id', + `reason` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '加签原因', + `target_user_ids` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '本批次目标用户快照', + `participant_user_ids_before` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作前参与人快照', + `participant_user_ids_after` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作后参与人快照', + `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '所属租户id', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_add_sign_node_record` (`tenant_id`,`node_record_id`) USING BTREE, + KEY `idx_add_sign_process_instance` (`tenant_id`,`process_instance_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='流程动态加签批次记录'; + + -- ---------------------------- -- Table structure for process_starter -- ---------------------------- diff --git a/db/upgrade/20260716_dynamic_add_sign.sql b/db/upgrade/20260716_dynamic_add_sign.sql new file mode 100644 index 000000000..e79a4594b --- /dev/null +++ b/db/upgrade/20260716_dynamic_add_sign.sql @@ -0,0 +1,30 @@ +-- 动态会签加签:存量数据库一次性升级脚本 + +ALTER TABLE `process_node_record_assign_user` + ADD COLUMN `node_record_id` bigint DEFAULT NULL COMMENT '本轮节点执行记录id' AFTER `local_data`, + ADD COLUMN `assignment_origin` varchar(20) NOT NULL DEFAULT 'ORIGINAL' COMMENT '分配来源 ORIGINAL/ADD_SIGN' AFTER `node_record_id`, + ADD COLUMN `initial_user_id` bigint DEFAULT NULL COMMENT '本份待办最初分配用户id' AFTER `assignment_origin`, + ADD COLUMN `add_sign_record_id` bigint DEFAULT NULL COMMENT '动态加签批次记录id' AFTER `initial_user_id`, + ADD UNIQUE KEY `uk_node_record_user` (`tenant_id`, `node_record_id`, `user_id`), + ADD KEY `idx_add_sign_record_id` (`add_sign_record_id`); + +CREATE TABLE `process_task_add_sign_record` ( + `id` bigint NOT NULL COMMENT '主键id', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `flow_id` varchar(255) NOT NULL COMMENT '流程id', + `process_instance_id` varchar(50) NOT NULL COMMENT '流程实例id', + `node_record_id` bigint NOT NULL COMMENT '本轮节点执行记录id', + `node_id` varchar(50) NOT NULL COMMENT '节点id', + `source_task_id` varchar(255) NOT NULL COMMENT '发起加签的任务id', + `added_by_user_id` bigint NOT NULL COMMENT '加签发起人id', + `reason` varchar(100) NOT NULL COMMENT '加签原因', + `target_user_ids` longtext NOT NULL COMMENT '本批次目标用户快照', + `participant_user_ids_before` longtext NOT NULL COMMENT '操作前参与人快照', + `participant_user_ids_after` longtext DEFAULT NULL COMMENT '操作后参与人快照', + `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '所属租户id', + PRIMARY KEY (`id`), + KEY `idx_add_sign_node_record` (`tenant_id`, `node_record_id`), + KEY `idx_add_sign_process_instance` (`tenant_id`, `process_instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='流程动态加签批次记录'; diff --git a/docs/adr/0001-model-add-sign-as-dynamic-countersign-append.md b/docs/adr/0001-model-add-sign-as-dynamic-countersign-append.md new file mode 100644 index 000000000..15626f380 --- /dev/null +++ b/docs/adr/0001-model-add-sign-as-dynamic-countersign-append.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 将加签建模为动态追加会签人 + +工作流中的“加签”采用动态追加会签人的语义:在当前会签节点仍处于运行状态时,为新增审批人创建独立待办,并将其纳入同一节点的会签完成条件。只有仍持有未完成待办的原始审批人可以发起动态加签;加签审批人、已经完成审批的原始审批人、流程发起人和普通管理员均不能发起。加签成功后,发起人的原待办保持待审批,且不需要等待新增审批人;原审批人与新增审批人的待办并行处理,加签操作本身不代表审批同意。同一流程实例的同一会签节点内,审批人按用户唯一,每个用户最多拥有一份待办;该约束由后端保证,前端同时过滤已有审批人。首版只允许并行会签模式使用动态加签;或签、依次审批和单人审批不提供加签能力。动态加签由流程设计者在每个并行会签节点上显式开启,默认关闭;配置直接保存在现有节点数据中,以简单实现优先,不提供发布版本隔离。一次加签可以选择多名审批人,但必须整批通过校验并原子提交;任何目标不合法时不创建任何新增待办,一次提交只形成一条包含全部新增人员的加签记录。同一原始审批人在完成自己的待办前可以多次发起加签,每次分别校验并记录;其待办完成后立即失去加签权限。首版不提供撤销加签或减签:新增待办一旦创建便作为正式会签待办参与完成条件。加签审批人可以转办自己的待办,但不能发起加签;转办目标不能是当前节点已有审批人,转办仅替换该待办的处理人,不改变会签任务总数。发起加签必须填写 2~100 字的原因;系统记录发起人、新增审批人、原因、时间、流程实例、节点、发起待办及变更前后的会签人员,并在审批记录中展示。桌面端与移动端在同一版本提供一致的加签入口和规则,流程设计配置仍由桌面端承担。现有基于任务委托的 `delegateTask` 保留为“委托”,不作为加签实现,以免委托返回原审批人的生命周期与会签人数变化混为一谈。 diff --git a/docs/adr/0002-use-flowable-dynamic-multi-instance-for-add-sign.md b/docs/adr/0002-use-flowable-dynamic-multi-instance-for-add-sign.md new file mode 100644 index 000000000..1703b29e7 --- /dev/null +++ b/docs/adr/0002-use-flowable-dynamic-multi-instance-for-add-sign.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 使用 Flowable 动态多实例实现加签 + +动态加签使用 Flowable 原生 `RuntimeService.addMultiInstanceExecution` 向运行中的会签活动添加执行实例,并由 Flowable 作为任务生命周期、实例数量和节点完成状态的唯一事实来源。PIGX 业务表只保存审批人映射、查询投影和加签审计,不创建一套独立的附加审批任务,以避免引擎状态与业务状态发生双写分歧。加签与节点完成发生竞争时,以先提交成功的事务为准:加签先成功则节点等待新增实例,节点先完成则加签失败;系统绝不重新打开已经完成的节点。 diff --git a/docs/design/workflow-dynamic-add-sign.md b/docs/design/workflow-dynamic-add-sign.md new file mode 100644 index 000000000..39408d257 --- /dev/null +++ b/docs/design/workflow-dynamic-add-sign.md @@ -0,0 +1,229 @@ +# 工作流动态加签设计 + +> 状态:已实现(2026-07-16) + +## 目标 + +为运行中的并行会签节点动态追加一名或多名独立审批人。新增审批人与原审批人并行处理,并进入同一会签完成条件。 + +首版不实现前加签、后加签、撤销加签或减签;现有 `delegateTask` 继续表示委托,不复用为加签。 + +## 业务规则 + +1. 仅配置为并行会签且开启“允许审批人加签”的节点可用。 +2. 只有仍持有未完成待办的原始审批人可以发起;加签审批人、转办接手人、已完成审批人、流程发起人和普通管理员均不可以发起。 +3. 加签不完成或暂停发起人的待办;所有待办继续并行处理。 +4. 同一轮节点执行中,每个用户最多参与一次。已有原始审批人、已有加签审批人及曾经接手过该轮任务的用户均不可再次加入。 +5. 一次可以选择多人,整批校验、整批成功或整批失败。 +6. 原始审批人在自己的待办完成前可以多次加签。 +7. 加签原因必填,长度为 2~100 字。 +8. 加签审批人可以转办,但不能继续加签;转办不改变会签实例总数。 +9. 任意新增审批人拒绝时,继续执行当前会签的拒绝规则;全部参与人同意后节点才通过。 +10. 加签与节点完成竞争时,以先提交成功的事务为准,不重新打开已完成节点。 + +## 配置模型 + +在 `Node` 增加: + +```java +private Boolean allowAddSign; +``` + +- 设计器仅在多人审批模式为会签时显示开关。 +- 新节点默认 `false`;字段为空时后端也按 `false` 处理。 +- 开关直接保存在现有 `process_node_data.data` JSON 中。 +- 为保持实现简单,不提供发布版本隔离;修改节点配置可能影响正在运行的实例。 + +## 接口设计 + +### 查询任务详情 + +扩展现有 `GET /task/task/queryTask` 响应: + +```json +{ + "canAddSign": true, + "participantUserIds": [1, 2, 3] +} +``` + +`canAddSign` 必须由后端根据当前登录人、活动任务、原始分配关系、节点模式和开关综合计算,前端不能自行推导权限。 + +### 发起加签 + +新增 `POST /task/task/addSign`: + +```json +{ + "taskId": "12501", + "targetUserIdList": [31, 32], + "reason": "需要法务和财务共同确认" +} +``` + +客户端不提交操作人、流程实例或节点 ID;这些值由后端根据登录人和 `taskId` 获取,避免越权和参数篡改。 + +成功返回本次加签记录 ID 和新增任务摘要;业务校验失败时整批不产生任务。 + +## 后端处理流程 + +```mermaid +flowchart TD + A["接收 taskId、目标用户、原因"] --> B["查询当前登录人的活动任务"] + B --> C["锁定当前 ProcessNodeRecord"] + C --> D["校验原始审批人、会签模式和允许加签开关"] + D --> E["批量校验租户用户、输入重复和节点参与历史"] + E --> F["创建一条批次加签审计记录"] + F --> G["逐人调用 addMultiInstanceExecution"] + G --> H["任务监听器写入执行人记录"] + H --> I["保存变更后参与人快照并提交事务"] +``` + +核心调用使用 Flowable 8 的: + +```java +runtimeService.addMultiInstanceExecution(nodeId, parentExecutionId, executionVariables); +``` + +处理要点: + +- 通过当前任务 `executionId` 找到并行多实例父执行实例。 +- `executionVariables` 写入模型已有的 `${nodeId}_assignee_temp` 执行人变量。 +- 同时写入 `assignmentOrigin=ADD_SIGN`、`initialUserId`、`addSignRecordId` 和 `addedByUserId` 等局部元数据。 +- Flowable 负责增加 `nrOfInstances`、创建任务和计算节点完成状态。 +- 不手工修改 Flowable 运行表,不在业务表中另造可完成的审批任务。 + +## 数据记录 + +### 执行人记录扩展 + +在 `process_node_record_assign_user` 增加: + +- `node_record_id`:本轮节点执行记录 ID,用于区分退回后再次进入同一节点。 +- `assignment_origin`:`ORIGINAL` 或 `ADD_SIGN`。 +- `initial_user_id`:本份待办最初分配的用户;转办后保持不变。 +- `add_sign_record_id`:加签产生的任务关联到对应批次记录,原始任务为空。 + +为新记录建立 `(tenant_id, node_record_id, user_id)` 唯一约束,配合节点行锁保证同一轮节点内人员唯一。历史兼容和数据回填不在本次范围内。 + +权限判断同时要求: + +- `assignment_origin = ORIGINAL`; +- 当前登录人等于 `initial_user_id`; +- 当前登录人仍是任务现任 `assignee`; +- 任务和节点都处于活动状态。 + +因此原始任务转办给其他用户后,接手人不能发起加签。 + +### 加签审计记录 + +新增 `process_task_add_sign_record`,一批操作一条记录,保存: + +- 租户、流程、流程实例、本轮节点记录、节点和来源任务; +- 发起人、加签原因、操作时间; +- 本次目标用户快照; +- 操作前和操作后的参与人快照。 + +只记录事务成功的加签;失败请求记录应用日志,不生成半成品审计数据。 + +## 并发与事务 + +- `addSign` 与会签任务 `completeTask` 在操作同一节点前,锁定相同的活动 `process_node_record` 行。 +- 所有目标用户先完成校验,再创建任何多实例执行。 +- 批次记录、Flowable 多实例、任务监听记录处于同一事务;任一步失败全部回滚。 +- 两个审批人同时加签同一用户时,一个成功,另一个在获得锁后因重复参与失败。 +- 节点先完成时返回“当前审批节点已结束,无法加签”;不做节点重开或补偿任务。 +- Flowable 乐观锁异常转换为明确的并发操作提示,不向前端暴露引擎异常文本。 + +## 转办约束 + +现有转办接口应收紧为单目标用户,并增加当前节点参与人校验: + +- 转办目标不得在本轮节点参与历史中出现。 +- 转办沿用原任务的 `assignment_origin`、`initial_user_id` 和 `add_sign_record_id`。 +- 加签任务转办后仍是加签任务;原始任务转办后,接手人不获得加签权限。 + +## 前端设计 + +### 流程设计器 + +在审批人抽屉的“多人审批方式”下增加“允许审批人加签”开关。切换到或签、依次审批或单人审批时隐藏并重置为 `false`。 + +### 桌面待办 + +- 在“提交、拒绝、转办”操作区增加“加签”。 +- 仅当后端返回 `canAddSign=true` 时显示。 +- 弹窗复用组织选择器,支持多选,过滤 `participantUserIds`。 +- 原因输入 2~100 字;提交期间禁用重复点击。 +- 成功后关闭弹窗、刷新任务详情与流程进度。 + +### 移动端待办 + +移动端使用相同接口和规则,增加“加签”入口及适配小屏的底部弹层;成功后刷新当前任务,不改变原待办的可审批状态。 + +## 审批记录展示 + +在流程进度时间线中增加独立事件: + +> 张三加签李四、王五:需要法务和财务共同确认 + +新增审批人的任务处理记录继续按普通会签任务展示,并标注“加签”。转办时继续展示完整的处理人变化轨迹。 + +## 错误提示 + +- 当前节点未开启加签 +- 仅并行会签节点支持加签 +- 当前任务已完成或已转办 +- 只有未完成审批的原始审批人可以加签 +- 请选择加签人员 +- 加签原因需为 2~100 字 +- 用户已参与当前会签:{姓名} +- 当前审批节点已结束,无法加签 +- 节点状态已变化,请刷新后重试 + +## 验收矩阵 + +### 后端 + +1. A、B 会签,A 加签 C 后产生三份并行待办,A 的原待办保持活动。 +2. A、B、C 全部同意后节点通过;C 拒绝时执行当前拒绝策略。 +3. A 一次加签 C、D,任一目标不合法时 C、D 都不产生任务。 +4. A 可以在完成前多次加签,完成后再次请求被拒绝。 +5. C 不能继续加签;C 可转办给未参与的 D,实例总数不变。 +6. 原始任务 A 转办给 D 后,D 不能加签。 +7. 重复用户、跨租户用户、无效用户、空原因和超长原因均被拒绝。 +8. 或签、依次审批、单人审批及未开启开关的节点均被拒绝。 +9. 并发加签同一用户只允许一个请求成功。 +10. 加签与最后审批并发时符合先提交者生效规则,不出现孤儿任务或节点重开。 +11. 审计记录、执行人记录与 Flowable 活动任务一致。 + +### 前端 + +1. 设计器开关仅在会签配置下可见并正确保存。 +2. 桌面端和移动端均按 `canAddSign` 控制入口。 +3. 已参与人员不可选择,批量选择和原因校验一致。 +4. 加签成功后流程进度、待办列表和审批记录即时刷新。 +5. 后端拒绝并发或越权请求时,页面保留输入并显示明确原因。 + +## 主要改动位置 + +后端集中在: + +- `pigx-flow-api/.../dto/Node.java` +- `pigx-flow-api/.../entity/ProcessNodeRecordAssignUser.java` +- `pigx-flow-biz/.../controller/ProcessTaskController.java` +- `pigx-flow-biz/.../service/ITaskService.java` +- `pigx-flow-biz/.../service/impl/TaskServiceImpl.java` +- `pigx-flow-biz/.../service/impl/FlowableEngineServiceImpl.java` +- `pigx-flow-biz/.../support/listeners/FlowProcessEventListener.java` +- `db/3pigxx_flow.sql` 与 `db/0pigxx_boot.sql` +- `db/upgrade/20260716_dynamic_add_sign.sql`(存量数据库升级) + +前端集中在: + +- `src/views/flow/workflow/components/drawer/approverDrawer.vue` +- `src/views/flow/workflow/components/addNode.vue` +- `src/api/flow/task/index.ts` +- `src/views/flow/task/pending.vue` +- `src/views/flow/task/handler/` +- `src/views/flow/mobile/task.vue` diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java index 73bad9241..980f97f24 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java @@ -105,6 +105,30 @@ public class ProcessInstanceConstant { */ public static final int MULTIPLE_MODE_ALL_SORT = 3; + /** 原始流程分配。 */ + public static final String ASSIGNMENT_ORIGIN_ORIGINAL = "ORIGINAL"; + + /** 动态加签分配。 */ + public static final String ASSIGNMENT_ORIGIN_ADD_SIGN = "ADD_SIGN"; + + /** 执行变量:分配来源。 */ + public static final String VAR_ASSIGNMENT_ORIGIN = "assignmentOrigin"; + + /** 执行变量:本份任务的最初审批人。 */ + public static final String VAR_INITIAL_USER_ID = "initialUserId"; + + /** 执行变量:本轮节点记录。 */ + public static final String VAR_NODE_RECORD_ID = "nodeRecordId"; + + /** 执行变量:加签审计记录。 */ + public static final String VAR_ADD_SIGN_RECORD_ID = "addSignRecordId"; + + /** 执行变量:加签发起人。 */ + public static final String VAR_ADDED_BY_USER_ID = "addedByUserId"; + + /** 任务变量:本次分配操作类型。 */ + public static final String VAR_ASSIGNMENT_OPERATION = "assignmentOperation"; + /** * 任务分配类型 定义审批节点的执行人分配方式 */ diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignResultDto.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignResultDto.java new file mode 100644 index 000000000..97662e999 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignResultDto.java @@ -0,0 +1,19 @@ +package com.pig4cloud.pigx.flow.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +/** + * 动态加签结果。 + */ +@Data +@AllArgsConstructor +public class AddSignResultDto { + + private Long recordId; + + private List tasks; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskDto.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskDto.java new file mode 100644 index 000000000..1a6f08d31 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskDto.java @@ -0,0 +1,22 @@ +package com.pig4cloud.pigx.flow.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 动态加签请求。 + */ +@Data +public class AddSignTaskDto { + + /** 当前审批任务 ID。 */ + private String taskId; + + /** 本批次新增的审批人。 */ + private List targetUserIdList; + + /** 加签原因。 */ + private String reason; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskSummaryDto.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskSummaryDto.java new file mode 100644 index 000000000..83528ab39 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/AddSignTaskSummaryDto.java @@ -0,0 +1,17 @@ +package com.pig4cloud.pigx.flow.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 动态加签后新建任务摘要。 + */ +@Data +@AllArgsConstructor +public class AddSignTaskSummaryDto { + + private String taskId; + + private Long userId; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java index 9c116da2b..d4f93c34d 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/Node.java @@ -85,6 +85,11 @@ public class Node { */ private Integer multipleMode; + /** + * 是否允许当前并行会签节点的原始审批人动态加签 + */ + private Boolean allowAddSign; + /** * 部门主管级别 当assignedType为LEADER时,指定查找第几级主管 */ diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/ProcessNodeRecordAssignUserParamDto.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/ProcessNodeRecordAssignUserParamDto.java index 0c209a352..bab6406e5 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/ProcessNodeRecordAssignUserParamDto.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/ProcessNodeRecordAssignUserParamDto.java @@ -117,4 +117,16 @@ public class ProcessNodeRecordAssignUserParamDto { */ private String taskType; + /** 本轮节点执行记录 ID。 */ + private Long nodeRecordId; + + /** 分配来源:ORIGINAL 或 ADD_SIGN。 */ + private String assignmentOrigin; + + /** 本份待办最初分配的用户。 */ + private Long initialUserId; + + /** 动态加签批次记录 ID。 */ + private Long addSignRecordId; + } diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessNodeRecordAssignUser.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessNodeRecordAssignUser.java index 45ab8b631..20e418278 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessNodeRecordAssignUser.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessNodeRecordAssignUser.java @@ -158,6 +158,22 @@ public class ProcessNodeRecordAssignUser { @TableField("local_data") private String localData; + /** 本轮节点执行记录 ID。 */ + @TableField("node_record_id") + private Long nodeRecordId; + + /** 分配来源:ORIGINAL 或 ADD_SIGN。 */ + @TableField("assignment_origin") + private String assignmentOrigin; + + /** 本份待办最初分配的用户。 */ + @TableField("initial_user_id") + private Long initialUserId; + + /** 动态加签批次记录 ID。 */ + @TableField("add_sign_record_id") + private Long addSignRecordId; + /** * 主键ID *

diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessTaskAddSignRecord.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessTaskAddSignRecord.java new file mode 100644 index 000000000..026bb9bff --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessTaskAddSignRecord.java @@ -0,0 +1,69 @@ +package com.pig4cloud.pigx.flow.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.pig4cloud.pigx.common.core.util.TenantTable; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * 动态加签批次审计记录。 + */ +@Getter +@Setter +@TenantTable +@Accessors(chain = true) +@TableName("process_task_add_sign_record") +public class ProcessTaskAddSignRecord { + + @TableId(value = "id", type = IdType.ASSIGN_ID) + private Long id; + + @TableField("flow_id") + private String flowId; + + @TableField("process_instance_id") + private String processInstanceId; + + @TableField("node_record_id") + private Long nodeRecordId; + + @TableField("node_id") + private String nodeId; + + @TableField("source_task_id") + private String sourceTaskId; + + @TableField("added_by_user_id") + private Long addedByUserId; + + @TableField("reason") + private String reason; + + @TableField("target_user_ids") + private String targetUserIds; + + @TableField("participant_user_ids_before") + private String participantUserIdsBefore; + + @TableField("participant_user_ids_after") + private String participantUserIdsAfter; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/AddSignRecordVo.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/AddSignRecordVo.java new file mode 100644 index 000000000..80d5004d4 --- /dev/null +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/AddSignRecordVo.java @@ -0,0 +1,24 @@ +package com.pig4cloud.pigx.flow.vo; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 流程进度中的动态加签事件。 + */ +@Data +public class AddSignRecordVo { + + private Long id; + + private String addedByName; + + private List targetUserNames; + + private String reason; + + private LocalDateTime createTime; + +} diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/NodeVo.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/NodeVo.java index c37f47289..d749d1732 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/NodeVo.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/NodeVo.java @@ -30,6 +30,11 @@ public class NodeVo { */ private List userVoList; + /** + * 当前节点的动态加签批次事件 + */ + private List addSignRecordList; + /** * 节点占位提示 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java index efc88cf45..1507d6315 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java @@ -1,6 +1,7 @@ package com.pig4cloud.pigx.flow.controller; import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.dto.AddSignTaskDto; import com.pig4cloud.pigx.flow.dto.TaskParamDto; import com.pig4cloud.pigx.flow.service.ITaskService; import lombok.RequiredArgsConstructor; @@ -93,6 +94,16 @@ public class ProcessTaskController { } + /** + * 向当前并行会签节点动态追加审批人。 + * @param addSignTaskDto 当前任务、目标用户及加签原因 + * @return 加签批次和新增任务摘要 + */ + @PostMapping("addSign") + public R addSign(@RequestBody AddSignTaskDto addSignTaskDto) { + return taskService.addSign(addSignTaskDto); + } + /** * 加签完成任务 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessNodeRecordMapper.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessNodeRecordMapper.java index b058829de..6c708460b 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessNodeRecordMapper.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessNodeRecordMapper.java @@ -3,6 +3,8 @@ package com.pig4cloud.pigx.flow.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * 流程节点记录数据访问接口 @@ -17,4 +19,40 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface ProcessNodeRecordMapper extends BaseMapper { + /** + * 锁定当前活动节点,串行化加签与任务完成。 + */ + @Select(""" + SELECT * + FROM process_node_record + WHERE process_instance_id = #{processInstanceId} + AND node_id = #{nodeId} + AND status = 1 + AND tenant_id = #{tenantId} + AND del_flag = '0' + ORDER BY id DESC + LIMIT 1 + FOR UPDATE + """) + ProcessNodeRecord selectActiveForUpdate(@Param("processInstanceId") String processInstanceId, + @Param("nodeId") String nodeId, @Param("tenantId") Long tenantId); + + /** + * 按任务已绑定的本轮节点记录加锁,避免误锁同节点的其他执行记录。 + */ + @Select(""" + SELECT * + FROM process_node_record + WHERE id = #{nodeRecordId} + AND process_instance_id = #{processInstanceId} + AND node_id = #{nodeId} + AND status = 1 + AND tenant_id = #{tenantId} + AND del_flag = '0' + FOR UPDATE + """) + ProcessNodeRecord selectActiveByIdForUpdate(@Param("nodeRecordId") Long nodeRecordId, + @Param("processInstanceId") String processInstanceId, @Param("nodeId") String nodeId, + @Param("tenantId") Long tenantId); + } diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessTaskAddSignRecordMapper.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessTaskAddSignRecordMapper.java new file mode 100644 index 000000000..0ed24c2b7 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/mapper/ProcessTaskAddSignRecordMapper.java @@ -0,0 +1,12 @@ +package com.pig4cloud.pigx.flow.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * 动态加签批次审计数据访问接口。 + */ +@Mapper +public interface ProcessTaskAddSignRecordMapper extends BaseMapper { +} diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java index 982b7f308..ac1cb7033 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java @@ -1,6 +1,7 @@ package com.pig4cloud.pigx.flow.service; import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.dto.AddSignTaskDto; import com.pig4cloud.pigx.flow.dto.TaskParamDto; /** @@ -57,6 +58,13 @@ public interface ITaskService { */ R delegateTask(TaskParamDto taskParamDto); + /** + * 向运行中的并行会签节点动态追加审批人。 + * @param addSignTaskDto 当前任务、目标用户及加签原因 + * @return 加签批次和新增任务摘要 + */ + R addSign(AddSignTaskDto addSignTaskDto); + /** * 加签完成任务 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordAssignUserServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordAssignUserServiceImpl.java index f0e81029f..78074af20 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordAssignUserServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordAssignUserServiceImpl.java @@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.pig4cloud.pigx.common.core.util.R; import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordAssignUserParamDto; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordAssignUserMapper; import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -29,10 +33,13 @@ import java.time.LocalDateTime; * @since 2023-05-10 */ @Service +@RequiredArgsConstructor public class ProcessNodeRecordAssignUserServiceImpl extends ServiceImpl implements IProcessNodeRecordAssignUserService { + private final IProcessNodeRecordService processNodeRecordService; + /** * 设置执行人 *

@@ -53,36 +60,100 @@ public class ProcessNodeRecordAssignUserServiceImpl */ @Override public R addAssignUser(ProcessNodeRecordAssignUserParamDto processNodeRecordAssignUserParamDto) { - // 如果有审批意见,说明是转办、委托等操作,需要更新上一条记录 - if (StrUtil.isNotBlank(processNodeRecordAssignUserParamDto.getApproveDesc())) { - ProcessNodeRecordAssignUser processNodeRecordAssignUser = this.lambdaQuery() + ProcessNodeRecordAssignUser previousAssignUser = this.lambdaQuery() .eq(ProcessNodeRecordAssignUser::getTaskId, processNodeRecordAssignUserParamDto.getTaskId()) .orderByDesc(ProcessNodeRecordAssignUser::getId) .last("limit 1") .one(); - if (processNodeRecordAssignUser != null) { - // 更新上一个执行人的记录为已完成 - processNodeRecordAssignUser.setApproveDesc(processNodeRecordAssignUserParamDto.getApproveDesc()); - processNodeRecordAssignUser.setTaskType(processNodeRecordAssignUserParamDto.getTaskType()); - processNodeRecordAssignUser.setStatus(NodeStatusEnum.YJS.getCode()); - processNodeRecordAssignUser.setEndTime(LocalDateTime.now()); - this.updateById(processNodeRecordAssignUser); - } + Long nodeRecordId = processNodeRecordAssignUserParamDto.getNodeRecordId(); + if (previousAssignUser != null) { + nodeRecordId = previousAssignUser.getNodeRecordId(); } + if (nodeRecordId == null) { + ProcessNodeRecord nodeRecord = processNodeRecordService.lambdaQuery() + .eq(ProcessNodeRecord::getProcessInstanceId, + processNodeRecordAssignUserParamDto.getProcessInstanceId()) + .eq(ProcessNodeRecord::getNodeId, processNodeRecordAssignUserParamDto.getNodeId()) + .eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode()) + .orderByDesc(ProcessNodeRecord::getId) + .last("limit 1") + .one(); + if (nodeRecord != null) { + nodeRecordId = nodeRecord.getId(); + } + } + + ProcessNodeRecordAssignUser existingParticipant = null; + if (nodeRecordId != null) { + existingParticipant = this.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecordId) + .eq(ProcessNodeRecordAssignUser::getUserId, processNodeRecordAssignUserParamDto.getUserId()) + .one(); + if (existingParticipant != null && (previousAssignUser == null + || !processNodeRecordAssignUserParamDto.getUserId().equals(previousAssignUser.getUserId()))) { + if (previousAssignUser != null + && StrUtil.equals("RESOLVED", processNodeRecordAssignUserParamDto.getTaskType())) { + finishAssignment(previousAssignUser, processNodeRecordAssignUserParamDto); + existingParticipant.setExecutionId(processNodeRecordAssignUserParamDto.getExecutionId()); + existingParticipant.setStatus(NodeStatusEnum.JXZ.getCode()); + existingParticipant.setStartTime(LocalDateTime.now()); + existingParticipant.setEndTime(null); + existingParticipant.setApproveDesc(StrUtil.EMPTY); + existingParticipant.setTaskType("RESOLVED"); + this.updateById(existingParticipant); + return R.ok(); + } + return R.failed("用户已参与当前会签"); + } + } + + if (previousAssignUser != null + && previousAssignUser.getUserId().equals(processNodeRecordAssignUserParamDto.getUserId()) + && previousAssignUser.getStatus().equals(NodeStatusEnum.JXZ.getCode())) { + return R.ok(); + } + + String assignmentOrigin = processNodeRecordAssignUserParamDto.getAssignmentOrigin(); + Long initialUserId = processNodeRecordAssignUserParamDto.getInitialUserId(); + Long addSignRecordId = processNodeRecordAssignUserParamDto.getAddSignRecordId(); + if (previousAssignUser != null) { + assignmentOrigin = previousAssignUser.getAssignmentOrigin(); + initialUserId = previousAssignUser.getInitialUserId(); + addSignRecordId = previousAssignUser.getAddSignRecordId(); + + finishAssignment(previousAssignUser, processNodeRecordAssignUserParamDto); + } + assignmentOrigin = StrUtil.blankToDefault(assignmentOrigin, + ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL); + initialUserId = initialUserId == null ? processNodeRecordAssignUserParamDto.getUserId() : initialUserId; // 创建新的执行人记录 ProcessNodeRecordAssignUser processNodeRecordAssignUser = BeanUtil .copyProperties(processNodeRecordAssignUserParamDto, ProcessNodeRecordAssignUser.class); + processNodeRecordAssignUser.setNodeRecordId(nodeRecordId); + processNodeRecordAssignUser.setAssignmentOrigin(assignmentOrigin); + processNodeRecordAssignUser.setInitialUserId(initialUserId); + processNodeRecordAssignUser.setAddSignRecordId(addSignRecordId); processNodeRecordAssignUser.setStartTime(LocalDateTime.now()); processNodeRecordAssignUser.setStatus(NodeStatusEnum.JXZ.getCode()); // 进行中状态 - processNodeRecordAssignUser.setApproveDesc(""); - processNodeRecordAssignUser.setTaskType(""); + processNodeRecordAssignUser.setApproveDesc(StrUtil.EMPTY); + processNodeRecordAssignUser.setTaskType(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN + .equals(assignmentOrigin) ? "ADD_SIGN" : StrUtil.EMPTY); this.save(processNodeRecordAssignUser); return R.ok(); } + private void finishAssignment(ProcessNodeRecordAssignUser assignment, + ProcessNodeRecordAssignUserParamDto paramDto) { + assignment.setApproveDesc(paramDto.getApproveDesc()); + assignment.setTaskType(StrUtil.blankToDefault(paramDto.getTaskType(), "TRANSFER")); + assignment.setStatus(NodeStatusEnum.YJS.getCode()); + assignment.setEndTime(LocalDateTime.now()); + this.updateById(assignment); + } + /** * 任务完成通知 *

@@ -116,6 +187,10 @@ public class ProcessNodeRecordAssignUserServiceImpl .one(); // 更新记录为已完成状态 + if (processNodeRecordAssignUser == null) { + return R.failed("未找到活动任务执行人记录"); + } + processNodeRecordAssignUser.setStatus(NodeStatusEnum.YJS.getCode()); processNodeRecordAssignUser.setApproveDesc(processNodeRecordAssignUserParamDto.getApproveDesc()); processNodeRecordAssignUser.setEndTime(LocalDateTime.now()); diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImpl.java index c6e7429e9..e727ad1cd 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImpl.java @@ -48,6 +48,16 @@ public class ProcessNodeRecordServiceImpl extends ServiceImpl @@ -274,6 +283,18 @@ public class TaskServiceImpl implements ITaskService { Node node = nodeDataService.getNodeData(taskResultDto.getFlowId(), taskResultDto.getNodeId()).getData(); Map formPerms = node.getFormPerms(); + ProcessNodeRecord activeNodeRecord = null; + List participantUserIds = Collections.emptyList(); + boolean canAddSign = false; + if (Boolean.TRUE.equals(currentTask) && task.isPresent()) { + ProcessNodeRecordAssignUser currentAssignment = findActiveAssignment(task.get().getId(), userId); + activeNodeRecord = findActiveNodeRecord(task.get(), currentAssignment); + if (activeNodeRecord != null) { + participantUserIds = listParticipantUserIds(activeNodeRecord.getId()); + canAddSign = canAddSign(task.get(), node, activeNodeRecord, currentAssignment, userId); + } + } + Process oaForms = processService.getByFlowId(taskResultDto.getFlowId()); if (oaForms == null) { return R.failed("流程不存在"); @@ -287,6 +308,8 @@ public class TaskServiceImpl implements ITaskService { .set("process", oaForms.getProcess()) .set("delegateAgain", taskResultDto.getDelegate()) .set("delegationTask", StrUtil.equals(taskResultDto.getDelegationState(), "PENDING")) + .set("canAddSign", canAddSign) + .set("participantUserIds", participantUserIds) .set("formItems", oaForms.getFormItems()) .set("formData", paramMap) .set("formConfig", oaForms.getFormConfig()) @@ -305,17 +328,31 @@ public class TaskServiceImpl implements ITaskService { * @return R 操作结果 */ @Override + @Transactional(rollbackFor = Exception.class) public R completeTask(TaskParamDto taskParamDto) { long userId = SecurityUtils.getUser().getId(); taskParamDto.setUserId(String.valueOf(userId)); + Task task = taskService.createTaskQuery() + .taskId(taskParamDto.getTaskId()) + .taskTenantId(TenantContextHolder.getTenantId().toString()) + .taskAssignee(String.valueOf(userId)) + .singleResult(); + if (task == null) { + return R.failed("当前任务已完成或无权限操作"); + } + ProcessNodeRecordAssignUser currentAssignment = findActiveAssignment(task.getId(), userId); + ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, currentAssignment, TenantContextHolder.getTenantId()); + if (nodeRecord == null) { + return R.failed("当前审批节点已结束,无法操作"); + } + Map taskLocalParamMap = taskParamDto.getTaskLocalParamMap(); if (CollUtil.isNotEmpty(taskLocalParamMap)) { taskService.setVariablesLocal(taskParamDto.getTaskId(), taskLocalParamMap); } // 始终获取 processInstanceId,确保触发器终止时可用 - Task task = taskService.createTaskQuery().taskId(taskParamDto.getTaskId()).singleResult(); taskParamDto.setProcessInstanceId(task.getProcessInstanceId()); if (CollUtil.isNotEmpty(taskParamDto.getFormData())) { @@ -400,6 +437,152 @@ public class TaskServiceImpl implements ITaskService { return R.failed("触发器调用失败: " + errorMsg); } + /** + * 向运行中的并行会签节点动态追加审批人。 + */ + @Override + @SneakyThrows + @Transactional(rollbackFor = Exception.class) + public R addSign(AddSignTaskDto addSignTaskDto) { + if (addSignTaskDto == null || StrUtil.isBlank(addSignTaskDto.getTaskId())) { + return R.failed("任务ID不能为空"); + } + List requestedUserIds = addSignTaskDto.getTargetUserIdList(); + if (CollUtil.isEmpty(requestedUserIds)) { + return R.failed("请选择加签人员"); + } + if (requestedUserIds.stream().anyMatch(Objects::isNull)) { + return R.failed("加签人员不能为空"); + } + LinkedHashSet targetUserIds = new LinkedHashSet<>(requestedUserIds); + if (targetUserIds.size() != requestedUserIds.size()) { + return R.failed("加签人员不能重复"); + } + String reason = StrUtil.trim(addSignTaskDto.getReason()); + if (reason == null || reason.length() < 2 || reason.length() > 100) { + return R.failed("加签原因需为 2~100 字"); + } + + long userId = SecurityUtils.getUser().getId(); + Long tenantId = TenantContextHolder.getTenantId(); + Task sourceTask = taskService.createTaskQuery() + .taskId(addSignTaskDto.getTaskId()) + .taskTenantId(tenantId.toString()) + .taskAssignee(String.valueOf(userId)) + .singleResult(); + if (sourceTask == null) { + return R.failed("当前任务已完成或已转办"); + } + + ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(sourceTask.getId(), userId); + ProcessNodeRecord nodeRecord = lockActiveNodeRecord(sourceTask, sourceAssignment, tenantId); + if (nodeRecord == null) { + return R.failed("当前审批节点已结束,无法加签"); + } + + String flowId = NodeUtil.getFlowId(sourceTask.getProcessDefinitionId()); + Node node = nodeDataService.getNodeData(flowId, sourceTask.getTaskDefinitionKey()).getData(); + if (!isParallelCountersign(node)) { + return R.failed("仅并行会签节点支持加签"); + } + if (!Boolean.TRUE.equals(node.getAllowAddSign())) { + return R.failed("当前节点未开启加签"); + } + if (isProcessStarter(sourceTask.getProcessInstanceId(), userId)) { + return R.failed("流程发起人不能发起加签"); + } + + if (!isOriginalApprover(sourceTask, sourceAssignment, userId)) { + return R.failed("只有未完成审批的原始审批人可以加签"); + } + + List participantUserIds = listParticipantUserIds(nodeRecord.getId()); + List targetUsers = queryValidTenantUsers(new ArrayList<>(targetUserIds), tenantId); + if (targetUsers.size() != targetUserIds.size()) { + return R.failed("加签用户不存在或不属于当前租户"); + } + Map targetUserMap = new HashMap<>(); + for (SysUser targetUser : targetUsers) { + targetUserMap.put(targetUser.getUserId(), targetUser); + } + for (Long targetUserId : targetUserIds) { + if (participantUserIds.contains(targetUserId)) { + SysUser duplicateUser = targetUserMap.get(targetUserId); + String userName = duplicateUser == null ? targetUserId.toString() + : StrUtil.blankToDefault(duplicateUser.getName(), duplicateUser.getUsername()); + return R.failed(StrUtil.format("用户已参与当前会签:{}", userName)); + } + } + + ProcessTaskAddSignRecord addSignRecord = new ProcessTaskAddSignRecord() + .setFlowId(flowId) + .setProcessInstanceId(sourceTask.getProcessInstanceId()) + .setNodeRecordId(nodeRecord.getId()) + .setNodeId(sourceTask.getTaskDefinitionKey()) + .setSourceTaskId(sourceTask.getId()) + .setAddedByUserId(userId) + .setReason(reason) + .setTargetUserIds(objectMapper.writeValueAsString(targetUserIds)) + .setParticipantUserIdsBefore(objectMapper.writeValueAsString(participantUserIds)); + processTaskAddSignRecordMapper.insert(addSignRecord); + + List taskSummaries = new ArrayList<>(); + try { + for (Long targetUserId : targetUserIds) { + Map executionVariables = new HashMap<>(); + executionVariables.put(StrUtil.format("{}_assignee_temp", sourceTask.getTaskDefinitionKey()), + targetUserId); + executionVariables.put(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN, + ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN); + executionVariables.put(ProcessInstanceConstant.VAR_INITIAL_USER_ID, targetUserId); + executionVariables.put(ProcessInstanceConstant.VAR_NODE_RECORD_ID, nodeRecord.getId()); + executionVariables.put(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID, addSignRecord.getId()); + executionVariables.put(ProcessInstanceConstant.VAR_ADDED_BY_USER_ID, userId); + executionVariables.put("addSignReason", reason); + + // Flowable 8 允许以流程实例 ID 为搜索根,递归定位当前活动的多实例根执行。 + runtimeService.addMultiInstanceExecution(sourceTask.getTaskDefinitionKey(), + sourceTask.getProcessInstanceId(), executionVariables); + + Task newTask = taskService.createTaskQuery() + .processInstanceId(sourceTask.getProcessInstanceId()) + .taskDefinitionKey(sourceTask.getTaskDefinitionKey()) + .taskAssignee(targetUserId.toString()) + .active() + .singleResult(); + if (newTask == null) { + return rollbackFailed("新增会签任务失败,请刷新后重试"); + } + boolean assignmentRecorded = processNodeRecordAssignUserService.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecord.getId()) + .eq(ProcessNodeRecordAssignUser::getTaskId, newTask.getId()) + .eq(ProcessNodeRecordAssignUser::getUserId, targetUserId) + .eq(ProcessNodeRecordAssignUser::getAssignmentOrigin, + ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN) + .exists(); + if (!assignmentRecorded) { + return rollbackFailed("新增会签任务记录失败,请刷新后重试"); + } + taskSummaries.add(new AddSignTaskSummaryDto(newTask.getId(), targetUserId)); + } + } + catch (FlowableOptimisticLockingException exception) { + log.warn("动态加签发生并发冲突: taskId={}", sourceTask.getId(), exception); + return rollbackFailed("节点状态已变化,请刷新后重试"); + } + catch (FlowableException exception) { + log.warn("动态加签时多实例节点已不可用: taskId={}", sourceTask.getId(), exception); + return rollbackFailed("当前审批节点已结束,无法加签"); + } + + LinkedHashSet participantUserIdsAfter = new LinkedHashSet<>(participantUserIds); + participantUserIdsAfter.addAll(targetUserIds); + addSignRecord.setParticipantUserIdsAfter(objectMapper.writeValueAsString(participantUserIdsAfter)); + processTaskAddSignRecordMapper.updateById(addSignRecord); + + return R.ok(new AddSignResultDto(addSignRecord.getId(), taskSummaries)); + } + /** * 前加签 *

@@ -412,9 +595,59 @@ public class TaskServiceImpl implements ITaskService { @Transactional @Override public R delegateTask(TaskParamDto taskParamDto) { - taskParamDto.setUserId(SecurityUtils.getUser().getId().toString()); - return flowableEngineService.delegateTask(taskParamDto); - } + if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) { + return R.failed("任务ID不能为空"); + } + if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1 + || taskParamDto.getTargetUserIdList().get(0) == null) { + return R.failed("委托仅支持选择一名用户"); + } + long userId = SecurityUtils.getUser().getId(); + Long tenantId = TenantContextHolder.getTenantId(); + Task task = taskService.createTaskQuery() + .taskId(taskParamDto.getTaskId()) + .taskTenantId(tenantId.toString()) + .taskAssignee(String.valueOf(userId)) + .singleResult(); + if (task == null) { + return R.failed("当前任务已完成或无权限委托"); + } + Long targetUserId = taskParamDto.getTargetUserIdList().get(0); + ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId); + ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId); + if (nodeRecord == null) { + return R.failed("当前审批节点已结束,无法委托"); + } + if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) { + return R.failed("委托目标已参与当前会签"); + } + if (queryValidTenantUsers(taskParamDto.getTargetUserIdList(), tenantId).size() != 1) { + return R.failed("委托用户不存在、不属于当前租户或已被锁定"); + } + + Map safeLocalVariables = new HashMap<>(); + if (CollUtil.isNotEmpty(taskParamDto.getTaskLocalParamMap())) { + safeLocalVariables.putAll(taskParamDto.getTaskLocalParamMap()); + } + List.of(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN, ProcessInstanceConstant.VAR_INITIAL_USER_ID, + ProcessInstanceConstant.VAR_NODE_RECORD_ID, ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID, + ProcessInstanceConstant.VAR_ADDED_BY_USER_ID, ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION) + .forEach(safeLocalVariables::remove); + taskParamDto.setTaskLocalParamMap(safeLocalVariables); + taskParamDto.setUserId(String.valueOf(userId)); + R result = flowableEngineService.delegateTask(taskParamDto); + if (result == null) { + return rollbackFailed("委托任务失败,请刷新后重试"); + } + if (!result.isOk()) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return result; + } + if (findActiveAssignment(task.getId(), targetUserId) == null) { + return rollbackFailed("委托任务记录失败,请刷新后重试"); + } + return result; + } /** * 加签完成任务 @@ -440,23 +673,74 @@ public class TaskServiceImpl implements ITaskService { * @return R 操作结果 */ @Override + @Transactional(rollbackFor = Exception.class) public R setAssignee(TaskParamDto taskParamDto) { + if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) { + return R.failed("任务ID不能为空"); + } + if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1 + || taskParamDto.getTargetUserIdList().get(0) == null) { + return R.failed("转办仅支持选择一名用户"); + } + + long userId = SecurityUtils.getUser().getId(); + Long tenantId = TenantContextHolder.getTenantId(); + Long targetUserId = taskParamDto.getTargetUserIdList().get(0); + Task task = taskService.createTaskQuery() + .taskId(taskParamDto.getTaskId()) + .taskTenantId(tenantId.toString()) + .taskAssignee(String.valueOf(userId)) + .includeTaskLocalVariables() + .singleResult(); + if (task == null) { + return R.failed("当前任务已完成或已转办"); + } + + ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId); + ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId); + if (nodeRecord == null) { + return R.failed("当前审批节点已结束,无法转办"); + } + if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) { + return R.failed("转办目标已参与当前会签"); + } + if (queryValidTenantUsers(List.of(targetUserId), tenantId).size() != 1) { + return R.failed("转办用户不存在或不属于当前租户"); + } + + if (sourceAssignment == null) { + return R.failed("未找到当前任务的执行人记录"); + } + Map taskLocalParamMap = taskParamDto.getTaskLocalParamMap(); if (CollUtil.isNotEmpty(taskLocalParamMap)) { taskService.setVariablesLocal(taskParamDto.getTaskId(), taskLocalParamMap); } + taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_NODE_RECORD_ID, nodeRecord.getId()); + taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN, + sourceAssignment.getAssignmentOrigin()); + taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_INITIAL_USER_ID, + sourceAssignment.getInitialUserId()); + if (sourceAssignment.getAddSignRecordId() != null) { + taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID, + sourceAssignment.getAddSignRecordId()); + } + taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION, "TRANSFER"); // 设置任务的 owner 为当前任务的 assignee - Task task = taskService.createTaskQuery() - .taskId(taskParamDto.getTaskId()) - .includeTaskLocalVariables() - .singleResult(); taskService.setOwner(taskParamDto.getTaskId(), task.getAssignee()); // 设置任务的 assignee 为目标用户 (转办用户) - for (Long targetUserId : taskParamDto.getTargetUserIdList()) { - taskService.setAssignee(taskParamDto.getTaskId(), targetUserId.toString()); - } + taskService.setAssignee(taskParamDto.getTaskId(), targetUserId.toString()); + boolean assignmentRecorded = processNodeRecordAssignUserService.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecord.getId()) + .eq(ProcessNodeRecordAssignUser::getTaskId, task.getId()) + .eq(ProcessNodeRecordAssignUser::getUserId, targetUserId) + .eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode()) + .exists(); + if (!assignmentRecorded) { + return rollbackFailed("转办任务记录失败,请刷新后重试"); + } // 更新表单 if (CollUtil.isNotEmpty(taskParamDto.getFormData())) { @@ -624,6 +908,122 @@ public class TaskServiceImpl implements ITaskService { return R.ok("重新提交成功"); } + private ProcessNodeRecord findActiveNodeRecord(Task task, ProcessNodeRecordAssignUser assignment) { + if (task == null || assignment == null || assignment.getNodeRecordId() == null) { + return null; + } + ProcessNodeRecord nodeRecord = processNodeRecordService.getById(assignment.getNodeRecordId()); + if (nodeRecord == null || !Objects.equals(nodeRecord.getStatus(), NodeStatusEnum.JXZ.getCode()) + || !Objects.equals(nodeRecord.getProcessInstanceId(), task.getProcessInstanceId()) + || !Objects.equals(nodeRecord.getNodeId(), task.getTaskDefinitionKey())) { + return null; + } + return nodeRecord; + } + + private ProcessNodeRecord lockActiveNodeRecord(Task task, ProcessNodeRecordAssignUser assignment, Long tenantId) { + if (task == null || assignment == null || assignment.getNodeRecordId() == null) { + return null; + } + return processNodeRecordMapper.selectActiveByIdForUpdate(assignment.getNodeRecordId(), + task.getProcessInstanceId(), task.getTaskDefinitionKey(), tenantId); + } + + private List listParticipantUserIds(Long nodeRecordId) { + if (nodeRecordId == null) { + return Collections.emptyList(); + } + return processNodeRecordAssignUserService.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecordId) + .orderByAsc(ProcessNodeRecordAssignUser::getId) + .list() + .stream() + .map(ProcessNodeRecordAssignUser::getUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + + private ProcessNodeRecordAssignUser findActiveAssignment(String taskId, long userId) { + return processNodeRecordAssignUserService.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getTaskId, taskId) + .eq(ProcessNodeRecordAssignUser::getUserId, userId) + .eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode()) + .orderByDesc(ProcessNodeRecordAssignUser::getId) + .last("limit 1") + .one(); + } + + private boolean canAddSign(Task task, Node node, ProcessNodeRecord nodeRecord, + ProcessNodeRecordAssignUser sourceAssignment, long userId) { + if (task == null || node == null || nodeRecord == null || !isParallelCountersign(node) + || !Boolean.TRUE.equals(node.getAllowAddSign()) + || isProcessStarter(task.getProcessInstanceId(), userId)) { + return false; + } + return isOriginalApprover(task, sourceAssignment, userId); + } + + private boolean isParallelCountersign(Node node) { + if (node == null || !Objects.equals(node.getMultipleMode(), ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME)) { + return false; + } + Integer assignedType = node.getAssignedType(); + if (assignedType == null) { + return false; + } + return switch (assignedType) { + case ProcessInstanceConstant.AssignedTypeClass.USER -> CollUtil.size(node.getNodeUserList()) > 1; + case ProcessInstanceConstant.AssignedTypeClass.LEADER, ProcessInstanceConstant.AssignedTypeClass.ROLE -> true; + case ProcessInstanceConstant.AssignedTypeClass.SELF_SELECT, + ProcessInstanceConstant.AssignedTypeClass.FORM_USER -> Boolean.TRUE.equals(node.getMultiple()); + case 7 -> node.getDeptLeaderLevel() != null && node.getDeptLeaderLevel() > 1; + default -> false; + }; + } + + private boolean isProcessStarter(String processInstanceId, long userId) { + return processInstanceRecordService.lambdaQuery() + .eq(ProcessInstanceRecord::getProcessInstanceId, processInstanceId) + .eq(ProcessInstanceRecord::getUserId, userId) + .exists(); + } + + private boolean isOriginalApprover(Task task, ProcessNodeRecordAssignUser assignment, long userId) { + return assignment != null + && ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL.equals(assignment.getAssignmentOrigin()) + && Objects.equals(assignment.getInitialUserId(), userId) + && Objects.equals(assignment.getUserId(), userId) + && Objects.equals(task.getAssignee(), String.valueOf(userId)); + } + + private List queryValidTenantUsers(List userIds, Long tenantId) { + try { + R> response = remoteUserService.getUserById(userIds); + if (response == null || !response.isOk() || response.getData() == null) { + return Collections.emptyList(); + } + Set requestedIds = new HashSet<>(userIds); + return response.getData() + .stream() + .filter(Objects::nonNull) + .filter(user -> requestedIds.contains(user.getUserId())) + .filter(user -> Objects.equals(user.getTenantId(), tenantId)) + .filter(user -> !StrUtil.equals("1", user.getDelFlag())) + .filter(user -> StrUtil.equals("0", user.getLockFlag())) + .toList(); + } + catch (Exception exception) { + log.warn("校验工作流目标用户失败: userIds={}", userIds, exception); + return Collections.emptyList(); + } + } + + private R rollbackFailed(String message) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return R.failed(message); + } + /** * 递归获取所有需要停止的子流程实例ID列表 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/FlowProcessEventListener.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/FlowProcessEventListener.java index 8a6c3c7cf..0f1663b83 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/FlowProcessEventListener.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/FlowProcessEventListener.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.pig4cloud.pigx.common.security.util.SecurityUtils; import com.pig4cloud.pigx.flow.dto.*; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; import com.pig4cloud.pigx.flow.service.IRemoteService; import com.pig4cloud.pigx.flow.support.utils.NodeUtil; @@ -262,12 +263,23 @@ public class FlowProcessEventListener implements FlowableEventListener { processNodeRecordAssignUserParamDto.setUserId(Long.parseLong(task.getAssignee())); processNodeRecordAssignUserParamDto.setTaskId(task.getId()); processNodeRecordAssignUserParamDto.setNodeName(task.getName()); + processNodeRecordAssignUserParamDto.setNodeRecordId( + Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_NODE_RECORD_ID), null)); + processNodeRecordAssignUserParamDto.setAssignmentOrigin( + Convert.toStr(task.getVariable(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN))); + processNodeRecordAssignUserParamDto.setInitialUserId( + Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_INITIAL_USER_ID), null)); + processNodeRecordAssignUserParamDto.setAddSignRecordId( + Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID), null)); // 根据委派状态设置任务类型 + String assignmentOperation = Convert + .toStr(task.getVariableLocal(ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION)); processNodeRecordAssignUserParamDto - .setTaskType(StrUtil.equals(DelegationState.PENDING.toString(), delegationStateString) ? "DELEGATION" - : (StrUtil.equals(DelegationState.RESOLVED.toString(), delegationStateString) ? "RESOLVED" - : "")); + .setTaskType(StrUtil.isNotBlank(assignmentOperation) ? assignmentOperation + : (StrUtil.equals(DelegationState.PENDING.toString(), delegationStateString) ? "DELEGATION" + : (StrUtil.equals(DelegationState.RESOLVED.toString(), delegationStateString) ? "RESOLVED" + : StrUtil.EMPTY))); processNodeRecordAssignUserParamDto.setApproveDesc(Convert.toStr(task.getVariableLocal("approveDesc"))); processNodeRecordAssignUserParamDto.setExecutionId(task.getExecutionId()); @@ -346,4 +358,4 @@ public class FlowProcessEventListener implements FlowableEventListener { return null; } -} \ No newline at end of file +} diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandler.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandler.java index 047fc4b62..08bdf5d06 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandler.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandler.java @@ -145,7 +145,7 @@ public class MultiInstanceHandler { if (multipleMode == ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME) { return BigDecimal.valueOf(nrOfCompletedInstances * 100L) - .compareTo(BigDecimal.valueOf(nrOfCompletedInstances).multiply(modePercentage)) > 0; + .compareTo(BigDecimal.valueOf(nrOfInstances).multiply(modePercentage)) >= 0; } return nrOfCompletedInstances == nrOfInstances; diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/utils/NodeFormatUtil.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/utils/NodeFormatUtil.java index 8edfa0500..3ce1c701e 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/utils/NodeFormatUtil.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/utils/NodeFormatUtil.java @@ -18,10 +18,13 @@ import com.pig4cloud.pigx.flow.dto.Node; import com.pig4cloud.pigx.flow.dto.NodeUser; import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord; import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; +import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord; +import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper; import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService; import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; import com.pig4cloud.pigx.flow.service.IRemoteService; import com.pig4cloud.pigx.flow.vo.NodeVo; +import com.pig4cloud.pigx.flow.vo.AddSignRecordVo; import com.pig4cloud.pigx.flow.vo.UserVo; import lombok.SneakyThrows; @@ -98,6 +101,17 @@ public class NodeFormatUtil { if (StrUtil.isNotBlank(processInstanceId)) { IProcessNodeRecordAssignUserService processNodeRecordAssignUserService = SpringUtil.getBean(IProcessNodeRecordAssignUserService.class); List processNodeRecordAssignUserList = processNodeRecordAssignUserService.lambdaQuery().eq(ProcessNodeRecordAssignUser::getNodeId, node.getId()).eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId).orderByAsc(ProcessNodeRecordAssignUser::getCreateTime).list(); + ProcessTaskAddSignRecordMapper addSignRecordMapper = SpringUtil + .getBean(ProcessTaskAddSignRecordMapper.class); + List addSignRecordList = addSignRecordMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ProcessTaskAddSignRecord::getProcessInstanceId, processInstanceId) + .eq(ProcessTaskAddSignRecord::getNodeId, node.getId()) + .orderByAsc(ProcessTaskAddSignRecord::getCreateTime)) + .stream() + .map(record -> buildAddSignRecord(record, objectMapper())) + .toList(); + nodeVo.setAddSignRecordList(addSignRecordList); Map> map = processNodeRecordAssignUserList.stream().collect(Collectors.groupingBy(ProcessNodeRecordAssignUser::getTaskId)); for (Map.Entry> entry : map.entrySet()) { @@ -111,7 +125,9 @@ public class NodeFormatUtil { userVo.setShowTime(w.getEndTime()); userVo.setApproveDesc(w.getApproveDesc()); userVo.setStatus(w.getStatus()); - userVo.setOperType(w.getTaskType()); + userVo.setOperType(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN + .equals(w.getAssignmentOrigin()) ? ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN + : w.getTaskType()); return userVo; }).filter(Objects::nonNull).toList(); userVoList.addAll(collect); @@ -237,6 +253,28 @@ public class NodeFormatUtil { return list; } + @SneakyThrows + private static AddSignRecordVo buildAddSignRecord(ProcessTaskAddSignRecord record, ObjectMapper objectMapper) { + AddSignRecordVo recordVo = new AddSignRecordVo(); + recordVo.setId(record.getId()); + UserVo addedBy = buildUser(record.getAddedByUserId()); + recordVo.setAddedByName(addedBy == null ? String.valueOf(record.getAddedByUserId()) : addedBy.getName()); + List targetUserIds = objectMapper.readValue(record.getTargetUserIds(), new TypeReference<>() { + }); + recordVo.setTargetUserNames(targetUserIds.stream() + .map(NodeFormatUtil::buildUser) + .filter(Objects::nonNull) + .map(UserVo::getName) + .toList()); + recordVo.setReason(record.getReason()); + recordVo.setCreateTime(record.getCreateTime()); + return recordVo; + } + + private static ObjectMapper objectMapper() { + return SpringUtil.getBean(ObjectMapper.class); + } + /** * 根据流程实例ID构建发起人用户信息 *

diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/controller/ProcessTaskControllerTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/controller/ProcessTaskControllerTests.java new file mode 100644 index 000000000..ad7bbe1a0 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/controller/ProcessTaskControllerTests.java @@ -0,0 +1,49 @@ +package com.pig4cloud.pigx.flow.controller; + +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.dto.AddSignTaskDto; +import com.pig4cloud.pigx.flow.service.ITaskService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ProcessTaskControllerTests { + + @Test + void originalApproverCanRequestBatchAddSignThroughPublicApi() throws Exception { + ITaskService taskService = mock(ITaskService.class); + when(taskService.addSign(org.mockito.ArgumentMatchers.any(AddSignTaskDto.class))).thenReturn(R.ok()); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ProcessTaskController(taskService)).build(); + + mockMvc.perform(post("/task/addSign") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "taskId": "12501", + "targetUserIdList": [31, 32], + "reason": "需要法务和财务共同确认" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AddSignTaskDto.class); + verify(taskService).addSign(captor.capture()); + assertThat(captor.getValue().getTaskId()).isEqualTo("12501"); + assertThat(captor.getValue().getTargetUserIdList()).containsExactly(31L, 32L); + assertThat(captor.getValue().getReason()).isEqualTo("需要法务和财务共同确认"); + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImplTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImplTests.java new file mode 100644 index 000000000..635f46974 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/ProcessNodeRecordServiceImplTests.java @@ -0,0 +1,67 @@ +package com.pig4cloud.pigx.flow.service.impl; + +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; +import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordParamDto; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProcessNodeRecordServiceImplTests { + + @Mock + private LambdaQueryChainWrapper activeRecordQuery; + + private ProcessNodeRecordServiceImpl service; + + @BeforeEach + void setUp() { + service = spy(new ProcessNodeRecordServiceImpl()); + doReturn(activeRecordQuery).when(service).lambdaQuery(); + when(activeRecordQuery.eq(any(SFunction.class), any())).thenReturn(activeRecordQuery); + when(activeRecordQuery.orderByAsc(any(SFunction.class))).thenReturn(activeRecordQuery); + when(activeRecordQuery.last(anyString())).thenReturn(activeRecordQuery); + } + + @Test + void startReusesActiveRecordForSameProcessNodeRound() { + ProcessNodeRecord existing = new ProcessNodeRecord() + .setId(101L) + .setProcessInstanceId("process-1") + .setNodeId("approve-1") + .setStatus(NodeStatusEnum.JXZ.getCode()); + when(activeRecordQuery.one()).thenReturn(existing); + + R result = service.start(request()); + + assertThat(result.getCode()).isZero(); + assertThat(result.getData()).isEqualTo(101L); + verify(service, never()).save(any(ProcessNodeRecord.class)); + } + + private ProcessNodeRecordParamDto request() { + ProcessNodeRecordParamDto request = new ProcessNodeRecordParamDto(); + request.setFlowId("flow-1"); + request.setProcessInstanceId("process-1"); + request.setNodeId("approve-1"); + request.setNodeName("会签审批"); + request.setExecutionId("execution-2"); + return request; + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceDelegateParticipationTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceDelegateParticipationTests.java new file mode 100644 index 000000000..4e7167e15 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceDelegateParticipationTests.java @@ -0,0 +1,138 @@ +package com.pig4cloud.pigx.flow.service.impl; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pig4cloud.pigx.admin.api.entity.SysUser; +import com.pig4cloud.pigx.admin.api.feign.RemoteUserService; +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder; +import com.pig4cloud.pigx.common.security.service.PigxUser; +import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; +import com.pig4cloud.pigx.flow.dto.TaskParamDto; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; +import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper; +import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper; +import com.pig4cloud.pigx.flow.service.IFlowableEngineService; +import com.pig4cloud.pigx.flow.service.IProcessCopyService; +import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService; +import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService; +import com.pig4cloud.pigx.flow.service.IProcessService; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.task.api.Task; +import org.flowable.task.api.TaskQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TaskServiceDelegateParticipationTests { + + private static final long USER_ID = 11L; + private static final long TARGET_USER_ID = 31L; + private static final long TENANT_ID = 1L; + private static final long NODE_RECORD_ID = 101L; + private static final String TASK_ID = "task-1"; + private static final String PROCESS_INSTANCE_ID = "process-1"; + private static final String NODE_ID = "approve-1"; + + @Mock private IProcessService processService; + @Mock private IProcessCopyService processCopyService; + @Mock private IProcessNodeDataService nodeDataService; + @Mock private IProcessNodeRecordAssignUserService processNodeRecordAssignUserService; + @Mock private IProcessInstanceRecordService processInstanceRecordService; + @Mock private IProcessNodeRecordService processNodeRecordService; + @Mock private IFlowableEngineService flowableEngineService; + @Mock private TaskService taskService; + @Mock private HistoryService historyService; + @Mock private RuntimeService runtimeService; + @Mock private ObjectMapper objectMapper; + @Mock private RemoteUserService remoteUserService; + @Mock private ProcessNodeRecordMapper processNodeRecordMapper; + @Mock private ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper; + @Mock(answer = Answers.RETURNS_SELF) private TaskQuery taskQuery; + @Mock private Task task; + @Mock private BaseMapper assignmentBaseMapper; + + @InjectMocks private TaskServiceImpl taskServiceImpl; + + @BeforeEach + void setCurrentUserAndTenant() { + PigxUser user = new PigxUser(USER_ID, "approver", List.of(), null, List.of(), List.of(), null, null, + "审批人", "审批人", null, TENANT_ID, "password", true, true, "sys", true, + LocalDateTime.now(), true, List.of()); + SecurityContextHolder.getContext() + .setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user, "password", List.of())); + TenantContextHolder.setTenantId(TENANT_ID); + } + + @AfterEach + void clearCurrentUserAndTenant() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @Test + void delegateRejectsUserAlreadyParticipatingInCurrentRound() { + when(taskService.createTaskQuery()).thenReturn(taskQuery); + when(taskQuery.singleResult()).thenReturn(task); + when(task.getId()).thenReturn(TASK_ID); + when(task.getProcessInstanceId()).thenReturn(PROCESS_INSTANCE_ID); + when(task.getTaskDefinitionKey()).thenReturn(NODE_ID); + when(processNodeRecordAssignUserService.lambdaQuery()) + .thenAnswer(invocation -> new LambdaQueryChainWrapper<>(assignmentBaseMapper)); + when(assignmentBaseMapper.selectOne(any())).thenReturn(originalAssignment()); + when(assignmentBaseMapper.selectList(any())).thenReturn(List.of(originalAssignment(), targetAssignment())); + when(processNodeRecordMapper.selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID, TENANT_ID)) + .thenReturn(new ProcessNodeRecord().setId(NODE_RECORD_ID)); + TaskParamDto request = new TaskParamDto(); + request.setTaskId(TASK_ID); + request.setTargetUserIdList(List.of(TARGET_USER_ID)); + R result = taskServiceImpl.delegateTask(request); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("委托目标已参与当前会签"); + verify(flowableEngineService, never()).delegateTask(any(TaskParamDto.class)); + } + + private ProcessNodeRecordAssignUser originalAssignment() { + return new ProcessNodeRecordAssignUser() + .setNodeRecordId(NODE_RECORD_ID) + .setTaskId(TASK_ID) + .setUserId(USER_ID) + .setInitialUserId(USER_ID) + .setAssignmentOrigin(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL) + .setStatus(NodeStatusEnum.JXZ.getCode()); + } + + private ProcessNodeRecordAssignUser targetAssignment() { + return new ProcessNodeRecordAssignUser() + .setNodeRecordId(NODE_RECORD_ID) + .setTaskId("task-2") + .setUserId(TARGET_USER_ID) + .setStatus(NodeStatusEnum.JXZ.getCode()); + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImplTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImplTests.java new file mode 100644 index 000000000..7068bdeb4 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImplTests.java @@ -0,0 +1,405 @@ +package com.pig4cloud.pigx.flow.service.impl; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pig4cloud.pigx.admin.api.entity.SysUser; +import com.pig4cloud.pigx.admin.api.feign.RemoteUserService; +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder; +import com.pig4cloud.pigx.common.security.service.PigxUser; +import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; +import com.pig4cloud.pigx.flow.dto.AddSignResultDto; +import com.pig4cloud.pigx.flow.dto.AddSignTaskDto; +import com.pig4cloud.pigx.flow.dto.Node; +import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; +import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord; +import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper; +import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper; +import com.pig4cloud.pigx.flow.service.IFlowableEngineService; +import com.pig4cloud.pigx.flow.service.IProcessCopyService; +import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService; +import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService; +import com.pig4cloud.pigx.flow.service.IProcessService; +import com.pig4cloud.pigx.flow.service.ITaskService; +import org.flowable.common.engine.api.FlowableOptimisticLockingException; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.runtime.Execution; +import org.flowable.task.api.Task; +import org.flowable.task.api.TaskQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TaskServiceImplTests { + + private static final long USER_ID = 11L; + + private static final long TENANT_ID = 1L; + + private static final long TARGET_USER_ID = 31L; + + private static final long NODE_RECORD_ID = 101L; + + private static final long ADD_SIGN_RECORD_ID = 901L; + + private static final String TASK_ID = "12501"; + + private static final String ADDED_TASK_ID = "12502"; + + private static final String FLOW_ID = "flow-1"; + + private static final String PROCESS_DEFINITION_ID = FLOW_ID + ":1:deployment"; + + private static final String PROCESS_INSTANCE_ID = "process-1"; + + private static final String NODE_ID = "approve-1"; + + @Mock + private IProcessService processService; + @Mock + private IProcessCopyService processCopyService; + @Mock + private IProcessNodeDataService nodeDataService; + @Mock + private IProcessNodeRecordAssignUserService processNodeRecordAssignUserService; + @Mock + private IProcessInstanceRecordService processInstanceRecordService; + @Mock + private IProcessNodeRecordService processNodeRecordService; + @Mock + private IFlowableEngineService flowableEngineService; + @Mock + private TaskService taskService; + @Mock + private HistoryService historyService; + @Mock + private RuntimeService runtimeService; + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + @Mock + private RemoteUserService remoteUserService; + @Mock + private ProcessNodeRecordMapper processNodeRecordMapper; + @Mock + private ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper; + @Mock(answer = Answers.RETURNS_SELF) + private TaskQuery taskQuery; + @Mock + private BaseMapper processInstanceBaseMapper; + @Mock + private BaseMapper assignmentBaseMapper; + @Mock + private Task sourceTask; + @Mock + private Task addedTask; + @Mock + private Execution addedExecution; + + @InjectMocks + private TaskServiceImpl taskServiceImpl; + + @BeforeEach + void setCurrentUserAndTenant() { + PigxUser user = new PigxUser(USER_ID, "approver", List.of(), null, List.of(), List.of(), null, null, + "审批人", "审批人", null, TENANT_ID, "password", true, true, "sys", true, + LocalDateTime.now(), true, List.of()); + SecurityContextHolder.getContext() + .setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user, "password", List.of())); + TenantContextHolder.setTenantId(TENANT_ID); + } + + @AfterEach + void clearCurrentUserAndTenant() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @Test + void addSignRejectsEmptyTargetBatch() { + AddSignTaskDto request = request(List.of(), "需要共同确认"); + + R result = taskServiceImpl.addSign(request); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("请选择加签人员"); + } + + @Test + void addSignRejectsDuplicateTargetUsers() { + AddSignTaskDto request = request(List.of(31L, 31L), "需要共同确认"); + + R result = taskServiceImpl.addSign(request); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("加签人员不能重复"); + } + + @Test + void addSignRejectsReasonOutsideLengthLimit() { + AddSignTaskDto request = request(List.of(31L), "短"); + + R result = taskServiceImpl.addSign(request); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("加签原因需为 2~100 字"); + } + + @Test + void addSignCreatesTaskAndAuditSnapshotForEligibleApprover() { + prepareTaskAndNode(addedTask); + prepareOriginalApprover(); + prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID)); + prepareAuditInsert(); + when(runtimeService.addMultiInstanceExecution(eq(NODE_ID), eq(PROCESS_INSTANCE_ID), anyMap())) + .thenReturn(addedExecution); + when(assignmentBaseMapper.selectCount(any())).thenReturn(1L); + + R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认")); + + assertThat(result.getCode()).isZero(); + assertThat(result.getData()).isInstanceOf(AddSignResultDto.class); + AddSignResultDto addSignResult = (AddSignResultDto) result.getData(); + assertThat(addSignResult.getRecordId()).isEqualTo(ADD_SIGN_RECORD_ID); + assertThat(addSignResult.getTasks()).hasSize(1); + assertThat(addSignResult.getTasks().get(0).getTaskId()).isEqualTo(ADDED_TASK_ID); + assertThat(addSignResult.getTasks().get(0).getUserId()).isEqualTo(TARGET_USER_ID); + + ArgumentCaptor recordCaptor = ArgumentCaptor + .forClass(ProcessTaskAddSignRecord.class); + verify(processTaskAddSignRecordMapper).insert(recordCaptor.capture()); + ProcessTaskAddSignRecord record = recordCaptor.getValue(); + assertThat(record.getSourceTaskId()).isEqualTo(TASK_ID); + assertThat(record.getAddedByUserId()).isEqualTo(USER_ID); + assertThat(record.getTargetUserIds()).isEqualTo("[31]"); + assertThat(record.getParticipantUserIdsBefore()).isEqualTo("[11]"); + assertThat(record.getParticipantUserIdsAfter()).isEqualTo("[11,31]"); + verify(processTaskAddSignRecordMapper).updateById(record); + verify(processNodeRecordMapper).selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID, + TENANT_ID); + verify(runtimeService).addMultiInstanceExecution(eq(NODE_ID), eq(PROCESS_INSTANCE_ID), + argThat(variables -> Long.valueOf(TARGET_USER_ID).equals(variables.get(NODE_ID + "_assignee_temp")) + && Long.valueOf(ADD_SIGN_RECORD_ID) + .equals(variables.get(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID)))); + } + + @Test + void addSignRejectsProcessStarterEvenWhenStarterOwnsTask() { + prepareTaskAndNode(null); + prepareActiveAssignment(); + when(processInstanceRecordService.lambdaQuery()) + .thenAnswer(invocation -> new LambdaQueryChainWrapper<>(processInstanceBaseMapper)); + when(processInstanceBaseMapper.selectCount(any())).thenReturn(1L); + + R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认")); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("流程发起人不能发起加签"); + verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class)); + } + + @Test + void addSignRejectsTargetUserFromAnotherTenant() { + prepareTaskAndNode(null); + prepareOriginalApprover(); + prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID + 1)); + + R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认")); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("加签用户不存在或不属于当前租户"); + verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class)); + } + + @Test + void addSignRejectsUserAlreadyParticipatingInCurrentCountersign() { + prepareTaskAndNode(null); + prepareOriginalApprover(); + ProcessNodeRecordAssignUser existingParticipant = new ProcessNodeRecordAssignUser() + .setNodeRecordId(NODE_RECORD_ID) + .setUserId(TARGET_USER_ID); + prepareTargetValidation(List.of(originalAssignment(), existingParticipant), + validUser(TARGET_USER_ID, TENANT_ID)); + + R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认")); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("用户已参与当前会签:目标审批人"); + verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class)); + } + + @Test + void addSignRollsBackAuditWhenFlowableReportsConcurrentChange() { + prepareTaskAndNode(null); + prepareOriginalApprover(); + prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID)); + prepareAuditInsert(); + when(runtimeService.addMultiInstanceExecution(anyString(), anyString(), anyMap())) + .thenThrow(new FlowableOptimisticLockingException("conflict")); + RecordingTransactionManager transactionManager = new RecordingTransactionManager(); + + R result = transactionalService(transactionManager) + .addSign(request(List.of(TARGET_USER_ID), "需要共同确认")); + + assertThat(result.getCode()).isEqualTo(1); + assertThat(result.getMsg()).isEqualTo("节点状态已变化,请刷新后重试"); + assertThat(transactionManager.rolledBack).isTrue(); + assertThat(transactionManager.committed).isFalse(); + verify(processTaskAddSignRecordMapper).insert(any(ProcessTaskAddSignRecord.class)); + verify(processTaskAddSignRecordMapper, never()).updateById(any(ProcessTaskAddSignRecord.class)); + } + + private void prepareTaskAndNode(Task taskCreatedAfterAddSign) { + when(taskService.createTaskQuery()).thenReturn(taskQuery); + if (taskCreatedAfterAddSign == null) { + when(taskQuery.singleResult()).thenReturn(sourceTask); + } + else { + when(taskQuery.singleResult()).thenReturn(sourceTask, taskCreatedAfterAddSign); + } + when(sourceTask.getProcessInstanceId()).thenReturn(PROCESS_INSTANCE_ID); + when(sourceTask.getTaskDefinitionKey()).thenReturn(NODE_ID); + when(sourceTask.getProcessDefinitionId()).thenReturn(PROCESS_DEFINITION_ID); + if (taskCreatedAfterAddSign != null) { + when(taskCreatedAfterAddSign.getId()).thenReturn(ADDED_TASK_ID); + } + + ProcessNodeRecord nodeRecord = new ProcessNodeRecord().setId(NODE_RECORD_ID); + when(processNodeRecordMapper.selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID, + TENANT_ID)) + .thenReturn(nodeRecord); + Node node = new Node(); + node.setAssignedType(ProcessInstanceConstant.AssignedTypeClass.ROLE); + node.setMultipleMode(ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME); + node.setAllowAddSign(true); + when(nodeDataService.getNodeData(FLOW_ID, NODE_ID)).thenReturn(R.ok(node)); + } + + private void prepareOriginalApprover() { + prepareActiveAssignment(); + when(sourceTask.getAssignee()).thenReturn(String.valueOf(USER_ID)); + when(processInstanceRecordService.lambdaQuery()) + .thenAnswer(invocation -> new LambdaQueryChainWrapper<>(processInstanceBaseMapper)); + } + + private void prepareActiveAssignment() { + when(sourceTask.getId()).thenReturn(TASK_ID); + when(processNodeRecordAssignUserService.lambdaQuery()) + .thenAnswer(invocation -> new LambdaQueryChainWrapper<>(assignmentBaseMapper)); + when(assignmentBaseMapper.selectOne(any())).thenReturn(originalAssignment()); + } + + private void prepareTargetValidation(List participants, SysUser targetUser) { + when(assignmentBaseMapper.selectList(any())).thenReturn(participants); + when(remoteUserService.getUserById(List.of(TARGET_USER_ID))).thenReturn(R.ok(List.of(targetUser))); + } + + private void prepareAuditInsert() { + when(processTaskAddSignRecordMapper.insert(any(ProcessTaskAddSignRecord.class))).thenAnswer(invocation -> { + ProcessTaskAddSignRecord record = invocation.getArgument(0); + record.setId(ADD_SIGN_RECORD_ID); + return 1; + }); + } + + private ProcessNodeRecordAssignUser originalAssignment() { + return new ProcessNodeRecordAssignUser() + .setNodeRecordId(NODE_RECORD_ID) + .setTaskId(TASK_ID) + .setUserId(USER_ID) + .setInitialUserId(USER_ID) + .setAssignmentOrigin(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL) + .setStatus(NodeStatusEnum.JXZ.getCode()); + } + + private SysUser validUser(long userId, long tenantId) { + SysUser user = new SysUser(); + user.setUserId(userId); + user.setUsername("target"); + user.setName("目标审批人"); + user.setTenantId(tenantId); + user.setDelFlag("0"); + user.setLockFlag("0"); + return user; + } + + private ITaskService transactionalService(RecordingTransactionManager transactionManager) { + ProxyFactory proxyFactory = new ProxyFactory(taskServiceImpl); + proxyFactory.addInterface(ITaskService.class); + proxyFactory.addAdvice(new TransactionInterceptor(transactionManager, + new AnnotationTransactionAttributeSource())); + return (ITaskService) proxyFactory.getProxy(); + } + + private AddSignTaskDto request(List userIds, String reason) { + AddSignTaskDto request = new AddSignTaskDto(); + request.setTaskId(TASK_ID); + request.setTargetUserIdList(userIds); + request.setReason(reason); + return request; + } + + private static final class RecordingTransactionManager extends AbstractPlatformTransactionManager { + + private boolean committed; + + private boolean rolledBack; + + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + committed = true; + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + rolledBack = true; + } + + } + +} diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandlerTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandlerTests.java new file mode 100644 index 000000000..c85358de8 --- /dev/null +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/node/MultiInstanceHandlerTests.java @@ -0,0 +1,40 @@ +package com.pig4cloud.pigx.flow.support.node; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pig4cloud.pigx.common.core.util.R; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; +import com.pig4cloud.pigx.flow.dto.Node; +import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; +import org.flowable.engine.impl.persistence.entity.ExecutionEntityImpl; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MultiInstanceHandlerTests { + + @Test + void countersignWaitsForDynamicallyExpandedInstanceCount() { + IProcessNodeDataService nodeDataService = mock(IProcessNodeDataService.class); + Node node = new Node(); + node.setMultipleMode(ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME); + when(nodeDataService.getNodeData("flow-1", "approve-1")).thenReturn(R.ok(node)); + MultiInstanceHandler handler = new MultiInstanceHandler(nodeDataService, Collections.emptyMap(), + new ObjectMapper()); + ExecutionEntityImpl execution = mock(ExecutionEntityImpl.class); + when(execution.getProcessDefinitionKey()).thenReturn("flow-1"); + when(execution.getCurrentActivityId()).thenReturn("approve-1"); + when(execution.getVariable("approve-1_approve_condition")).thenReturn(true); + when(execution.getVariable("nrOfInstances")).thenReturn(3); + when(execution.getVariable("nrOfCompletedInstances")).thenReturn(2); + + assertThat(handler.completionCondition(execution)).isFalse(); + + when(execution.getVariable("nrOfCompletedInstances")).thenReturn(3); + assertThat(handler.completionCondition(execution)).isTrue(); + } + +}