当前位置: 首页 > news >正文

CRM工单系统开发实战:分支流程引擎与全链路追踪的设计与实现

前言

做CRM SaaS开发的同学应该都有体会:工单系统看着简单,做到后面全是坑。最近看到企客宝CRM对工单系统做了两项升级——动态分支流程全链路过程追踪,正好是我之前踩过的两个大坑。本文结合企客宝的实现方案,聊聊这两个功能的设计思路和代码实现。


一、线性流程到分支流程:状态机的升级

1.1 线性状态机的局限

标准工单流程是一个线性状态机:

public enum TicketStatus { CREATED, ACCEPTED, PROCESSING, REVIEWING, CLOSED }

每个状态只有一个后继状态,适合简单场景。但实际业务中,流程路径往往在中间环节才能确定。比如客户投诉工单,受理后才知道该走技术处理还是销售处理。

1.2 有向图模型

解决方案是将线性链表升级为有向图。核心数据模型:

-- 流程节点 CREATE TABLE wf_node ( id BIGINT PRIMARY KEY, template_id BIGINT, node_type ENUM('start', 'normal', 'branch', 'end'), node_name VARCHAR(100), config JSON, sort_order INT ); -- 流程边(连接关系) CREATE TABLE wf_edge ( id BIGINT PRIMARY KEY, from_node_id BIGINT, to_node_id BIGINT, edge_type ENUM('normal', 'branch'), branch_label VARCHAR(100), condition JSON, -- 自动判断条件 sort_order INT ); -- 工单实例 CREATE TABLE ticket ( id BIGINT PRIMARY KEY, template_id BIGINT, current_node_id BIGINT, current_branch_id BIGINT, status ENUM('open', 'in_progress', 'closed') );

1.3 分支节点的执行逻辑

