☰
〖实战〗零担物流管理系统详细设计:配载决策、中转路由与计费分摊的工程实现
2026/10/21 2:30:46 网站建设 项目流程

一、零担系统真正难的不是“运输”,是“拼”与“分”的决策

零担物流(LTL)的系统复杂度,常被低估为“整车系统的简化版”。实际上恰恰相反:整车运输的决策空间是“一车一单,选一条路”;零担的决策空间是“多票货、多个目的站、多个中转节点、有限的车辆容量,如何组合才能使总成本最低、时效可控”。一票从广州到西安的零担货物,可能经过广州分拨集货、长沙中转拆解、西安分拨再配载、末端网点派送,在每一个节点上,系统都要回答三个问题:这票货和谁拼在一起(配载)、走哪条线在哪个节点中转(路由)、这一趟的成本如何分摊到每一票(计费分摊)。

这三个问题彼此耦合:配载结果决定路由选择,路由选择反过来约束配载可行性,而计费分摊的准确性依赖于前两者产生的装载和节点数据。一个零担系统如果只在“运单管理”层面做增删改查,而在配载、路由、分摊的决策逻辑上含糊,运营现场就会退化为“调度员凭经验拼车、财务凭Excel分摊”,系统沦为记录工具而非决策工具。

这篇文章围绕这三个核心决策展开:微服务与事件驱动的整体架构、配载与中转的算法实现、计费重与成本分摊的规则引擎设计、运单在多节点状态机中的一致性保障,给出可落地的表结构、关键代码和踩坑记录。

二、整体架构:按决策边界拆分,而非按页面拆分

2.1 服务划分

零担系统的微服务拆分,常见的错误是按“运单页面、调度页面、财务页面”来切,导致配载逻辑散落在调度服务和运单服务两处,路由规则又嵌在轨迹服务里。正确的拆分边界是按决策类型划分:

服务核心职责关键决策
运单服务运单创建、货物明细、状态机运单是否可被配载
配载服务车辆容量管理、拼载决策、配载明细哪些货运单装同一辆车
路由与中转服务线路网络、中转节点选择、节点作业走哪条线、在哪个节点拆解
计费服务计费重计算、运价规则、成本分摊每票收多少、每段成本多少
轨迹服务GPS/扫描事件、节点打卡、异常检测货实际到了哪里

物流系统的实践基于 Spring Cloud Alibaba 拆分为 7 个业务域,调度引擎用 Drools 规则引擎承载配载规则,轨迹服务用 Netty 长连接维持 10 万+ 并发连接并做 Douglas-Peucker 轨迹压缩。这套拆分思路的合理之处在于:配载规则的变化频率远低于运单字段的变化频率,独立成服务后,调整配载策略不需要动运单的主流程。

2.2 事件驱动的数据流转

零担系统的核心链路是异步的:运单创建后,不是同步调用配载服务,而是发布waybill_created事件;配载服务消费事件后将运单纳入待配载池;配载完成后发布loading_confirmed事件;路由服务据此生成该运单的节点路径;计费服务在节点打卡事件到达时逐段计算费用。

腾讯云那套 TMS 的实践用 Kafka 作为事件总线,订单创建后立即发布order_created,以orderId作为分区键保证同一订单的事件顺序性。零担场景同理:以运单号为分区键,保证同一票货的“到达—发出—再配载”事件按序消费,避免状态机因为乱序事件而回退。

三、核心数据模型:运单、配载、节点三张网的交叉

3.1 运单与货物明细

零担运单与快递运单的本质区别是一票多件、一件多品。运单头记录合同主体和目的站,货物明细记录每个 SKU 的重量、体积、件数、包装类型。

sql

CREATE TABLE ltl_waybill ( waybill_no VARCHAR(32) PRIMARY KEY, origin_station VARCHAR(16) NOT NULL, dest_station VARCHAR(16) NOT NULL, shipper_id BIGINT, consignee_id BIGINT, total_weight DECIMAL(10,3), -- 汇总,冗余 total_volume DECIMAL(10,4), total_pieces INT, chargeable_weight DECIMAL(10,3), -- 计费重,见第六节 status VARCHAR(24) NOT NULL, -- 见第五节状态机 max_transit TINYINT DEFAULT 2, -- 允许的最大中转次数 created_at DATETIME ); CREATE TABLE ltl_waybill_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, sku_code VARCHAR(64), sku_name VARCHAR(128), pieces INT NOT NULL, weight DECIMAL(10,3) NOT NULL, volume DECIMAL(10,4) NOT NULL, length_cm DECIMAL(8,2), width_cm DECIMAL(8,2), height_cm DECIMAL(8,2), INDEX idx_wb (waybill_no) );

