mirror of
http://git.pig4cloud.com/pig/pigx.git
synced 2026-11-08 04:39:00 +08:00
feat(flow): support dynamic countersign add-sign
This commit is contained in:
1 parent
5931a5d6d7
commit
2b6a4d2e85
32 files changed
+1916
-37
No files matched your search
+26
@@ -0,0 +1,26 @@
|
||||
# PIGX 后端
|
||||
|
||||
PIGX 后端承载平台业务规则与运行状态。本上下文中的工作流语言用于描述审批任务及其参与者关系。
|
||||
|
||||
## 工作流审批
|
||||
|
||||
**会签**:
|
||||
同一审批节点为多名审批人分别生成独立待办,并以全部审批人同意作为节点通过条件。
|
||||
|
||||
**动态加签**:
|
||||
在会签节点运行期间追加一名或多名独立审批人;新增审批人与原审批人共同遵守该节点的会签完成条件。
|
||||
_避免使用_:委托、转办
|
||||
|
||||
**原始审批人**:
|
||||
会签节点进入运行状态时,根据流程定义确定并获得独立待办的审批人。
|
||||
|
||||
**加签审批人**:
|
||||
会签节点运行期间通过动态加签加入并获得独立待办的审批人;不属于该节点的原始审批人。
|
||||
|
||||
**委托**:
|
||||
当前审批人临时将自己的待办交给另一人处理,处理结果随后返回原审批人;委托不增加会签人数。
|
||||
_避免使用_:加签
|
||||
|
||||
**转办**:
|
||||
当前审批人将自己的待办及处理权永久移交给另一人,原审批人不再处理该待办。
|
||||
_避免使用_:加签、委托
|
||||
+33
-1
@@ -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
|
||||
-- ----------------------------
|
||||
|
||||
+33
-1
@@ -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
|
||||
-- ----------------------------
|
||||
|
||||
@@ -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='流程动态加签批次记录';
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# 将加签建模为动态追加会签人
|
||||
|
||||
工作流中的“加签”采用动态追加会签人的语义:在当前会签节点仍处于运行状态时,为新增审批人创建独立待办,并将其纳入同一节点的会签完成条件。只有仍持有未完成待办的原始审批人可以发起动态加签;加签审批人、已经完成审批的原始审批人、流程发起人和普通管理员均不能发起。加签成功后,发起人的原待办保持待审批,且不需要等待新增审批人;原审批人与新增审批人的待办并行处理,加签操作本身不代表审批同意。同一流程实例的同一会签节点内,审批人按用户唯一,每个用户最多拥有一份待办;该约束由后端保证,前端同时过滤已有审批人。首版只允许并行会签模式使用动态加签;或签、依次审批和单人审批不提供加签能力。动态加签由流程设计者在每个并行会签节点上显式开启,默认关闭;配置直接保存在现有节点数据中,以简单实现优先,不提供发布版本隔离。一次加签可以选择多名审批人,但必须整批通过校验并原子提交;任何目标不合法时不创建任何新增待办,一次提交只形成一条包含全部新增人员的加签记录。同一原始审批人在完成自己的待办前可以多次发起加签,每次分别校验并记录;其待办完成后立即失去加签权限。首版不提供撤销加签或减签:新增待办一旦创建便作为正式会签待办参与完成条件。加签审批人可以转办自己的待办,但不能发起加签;转办目标不能是当前节点已有审批人,转办仅替换该待办的处理人,不改变会签任务总数。发起加签必须填写 2~100 字的原因;系统记录发起人、新增审批人、原因、时间、流程实例、节点、发起待办及变更前后的会签人员,并在审批记录中展示。桌面端与移动端在同一版本提供一致的加签入口和规则,流程设计配置仍由桌面端承担。现有基于任务委托的 `delegateTask` 保留为“委托”,不作为加签实现,以免委托返回原审批人的生命周期与会签人数变化混为一谈。
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# 使用 Flowable 动态多实例实现加签
|
||||
|
||||
动态加签使用 Flowable 原生 `RuntimeService.addMultiInstanceExecution` 向运行中的会签活动添加执行实例,并由 Flowable 作为任务生命周期、实例数量和节点完成状态的唯一事实来源。PIGX 业务表只保存审批人映射、查询投影和加签审计,不创建一套独立的附加审批任务,以避免引擎状态与业务状态发生双写分歧。加签与节点完成发生竞争时,以先提交成功的事务为准:加签先成功则节点等待新增实例,节点先完成则加签失败;系统绝不重新打开已经完成的节点。
|
||||
@@ -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`
|
||||
+24
@@ -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";
|
||||
|
||||
/**
|
||||
* 任务分配类型 定义审批节点的执行人分配方式
|
||||
*/
|
||||
|
||||
+19
@@ -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<AddSignTaskSummaryDto> tasks;
|
||||
|
||||
}
|
||||
@@ -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<Long> targetUserIdList;
|
||||
|
||||
/** 加签原因。 */
|
||||
private String reason;
|
||||
|
||||
}
|
||||
+17
@@ -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;
|
||||
|
||||
}
|
||||
@@ -85,6 +85,11 @@ public class Node {
|
||||
*/
|
||||
private Integer multipleMode;
|
||||
|
||||
/**
|
||||
* 是否允许当前并行会签节点的原始审批人动态加签
|
||||
*/
|
||||
private Boolean allowAddSign;
|
||||
|
||||
/**
|
||||
* 部门主管级别 当assignedType为LEADER时,指定查找第几级主管
|
||||
*/
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
}
|
||||
+16
@@ -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
|
||||
* <p>
|
||||
|
||||
+69
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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<String> targetUserNames;
|
||||
|
||||
private String reason;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public class NodeVo {
|
||||
*/
|
||||
private List<UserVo> userVoList;
|
||||
|
||||
/**
|
||||
* 当前节点的动态加签批次事件
|
||||
*/
|
||||
private List<AddSignRecordVo> addSignRecordList;
|
||||
|
||||
/**
|
||||
* 节点占位提示
|
||||
* <p>
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加签完成任务
|
||||
* <p>
|
||||
|
||||
+38
@@ -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<ProcessNodeRecord> {
|
||||
|
||||
/**
|
||||
* 锁定当前活动节点,串行化加签与任务完成。
|
||||
*/
|
||||
@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);
|
||||
|
||||
}
|
||||
+12
@@ -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<ProcessTaskAddSignRecord> {
|
||||
}
|
||||
+8
@@ -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);
|
||||
|
||||
/**
|
||||
* 加签完成任务
|
||||
* <p>
|
||||
|
||||
+88
-13
@@ -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<ProcessNodeRecordAssignUserMapper, ProcessNodeRecordAssignUser>
|
||||
implements IProcessNodeRecordAssignUserService {
|
||||
|
||||
private final IProcessNodeRecordService processNodeRecordService;
|
||||
|
||||
/**
|
||||
* 设置执行人
|
||||
* <p>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务完成通知
|
||||
* <p>
|
||||
@@ -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());
|
||||
|
||||
+11
-1
@@ -48,6 +48,16 @@ public class ProcessNodeRecordServiceImpl extends ServiceImpl<ProcessNodeRecordM
|
||||
*/
|
||||
@Override
|
||||
public R start(ProcessNodeRecordParamDto processNodeRecordParamDto) {
|
||||
ProcessNodeRecord activeRecord = this.lambdaQuery()
|
||||
.eq(ProcessNodeRecord::getProcessInstanceId, processNodeRecordParamDto.getProcessInstanceId())
|
||||
.eq(ProcessNodeRecord::getNodeId, processNodeRecordParamDto.getNodeId())
|
||||
.eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode())
|
||||
.orderByAsc(ProcessNodeRecord::getId)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
if (activeRecord != null) {
|
||||
return R.ok(activeRecord.getId());
|
||||
}
|
||||
|
||||
ProcessNodeRecord processNodeRecord = BeanUtil.copyProperties(processNodeRecordParamDto,
|
||||
ProcessNodeRecord.class);
|
||||
@@ -55,7 +65,7 @@ public class ProcessNodeRecordServiceImpl extends ServiceImpl<ProcessNodeRecordM
|
||||
processNodeRecord.setStatus(NodeStatusEnum.JXZ.getCode());
|
||||
|
||||
this.save(processNodeRecord);
|
||||
return R.ok();
|
||||
return R.ok(processNodeRecord.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+415
-15
@@ -6,23 +6,25 @@ import cn.hutool.core.lang.Dict;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
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.util.SecurityUtils;
|
||||
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
|
||||
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
|
||||
import com.pig4cloud.pigx.flow.dto.IndexPageStatistics;
|
||||
import com.pig4cloud.pigx.flow.dto.Node;
|
||||
import com.pig4cloud.pigx.flow.dto.TaskParamDto;
|
||||
import com.pig4cloud.pigx.flow.dto.TaskResultDto;
|
||||
import com.pig4cloud.pigx.flow.dto.*;
|
||||
import com.pig4cloud.pigx.flow.entity.*;
|
||||
import com.pig4cloud.pigx.flow.entity.Process;
|
||||
import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper;
|
||||
import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper;
|
||||
import com.pig4cloud.pigx.flow.service.*;
|
||||
import com.pig4cloud.pigx.flow.support.utils.NodeUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.flowable.common.engine.api.FlowableException;
|
||||
import org.flowable.common.engine.api.FlowableOptimisticLockingException;
|
||||
import org.flowable.engine.HistoryService;
|
||||
import org.flowable.engine.RuntimeService;
|
||||
import org.flowable.engine.TaskService;
|
||||
@@ -36,6 +38,7 @@ import org.flowable.task.api.history.HistoricTaskInstance;
|
||||
import org.flowable.variable.api.history.HistoricVariableInstance;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@@ -78,6 +81,12 @@ public class TaskServiceImpl implements ITaskService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final RemoteUserService remoteUserService;
|
||||
|
||||
private final ProcessNodeRecordMapper processNodeRecordMapper;
|
||||
|
||||
private final ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper;
|
||||
|
||||
/**
|
||||
* 查询首页数据看板
|
||||
* <p>
|
||||
@@ -274,6 +283,18 @@ public class TaskServiceImpl implements ITaskService {
|
||||
Node node = nodeDataService.getNodeData(taskResultDto.getFlowId(), taskResultDto.getNodeId()).getData();
|
||||
Map<String, String> formPerms = node.getFormPerms();
|
||||
|
||||
ProcessNodeRecord activeNodeRecord = null;
|
||||
List<Long> 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<String, Object> 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<Long> requestedUserIds = addSignTaskDto.getTargetUserIdList();
|
||||
if (CollUtil.isEmpty(requestedUserIds)) {
|
||||
return R.failed("请选择加签人员");
|
||||
}
|
||||
if (requestedUserIds.stream().anyMatch(Objects::isNull)) {
|
||||
return R.failed("加签人员不能为空");
|
||||
}
|
||||
LinkedHashSet<Long> 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<Long> participantUserIds = listParticipantUserIds(nodeRecord.getId());
|
||||
List<SysUser> targetUsers = queryValidTenantUsers(new ArrayList<>(targetUserIds), tenantId);
|
||||
if (targetUsers.size() != targetUserIds.size()) {
|
||||
return R.failed("加签用户不存在或不属于当前租户");
|
||||
}
|
||||
Map<Long, SysUser> 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<AddSignTaskSummaryDto> taskSummaries = new ArrayList<>();
|
||||
try {
|
||||
for (Long targetUserId : targetUserIds) {
|
||||
Map<String, Object> 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<Long> participantUserIdsAfter = new LinkedHashSet<>(participantUserIds);
|
||||
participantUserIdsAfter.addAll(targetUserIds);
|
||||
addSignRecord.setParticipantUserIdsAfter(objectMapper.writeValueAsString(participantUserIdsAfter));
|
||||
processTaskAddSignRecordMapper.updateById(addSignRecord);
|
||||
|
||||
return R.ok(new AddSignResultDto(addSignRecord.getId(), taskSummaries));
|
||||
}
|
||||
|
||||
/**
|
||||
* 前加签
|
||||
* <p>
|
||||
@@ -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<String, Object> 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<String, Object> 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<Long> 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<SysUser> queryValidTenantUsers(List<Long> userIds, Long tenantId) {
|
||||
try {
|
||||
R<List<SysUser>> response = remoteUserService.getUserById(userIds);
|
||||
if (response == null || !response.isOk() || response.getData() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<Long> 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列表
|
||||
* <p>
|
||||
|
||||
+16
-4
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+39
-1
@@ -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<ProcessNodeRecordAssignUser> processNodeRecordAssignUserList = processNodeRecordAssignUserService.lambdaQuery().eq(ProcessNodeRecordAssignUser::getNodeId, node.getId()).eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId).orderByAsc(ProcessNodeRecordAssignUser::getCreateTime).list();
|
||||
ProcessTaskAddSignRecordMapper addSignRecordMapper = SpringUtil
|
||||
.getBean(ProcessTaskAddSignRecordMapper.class);
|
||||
List<AddSignRecordVo> addSignRecordList = addSignRecordMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ProcessTaskAddSignRecord>()
|
||||
.eq(ProcessTaskAddSignRecord::getProcessInstanceId, processInstanceId)
|
||||
.eq(ProcessTaskAddSignRecord::getNodeId, node.getId())
|
||||
.orderByAsc(ProcessTaskAddSignRecord::getCreateTime))
|
||||
.stream()
|
||||
.map(record -> buildAddSignRecord(record, objectMapper()))
|
||||
.toList();
|
||||
nodeVo.setAddSignRecordList(addSignRecordList);
|
||||
Map<String, List<ProcessNodeRecordAssignUser>> map = processNodeRecordAssignUserList.stream().collect(Collectors.groupingBy(ProcessNodeRecordAssignUser::getTaskId));
|
||||
|
||||
for (Map.Entry<String, List<ProcessNodeRecordAssignUser>> 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<Long> 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构建发起人用户信息
|
||||
* <p>
|
||||
|
||||
+49
@@ -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<AddSignTaskDto> 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("需要法务和财务共同确认");
|
||||
}
|
||||
|
||||
}
|
||||
+67
@@ -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<ProcessNodeRecord> 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;
|
||||
}
|
||||
|
||||
}
|
||||
+138
@@ -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<ProcessNodeRecordAssignUser> 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());
|
||||
}
|
||||
|
||||
}
|
||||
+405
@@ -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<ProcessInstanceRecord> processInstanceBaseMapper;
|
||||
@Mock
|
||||
private BaseMapper<ProcessNodeRecordAssignUser> 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<ProcessTaskAddSignRecord> 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<ProcessNodeRecordAssignUser> 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<Long> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+40
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user