public class BranchNodeHandler { /** * 处理分支节点 * @param ticket 工单实例 * @param branchNode 分支节点 * @param selectedBranchId 手动选择的分支ID(可为空) */ public void handleBranch(Ticket ticket, WfNode branchNode, Long selectedBranchId) { List<WfEdge> branches = edgeDao.findByFromNode(branchNode.getId()); Long targetBranchId = null; // 1. 优先尝试自动判断 if (selectedBranchId == null) { for (WfEdge branch : branches) { if (branch.getCondition() != null && evaluateCondition(branch.getCondition(), ticket)) { targetBranchId = branch.getId(); break; } } } // 2. 自动判断失败或未配置,使用手动选择 if (targetBranchId == null) { targetBranchId = selectedBranchId; } // 3. 验证分支有效性 WfEdge selectedEdge = branches.stream() .filter(e -> e.getId().equals(targetBranchId)) .findFirst() .orElseThrow(() -> new BusinessException("无效的分支选择")); // 4. 更新工单状态 ticket.setCurrentNodeId(selectedEdge.getToNodeId()); ticket.setCurrentBranchId(targetBranchId); // 5. 记录分支选择 ticketAcceptDetailDao.save(buildBranchRecord(ticket, branchNode, selectedEdge)); } /** * 评估条件表达式 */ private boolean evaluateCondition(String conditionJson, Ticket ticket) { Condition condition = JsonUtils.parse(conditionJson, Condition.class); // 递归评估 AND/OR 组合条件 return ConditionEvaluator.evaluate(condition, ticket.getFormData()); } }

1.4 条件引擎设计

JSON格式的规则定义,支持字段比较和逻辑组合:

{ "operator": "OR", "conditions": [ { "field": "fault_type", "operator": "equals", "value": "硬件" }, { "operator": "AND", "conditions": [ {"field": "fault_type", "operator": "equals", "value": "软件"}, {"field": "severity", "operator": "equals", "value": "紧急"} ] } ] }

条件评估器:

public class ConditionEvaluator { public static boolean evaluate(Condition cond, Map<String, Object> formData) { if (cond.getField() != null) { // 叶子条件:字段比较 Object actual = formData.get(cond.getField()); return compare(actual, cond.getOperator(), cond.getValue()); } // 组合条件:AND/OR boolean result = "AND".equals(cond.getOperator()); for (Condition child : cond.getConditions()) { boolean childResult = evaluate(child, formData); if ("AND".equals(cond.getOperator())) { result = result && childResult; } else { result = result || childResult; } } return result; } private static boolean compare(Object actual, String operator, Object expected) { switch (operator) { case "equals": return String.valueOf(actual).equals(expected); case "contains": return String.valueOf(actual).contains(String.valueOf(expected)); case "gt": return Double.parseDouble(String.valueOf(actual)) > Double.parseDouble(String.valueOf(expected)); case "lt": return Double.parseDouble(String.valueOf(actual)) < Double.parseDouble(String.valueOf(expected)); default: return false; } } }

1.5 多级分支

分支内部可以再包含分支节点,执行逻辑是递归的。但建议限制层级(2-3级),避免流程图过于复杂。

1.6 流程版本管理

// 发布新版本时,不修改现有版本,而是创建新版本 public void publishTemplate(Long templateId) { WfTemplate current = templateDao.findById(templateId); current.setIsActive(false); // 旧版本标记为不活跃 WfTemplate newVersion = current.clone(); newVersion.setVersion(current.getVersion() + 1); newVersion.setIsActive(true); templateDao.save(newVersion); }

运行中的工单使用创建时的版本,新工单使用最新版本。


二、全链路过程追踪:受理明细的设计与实现

2.1 数据模型

CREATE TABLE ticket_accept_detail ( id BIGINT PRIMARY KEY, ticket_id BIGINT, node_name VARCHAR(100), handler_id BIGINT, accept_time DATETIME, complete_time DATETIME, duration_seconds INT, -- 自动计算 action_type VARCHAR(50), -- accept/transfer/return/complete action_detail JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_ticket_id (ticket_id), INDEX idx_handler_time (handler_id, accept_time) );

2.2 时长计算的核心逻辑

直接用complete_time - accept_time不行,因为需要排除非工作时间和挂起时间。

public class DurationCalculator { /** * 计算实际处理时长(秒) */ public int calculateDuration(LocalDateTime acceptTime, LocalDateTime completeTime, WorkCalendar calendar, List<SuspendPeriod> suspends) { int totalSeconds = 0; LocalDateTime current = acceptTime; while (current.isBefore(completeTime)) { // 跳过非工作日 if (!calendar.isWorkDay(current.toLocalDate())) { current = nextWorkDayStart(current, calendar); continue; } // 跳过非工作时间 if (current.toLocalTime().isBefore(calendar.getWorkStart())) { current = LocalDateTime.of(current.toLocalDate(), calendar.getWorkStart()); continue; } if (current.toLocalTime().isAfter(calendar.getWorkEnd())) { current = nextWorkDayStart(current, calendar); continue; } // 跳过挂起时段 LocalDateTime segmentEnd = getSegmentEnd(current, completeTime, calendar, suspends); totalSeconds += Duration.between(current, segmentEnd).getSeconds(); current = segmentEnd; } return totalSeconds; } }

2.3 操作记录的细粒度

public class TicketActionRecorder { /** * 记录操作 */ public void recordAction(Ticket ticket, ActionType type, Map<String, Object> detail) { TicketAcceptDetail record = new TicketAcceptDetail(); record.setTicketId(ticket.getId()); record.setNodeName(ticket.getCurrentNodeName()); record.setHandlerId(getCurrentUserId()); record.setActionType(type.name()); record.setActionDetail(JsonUtils.toJson(detail)); record.setCreatedAt(LocalDateTime.now()); // 如果是受理操作,记录受理时间 if (type == ActionType.ACCEPT) { record.setAcceptTime(LocalDateTime.now()); } // 如果是完成操作,记录完成时间并计算时长 if (type == ActionType.COMPLETE) { record.setCompleteTime(LocalDateTime.now()); TicketAcceptDetail acceptRecord = findLastAcceptRecord(ticket.getId()); if (acceptRecord != null && acceptRecord.getAcceptTime() != null) { int duration = durationCalculator.calculateDuration( acceptRecord.getAcceptTime(), record.getCompleteTime(), getWorkCalendar(), getSuspendPeriods(ticket.getId()) ); record.setDurationSeconds(duration); } } detailDao.save(record); } }

操作详情的JSON格式:

// 字段修改 Map<String, Object> detail = Map.of( "type", "field_change", "field", "priority", "old_value", "普通", "new_value", "紧急" ); // 转交 Map<String, Object> detail = Map.of( "type", "transfer", "from_user", "张三", "to_user", "李四", "reason", "需要技术专家处理" ); // 退回 Map<String, Object> detail = Map.of( "type", "return", "from_node", "技术处理", "to_node", "受理", "reason", "信息不完整,需要补充" );

2.4 Excel导出

大数据量导出使用流式写入:

public void exportDetails(ExportCriteria criteria, OutputStream out) { Workbook workbook = new SXSSFWorkbook(100); // 保留100行在内存 Sheet sheet = workbook.createSheet("工单受理明细"); // 写表头 Row header = sheet.createRow(0); String[] headers = {"工单ID", "环节名称", "受理人", "受理时间", "完成时间", "处理时长(分)", "操作类型", "操作详情"}; for (int i = 0; i < headers.length; i++) { header.createCell(i).setCellValue(headers[i]); } // 分页查询,流式写入 int page = 0; List<TicketAcceptDetail> records; do { records = detailDao.findByCriteria(criteria, page++, 1000); for (TicketAcceptDetail record : records) { Row row = sheet.createRow(sheet.getLastRowNum() + 1); row.createCell(0).setCellValue(record.getTicketId()); row.createCell(1).setCellValue(record.getNodeName()); // ... 其他字段 row.createCell(5).setCellValue(record.getDurationSeconds() / 60); } } while (!records.isEmpty()); workbook.write(out); workbook.dispose(); // 清理临时文件 }

2.5 性能优化

场景优化方案
按ticket_id查询明细B+树索引,单次查询<10ms
按handler_id+时间范围统计复合索引 + 预聚合
大数据量导出SXSSFWorkbook流式写入
历史数据归档按月分表 + 冷热分离

三、SaaS多租户设计

3.1 数据隔离

// MyBatis拦截器自动注入租户条件 @Intercepts(@Signature(type = Executor.class, method = "query", args = {...})) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) { // 自动在SQL中添加 tenant_id 条件 // ... } }

3.2 流程模板的租户隔离

每个租户有独立的流程模板定义。流程模板的导入导出功能,支持跨租户复制:

public void importTemplate(Long targetTenantId, String templateJson) { WfTemplate template = JsonUtils.parse(templateJson, WfTemplate.class); template.setTenantId(targetTenantId); template.setId(null); // 清除原ID template.setVersion(1); template.setIsActive(true); // 重建节点和边的ID引用 Map<Long, Long> idMapping = new HashMap<>(); for (WfNode node : template.getNodes()) { Long oldId = node.getId(); node.setId(null); nodeDao.save(node); idMapping.put(oldId, node.getId()); } // 重建边的引用... }

四、实际案例

企客宝CRM的这两项功能,分别来自真实客户需求:

  • 分支流程:某制造企业设备故障工单需要按故障类型分流
  • 过程追踪:某服务企业需要按处理明细核算绩效

企客宝CRM将个性化需求提炼为通用方案,面向所有租户开放。这种"需求驱动 + 通用设计"的SaaS产品方法论,值得借鉴。


五、FAQ

Q1:自动判断条件支持多复杂?支持字段值比较(equals/contains/gt/lt)和AND/OR逻辑组合。复杂场景建议走人工判断。

Q2:受理明细的存储增长?单条工单10-50条明细。大规模场景建议按月分表 + 自动归档。

Q3:流程定义修改后运行中的工单?版本控制:运行中工单用创建时版本,新工单用最新版本。

Q4:时长计算是否考虑节假日?是,通过工作日历配置实现,支持自定义工作日、工作时间和节假日。

Q5:权限如何设计?三级:个人(看自己的)、部门经理(看本部门)、管理员(看全部)。


总结

工单系统的两个核心开发难题:

  1. 动态分支流程:从线性状态机到有向图,核心是分支节点的执行逻辑和条件引擎
  2. 全链路过程追踪:从状态记录到操作明细,核心是工作日历时长计算和流式数据导出

企客宝CRM的实践方案在灵活性和性能之间做了合理权衡,可供SaaS开发者参考。


企客宝CRM——专注中小企业客户关系管理。

http://www.jsqmd.com/news/854039/

相关文章:

  • DeepSeek 两次降价打到 2 分钱、Kimi 再融 140 亿:2026 中国大模型没有终局,只有下一轮
  • 从Faster R-CNN到Cascade R-CNN:一个‘打补丁’思路如何刷爆COCO榜单?
  • (技术解析)面向极端天气的配电网韧性强化:应急移动电源预配置的鲁棒优化建模与求解
  • 测试工程师的写作技巧:如何写出受欢迎的测试文章
  • 从零到一:Deformable-DETR实战个人数据集训练与调优
  • 国内高校学生最适用的AI论文写作软件有哪些?
  • 避坑指南:展锐平台Camera驱动移植中那些容易出错的配置项(以OV08A10为例)
  • 开源3D打印人形机器人平台设计与实现
  • Unity VR开发实战:Oculus Quest 2环境配置与开发者工具链全解析
  • 告别Office安装烦恼:5分钟实现个性化部署的智能方案
  • 3分钟解决方案:G-Helper如何让华硕笔记本性能提升40%并减少90%资源占用
  • 嵌入式工控平台升级实战:从EM9161到EM9171的平滑迁移指南
  • AI论文写作软件的合规使用指南:什么程度算学术不端?
  • 测试工程师的演讲技巧:如何做好测试技术分享
  • STM32串口发送浮点数的“坑”我帮你踩完了:从sprintf截断到大小端问题,一篇讲透
  • 3步搞定Windows安卓应用:APK Installer终极安装指南
  • 毕业党救急必看!10款论文降AI工具红黑榜,告别生硬同义词替换
  • 告别盲目充电:手把手教你为51单片机太阳能路灯添加智能充放电保护
  • 如何快速为代码生成软著文档:Flutter版智能工具终极指南
  • 别再只改Host头了!深入理解HTTP Host头攻击的5种变异场景与防御盲区
  • 沈阳网站制作与建设公司推荐
  • Postman脚本进阶:用JavaScript自动管理登录Token,告别接口测试的复制粘贴
  • 鸿蒙PC三方库和命令行工具迁移实战--直播PPT
  • 不止是安装:用RT-Thread Studio图形化配置系统,5分钟创建一个能点灯的NANO工程
  • 告别音乐播放器自带的简陋歌词!在Ubuntu 22.04上用OSD Lyrics打造桌面KTV(附Audacious联动配置)
  • 2026年华南地区GEO优化服务商专业甄选:3家优质机构深度解析 - 产业观察网
  • 从51单片机到STM32:我踩过的坑和快速上手指南(基于Keil5和标准库)
  • 中性蛋白酶选购指南:如何科学选择合适产品 - 资讯速览
  • 终极实战指南:高效构建可视化AI工作流的46个专业模板
  • 避障小车代码调试踩坑实录:STM32 HAL库下超声波输入捕获与舵机PWM的那些‘坑’