SpringBoot+Vue3+Flowable 企业工单系统全流程设计——4张表、6种状态、3种分配策略,BPM审批后自动路由到工单池

0 阅读18分钟

SpringBoot+Vue3+Flowable 企业工单系统全流程设计——4张表、6种状态、3种分配策略,BPM审批后自动路由到工单池

🌐 文档地址ruoyioffice.com | 📦 源码1gitcode.com/zhouzhongya… | 📦 源码2gitcode.com/zhouzhongya… | 📦 源码3github.com/yuqing2026/… | 💬 微信:17156169080(备注「RuoYi Office」)

工单是企业内部服务协同的"毛细血管"——员工 VPN 坏了、打印机卡纸了、食堂餐标被投诉了、客户反馈官网报 502 了,每一件都要有人收、有人办、有人追进度。看起来就是"让员工提个单子、让客服去处理"这么简单,真做成系统却要回答一连串难题:**该派给谁?派错了谁转?多久必须响应?过时了要不要告警?审批通过前能不能动?同一张单能不能被两个人同时抢?**RuoYi Office 用 4 张表 + 6 种状态 + 3 种分配策略,把这些问题全部压进了数据模型,让 OA、客服、IT 运维三类工单共用一套骨架。 ticket-lifecycle-flow.png

▲ 工单全生命周期:申请端创建/提交 → BPM 审批 → onProcessApproved 回调匹配 SLA + 自动分配 → 处理端接单/回复/转派/完成/关闭,所有状态最终都落回 oa_ticket_bill 一张表

引言:工单系统到底难在哪?

"不就是一张表加个状态字段吗?"很多人第一次做工单都这么想。做到第二周就会发现,真正吃劲的不是"如何存工单",而是如何在"一张单"里同时满足申请人、处理人、主管三方截然不同的诉求

申请人要"看得见、催得动":提交后不想听人说"还没轮到",需要看到处理人是谁、什么时候会回我、超没超时。

处理人要"收得到、分得清":当我点开工单池,只应该看到"我负责那一类"或"暂时没人管"的单,而不是全公司所有工单一起砸过来。

主管要"管得住、查得到":谁在拖工单、哪类工单响应最慢、哪个人负载最高,都需要有数据支持,不能靠问。

BPM 审批要"压得住":工单可能包含敏感信息(投诉、财务相关问题),提交后必须先走审批,审批通过前处理端根本不能看到这张单;同时审批通过后要立刻路由给处理组,不能让 HR 再手动派一次。

痛点传统做法后果
状态字段混为一谈用一个 status 同时表示"审批中 / 处理中 / 已关闭"BPM 状态和业务状态打架,看不出是"谁卡住了"
工单池随便打开全公司工单都能看到申请人隐私泄漏、处理人被淹没
派单靠组长手工主管每天早上按经验分派主管请假 = 系统停摆
没有 SLA"尽快处理"写在合同上没人知道到底还剩多久
回复只有一种所有沟通都是"公开消息"内部备注、系统记录全都暴露给申请人
转派丢状态换人时把状态清零重新走一遍上一段处理记录不连续

本文就以 RuoYi Office 的工单模块为例,拆解它如何通过 4 张表 + 双状态字段 + 处理组路由 + 三段式权限 把上面这些痛点一次性解决。


一、业务设计:为什么需要 4 张表?

1.1 业务全景:一张单的 3 段人生

一张工单从提出到终结,最多穿越 3 个角色 + 6 种状态

阶段角色操作process_statusticket_status
申请申请人保存草稿 / 提交0 草稿 → 1 审批中0 未启动
审批审批人(BPM)通过 / 拒绝2 审批通过 / 3 拒绝0 未启动
路由系统自动匹配 SLA + 自动分配2 审批通过1 待分配 → 2 待处理
处理处理人接单 / 回复 / 转派 / 完成2 审批通过3 处理中 → 4 已完成
关闭申请人 / 系统确认 / 激活2 审批通过5 已关闭(可反激活回 3)

