鉴于篇幅原因,本篇文章仅介绍利用 Activiti 实现 OA 申请流程的关键步骤,页面设置和操作等不再赘述,更多内容可以直接查看。
申请流
在中已经实现了在系统中通过上传 BPMN2 文件的方式部署审批流。要想让业务和 Activiti 中的工作流实例进行关联,需要创建对应的业务流程实例,在这个 OA 系统中,我称其为“申请实例”,由下面下面的表结构定义:
CREATE TABLE `apply_instance` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '申请实例id',
`user_id` bigint unsigned NOT NULL COMMENT '用户id',
`apply_process_id` bigint unsigned NOT NULL COMMENT '申请流id',
`process_key` varchar(255) NOT NULL COMMENT 'Activiti流程key',
`form_id` bigint NOT NULL COMMENT '表单id',
`form_data` text NOT NULL COMMENT '申请单内数据',
`process_instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'activiti工作流实例id',
`status` tinyint NOT NULL COMMENT '状态(0 待审批,1 审批中,2 已通过,3 未通过)',
`create_time` datetime NOT NULL COMMENT '申请时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='申请流实例'
一个申请实例代表用户提交的一个关联了 Activiti 工作流实例的审批申请。这种关联通过在启动 Activiti 工作流时指定 Business Key 实现。单方面的关联不利于查询,所以这里做了双向关联,因此apply_instance
表中还存在一个process_instance_id
字段。
下面是创建申请实例、启动 Activiti 工作流并进行双向关联的关键代码:
boolean saved = this.save(applyInstance);
if (saved) {
// 启动关联的 activiti 工作流
Map<String, Object> vars = new HashMap<>();
vars.put("applier", applyInstance.getUserId().toString());
vars.put("days", applyInstance.getFormData().getExtraData().get("days"));
vars.put("budget", applyInstance.getFormData().getExtraData().get("budget"));
ProcessInstance processInstance = activitiUtils.startAndNext(applyInstance.getProcessKey(), applyInstance.getId(), vars);
// 更新申请实例表,关联工作流id
ApplyInstance instance = new ApplyInstance();
instance.setId(applyInstance.getId());
instance.setProcessInstanceId(processInstance.getId());
this.updateById(instance);
return Result.success(applyInstance.getId());
}
当然,启动哪个申请流,取决于apply_instance
绑定的process_key
字段,它表示一个有效的 Activiti 流程定义 key,会启动其对应的最新版本的 Activiti 工作流。
表单
现在,解决了将用户提交的审批申请和 Activiti 工作流关联的问题,还需要解决的一个问题是关联申请单。
要知道,OA 的申请单是一个会经常变动的因素,会随着公司发展和业务需要调整同一个申请的申请单。换言之,我们需要在系统中保存同一个类型的申请的多个版本的申请单,以确保变更申请单前后的申请都能正确显示申请单。
这里我采用由前端实现不同版本的申请单,由后端配置的方式“注册”其路径,并在实际使用的时候根据版本的新旧决定使用哪个版本的申请单进行申请。
另一种方式是由数据库保存申请单的 vue 源码,由前端动态加载。但这种实现比较复杂,需要用到 vue 的编译器,将 vue 源码编译为 javascript 和 html 代码后加载,会遇到一些编译 vue 源码的相关问题,且还需要注意和处理随之而来的安全性问题,所以这里没使用。
服务端表示表单的表结构:
CREATE TABLE `apply_form` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`key` varchar(50) NOT NULL COMMENT '表单key',
`version` int NOT NULL COMMENT '表单版本,只有最大版本的表单生效',
`name` varchar(100) NOT NULL COMMENT '表单名称',
`path` varchar(255) NOT NULL COMMENT 'vue文件路径',
`view_path` varchar(255) NOT NULL COMMENT '查看申请单时候的文件路径',
`create_time` datetime NOT NULL COMMENT '创建表单的时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='申请表单'
这里path
字段表示提交申请时使用的表单的 vue 源码路径,view_path
表示查看或审批申请时使用的表单的 vue 源码路径。前端需要在提交、查看或审批申请时使用这些路径信息动态加载代表表单的 vue 组件。
如何用 vue 动态加载组件,可以看。
审批状态
当然,我们可以利用查询 Activiti 运行时相关信息判断 Activiti 工作流是否活动中或者已经结束来判断对应的OA申请的状态。但显然这样是效率低下的方式,所以在apply_instance
中存在一个status
字段,表示申请实例的状态。对应的实体类中由一个枚举类型表示:
public enum ApprovalStatus implements IDescEnum<Integer> {
PENDING_APPROVAL(0,"待审批"),
UNDER_APPROVAL(1,"审批中"),
PASSED(2,"已通过"),
FAILED(3,"未通过");
ApprovalStatus(Integer value, String desc) {
this.value = value;
this.desc = desc;
}
private Integer value;
private String desc;
public static ApprovalStatus valueOf( Integer statusVal) {
for (ApprovalStatus value : ApprovalStatus.values()) {
if (statusVal.equals(value.getValue())) {
return value;
}
}
return null;
}
public String getDesc() {
return this.desc;
}
public Integer getValue() {
return this.value;
}
}
提交申请时,该字段会被设置为待审批:
applyInstance.setStatus(ApplyInstance.ApprovalStatus.PENDING_APPROVAL);
boolean saved = this.save(applyInstance);
任意审批环节的负责人审批后,将其改为审批中:
activitiUtils.completeTaskWithCheck(dto.getUserId().toString(), dto.getTaskId(), vars);
// 如果申请实例状态是待审批,修改状态为审批中
ApplyInstance applyInstance = this.getById(dto.getApplyInstanceId());
if (applyInstance != null && applyInstance.getStatus() != null
&& applyInstance.getStatus().equals(ApplyInstance.ApprovalStatus.PENDING_APPROVAL)) {
this.changeStatus(dto.getApplyInstanceId(), ApplyInstance.ApprovalStatus.UNDER_APPROVAL);
}
注意,这里是先通过封装的 API 完成了审批环节对应的 Activiti 用户任务,然后才修改申请实例状态,因此需要判断,只有申请实例是待审批状态时才进行修改。否则如果 Activiti 用户任务是最后一个活动,完成后直接工作流结束,就会触发系统将申请流状态修改为已通过的行为,这时候再修改状态为审批中就是一个 BUG。
activitiUtils
封装的用法可以直接查看源码或。
对应的,如果驳回申请,就修改为未通过状态:
activitiUtils.rejectTask(dto.getTaskId(), dto.getUserId().toString(), "审批未通过", vars);
this.changeStatus(dto.getApplyInstanceId(), ApplyInstance.ApprovalStatus.FAILED);
审批都通过后,需要将申请实例状态设置为已通过。可以利用 Activiti 结束事件(End Event)的监听器实现。
首先,创建一个 Activiti 监听器:
"processEndListener")
(
public class ProcessEndListener implements ExecutionListener {
private ApplyInstanceService applyInstanceService;
public void notify(DelegateExecution execution) {
// 获取流程实例ID和业务键
String processInstanceId = execution.getProcessInstanceId();
String businessKey = execution.getProcessInstanceBusinessKey();
// 执行业务逻辑(如数据清理、通知等)
log.info("流程结束,实例ID:" + processInstanceId + ",业务键:" + businessKey);
applyInstanceService.endProcess(processInstanceId);
}
}
注意,这里实现的接口是
ExecutionListener
,并非监听用户任务时通常使用的TaskListener
,这是两种不同用途的监听器,前者用于事件(Event),后者用于任务(Task)。如果使用错了类型,会出现无法触发监听也没有任何错误提示的 BUG。为了方便调用其它 Spring Bean,这里将监听器也设置为 Spring Bean(
@component
)。
这个监听器很简单,就是通过方法调用,将申请实例的状态修改为已通过:
public void endProcess(String processInstanceId) {
// 修改申请实例状态为已通过
ApplyInstance applyInstance = this.getApplyInstanceByProcessInstanceId(processInstanceId);
if (applyInstance == null) {
return;
}
this.changeStatus(applyInstance.getId(), ApplyInstance.ApprovalStatus.PASSED);
}
要想在工作流结束时触发这个监听器,需要在 BPMN2 文件中的结束事件(End Event)中设置监听器:
<endEvent id="sid-c35c58e5-ce4e-4363-84fb-449b3aadd522">
<extensionElements>
<activiti:executionListener event="end" delegateExpression="${processEndListener}"/>
</extensionElements>
</endEvent>
这里的
delegateExpression="${processEndListener}"
表示该监听器以 Java Bean 的形式存在,名称为processEndListener
。
审批环节展示
显然,申请在提交后需要展示包含哪些审批环节,下个环节名称是什么,由哪个职位的人审批,已经通过的环节有哪些,都是谁审批的,审批意见是什么等等。
首先应当考虑的是审批环节的状态信息以及审批意见等相关内容保存在哪里。当然我们可以自行创建额外的业务表保存,但是考虑到所有审批环节都是由 Activiti 工作流控制的,因此使用 Activiti 的工作流变量更为恰当。
vars.put("opinion", dto.getOpinion());
vars.put("status", ApplyInstance.ApprovalStatus.PASSED.getValue());
activitiUtils.completeTaskWithCheck(dto.getUserId().toString(), dto.getTaskId(), vars);
Activiti 工作流实例的变量分为两种:实例范围和任务范围,显然这里添加的是仅任务范围生效的变量,事实上
activitiUtils
封装也是这么实现的。
现在可以考虑怎么获取审批环节信息了,这些信息分为两部分,已经完成审批的环节和待审批的环节。
已经完成审批的环节可以通过 Activiti 的历史记录查询:
List<HistoricTaskInstance> historicTaskInstances = activitiUtils.listHistoryTasks(applyInstance.getProcessInstanceId());
// 历史审批环节
for (HistoricTaskInstance historicTaskInstance : historicTaskInstances) {
if (historicTaskInstance.getEndTime() == null
|| historicTaskInstance.getName().equals("创建申请")) {
// 不包含未完成的审批环节
// 不包含创建申请环节
continue;
}
Map<String, Object> taskVariables = activitiUtils.getTaskVariables(historicTaskInstance.getId());
ApplyApprovalDTO approvalDTO = new ApplyApprovalDTO();
approvalDTO.setTime(historicTaskInstance.getEndTime());
String opinion = "";
if (taskVariables.get("opinion") != null) {
opinion = taskVariables.get("opinion").toString();
}
approvalDTO.setOpinion(opinion);
approvalDTO.setTitle(historicTaskInstance.getName());
if (historicTaskInstance.getAssignee() != null) {
Long assigneeUserId = Long.valueOf(historicTaskInstance.getAssignee());
User assigneeUser = userService.getById(assigneeUserId);
approvalDTO.setUserName(assigneeUser.getName());
}
String statusText = "";
if (taskVariables.get("status") != null) {
Integer statusVal = Integer.valueOf(taskVariables.get("status").toString());
ApplyInstance.ApprovalStatus status = ApplyInstance.ApprovalStatus.valueOf(statusVal);
if (status != null) {
statusText = status.getDesc();
}
}
approvalDTO.setStatusText(statusText);
approvalDTO.setCanApproval(false);
approvalDTOS.add(approvalDTO);
}
事实上待审批环节也在历史记录中有保留,所以这里需要按照是否有任务完成时间(End Time)来过滤,但我之前的代码实现已经这样做了,就没再修改了。
待审批环节可以从 Activiti 的运行时相关表中获取:
Task lastTask = activitiUtils.getLastTask(applyInstance.getProcessInstanceId());
// 当前生效的审批环节
if (lastTask != null) {
List<String> candidates = activitiUtils.listCandidates(lastTask.getId());
for (String candidate : candidates) {
Long approvalUserId = Long.valueOf(candidate);
User user = userService.getById(approvalUserId);
ApplyApprovalDTO approvalDTO = new ApplyApprovalDTO();
approvalDTO.setTime(new Date());
approvalDTO.setOpinion("");
approvalDTO.setUserName(user.getName());
approvalDTO.setTitle(lastTask.getName());
ApplyInstance.ApprovalStatus pendingApproval = ApplyInstance.ApprovalStatus.PENDING_APPROVAL;
approvalDTO.setStatusText(pendingApproval.getDesc());
// 当前用户是审批人且是待审批环节,表明可以审批该环节
boolean canApproval = approvalUserId.equals(UserHolder.getUser().getId());
approvalDTO.setCanApproval(canApproval);
approvalDTO.setTaskId(lastTask.getId());
approvalDTOS.add(approvalDTO);
}
}
查找审批人
显然,OA 审批中根据申请提交人确定一个审批人(通常是按职位,比如经理审批、高级经理审批等)是一个常见功能。可以利用 Activiti 的任务监听创建一个通用的查找审批人作为当前任务的候选人的监听:
"reAssigneeListener")
(public class ReAssigneeListener implements TaskListener {
private RuntimeService runtimeService;
private ApplyInstanceService applyInstanceService;
private UserService userService;
private TaskService taskService;
public void notify(DelegateTask delegateTask) {
if ("create".equals(delegateTask.getEventName())) {
// 只在任务实例创建后,指定委托人之前生效
// 获取申请人
String processInstanceId = delegateTask.getProcessInstanceId();
ProcessInstance processInstance = runtimeService.createProcessInstanceQuery()
.processInstanceId(processInstanceId)
.singleResult();
String businessKey = processInstance.getBusinessKey();
ApplyInstance applyInstance = applyInstanceService.getById(Long.valueOf(businessKey));
Long userId = applyInstance.getUserId();
List<User> users;
// 根据审批环节名称匹配审批责任人
switch (delegateTask.getName()) {
case "经理审批":
users = userService.matchApprovalUsers(userId, Position.POSITION_KEY_JINGLI);
for (User user : users) {
taskService.addCandidateUser(delegateTask.getId(), user.getId().toString());
}
break;
case "高级经理审批":
users = userService.matchApprovalUsers(userId, Position.POSITION_KEY_GAOJIJINGLI);
for (User user : users) {
taskService.addCandidateUser(delegateTask.getId(), user.getId().toString());
}
break;
case "财务审批":
users = userService.getFinanceApprovalUsers();
for (User user : users) {
taskService.addCandidateUser(delegateTask.getId(), user.getId().toString());
}
break;
default:
//不做任何处理
}
}
}
}
taskService.addCandidateUser
是和组织架构相关联的业务逻辑,比如“高级经理审批”,就需要先查看申请人所在的部门中有没有高级经理职位的用户,如果有就列为候选人。如果没有,则需要查看该部门的虚拟员工中有没有具备高级经理职位的人,如果有,列为候选人。如果都没有,则需要查询该部门的父部门,依次递归查询,直到有结果或者根部门也没有找到的情况发生。
userService.getFinanceApprovalUsers
比较简单,通常财务审批是指财务部门下的普通员工,这个只要按特定部门查询即可,部门可以是部门 ID 硬编码,也可以是按照部门名称匹配,亦或者由后端特定配置标记。在这个示例项目中,简单按照部门名称匹配。
现在只需要将需要按照这种查找规则自动分配候选人的任务配置上任务监听即可:
<userTask id="sid-113caa7f-ce80-46a3-8ac3-7d3e90e09b3b" name="高级经理审批">
<extensionElements>
<activiti:taskListener event="create" delegateExpression="${reAssigneeListener}"/>
</extensionElements>
</userTask>
需要注意的是,这里存在一个潜在 BUG,即如果 BPMN2 文件中第一个用户任务就使用该监听分配候选人,实际启动对应的工作流实例就会报错,错误信息显示流程实例(Process Instance)空指针异常,查看数据库会发现,此时数据库中 Activiti 运行时相关的表(act_run_XXX)中并没有相关流程实例的数据。换言之,在工作流的第一个用户任务的创建事件(Create Event)时,相应的流程实例还未创建。
这个 BUG 可以通过在工作流的开始事件(Start Event)后添加一个会自动完成的用户任务来解决:
<userTask id="sid-5e762873-c011-49cc-8ccc-8188c12f8b20" name="创建申请"/>
这个任务没有指定委托人,在工作流实例启动后由代码自动完成:
ProcessInstance processInstance = activitiUtils.startAndNext(applyInstance.getProcessKey(), applyInstance.getId(), vars);
startAndNext
封装会在工作流实例启动后自动完成第一个用户任务。我还尝试过将工作流的第一个活动设置为服务任务(Service Task),这样不需要在启动流程的时候用代码的方式完成第一个任务,该类型的任务本就可以自动执行一段代码,不需要人为干预。但实际测试发现这样不起作用,服务任务执行完后执行用户任务,依然会出现空指针异常的 BUG。
本文的完整示例可以从获取。
The End.
文章评论