max_transit是零担系统必须显式建模的字段。不同的客户对中转次数有不同的容忍度:高价值货物可能要求最多 1 次中转,普通零担可以接受 2–3 次。路由和配载决策都必须把这个约束纳入计算,而不是事后由调度员人工判断。

3.2 配载单与配载明细

配载单是一次装车的实体,配载明细是“这票货在这辆车上占了多少”的粒度。关键设计:同一票运单可以被拆到多条配载明细中,因为一票货可能先走一部分,剩余部分等下一班车。

sql

CREATE TABLE ltl_load ( load_no VARCHAR(32) PRIMARY KEY, truck_no VARCHAR(20) NOT NULL, route_id BIGINT NOT NULL, depart_station VARCHAR(16) NOT NULL, arrive_station VARCHAR(16) NOT NULL, planned_depart DATETIME, total_weight DECIMAL(10,3), total_volume DECIMAL(10,4), status VARCHAR(16) DEFAULT 'OPEN' -- OPEN/FULL/DEPARTED ); CREATE TABLE ltl_load_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, load_no VARCHAR(32) NOT NULL, waybill_no VARCHAR(32) NOT NULL, loaded_weight DECIMAL(10,3) NOT NULL, -- 本次装载量,不是运单总量 loaded_volume DECIMAL(10,4) NOT NULL, loaded_pieces INT NOT NULL, board_station VARCHAR(16), -- 上车站 unboard_station VARCHAR(16), -- 下车站 UNIQUE KEY uk_load_wb (load_no, waybill_no) );

loaded_weight和loaded_volume是本次装载量,不是运单总量。如果一票 10 吨的货运单先装了 6 吨,配载明细记录 6 吨,剩余 4 吨在后续配载中单独记录。这个设计让“部分发运”和“多段拼载”不需要修改运单本身,只需要在配载明细层做拆分。

3.3 节点作业与状态流转

零担运单在每一个中转节点经历“到达”和“发出”两次独立的状态变更。节点作业表记录每一次操作:

sql

CREATE TABLE ltl_node_operation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, node_code VARCHAR(16) NOT NULL, operation_type VARCHAR(16) NOT NULL, -- ARRIVE / DEPART / CROSS_DOCK load_no VARCHAR(32), -- 关联的配载单 operator_id BIGINT, occurred_at DATETIME NOT NULL, source VARCHAR(16) DEFAULT 'PDA', -- PDA/GPS_GEOFENCE/MANUAL INDEX idx_wb_node (waybill_no, node_code, occurred_at) );

source字段区分打卡来源:PDA 扫描是主动确认,GPS 电子围栏触发是自动化补充。零担运营指南中提到,GPS 轨迹回传与电子围栏技术可以实现节点打卡自动化,消除人工录入误差。工程上不能完全依赖 GPS——仓库屋顶遮挡会导致围栏误判——但 GPS 打卡可以作为 PDA 漏扫时的兜底证据,在异常处理时提供时间线参考。

四、配载决策:带目的站约束的贪心 + 局部优化

4.1 为什么标准 Bin Packing 不够

零担配载表面上像 Bin Packing(把货物装入容量有限的车辆),但它比 Bin Packing 多两个约束:目的站可达性(车辆的线路必须经过该货的目的站,或者至少经过一个能中转过去的分拨)和中转次数上限(绕行太多节点会导致时效不可接受)。单纯按体积或重量排序做首次适应递减,会把目的站不匹配的货装上车,到下一个节点再卸下来重新配,反而增加无效装卸。

4.2 配载决策的骨架

一个可落地的配载决策分三步:候选过滤 → 贪心装入 → 局部交换优化。

java