注意这里最关键的设计:process_status 描述"审批走到哪了",ticket_status 描述"处理走到哪了"。两个字段彻底解耦,BPM 的同学改他的流程定义不会影响业务侧,业务侧加状态也不用动 BPM。

1.2 为什么分 4 张表?——职责彻底分离

表名存什么承担的职责
oa_ticket_bill工单主体,包含 BPM 状态、业务状态、SLA 时间戳单据 + 状态机 + 时效快照
oa_ticket_handler_group处理组:成员、组长、分配策略权限路由(谁能看到谁能接)
oa_ticket_sla_ruleSLA 规则:优先级 × 分类 → 响应/解决时限时效计算(deadline 的来源)
oa_ticket_reply回复:公开 / 内部 / 系统三类沟通+审计(带类型的消息流)

这套拆分背后是一个朴素原则:"单据是快照,规则是参数,沟通是事件"。把这三件事混在同一张表里,未来任意一个维度想扩展都会牵一发动全身;拆开之后,增加一种 SLA 规则、增加一种回复类型、增加一种分配策略,都只动一张表。

1.3 两个状态字段的角色分工

process_status  # BPM 流程状态,由 FlowBillService 回调更新
  0: 草稿
  1: 审批中
  2: 审批通过
  3: 审批拒绝
  4: 已取消

ticket_status   # 工单业务状态,由处理人和系统自动更新
  0: 未启动
  1: 待分配  # BPM 通过,还没指定处理人
  2: 待处理  # 已分配处理人,但处理人没点"接单"
  3: 处理中  # 处理人点了"接单" / "处理"
  4: 已完成  # 处理人点了"标记完成"
  5: 已关闭  # 申请人确认

一张单合法的"双状态组合"只有这么几种:

process_statusticket_status场景
0 草稿0 未启动新建保存未提交
1 审批中0 未启动提交后等审批
3 拒绝0 未启动审批被驳
2 审批通过1 待分配在工单池里等人抢
2 审批通过2 待处理自动分配到某人但他没点"接单"
2 审批通过3 处理中在处理了
2 审批通过4 已完成提交人验收前
2 审批通过5 已关闭归档

这张表可以直接贴进测试用例——所有非法组合(比如 process=3 拒绝ticket=3 处理中)都要被后端守住。


二、系统设计:三个 Service 把"单/组/时效"串起来

2.1 模块组成

ticket-architecture.png

▲ 工单模块系统结构:4 张表在数据层解耦,工单池路由、SLA 引擎、XXL-Job 定时器在应用层协作,对外暴露申请端/处理端两套接口

子模块核心类面向角色
工单主服务TicketBillServiceImpl申请人 + 处理人共用,负责 CRUD 与状态翻转
处理组服务TicketHandlerGroupServiceImpl管理员配置处理组,运行时被路由层查询
SLA 规则服务TicketSlaRuleServiceImpl管理员配置规则,审批通过时由主服务调用 matchSlaRule
自动分配服务TicketAssignServiceImpl实现 3 种策略,返回被选中的 userId
回复服务TicketReplyServiceImpl申请端 / 处理端两种视角,过滤内部备注
BPM 回调TicketBillServiceImpl implements FlowBillServiceBPM 审批通过/拒绝时被框架反向调用

2.2 核心设计决策

决策点方案理由
状态字段如何分层process_status + ticket_status 双字段BPM 状态和业务状态各自演进互不影响
派单路由放在哪一层路由 = 查询条件,不是中间件工单池的 SQL 里直接 group_id IN (:myGroups) OR group_id IS NULL,零中间件成本
SLA 是实时计算还是落库审批通过时一次性算好并写入 deadline避免每次列表查询都跑一次规则匹配,也便于索引查超时
自动分配失败怎么办落回工单池待抢单宁可让人抢,也不能让工单无人认领
内部备注怎么对申请端隐藏Service 层按 source=apply 过滤 type=2比"前端不显示"更安全,前端显示逻辑再复杂也防不了抓包
转派会不会丢历史转派只改 handler_id,不清 replies回复表以 ticket_id 为外键,处理人换了历史对话还在

