大数跨境

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(六):流程篇——接入审批,让业务单据真正流转起来

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(六):流程篇——接入审批,让业务单据真正流转起来 企业软件源码
2026-10-06
11
导读:以 OA 用车申请为主线,讲清业务单据与 Flowable 流程实例的分工,从流程模型、提交发起、待办审批到状态回写,走完一条完整闭环,并指出异步回写需要对账的位置。

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(六):流程篇——接入审批,让业务单据真正流转起来

🌐 文档地址:https://ruoyioffice.com
 👇 文章底部获取源码和演示地址 👇
 💬 :17156169080(获取产品咨询)

这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 6 篇。前五天我们有了接口、页面和权限边界,但用车申请还只是一张“填完就躺在表里”的单据。第六天接入审批:让它能提交、能被不同的人审批、能被撤回和驳回,并且审批的结果能准确地回到业务单据上。读完你能回答三个问题:业务单据和流程实例到底是什么关系、一次审批的结果经过哪几站才落到业务表、哪些状态必须由业务模块自己管。

▲ 本篇核心视觉:从上到下依次是入口、业务模块、BPM 引擎和状态回写通道。向下的箭头是“提交”,左侧向上的箭头是“回写”,两者合起来就是一条闭环;右下角的虚线框提醒回写失败只记日志。

引言:先分清两个对象

接入审批最常见的误区,是以为“单据”和“流程”是同一个东西。实际上它们是两个对象,各有各的生命周期:

对象
谁负责
回答的问题
典型标识
业务单据
业务模块(如 OA)
谁要用哪辆车、什么时候、为什么
单据表主键、单据号
流程实例
BPM 引擎(Flowable)
现在走到哪个节点、轮到谁处理
流程实例编号
关联键
两边共同约定
这个实例属于哪张单据
businessKey
业务进度
业务模块
车有没有还回来
如还车状态

用车申请的关系可以用一句话概括:单据主键作为 businessKey 传给引擎,引擎生成的实例编号回填到单据的 process_instance_id。两边靠这一对键互相找到对方。

说明:本文代码节选自 yudao-module-bpm、yudao-framework 与 yudao-module-oa,为便于阅读省略了日志与部分空行。截图里的审批详情用的是费用申请单,用来展示通用的审批界面,用车申请的业务代码仍是本文主线。

一、流程模型:把一张单据接到一个流程 key 上

在“流程中心”的流程模型里,新建模型的第一步是填写基本信息。其中最关键的是流程标识(key):用车申请的 key 是 oa_car_apply_bill,它与后端枚举 OaBillTypeEnum 里的定义、前端页面里的 PROCESS_DEFINITION_KEY 三处必须一致。

▲ 流程模型的“基本信息”步骤:流程标识决定单据与流程的对应关系,流程类型可选 BPMN 设计器或 SIMPLE 设计器,“发起所需权限”可以填写类似 oa:car-apply-bill:create 的权限标识。

模型后面还有表单设计、流程设计和更多设置。和前端页面直接相关的有两个配置,保存在模型的 JSON 里:

配置项
用车申请的值
作用
formCustomCreatePath /oa/car/car-apply-info
在流程中心发起时,跳转到哪个前端路由
formCustomViewPath /oa/car/carapply/info/index.vue
在待办、已办里,把哪个组件嵌进审批详情

这两个值如果还指向已经删掉的页面路径,现象就是“待办详情空白”。修法是改流程模型里的路径(或出数据库脚本),不是去改前端的路由匹配。这个原则在第四篇已经强调过一次。

流程设计器里的节点和审批人策略,也有对应的真实枚举,下面列出常用的几类:

类别
可选项
说明
节点类型
发起人、审批人、抄送人、办理人、延迟器、触发器、子流程
来自 BpmSimpleModelNodeTypeEnum
分支类型
条件分支、并行分支、包容分支
条件分支对应排他网关,并行与包容分支对应包容网关
多人审批方式
会签(按通过比例)、或签、依次审批
来自 BpmUserTaskApproveMethodEnum
驳回处理
终止流程、驳回到指定任务节点
来自 BpmUserTaskRejectHandlerTypeEnum
审批人来源
角色、部门成员、部门负责人、连续多级部门负责人、岗位、指定用户、用户组、表单内用户字段、流程表达式、发起人自选、审批人自选、发起人自己、发起人部门负责人
来自 BpmTaskCandidateStrategyEnum