public LoadPlan buildLoadPlan(TruckCapacity truck, Route route, List<Waybill> pending) { // 1. 候选过滤:线路可达 + 中转次数可接受 List<Waybill> feasible = pending.stream() .filter(w -> route.getStops().contains(w.getDestStation()) || route.canTransitTo(w.getDestStation(), w.getMaxTransit())) .collect(Collectors.toList()); // 2. 排序:按“目的站在线路上的先后”+ 计费重降序 // 目的站靠前的先装,减少后续节点的不必要卸货 feasible.sort(Comparator .comparingInt((Waybill w) -> route.indexOfStop(w.getDestStation())) // 靠前的优先 .thenComparing(Comparator.comparingDouble( Waybill::getChargeableWeight).reversed())); // 3. 贪心装入:重量和体积双重约束 LoadPlan plan = new LoadPlan(truck); for (Waybill w : feasible) { if (plan.canFit(w.getWeight(), w.getVolume())) { plan.add(w); } } // 4. 局部交换:如果剩余容量能装入一个更靠后目的站的货, // 尝试替换一个靠前的货,提升“同站卸货率” plan = localSwapImprove(plan, feasible); return plan; }

排序策略的核心是“目的站先后”而非“重量降序”。同一辆车上,目的站靠前的货应该先装(在车厢靠外的位置),靠后的货后装。这样沿途卸货时不需要翻动后面的货物。按目的站在线路上的索引排序,是零担配载区别于普通装箱的关键启发式。

4.3 装载率的两维度量

零担的装载率不能只报一个“百分比”。重量装载率和体积装载率分别反映不同的瓶颈:

java

public class LoadRate { // 重量装载率:实际重量 / 额定载重 public static double weightRate(LoadPlan plan) { return plan.getTotalWeight().doubleValue() / plan.getTruck().getRatedWeight(); } // 体积装载率:实际体积 / 车厢容积 public static double volumeRate(LoadPlan plan) { return plan.getTotalVolume().doubleValue() / plan.getTruck().getRatedVolume(); } // 综合装载率:取两者中的较小值作为“有效装载率” // 因为任一维度先打满,另一维度就无法继续利用 public static double effectiveRate(LoadPlan plan) { return Math.min(weightRate(plan), volumeRate(plan)); } }

“有效装载率”取两维较小值,是因为零担车辆的实际瓶颈可能是重量,也可能是体积。一车轻抛货可能在体积打满时重量只用了 60%,此时综合装载率就是 60%,继续加货会超体积;反之重货则重量先打满。调度看板必须同时展示两个维度和有效值,才能判断是该换大车还是该拆分配载。

三维装载约束(长宽高和重心平衡)在工程上属于精细化阶段。专利文献中给出了三维剩余空间描述和重量平衡约束的模型,但在日均数百票的零担场景中,二维(重量+体积)过滤加上目的站排序已经能覆盖 90% 以上的配载决策,三维装箱更适合作为大客户定制化的增值能力,而不是系统的基础依赖。

五、中转路由与状态机:让每一票货的“到达/发出”可追溯

5.1 路由规划:最少中转优先,而非最短路径优先

零担路由和快递路由有一个关键差异:中转次数本身就是成本。每一次中转意味着卸车、分拣、再装车,产生场地操作成本和货损风险。2025 年零担运营的核心命题之一就是“用最小化中转次数对冲场里成本”。

因此路由的目标函数不是“总里程最短”,而是“中转次数最少,在此基础上总成本最低”:

java

public RoutePlan selectRoute(String origin, String dest, int maxTransit) { List<RoutePlan> candidates = routeNetwork.findRoutes( origin, dest, maxTransit); return candidates.stream() .min(Comparator .comparingInt(RoutePlan::getTransitCount) // 第一优先级 .thenComparingDouble(RoutePlan::getTotalCost)) .orElseThrow(() -> new NoRouteException(origin, dest)); }

在实际网络规划中,传统“辐射型”路由是漏斗式集货:所有货先集中到省级 HUB,再向下分发。“涟漪型”路由则允许多波次扩散和动态回流,同一批货最多允许 2 次分拨,超出则触发“波次合并”强制等待下一高频发车。工程上不需要一步到位实现涟漪型,但“中转次数硬约束 + 成本次优”这个优先级顺序必须从一开始就体现在路由选择器里。

5.2 运单状态机:终态保护与节点合法性

零担运单的状态不是一条直线,而是在每一个中转节点分叉出“到达”和“发出”两个子状态。用扁平的状态枚举无法表达“到达了哪个节点”“从哪个节点发出”,必须把节点编码纳入状态迁移的上下文。

java

public enum WaybillStatus { CREATED, // 已建单 PICKED_UP, // 已揽收 IN_TRANSIT, // 在途(两个节点之间) AT_NODE, // 已到达某节点,尚未发出 OUT_FOR_DELIVERY, // 末端派送中 DELIVERED, // 已签收(终态) RETURNED, // 已退回(终态) CANCELLED // 已取消(终态) } private static final Set<WaybillStatus> TERMINAL = Set.of(DELIVERED, RETURNED, CANCELLED); public void applyNodeEvent(Waybill wb, NodeOperation op) { // 终态保护:已签收/退回/取消后,普通节点事件一律拒绝 if (TERMINAL.contains(wb.getStatus())) { throw new StateMachineException( "终态运单不接受节点事件: " + wb.getWaybillNo()); } switch (op.getOperationType()) { case "ARRIVE": // 到达节点:只有在途或待发状态可以进入 AT_NODE if (wb.getStatus() != WaybillStatus.IN_TRANSIT && wb.getStatus() != WaybillStatus.PICKED_UP) { return; // 重复到达事件,静默忽略 } wb.setStatus(WaybillStatus.AT_NODE); wb.setCurrentNode(op.getNodeCode()); break; case "DEPART": // 发出:必须当前就在该节点,且没有终态 if (wb.getStatus() != WaybillStatus.AT_NODE || !op.getNodeCode().equals(wb.getCurrentNode())) { return; } wb.setStatus(WaybillStatus.IN_TRANSIT); wb.setCurrentNode(null); break; case "DELIVER": // 签收:只在派送中允许 if (wb.getStatus() != WaybillStatus.OUT_FOR_DELIVERY) { return; } wb.setStatus(WaybillStatus.DELIVERED); break; } }

“重复到达事件静默忽略”而不是抛异常,是因为 PDA 扫描和 GPS 围栏可能对同一次到达产生多条事件。状态机把“当前状态 + 节点编码”作为合法性判断的依据,已经在 AT_NODE 的运单再次收到 ARRIVE 事件时直接丢弃,而不是报错阻塞消息消费。

六、计费与分摊:计费重快照与规则引擎

6.1 计费重:实重与体积重的较大值

零担计费的核心规则是:计费重取实际重量和体积重量中的较大值。体积重的通用算法是长×宽×高÷抛比系数,常见系数有 5000 和 6000,不同承运商、不同线路的系数不同。

java

public BigDecimal calculateChargeableWeight(Waybill wb) { BigDecimal actualWeight = wb.getTotalWeight(); // 体积重 = 长×宽×高(cm³) / 抛比系数 // 抛比系数从该运单适用费率的合同中读取,不硬编码 BigDecimal divisor = rateContract.getVolumeDivisor(wb); BigDecimal volumeWeight = wb.getTotalVolumeCm3() .divide(divisor, 3, RoundingMode.HALF_UP); return actualWeight.max(volumeWeight); }

抛比系数必须从合同规则中读取,而不是写成常量。同一家物流企业的不同客户、不同线路可能使用不同的抛比系数,硬编码会导致“系统算出来的计费重和合同对不上”,这是零担计费争议的高发点。计费重计算完成后,在运单上落一个快照字段chargeable_weight,后续所有结算和分摊都以这个快照为准,不再重新计算。重算会导致同一票货在不同时间算出不同金额,财务无法解释。

6.2 成本分摊:按实际驱动因素分摊

一趟车的总成本(油费、路桥费、司机工资)需要分摊到每一票货上,才能算出单票的真实成本。分摊规则的选择原则是“运费按什么收,就大致按什么分摊”:重货按重量分摊,抛货按体积分摊,混装时先分组再分摊。

java

public Map<String, BigDecimal> allocateTripCost( Trip trip, List<Waybill> waybills) { BigDecimal totalCost = trip.getTotalCost(); Map<String, BigDecimal> allocation = new HashMap<>(); // 先判断整车的驱动类型:重量驱动还是体积驱动 boolean weightDriven = trip.getTotalChargeableWeight() .compareTo(trip.getTotalVolumeWeight()) >= 0; BigDecimal totalBasis = weightDriven ? trip.getTotalChargeableWeight() : trip.getTotalVolumeCm3(); for (Waybill wb : waybills) { BigDecimal basis = weightDriven ? wb.getChargeableWeight() : wb.getTotalVolumeCm3(); BigDecimal share = basis.divide(totalBasis, 6, RoundingMode.HALF_UP); allocation.put(wb.getWaybillNo(), totalCost.multiply(share).setScale(2, RoundingMode.HALF_UP)); } return allocation; }

分摊结果必须可回放到每一段。如果一票货经历了两次中转、三段运输,每一段的分摊金额应该独立记录,而不是只在最后算一个总数。财务对账时如果客户质疑“为什么这票比上次贵”,系统能定位到是某一段的中转成本或某一段的装载率偏低导致的分摊额上升。

6.3 规则引擎:把计费规则从代码里抽出来

零担计费规则的变化频率很高:首重+续重、按吨公里、按方公里、最低一票、季节性附加费、燃油附加费。硬编码会导致每次调价都要改代码、发版、回归测试。洞隐的 TMS 用 200+ 计费规则和智能计费引擎来承载这种变化,唯智的 BMS 则用自然语言定义计算公式,不同时间段的运价按版本区分、无缝切换。

工程上不必一步到位做自然语言规则引擎,但计费规则的表达式化是必要的:

java

// 规则表达式示例:首重+续重,含最低一票 // 规则存储在数据库,按合同+线路+生效时间版本化 String expression = "max(minCharge, " + "firstPrice + max(0, chargeableWeight - firstWeight) * addPrice)"; BigDecimal freight = expressionEvaluator.evaluate( expression, Map.of( "firstPrice", contract.getFirstPrice(), "firstWeight", contract.getFirstWeight(), "addPrice", contract.getAddPrice(), "minCharge", contract.getMinCharge(), "chargeableWeight", waybill.getChargeableWeight() ));

规则版本与生效时间绑定,运单计费时取该运单创建时间对应的规则版本,而不是“当前最新版本”。这样调价不会追溯影响已发生的运单,对账时口径稳定。

七、避坑指南

坑1:配载明细记录运单总量,部分发运时无法拆分

现象:一票 10 吨的货先装了 6 吨,配载明细写了 10 吨,系统认为该运单已全部装车,剩余 4 吨无法再配。

原因:配载明细的loaded_weight被当作运单总量写入,而不是本次实际装载量。

解决:配载明细必须记录本次装载量,并支持同一运单对应多条配载明细。运单的“已发运量”是配载明细的汇总,而不是运单本身的一个固定字段。配载前先校验“剩余可发量 ≥ 本次装载量”,防止超发。

坑2:路由只优化里程,中转次数失控

现象:系统算出的路径总里程最短,但中间经过 4 个中转节点,货物走了 6 天,客户投诉时效。

原因:路由目标函数只有distance,没有把中转次数作为一等约束。

解决:路由选择的第一优先级是transitCount ≤ maxTransit,第二优先级才是成本/里程。maxTransit来自运单的客户协议,不是全局统一值。同时,路由结果必须与配载服务共享:如果某条路径上的配载单已满,路由需要感知这个容量约束,而不是生成一条“理论上可行但实际装不上”的路径。

坑3:GPS 节点打卡替代 PDA 扫描,导致漏扫无法追溯

现象:仓库屋顶遮挡导致 GPS 围栏未触发,同时 PDA 漏扫,运单的到达时间缺失,异常检测和时效分析全部失真。

解决:PDA 扫描是主记录,GPS 围栏是辅助证据。节点作业表的source字段区分两者,业务逻辑以 PDA 扫描为准;GPS 事件只用于在 PDA 缺失时提供时间线参考和异常预警,不直接改写运单状态。数据完整度要靠“强制扫描”的流程约束来保证,而不是靠 GPS 的自动化兜底。

八、总结

零担物流管理系统的核心,不是把运单、车辆、轨迹做成增删改查,而是把配载、路由、分摊三个决策做成可执行、可审计、可优化的算法链路。关键经验可以概括为三句话:

  1. 配载的约束是“目的站 + 中转次数 + 双维容量”:排序按目的站在线路上的先后,而不是单纯按重量或体积降序。装载率要同时看重量和体积两维,有效装载率取较小值。

  2. 路由的第一优先级是“最少中转”,不是“最短里程”:中转次数是硬约束,来自运单的客户协议;成本优化在满足中转约束的候选路径中进行。运单状态机必须把“到达/发出”绑定到具体节点编码,终态保护防止签收后回退。

  3. 计费重必须落快照,分摊必须按实际驱动因素:体积重系数从合同读取,不硬编码;计费规则表达式化并按版本生效,不追溯已发生运单;成本分摊按重量或体积的驱动方向选择基数,每一段独立记录,让单票成本可回放。

> 本文作者:深圳+Eyf•半字节科技▪技术产品团队
> 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。

你在零担系统中遇到的最棘手决策是哪一类——是配载装不下、路由绕太多,还是分摊算不准?欢迎在评论区分享具体场景。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询