2.3 三层权限:申请端 / 工单池 / 详情

这是整个工单系统权限模型的精髓——不用新建角色,纯靠数据关系划分可见性

页面数据来源过滤条件
工单列表(申请端)getTicketBillPage(viewType=mine)creator = currentUserId
工单池-待接单getTicketPoolPage(viewType=pending)ticket_status = 1 AND handler_group_id IN (myGroups) OR IS NULL
工单池-我处理的getTicketPoolPage(viewType=my_handling)handler_id = currentUserId AND ticket_status IN (2,3,4)
工单池-全部getTicketPoolPage(viewType=all)process_status=2 AND handler_group_id IN (myGroups) OR IS NULL
工单详情getTicketPoolDetail额外验证"当前用户是否为处理人/组员"

三段权限加一张 SQL 就搞定,没有任何一行代码去维护"用户X可见哪些工单"这种关系表——因为关系都藏在处理组的成员字段里了


三、PC 端功能实现

3.1 工单列表(申请端视角)

ticket-bill-list.png

▲ 申请端的工单列表:只显示当前用户创建的工单,区分流程状态(审批通过/待分配)与业务状态(待处理/处理中/已完成/已关闭),优先级用颜色徽标突出,"紧急"标红

设计要点

  • 左边菜单把"我的工单 / 工单池 / 处理组管理 / SLA 规则"四个入口分清,避免申请人误入后台配置
  • 工单编号使用 OA120-{yyyyMMdd}{5位流水} 规范(与公文、请假、差旅等单据统一),前 3 位是单据类型代码
  • 流程状态用灰色药丸式 Tag,业务状态用彩色 Tag——颜色密度反过来映射"关注度":审批通过了状态可以"灰",处理中的工单必须"显眼"

3.2 工单新增表单

ticket-bill-create.png

▲ 工单创建表单:标题/分类/优先级/处理组四要素 + 富文本问题描述 + 附件上传,申请部门/申请人自动从当前登录用户带入,避免手填

表单字段分组

字段组字段是否必填说明
基础信息工单标题一句话说清楚
工单分类字典 oa_ticket_category(问题咨询/故障报修/需求建议/投诉/其他)
优先级字典 oa_ticket_priority(低/中/高/紧急)
处理组可以不选,由系统按分类默认路由或进入兜底工单池
内容问题描述富文本,支持图片粘贴
备注纯文本补充
附件附件列表通过 attachmentService 挂到 OA_TICKET_BILL 业务类型上

"优先级"和"分类"两个字段是整个 SLA 体系的入口——不是装饰,而是计算 deadline 的两个变量。

3.3 工单池(处理端视角)

ticket-pool-pending.png

▲ 工单池-待接单视图:只显示"已审批通过 + 待分配 + 属于我所在处理组(或无组)"的工单,SLA 响应/解决状态用红色"已超时"徽标直接标出,一眼看到最紧急的

工单池的三个 Tab 就是工单模块最重要的"视图抽象":

TabviewType对应筛选条件谁该关注
待接单pendingticket_status=1 且在我组组员、组长(抢单)
我处理的my_handlinghandler_id=me 且状态 2/3/4处理人(跟进)
全部all审批通过 + 在我组组长(审查)

四、关键业务逻辑

4.1 提交工单:走 BPM

提交工单本质是"创建单据 + 启动流程 + 绑定 businessKey":