“发起人自选”这一项和第四篇的 startUserSelectAssignees 是同一件事:提交时由发起人选定该节点的审批人,前端把选择结果放进提交请求,后端传给引擎。

用户从哪里发起?既可以在业务菜单里点“新增”,也可以从流程中心的发起大厅选择流程:

▲ 发起大厅:流程按分类排列,OA 用车申请单在 OA 协同办公分类里。点击后会按模型里的 formCustomCreatePath 打开对应的前端页面。

二、提交:业务模块与引擎第一次握手

用户在表单页点击“提交”,第四篇已经讲到前端调用 /submit。后端 CarApplyBillServiceImpl.submitCarApplyBill 的最后一段,是把单据交给引擎:

// 保存或更新CarApplyBillDO carApplyBill = BeanUtils.toBean(saveReqVO, CarApplyBillDO.class)        .setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus());carApplyBillMapper.insertOrUpdate(carApplyBill);// 智能提交 BPM 流程(如果流程实例不存在则创建,存在则审批发起人任务)Map<String, Object> processInstanceVariables = BpmProcessVariableUtils.buildBillVariables(saveReqVO);String processInstanceId = processInstanceApi.submitProcessInstance(Long.valueOf(saveReqVO.getCreator()),        new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(OaBillTypeEnum.OA_CAR_APPLY_BILL.getProcessDefinitionKey())                .setVariables(processInstanceVariables).setStartUserSelectAssignees(saveReqVO.getStartUserSelectAssignees()).setBusinessKey(String.valueOf(carApplyBill.getId()))).getCheckedData();// 将工作流的编号,更新到单据中carApplyBillMapper.updateById(new CarApplyBillDO().setId(carApplyBill.getId()).setProcessInstanceId(processInstanceId));

注释里写的“智能提交”,指的是引擎侧的 submitProcessInstance 会判断:同一个 businessKey 下是否已有活动中的实例。

