简介:专线物流系统是一套面向物流企业及Java开发者的完整软件解决方案,覆盖订单管理、车辆调度、货物追踪、费用计算与报表分析等核心业务环节,适合需要理解物流信息化架构或进行二次开发的技术人员。资源包共1028个文件,约1.98MB,以gif与png界面素材、java源码与class编译文件为主体,辅以jsp页面、xml配置、js脚本、css样式及db数据库文件,并附doc设计文档与规范说明,便于对照代码理解系统结构。已有193人学习下载。通过阅读源码与设计文档,读者可掌握需求分析、模块划分、数据库设计与接口定义等关键内容,并基于Spring Boot、MyBatis等技术栈进行功能扩展、界面调整或第三方服务集成,是学习企业级物流系统开发与二次配置的实用参考。
1. 专线物流系统到底在解决什么问题:从一张运单的流转说起
零担专线这个行当,外行看是「一车货从 A 拉到 B」,内行看是「一张运单在六个角色手里来回倒」。客户下单、网点收货、分拨集货、干线发车、目的站卸货、末端派送、回单签收、对账结算——每一步都在改运单状态,每一步都可能丢信息。我见过太多小专线还在用微信群加 Excel 管货:客服在群里喊「XX 单到哪了」,调度翻聊天记录,财务月底对着两张表核半天。专线物流系统要干的事,就是把这套靠人肉记忆和口头传递的流程,变成一张有状态、有轨迹、有账目的数据表。
它适合谁?一是三五条线路、日均几十到几百票的中小专线,想摆脱 Excel 但买不起也养不起大型 TMS;二是想自己搭一套、能改能扩的技术团队,拿现成代码和设计文档做底座,比从零画表结构省至少两周。这篇文章不讲空概念,我按「表怎么设计、单怎么流转、钱怎么算、坑在哪」的顺序,把一套能跑起来的专线系统拆开讲,代码和设计文档的思路都会落到具体字段和接口上。
2. 先定数据模型:运单、货物、车辆、账目四张核心表怎么设计
专线系统的地基是数据模型,模型错了后面全是补丁。我一般先把业务对象拆成四类:运单(一票货的业务主键)、货物明细(一票多件)、车辆与班次(干线运力)、账目(应收应付)。这四类之间的关系决定了你后面查询和统计顺不顺。
2.1 运单主表:状态机字段是灵魂
运单表最容易被新手写成一个「大杂烩」,什么字段都往里塞。我的做法是主表只放业务主键和状态,明细和轨迹拆出去。核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| waybill_no | varchar(32) | 运单号,业务主键,建议规则:网点码+日期+流水 |
| customer_id | bigint | 客户 ID,关联客户表 |
| origin_station | varchar(16) | 始发网点编码 |
| dest_station | varchar(16) | 目的网点编码 |
| status | tinyint | 状态机:10待收货 20已收货 30已发车 40已到达 50派送中 60已签收 90异常 |
| total_qty | int | 总件数 |
| total_weight | decimal(10,2) | 总重量 kg |
| total_volume | decimal(10,3) | 总体积 m³ |
| freight_fee | decimal(10,2) | 运费 |
| created_at | datetime | 下单时间 |
状态字段用数字而不是字符串,是为了后面做状态流转校验时比较方便,也省索引空间。这里有个血泪经验:状态值一旦上线就别改含义,我见过有人把 30 从「已发车」改成「已到达」,结果历史数据全乱,只能写脚本回滚。
2.2 货物明细与轨迹表:一票多件和全程留痕
一票货可能有三件不同规格的包裹,所以货物明细必须独立成表,用 waybill_no 关联。轨迹表则记录每一次状态变更,谁改的、什么时候改的、在哪个网点改的,都要留痕,这是后面客户查件和内部追责的依据。
-- 货物明细表 CREATE TABLE waybill_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, item_name VARCHAR(64) COMMENT '品名', qty INT DEFAULT 1 COMMENT '件数', weight DECIMAL(10,2) COMMENT '重量kg', volume DECIMAL(10,3) COMMENT '体积m³', pack_type VARCHAR(16) COMMENT '包装类型', KEY idx_waybill (waybill_no) ) COMMENT '运单货物明细'; -- 轨迹表 CREATE TABLE waybill_track ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL COMMENT '变更后状态', station_code VARCHAR(16) COMMENT '操作网点', operator VARCHAR(32) COMMENT '操作人', remark VARCHAR(255) COMMENT '备注', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_waybill_time (waybill_no, created_at) ) COMMENT '运单轨迹';逻辑说明:明细表用 waybill_no 做普通索引而不是外键,是因为专线系统经常要批量导入历史数据,外键约束会拖慢导入还可能因为脏数据直接失败。轨迹表的联合索引 (waybill_no, created_at) 是为了查件页面按时间倒序拉轨迹,这个查询在高峰期非常频繁,索引顺序不能反。
参数说明:weight 用 decimal(10,2) 而不是 float,运费计算对精度敏感,float 累加会出现 0.30000000000000004 这种鬼东西。volume 保留三位小数是因为轻抛货按体积算钱,差 0.001 立方可能就差几毛钱,对账时客户会揪着不放。
2.3 车辆班次表:干线运力的排班基础
干线发车不是随到随走,而是按班次排。班次表要记录线路、发车时间、车牌、司机、核定载重,运单发车时挂到某个班次上,这样一趟车装了多少票、总重多少、有没有超载,一查便知。
CREATE TABLE trip ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trip_no VARCHAR(32) NOT NULL COMMENT '班次号', line_code VARCHAR(16) COMMENT '线路编码', plate_no VARCHAR(16) COMMENT '车牌号', driver_name VARCHAR(32) COMMENT '司机', depart_time DATETIME COMMENT '计划发车', arrive_time DATETIME COMMENT '计划到达', max_weight DECIMAL(10,2) COMMENT '核定载重kg', status TINYINT DEFAULT 0 COMMENT '0待发车 1在途 2已到达', UNIQUE KEY uk_trip (trip_no) ) COMMENT '干线班次';这里的关键是 max_weight,配载时要用它做校验。我一般会在发车接口里加一道判断:本班次已挂运单总重 + 当前运单重量 > max_weight 就拦下来,提示调度换班次。这个校验能避免超载罚款,是实打实省钱的功能。
3. 运单状态流转怎么落地:从收货到签收的接口与校验
数据模型定好之后,核心就是状态怎么往前走。专线系统的状态流转不能随便改,必须按预设路径走,否则会出现「没发车就签收」这种脏数据。我的做法是把状态流转规则集中写在一个服务里,所有改状态的入口都调它。
3.1 状态机规则与非法流转拦截
先明确合法路径:10→20→30→40→50→60,异常状态 90 可以从任意状态进入,但进入 90 后只能由人工改回正常状态。反向流转一律禁止,比如 40 不能直接回 30。下面是一个简化的状态校验函数:
# 合法流转映射:key 是当前状态,value 是允许的下一状态集合 ALLOWED_TRANSITIONS = { 10: {20, 90}, # 待收货 -> 已收货 / 异常 20: {30, 90}, # 已收货 -> 已发车 / 异常 30: {40, 90}, # 已发车 -> 已到达 / 异常 40: {50, 90}, # 已到达 -> 派送中 / 异常 50: {60, 90}, # 派送中 -> 已签收 / 异常 60: set(), # 已签收,终态 90: {10, 20, 30, 40, 50} # 异常可人工修正回正常态 } def change_status(waybill_no, current_status, target_status, operator, station): if target_status not in ALLOWED_TRANSITIONS.get(current_status, set()): raise ValueError(f"非法流转: {current_status} -> {target_status}") # 更新主表状态 update_waybill_status(waybill_no, target_status) # 写轨迹 insert_track(waybill_no, target_status, operator, station) return True逻辑说明:把流转规则写成字典,改规则只改这一处,不用满代码找 if-else。change_status 先校验再更新,保证主表状态和轨迹表永远一致。参数说明:current_status 必须从数据库实时读,不能信前端传过来的值,否则有人改个请求就能把已签收的单改回待收货。
3.2 收货接口:重量体积录入与运费初算
收货是运单第一次产生实际数据,重量体积录错,后面运费全错。收货接口要做三件事:写明细、算运费、改状态。运费计算规则常见的是「取重量和体积重量的较大值」,体积重量 = 体积 × 抛比系数,专线一般用 200 到 250。
def calc_freight(weight, volume, unit_price, ratio=220): # 体积重量 = 体积(m³) * 抛比系数 volume_weight = volume * ratio charge_weight = max(weight, volume_weight) return round(charge_weight * unit_price, 2) def receive_goods(waybill_no, items, operator, station): total_w = sum(i['weight'] for i in items) total_v = sum(i['volume'] for i in items) # 从线路报价表取单价 unit_price = get_unit_price(waybill_no) freight = calc_freight(total_w, total_v, unit_price) save_items(waybill_no, items) update_freight(waybill_no, freight) change_status(waybill_no, 10, 20, operator, station)逻辑说明:calc_freight 把抛比系数做成参数,不同线路可以配不同值,不用改代码。参数说明:ratio 默认 220 是常见值,轻抛货多的线路可以调到 250,重货线路调到 200 甚至更低,这个值直接决定运费,上线前必须和业务确认清楚。
3.3 发车与到达:批量操作和班次绑定
发车和到达都是批量操作,一车几十票一起改状态。这里要注意事务,要么全成功要么全失败,不能改一半。我一般用批量更新加轨迹批量插入,包在一个事务里。
-- 发车:批量改状态并绑定班次 START TRANSACTION; UPDATE waybill SET status = 30, trip_no = 'TRIP20240101001' WHERE waybill_no IN ('WB001','WB002','WB003') AND status = 20; -- 检查影响行数是否等于传入数量,不等则回滚 INSERT INTO waybill_track (waybill_no, status, station, operator) SELECT waybill_no, 30, '始发站', '调度A' FROM waybill WHERE trip_no = 'TRIP20240101001' AND status = 30; COMMIT;逻辑说明:UPDATE 带 status = 20 条件,防止把已经发车的单重复发车。影响行数校验是关键,如果传入 3 票但只更新了 2 票,说明有一票状态不对,必须回滚并报错。参数说明:trip_no 在发车时写入,后面查班次装载情况直接按 trip_no 聚合。
4. 运费与对账:计费规则、应收应付和差异排查
专线系统里钱的事最敏感,运费算错一次客户就记你一辈子。计费模块要解决三个问题:单价从哪来、费用怎么算、对账差异怎么查。
4.1 报价表设计:按线路和客户分级定价
单价不能写死在代码里,要有一张报价表。常见做法是按「线路 + 客户等级」定价,大客户单价低,散客单价高。报价表还要有生效时间,调价时新增一条记录而不是改旧记录,这样历史运单能按当时的价算。
CREATE TABLE price_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_code VARCHAR(16) NOT NULL COMMENT '线路编码', customer_level TINYINT COMMENT '客户等级 1大客户 2普通 3散客', unit_price DECIMAL(10,2) COMMENT '单价 元/kg', min_charge DECIMAL(10,2) COMMENT '最低一票', ratio INT DEFAULT 220 COMMENT '抛比系数', effective_from DATE COMMENT '生效日期', KEY idx_line_level (line_code, customer_level, effective_from) ) COMMENT '报价规则';逻辑说明:查询单价时按 line_code + customer_level + effective_from <= 运单日期 排序取最新一条。参数说明:min_charge 是最低一票收费,很多专线对小票货有起步价,比如 30 元一票,算出来低于 30 就按 30 收,这个字段不加,小票货会亏本。
4.2 应收应付对账:两张表对不上的排查思路
对账就是拿系统里的应收和客户实际付款对,拿应付和司机/外发成本对。差异无非三类:漏单、金额错、状态错。排查顺序我一般是:先按运单号全量比对找出漏单,再对金额差异的单逐票看计费参数,最后看状态是不是卡在异常没结算。
def reconcile(system_bills, external_bills): sys_map = {b['waybill_no']: b for b in system_bills} ext_map = {b['waybill_no']: b for b in external_bills} missing = set(sys_map) - set(ext_map) # 系统有外部无 extra = set(ext_map) - set(sys_map) # 外部有系统无 diff = [] for no in set(sys_map) & set(ext_map): if abs(sys_map[no]['amount'] - ext_map[no]['amount']) > 0.01: diff.append((no, sys_map[no]['amount'], ext_map[no]['amount'])) return missing, extra, diff逻辑说明:用集合运算快速定位漏单,金额差异用 0.01 容差避免浮点误差误报。参数说明:容差 0.01 是分,如果业务允许角级差异可以调到 0.1,但一般财务要求分毫不差,所以保持 0.01。
5. 避坑与常见问题:上线后最容易翻车的五个地方
这套系统我从设计到上线踩过不少坑,挑五个最典型的,按「现象→原因→解决」写清楚,你照着排查能省很多时间。
坑一:并发改状态导致轨迹丢失。现象是同一票货出现两条相同状态轨迹,或者状态跳变。原因是两个操作员同时点发车,都读到了旧状态 20,都通过了校验。解决是在 change_status 里加乐观锁,用 status 做版本号:UPDATE waybill SET status = 30 WHERE waybill_no = ? AND status = 20,影响行数为 0 就说明被别人改过,直接报错让操作员刷新。
坑二:重量体积单位不统一。现象是运费忽高忽低,同一票货两次算出来差一倍。原因是收货员有的填克有的填千克,体积有的填立方厘米有的填立方米。解决是在录入接口做单位归一化,前端强制选单位,后端统一转成 kg 和 m³ 再入库,并在明细表加原始单位字段备查。
坑三:报价表生效日期边界。现象是调价当天发的货按旧价算了。原因是 effective_from 用 DATE,查询用运单创建时间(带时分秒)比较,当天 00:00 之后创建的运单匹配不到当天生效的价。解决是把生效时间改成 DATETIME,或者查询时把运单日期截断到天再比较。
坑四:批量发车事务太大锁表。现象是发车时其他查询变慢甚至超时。原因是一次事务里更新几百票加插几百条轨迹,锁持有时间太长。解决是分批提交,每 50 票一个事务,或者把轨迹插入改成异步队列,主表状态先更新。
坑五:异常状态没有出口。现象是货卡在异常状态没人管,客户投诉。原因是异常状态 90 进去容易出来难,没有超时提醒。解决是加定时任务,扫描 status = 90 超过 24 小时的运单,推给客服主管,并在轨迹里记录每次人工处理。
6. 让系统真正跑起来:从单机验证到多网点部署的进阶技巧
代码和表结构有了,怎么验证它真能用?我的习惯是先造一批模拟数据跑全流程,再上真实业务。造数据不是随便插,要覆盖边界:一票一件、一票多件、超重、轻抛、异常回退、批量发车。下面这个脚本能快速生成一批测试运单,跑完收货到签收的完整链路。
import random from datetime import datetime, timedelta def gen_test_waybills(n=100): stations = ['BJ01', 'SH01', 'GZ01', 'CD01'] for i in range(n): origin = random.choice(stations) dest = random.choice([s for s in stations if s != origin]) qty = random.randint(1, 5) weight = round(random.uniform(5, 500), 2) volume = round(random.uniform(0.01, 3), 3) # 故意制造 10% 的轻抛货 if random.random() < 0.1: volume = round(weight / 200 * 1.5, 3) waybill_no = f"{origin}{datetime.now().strftime('%Y%m%d')}{i:04d}" create_waybill(waybill_no, origin, dest, qty, weight, volume) print(f"生成 {n} 票测试运单完成") def run_full_flow(waybill_no): # 模拟收货 -> 发车 -> 到达 -> 派送 -> 签收 receive_goods(waybill_no, [{'weight': 100, 'volume': 0.5}], '测试员', 'BJ01') change_status(waybill_no, 20, 30, '调度', 'BJ01') change_status(waybill_no, 30, 40, '目的站', 'SH01') change_status(waybill_no, 40, 50, '派送员', 'SH01') change_status(waybill_no, 50, 60, '派送员', 'SH01') print(f"{waybill_no} 全流程通过")逻辑说明:gen_test_waybills 里故意让 10% 的货体积偏大,触发体积重量计费,验证抛比逻辑。run_full_flow 按状态机顺序走一遍,任何一步抛异常就说明流转规则有问题。参数说明:n 默认 100,压测时可以调到 1000,但要注意数据库连接池大小,别把测试库打挂。
单机验证通过后,多网点部署要解决数据隔离和同步。常见做法是每个网点一个库,运单归属始发网点,目的站通过接口查。但这样跨网点查件慢,我的建议是运单主表和轨迹表集中存,网点只存本地操作日志,用网点编码做数据权限过滤。这样查件快,权限也好控。
最后一个技巧:上线前一定要做一次「对账演练」。拿过去一个月的历史数据,手工算一遍运费,和系统算的比对,差异超过 1% 就说明计费规则有问题。我吃过这个亏,上线第一周客户就发现三票运费算错,虽然金额不大,但信任度掉得厉害。后来我养成了习惯,任何计费改动,先跑历史数据回归,通过了再上线。希望帮到你。
本文还有配套的精品资源,点击获取