@Override
@Transactional(rollbackFor = Exception.class)
public Long submitTicketBill(TicketBillSaveReqVO saveReqVO) {
    if (StringUtils.isBlank(saveReqVO.getBillCode())) {
        saveReqVO.setBillCode(BillCodeUtils.generateBillCode(
            SystemEnum.OA, OaBillTypeEnum.OA_TICKET_BILL));
    }

    TicketBillDO ticketBill = BeanUtils.toBean(saveReqVO, TicketBillDO.class)
            .setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus())
            .setSubmittedTime(LocalDateTime.now());
    ticketBillMapper.insertOrUpdate(ticketBill);

    Map<String, Object> vars = BpmProcessVariableUtils.buildBillVariables(saveReqVO);
    vars.put(BpmProcessVariableConstants.CAUSE,
        saveReqVO.getCreatorName() + "提交工单:" + saveReqVO.getTitle());

    String processInstanceId = processInstanceApi.submitProcessInstance(
        Long.valueOf(saveReqVO.getCreator()),
        new BpmProcessInstanceCreateReqDTO()
            .setProcessDefinitionKey(OaBillTypeEnum.OA_TICKET_BILL.getProcessDefinitionKey())
            .setVariables(vars)
            .setBusinessKey(String.valueOf(ticketBill.getId()))
    ).getCheckedData();

    ticketBillMapper.updateById(new TicketBillDO()
        .setId(ticketBill.getId()).setProcessInstanceId(processInstanceId));
    return ticketBill.getId();
}

三处值得注意:

  1. 单据先落库再启流程:保证即使流程启动失败,单据也能被清理掉;反过来则会产生孤儿流程
  2. businessKey = 单据 id:BPM 回调时用这个反查业务单
  3. CAUSE 变量:BPM 审批列表的"事由"列直接展示,不用审批人点进详情

4.2 审批通过回调:一次性搞定 SLA + 分配

这是整个工单系统最有设计感的一段代码——FlowBillService.onProcessApproved 是 BPM 框架在流程"审批通过"结束节点时自动回调的钩子:

@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessApproved(String businessKey) {
    Long id = Long.parseLong(businessKey);
    TicketBillDO bill = ticketBillMapper.selectById(id);
    if (bill == null) return;

    TicketBillDO updateObj = new TicketBillDO();
    updateObj.setId(id);
    updateObj.setTicketStatus(TicketStatusEnum.PENDING_ASSIGN.getStatus()); // 先置"待分配"

    // 1. 匹配 SLA 规则并算 deadline
    TicketSlaRuleDO slaRule = ticketSlaRuleService.matchSlaRule(
        bill.getPriority(), bill.getCategory());
    if (slaRule != null) {
        LocalDateTime baseTime = bill.getSubmittedTime() != null
            ? bill.getSubmittedTime() : LocalDateTime.now();
        if (slaRule.getResponseHours() != null && slaRule.getResponseHours() > 0) {
            updateObj.setResponseDeadline(baseTime.plusHours(slaRule.getResponseHours()));
        }
        if (slaRule.getResolveHours() != null && slaRule.getResolveHours() > 0) {
            updateObj.setResolveDeadline(baseTime.plusHours(slaRule.getResolveHours()));
        }
    }

    // 2. 尝试自动分配(如果工单指定了处理组)
    if (bill.getHandlerGroupId() != null) {
        TicketHandlerGroupDO group = ticketHandlerGroupService.getHandlerGroup(bill.getHandlerGroupId());
        if (group != null) {
            Long assignedUserId = ticketAssignService.tryAutoAssign(bill, group);
            if (assignedUserId != null) {
                updateObj.setHandlerId(assignedUserId);
                AdminUserRespDTO user = adminUserApi.getUser(assignedUserId).getCheckedData();
                if (user != null) updateObj.setHandlerName(user.getNickname());
                updateObj.setAssignedTime(LocalDateTime.now());
                updateObj.setTicketStatus(TicketStatusEnum.PENDING_PROCESS.getStatus()); // 自动分配成功则进"待处理"
            }
        }
    }

    ticketBillMapper.updateById(updateObj);
    ticketReplyService.addSystemRecord(id, "工单审批通过,进入" +
        (TicketStatusEnum.PENDING_PROCESS.getStatus().equals(updateObj.getTicketStatus())
            ? "待处理" : "工单池"));
}

三步合并成一个事务

  1. 状态改为"待分配" —— 兜底:哪怕 SLA 和分配都失败,工单也已进入工单池
  2. 匹配 SLA 规则 —— 算出两个 deadline 并落库
  3. 尝试自动分配 —— 命中则升级为"待处理",没命中就保持"待分配"让组员抢