@Overridepublic String submitProcessInstance(Long userId, @Valid BpmProcessInstanceCreateReqDTO createReqDTO) {    Map<String, List<Long>> startUserSelectAssignees = getEffectiveStartUserSelectAssignees(            createReqDTO.getStartUserSelectAssignees());    return FlowableUtils.executeAuthenticatedUserId(userId, () -> {        // 1. 根据businessKey查找现有的流程实例        ProcessInstance existingInstance = findActiveProcessInstanceByBusinessKey(                createReqDTO.getProcessDefinitionKey(), createReqDTO.getBusinessKey());        if (existingInstance != null) {            // 2. 如果流程实例存在,查找发起人的待办任务并审批            Task startUserTask = findStartUserTask(userId, existingInstance.getId());            if (startUserTask != null) {                // 2.2 更新流程变量(如果有新的变量) ...省略                // 更新单据状态                updateProcessInstanceRunning(existingInstance);                // 2.3 审批发起人任务                BpmTaskApproveReqVO approveReqVO = new BpmTaskApproveReqVO()                        .setId(startUserTask.getId())                        .setReason("重新提交申请")                        .setNextAssignees(startUserSelectAssignees);                taskService.approveTask(userId, approveReqVO);                return existingInstance.getId();            } else {                throw exception(TASK_NOT_EXISTS);            }        } else {            // 3. 如果流程实例不存在,创建新的流程实例            ProcessDefinition definition = processDefinitionService                    .getActiveProcessDefinition(createReqDTO.getProcessDefinitionKey());            return createProcessInstance0(userId, definition, createReqDTO.getVariables(),                    createReqDTO.getBusinessKey(), startUserSelectAssignees);        }    });}

这段逻辑解释了三种提交场景为什么能共用一个接口:

场景
引擎里发生什么
单据侧表现
第一次提交
找不到活动实例,新建实例
回填 process_instance_id
被驳回或撤回后再次提交
找到实例,审批“发起人节点”的待办
单据状态重新变为审批中
提交时选了自选审批人
校验后写入流程变量
审批链路按选择的人生成

submitProcessInstance 的参数里没有任何用车业务字段,业务字段通过 buildBillVariables 变成流程变量,用于条件分支和审批人表达式。引擎只拿到“它判断路径所需要的变量”,而不是整张单据。

三、审批:待办里有哪些动作

提交成功后,引擎根据设计器里的配置生成第一批待办任务。审批人在 PC 或移动端都能看到:

▲ 移动端审批页:四个标签对应四种视角。卡片上的“任务节点”显示当前停在哪个节点,这里是部门负责人。

PC 端的审批详情分为“单据信息、审批信息、流程图”等页签,底部是操作栏:

▲ 审批详情(以费用申请为例):时间线展示已完成与当前节点,审批记录表列出每个节点的处理结果,底部操作栏集中了所有审批动作。

这些动作在后端 BpmTaskController 与 BpmProcessInstanceController 中有对应的接口:

动作
接口
说明
同意
PUT /bpm/task/approve
另有 batch-approve 批量同意
拒绝
PUT /bpm/task/reject
按设计器中的驳回处理方式生效
退回
PUT /bpm/task/return
退回到之前的节点,list-by-return 提供可选节点
委派与转办
PUT /bpm/task/delegate
、PUT /bpm/task/transfer
把任务交给他人处理
加签与减签
PUT /bpm/task/create-sign
、DELETE /bpm/task/delete-sign
临时增加审批人
抄送
PUT /bpm/task/copy
通知相关人员查看
撤回
PUT /bpm/task/withdraw
、PUT /bpm/task/withdraw-to-start
前者针对任务,后者把流程撤回到发起节点
取消与删除
DELETE /bpm/process-instance/cancel-by-start-user
、delete 等
发起人取消,管理员取消或删除
重新提交
PUT /bpm/process-instance/resubmit
被驳回或撤回后重新发起

业务页面自己并不实现这些动作。第四篇的 info/index.vue 只处理“保存、提交、撤回、删除”,其中撤回直接调用引擎的接口:

// 撤回async function handleRevoke(reason: string) {  if (    formData.value.processInstanceId !== undefined &&    formData.value.processInstanceId !== null  ) {    loading.value = true;    try {      await withdrawProcessToStart({        processInstanceId: formData.value.processInstanceId,        reason: reason || '制单人撤回',      });      message.success('撤回成功');      await loadData();    } catch (error) {      console.error('撤回失败:', error);    } finally {      loading.value = false;    }  }}

撤回成功后页面调用 loadData() 重新拉取,页面是否可编辑,由新的流程状态决定,这与第四篇的 computeBusinessFormReadonly 前后呼应。

四、回写:审批结果怎么回到业务表

审批人点击“同意”之后,业务表怎么知道?答案是引擎在实例状态变化时发布一个通知,业务模块的监听器收到后调用约定好的 Service 方法。这条链路有四站。

第一站:通知管理器。BpmNotificationManager 根据流程 key 选择通知方式,默认是本地事件,并且默认异步发送:

private void sendNotification(BpmProcessInstanceStatusMessage message, String processDefinitionKey) {    // 获取通知方式    BpmNotificationTypeEnum notificationType = getNotificationType(processDefinitionKey);    // 获取对应的处理器    BpmNotificationHandler handler = handlerMap.get(notificationType);    if (handler == null) {        log.warn("[sendNotification] 未找到对应的通知处理器: {}", notificationType);        return;    }    // 发送通知    if (Boolean.TRUE.equals(asyncProcess)) {        Long tenantId = TenantContextHolder.getTenantId();        CompletableFuture.runAsync(() -> {            try {                if (tenantId != null) {                    TenantContextHolder.setTenantId(tenantId);                }                handler.handleNotification(message);            } catch (Exception e) {                log.error("[sendNotification] 异步通知处理失败", e);            } finally {                TenantContextHolder.clear();            }        });    } else {        // 同步处理 ...省略    }}

对应的配置项是 yudao.bpm.notification.default-type(默认 local)与 yudao.bpm.notification.async(默认 true)。

第二站:本地事件发布。BpmLocalEventNotificationHandler 把消息转成 BpmProcessInstanceStatusEvent,交给 Spring 事件机制。发布过程被 try/catch 包住,失败只写日志。

第三站:模块监听器。 每个业务模块有一个很薄的监听器子类,OA 的只有指定“我是 OA”和“用哪个工厂”两件事:

@Componentpublic class OaLocalNotificationListener extends AbstractFlowLocalNotificationListener<OaBillTypeEnum> {    @Resource    private OaFlowBillServiceFactory flowBillServiceFactory;    @Override    protected SystemEnum getSystem() {        return SystemEnum.OA;    }    @Override    protected FlowBillServiceFactory<OaBillTypeEnum> getFlowBillServiceFactory() {        return flowBillServiceFactory;    }}

真正的逻辑在抽象父类里:先按流程 key 前缀判断“是不是我的流程”,再把实例事件分发出去:

@Overridepublic void onApplicationEvent(BpmProcessInstanceStatusEvent event) {    if (event.getProcessInstanceInfo() == null || event.getEventType() == null) {        return;    }    String processDefinitionKey = event.getProcessDefinitionKey();    if (!FlowProcessPrefixUtils.match(getSystem(), processDefinitionKey)) {        log.debug("[onApplicationEvent] 非本模块流程,跳过处理: {}", processDefinitionKey);        return;    }    BpmEventTypeEnum eventType = event.getEventType();    if (eventType.isProcessInstanceEvent()) {        handleProcessInstanceEvent(event);        return;    }    if (eventType.isTaskEvent()) {        handleTaskEvent(event);    }}

FlowProcessPrefixUtils.match 按“系统编码加下划线”的前缀判断,OA 的流程 key 以 oa_ 开头,所以 oa_car_apply_bill 会被 OA 监听器接手,而其他模块的流程会被跳过。新增流程时流程 key 的前缀命名是硬约定,起错了前缀,事件就没人接收。

第四站:回写与钩子。 监听器通过工厂按流程 key 找到对应的 FlowBillService 实现,在正确的租户上下文里调用 updateProcessStatus,随后根据最终状态触发生命周期钩子:

Runnable hookAction = null;if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(status)) {    hookAction = () -> flowBillService.onProcessApproved(businessKey);} else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(status)) {    hookAction = () -> flowBillService.onProcessRejected(businessKey);} else if (BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(status)) {    hookAction = () -> flowBillService.onProcessCancelled(businessKey);}if (hookAction != null) {    executeWithTenant(message, hookAction);}

FlowBillService 接口要求业务模块实现 getSupportedBillType 和 updateProcessStatus,三个钩子与 deleteBill 默认是空实现,按需重写。用车申请单的实现里,状态回写之外还多做了一件业务的事:审批通过时,把还车状态置为“待还车”:

@Overridepublic void updateProcessStatus(String businessKey, Integer status) {    Long id = Long.parseLong(businessKey);    // 校验用车申请单存在    validateCarApplyBillExists(id);    // 更新流程状态    CarApplyBillDO updateObj = new CarApplyBillDO();    updateObj.setId(id);    updateObj.setProcessStatus(status);    // 如果审批通过,设置还车状态为待还车    if (APPROVE.getStatus().equals(status)) {        updateObj.setReturnStatus(CarReturnStatusEnum.PENDING_RETURN.getStatus());    }    carApplyBillMapper.updateById(updateObj);}

两张单据之间的联动,也是通过同样的回写入口完成的。还车申请单审批的结果,会反过来修改它关联的用车申请单:

private void handleApplyBillReturnStatus(Long returnBillId, Integer status) {    try {        CarReturnBillDO returnBill = getCarReturnBill(returnBillId);        if (returnBill == null || returnBill.getApplyBill() == null) {            return;        }        CarApplyBillDO applyBill = carApplyBillService.getCarApplyBillByCode(returnBill.getApplyBill());        if (applyBill == null) {            return;        }        // 根据流程状态处理用车申请单的还车状态        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(status)) {            // 审批通过:标记为已还车            carApplyBillService.markAsReturned(applyBill.getId());        } else if (BpmTaskStatusEnum.REJECT.getStatus().equals(status) ||                   BpmTaskStatusEnum.CANCEL.getStatus().equals(status) ||                   BpmTaskStatusEnum.RETURN.getStatus().equals(status)) {            // 审批拒绝、取消或退回:回滚为未还车            carApplyBillService.markAsNotReturned(applyBill.getId());        }    } catch (Exception e) {        log.error("[handleApplyBillReturnStatus] 处理用车申请单还车状态失败,returnBillId: {}, status: {}", returnBillId, status, e);    }}

把这四站放在一起,状态的对应关系是:

引擎状态
数值
用车申请单 process_status
业务侧额外动作
未提交
-1
草稿,只存在于前端或首次保存
无
审批中
1
提交时已预置为审批中
无
审批通过
2
回写为通过
return_status
 置为待还车
审批不通过
3
回写为不通过
可编辑后重新提交
已取消
4
回写为已取消
触发取消钩子(默认空实现)
已撤回
10
回写为已撤回
可编辑后重新提交

五、闭环:提交、发起、审批、回写

现在可以把“提交 → 发起 → 审批 → 回写”完整放进一张时序图:

▲ 时序图:前 6 步是同步的提交与发起,第 7 到 9 步是审批,第 10 到 13 步是事件驱动的异步回写。注意第 2 步先把单据状态写成“审批中”,第 13 步才由引擎的最终结果覆盖它。

阶段
编号
谁负责
数据落点
提交
1 到 2
用车单 Service
单据表写入,状态为审批中
发起
3 到 6
BPM 引擎
流程实例,回填实例编号
审批
7 到 9
审批人与引擎
任务、审批记录
回写
10 到 13
通知管理器与监听器
单据表更新状态和业务进度

六、为什么业务模块不能把状态全交给引擎

读到这里可能有人会问:既然引擎已经有状态,业务表为什么还要自己存 process_status 和 return_status?有四个理由:

理由
说明
列表要快
业务列表按状态筛选和排序,直接查业务表,不必每行去关联引擎的运行时表
状态含义不同
流程状态只描述审批走到哪,“待还车”这类业务进度引擎并不知道
引擎可以升级
引擎内部表和版本会变化,业务表只依赖约定好的几个状态值,耦合更小
数据要自洽
流程实例被清理或归档后,业务单据仍要能被查询和导出

反过来,业务模块也不应该重新实现审批本身:谁来审、几个人会签、驳回到哪里,这些全部交给流程模型配置。业务代码只管三件事:提交时调用引擎、实现回写接口、处理自己的业务进度。

七、常见故障矩阵

现象
最可能的根因层
如何确认
正确的修法
待办详情空白
流程模型里的表单路径过期
对照模型 JSON 里的 formCustomViewPath 与实际页面文件
改流程模型或出数据库脚本,不改前端匹配逻辑
发起大厅点了没反应
formCustomCreatePath
 配错或没有发起权限
看模型的发起权限与创建路径
修正模型配置
审批通过了但单据仍是审批中
回写未执行或失败
搜后端日志里的 updateProcessStatus、通知处理失败
先查异常原因,再对账补偿,不要手改库后不管
审批通过后过一会儿才变状态
回写默认是异步线程
看通知是否异步,刷新后是否更新
属于预期,页面不要在审批后立即信任旧状态
新流程完全没回写
流程 key 前缀不是模块前缀
看 key 是否以 oa_ 等开头
按约定重命名流程 key 并重新发布
提交报“任务不存在”
找到了活动实例但没有发起人待办
看实例当前所在节点
检查是否已被审批流转,必要时走重新提交
还车审批通过,用车单没变成已还车
联动处理被异常吞掉
搜日志 handleApplyBillReturnStatus
修复异常根因后补偿状态
流程分类或模型找不到
模型未发布或 key 不一致
在流程模型里对照 key 与发布状态
发布模型,统一三处 key

八、本文发现的几处不足

  1. 回写没有重试与补偿。
     通知默认异步,发布失败与处理失败都只记日志;一旦监听器抛出异常,引擎已经完成审批,业务单据却可能停在旧状态,需要依赖日志或对账脚本发现。
  2. 任务级事件目前只是日志。
     抽象监听器里,任务通过、拒绝、撤回、转办、委派的处理方法只有日志输出,业务状态由实例级事件驱动,没有落库动作。
  3. 还车联动吞掉了异常。handleApplyBillReturnStatus
     捕获所有异常后只记日志,不抛出也不重试。
  4. 还车驳回的回滚值待确认。markAsNotReturned
     实际写入的是枚举 NOT_EFFECTIVE(未生效),而不是“待还车”,是否符合业务预期,需要产品和研发共同确认,本文没有擅自修改。
  5. 没有真实运行验证。
     本文所有结论来自阅读源码与现有截图,没有在本地跑一遍完整审批流程,上线前请在测试环境走完“提交、同意、驳回、撤回、重新提交”五个场景。

九、今天学完你应该能做到

  1. 能说出业务单据和流程实例各管什么,并解释 businessKey 与 process_instance_id 的对应关系。
  2. 能讲清“智能提交”在三种场景下引擎里分别发生什么。
  3. 能画出审批结果回写业务表的四站,并指出哪一站默认是异步的。
  4. 能说出为什么业务表要保留自己的状态字段,以及业务进度为什么不能交给引擎。
  5. 能根据现象判断该查流程模型、通知链路还是业务回写。

常见问题(FAQ)

业务表里的 process_status 和引擎里的状态为什么要分开?

引擎状态描述审批进度,业务表状态用于列表筛选、导出和业务判断。两者靠回写保持一致。业务进度(如还车状态)则完全属于业务模块,引擎并不知道。

流程 key 可以随便起吗?

不可以。后端监听器按“模块编码加下划线”的前缀判断流程归属,前端页面里的 PROCESS_DEFINITION_KEY、枚举里的流程标识、流程模型里的 key 三处必须一致。

回写是同步还是异步?

默认是异步:yudao.bpm.notification.async 默认为 true,通过独立线程执行。也可以改为同步,但回写失败仍然只会记录日志,不会回滚审批。

单体和微服务部署,回写方式一样吗?

通知方式由 yudao.bpm.notification.default-type 决定,默认是本地事件,适合单体。微服务场景下仓库还提供了消息队列等通知处理器,选择哪一种要结合部署架构,并在测试环境验证。

驳回后用户怎么改了再提交?

驳回后实例状态变为“审批不通过”,在前端的可编辑状态集合里。用户修改后再次点击提交,引擎会找到同一 businessKey 下的活动实例,审批发起人节点完成重新提交。

系列进度与下一篇预告

篇目
主题
状态
(一)
架构篇——一个底座,多端通达
已发布
(二)
启动篇——从源码到前后端联调,跑通开发环境
已发布
(三)
后端篇——从业务建模到接口开发,做出一个完整模块
已发布
(四)
前端篇——从菜单路由到表单列表,完成业务页面
已发布
(五)
权限篇——把登录、角色、数据权限与租户隔离讲透
已发布
(六)
流程篇——接入审批,让业务单据真正流转起来
本篇
(七)
上线篇——从测试验收到部署上线,完成企业项目交付
下一篇

功能、页面、权限、审批都已成型,最后一天要回答的是:怎么把它放到真实环境里,并且出了问题能找得到、退得回。下一篇进入上线篇:从开发、测试、生产的差异说起,梳理单体与微服务的取舍、前端构建与 Nginx、HTTPS 与跨域、中间件与配置、日志与备份,最后给出一份可以直接对照的上线验收清单。想深入同一业务的审批设计,可延伸阅读 公务用车:申请占坑、还车释放、撞时段怎么拦。

如果这篇对你有用,点个「在看」或收藏。

🌐 演示地址:https://ruoyioffice.com/web
 📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
 📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
 💬 微信:17156169080(获取产品咨询)

打开演示地址直接查看系统。

【声明】内容源于网络
0
0
企业软件源码
RuoyiOffice 是一套基于 Spring Boot + Vue3 +Uniapp 的企业一体化管理平台,集 OA、CRM、ERP、工作流、HR、资产、合同、项目、AI应用等业务于一体,帮助企业用一个系统协同管理多类核心业务。
内容 144
粉丝 0
企业软件源码 RuoyiOffice 是一套基于 Spring Boot + Vue3 +Uniapp 的企业一体化管理平台,集 OA、CRM、ERP、工作流、HR、资产、合同、项目、AI应用等业务于一体,帮助企业用一个系统协同管理多类核心业务。
总阅读2.5k
粉丝0
内容144