前言

做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——专注中小企业客户关系管理。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