代码还藏了一个细节:SLA 匹配失败包 try-catch 但不抛异常。因为业务上"分配失败"比"SLA 配置错误"严重得多,不能让一条烂规则把整张单卡住。

4.3 接单:校验"能不能接"是重点

@Override
@Transactional(rollbackFor = Exception.class)
public void acceptTicket(Long id) {
    TicketBillDO bill = validateTicketExists(id);
    Long currentUserId = SecurityFrameworkUtils.getLoginUserId();

    // 合法状态:待分配(抢单) 或 待处理(点名指派后的确认接单)
    if (!PENDING_ASSIGN.equals(bill.getTicketStatus())
            && !PENDING_PROCESS.equals(bill.getTicketStatus())) {
        throw exception(TICKET_BILL_STATUS_INVALID);
    }

    // 抢单必须是处理组成员
    if (PENDING_ASSIGN.equals(bill.getTicketStatus()) && bill.getHandlerGroupId() != null) {
        TicketHandlerGroupDO group = ticketHandlerGroupService.getHandlerGroup(bill.getHandlerGroupId());
        if (group != null && isNotBlank(group.getMemberUserIds())) {
            boolean isMember = Arrays.stream(group.getMemberUserIds().split(","))
                    .map(String::trim).anyMatch(uid -> uid.equals(String.valueOf(currentUserId)));
            if (!isMember) throw exception(TICKET_BILL_NOT_IN_GROUP);
        }
    }
    // 指派单必须是本人接单
    if (PENDING_PROCESS.equals(bill.getTicketStatus())
            && bill.getHandlerId() != null
            && !bill.getHandlerId().equals(currentUserId)) {
        throw exception(TICKET_BILL_NOT_HANDLER);
    }

    AdminUserRespDTO user = adminUserApi.getUser(currentUserId).getCheckedData();
    ticketBillMapper.updateById(new TicketBillDO()
        .setId(id)
        .setHandlerId(currentUserId)
        .setHandlerName(user != null ? user.getNickname() : String.valueOf(currentUserId))
        .setAcceptedTime(LocalDateTime.now())
        .setTicketStatus(TicketStatusEnum.PROCESSING.getStatus()));

    ticketReplyService.addSystemRecord(id, (user != null ? user.getNickname() : "处理人") + " 已接单");
}

三种异常场景被分得一清二楚:状态非法 / 不在本组 / 不是本人。任何一项没守住,都会让"谁都能接别人的单"成真,工单系统的公信力就塌了。

4.4 三种回复:公开 / 内部 / 系统

回复服务的核心不是"存数据",而是"按身份过滤":

public List<TicketReplyRespVO> getReplyListByTicketId(Long ticketId, String source) {
    List<TicketReplyDO> replies = ticketReplyMapper.selectListByTicketId(ticketId);
    // 申请端过滤掉内部备注(type=2)
    if ("apply".equals(source)) {
        replies = replies.stream()
                .filter(r -> r.getType() == null || r.getType() != 2)
                .toList();
    }
    // ... 附带 creator 的 nickname
    return BeanUtils.toBean(replies, TicketReplyRespVO.class);
}
type用途申请端可见处理端可见
1 公开回复处理人和申请人之间的正式对话
2 内部备注处理组内部"我先查一下,8点前回他"
3 系统记录"X 已接单"、"SLA 响应超时"

安全点在 Service 层过滤而不在前端过滤——前端再怎么改模板也只是表象,接口层过滤才是真的"看不见"。


五、工单处理详情:三个页面合一的"处理中枢"

ticket-pool-detail.png

▲ 工单处理详情:左侧工单信息+回复区,右侧 SLA 时效+关键时间节点,顶部按钮组按当前状态动态出现(接单/转派/完成/关闭/激活)

这个页面的真正设计精髓在于 "按状态出按钮"

<Button v-if="formData.ticketStatus === 1" @click="handleAccept">接单</Button>
<Button v-if="formData.ticketStatus === 1" @click="handleAssign">分配</Button>
<Button v-if="formData.ticketStatus === 2 && isHandler" @click="handleAccept">接单</Button>
<Button v-if="formData.ticketStatus === 3 && isHandler" @click="handleComplete">标记完成</Button>
<Button v-if="[2, 3].includes(formData.ticketStatus)" @click="handleTransfer">转派</Button>
<Button v-if="[1, 4].includes(formData.ticketStatus)" @click="handleCloseTicket">关闭</Button>
<Button v-if="formData.ticketStatus === 5" @click="handleActivate">激活</Button>

这种写法的好处是状态机永远是"真相的唯一来源"——新增一种状态,只要在 Service 层定义好合法操作,前端加一行 v-if 就够了,不需要写复杂的 RBAC 权限表。


六、RuoYi Office 工单模块的 5 个创新设计

6.1 无组工单(兜底池)

handler_group_id IN (:myGroupIds) OR handler_group_id IS NULL

这行 SQL 是整个工单池的灵魂。当申请人没指定处理组,或者某个组被停用了,工单会进入"无组工单池",公司里所有组员都能看到并抢单——这是 RuoYi Office 避免工单无人认领的最后一道兜底。

6.2 双计时器:响应 + 解决

很多工单系统只有一个 SLA 时限,但这会漏掉"接了单但一直拖着不办"的场景。RuoYi Office 用两个独立时间戳:

  • response_deadline接单截止时间——检验"接单速度"
  • resolve_deadline结案截止时间——检验"办结速度"
场景response_timeoutresolve_timeout
接单慢但办得快
接单快但拖延
接单慢且拖延
合格

6.3 自动分配失败"主动降级"

自动分配不是"保证一定分出去",而是"如果能分就分,分不了就进工单池"。源码里:

try {
    Long assignedUserId = ticketAssignService.tryAutoAssign(bill, group);
    if (assignedUserId != null) { /* 升级为待处理 */ }
} catch (Exception e) {
    log.warn("[onProcessApproved] 自动分配失败,billId: {}, 错误: {}", id, e.getMessage());
}

分配失败只打 WARN 日志,不抛异常——哪怕组成员 ID 烂了、数据库连接抖动了,工单也能按"待分配"落库等人抢。

6.4 转派不丢历史

public void transferTicket(TicketBillTransferReqVO reqVO) {
    // 只改 handler_id / handler_group_id,不动 replies、不动 SLA
    updateObj.setHandlerId(reqVO.getHandlerId());
    updateObj.setHandlerName(reqVO.getHandlerName());
    // ticketStatus 保持 PENDING_PROCESS 或 PROCESSING
    ticketReplyService.addSystemRecord(reqVO.getId(), "工单已转派给 " + reqVO.getHandlerName());
}

转派只改两个字段,SLA 的两个 deadline 都不重置——因为从客户视角看,"换了谁处理"跟"你们还欠我多久回复"没关系,时限应该从最初提交算起。

6.5 反激活:关闭 ≠ 结束

public void activateTicket(Long id) {
    // 只有已关闭的工单能被激活
    if (!CLOSED.equals(bill.getTicketStatus())) throw ...;
    updateObj.setTicketStatus(PROCESSING.getStatus());
    updateObj.setClosedTime(null);
    updateObj.setCompletedTime(null);
}

关闭后的工单可以被"反激活"回到处理中状态——这对于"关闭后客户又回来说没修好"的场景至关重要,不用新建工单、历史回复全在


七、数据结构

7.1 oa_ticket_bill(工单主表)

字段类型约束说明
idBIGINTPK主键
bill_codeVARCHAR(50)UNIQUEOA120-{yyyyMMdd}{5位流水}
process_instance_idVARCHAR(64)BPM 流程实例 ID
process_statusTINYINT0草稿/1审批中/2通过/3拒绝/4取消
title / content / category / priority工单本体
ticket_statusTINYINT0未启动/1待分配/2待处理/3处理中/4已完成/5已关闭
handler_group_id / handler_id / handler_name处理组和当前处理人
response_deadline / resolve_deadlineDATETIMESLA 双计时器
response_timeout / resolve_timeoutBIT(1)被 XXL-Job 标记的超时标志
submitted_time / assigned_time / accepted_time / completed_time / closed_timeDATETIME生命周期 5 个关键时间戳
reply_countINT回复数量缓存,避免每次 count
tenant_id / creator / create_time / ...公共字段

设计要点

  • 单独建了 idx_ticket_statusidx_handler_ididx_handler_group_ididx_resolve_deadline 四个索引,对应四类最高频查询(工单池按状态、我处理的按 handler、处理组过滤、SLA 扫描)
  • reply_count 是冗余字段:新增回复时 +1,避免在列表里 count 子查询
  • 5 个时间戳字段看似冗余,实际是"可重建的审计日志"——不用翻历史事件表就能看到工单的全部节点

7.2 oa_ticket_handler_group(处理组)

ticket-handler-group.png

▲ 处理组列表:IT 运维组(轮询分配)、综合客服组(负载最少)、投诉专项组(手动分配),三种策略对应三种业务场景 ticket-handler-group-form.png

▲ 处理组编辑:成员用逗号分隔的用户 ID 存储,组长用于超时通知抄送,分配策略决定 onProcessApproved 怎么派单

字段类型说明
name / description处理组基本信息
member_user_idsVARCHAR(2000)核心字段:逗号分隔的成员用户 ID
leader_user_idBIGINT组长,SLA 超时时抄送通知
assign_strategyTINYINT0 手动 / 1 轮询 / 2 负载最少
last_assign_indexINT轮询游标,每次派单后 (+1) % size

7.3 oa_ticket_sla_rule(SLA 规则)

ticket-sla-rule.png

▲ SLA 规则按 sort 升序匹配:投诉类 SLA(sort=5) → 紧急通用(10) → 故障报修-高(20) → 中优先级通用(40) → 低优先级咨询(50) → 兜底(999),最具体的规则排最前保证命中

字段说明
priority必须匹配的优先级(1-4)
category可选匹配的分类,为 NULL 表示"不限分类"
response_hours / resolve_hours两个 SLA 时限
warn_before_minutes提前预警分钟数(默认 30)
sort越小越先匹配

7.4 oa_ticket_reply(回复)

字段说明
ticket_id外键指向工单
contentTEXT,支持富文本
type1 公开 / 2 内部 / 3 系统

没有 creator_name 字段——显示时按 creator 反查 system_users.nickname,保证员工改名后所有历史回复的名字同步更新。


八、技术亮点总结

设计要点实现方式价值
双状态字段解耦process_status + ticket_status 两字段独立BPM 和业务各自演进互不影响
SLA 一次算好落库onProcessApproved 回调时一次性写入 response_deadline / resolve_deadline列表查询 0 成本,超时扫描可走索引
路由 = SQL 条件group_id IN (:myGroups) OR IS NULL不用中间件、不用多表 join
自动分配主动降级失败只打 WARN 不抛异常,落回工单池工单永远不会因为分配失败而卡住
回复三类 + 服务端过滤type=1/2/3,apply 视图过滤 type=2前端再怎么改模板都看不到内部备注
转派不丢 SLA只更新 handler 字段,deadline 从提交时间算换人不延期,客户感知一致
反激活已关闭 → 处理中不用新建工单,历史对话全保留
兜底工单池handler_group_id 可为 NULL无组工单全员可见,避免无人认领
XXL-Job 扫描超时每分钟扫一遍 + 提前 30min 预警不用 MQ、不用延时队列,纯定时任务
时间戳驱动审计5 个关键时间戳字段全落库不用事件表就能重建完整生命周期

九、快速体验

操作路径:登录 → OA → 工单列表 / 工单池 / 处理组管理 / SLA 规则

推荐体验流程(5 分钟):

  1. 进入 处理组管理,编辑