Java TMS运输管理系统源码拆解:运单状态机与结算对账
简介:这份TMS运输管理系统压缩包基于Java语言构建,面向物流信息化从业者、Java后台管理系统学习者以及需要运输管理项目参考的开发者,围绕订单处理、路线规划与优化、车辆司机调度、运输成本控制、货物GPS追踪、报表分析及ERP/WMS接口集成等业务场景,提供一套较完整的运输管理后台实现思路。压缩包约74.39MB,内含KYTMS子目录,涉及系统关键组件、源代码、配置文件与配套文档等基础内容,便于按模块查阅系统结构与运行所需文件。目前已有2375人学习,说明其在运输管理类Java项目中具备一定参考热度。读者可从中了解TMS各功能模块的组织方式、后台管理系统的分层设计以及物流业务数据流转逻辑,适合用于课程设计、毕业设计选题参考、企业物流系统预研或二次开发前的结构梳理,也可作为Java管理系统模块拆分与业务建模的对照材料。
1. 月底对账差三万,问题往往出在运单状态上
月底对账差了三万块,调度说运单状态改过了,财务说结算单没跟着变——这种事在没上系统的运输公司里一年能演好几回。TMS 运输管理系统.zip 就是冲着这类问题去的一套 Java 后台管理系统源码:运单从创建到签收、结算,状态怎么流转、车辆怎么派、运费怎么算、哪个角色能看哪个分公司的数据,都落在一套代码里。它适合三类人:想拿现成骨架做二次开发的后端,需要一套能跑起来的运输业务模型做课程设计或练手的同学,以及手上堆着一堆 Excel 运单、想搞清楚正规运输管理系统该有哪些字段和校验的业务方。别指望开箱即用,这套东西的价值在业务模型的完整度,不在界面好不好看。
2. 拆包先认栈:从 pom.xml 到本地跑起来的三道关
拿到一个 Java 后台管理系统的压缩包,最忌讳的操作是直接 import 到 IDE 然后点运行。运输管理这类系统通常带着一套自己的分层约定、一套权限框架、一套代码生成器留下的痕迹,不了解清楚就动手,改三行报五个错。我一般的顺序是:先看目录判断形态,再读 pom 判断技术栈,最后才是建库改配置启动。这三步走完,你才知道这份源码是「能改」还是「只能看」。
2.1 目录结构先看分层:单体分层和前后端分离是两种改法
解压之后先别打开 IDE,用命令行把顶层结构列出来。运输管理系统的目录形态直接决定了你后面改页面的成本:如果前端页面在src/main/resources/static或者templates下,那就是服务端渲染或者静态页嵌入,改一个列表页要动 HTML + JS + Controller 三处;如果根目录下有一个独立的vue、admin-ui、web目录,那就是前后端分离,后端只出接口。
# 解压后先看清楚顶层结构,别急着 import 到 IDE unzip TMS运输管理系统.zip -d tms cd tms # 只看三层目录,避免被 node_modules 之类的依赖目录刷屏 find . -maxdepth 3 -type d \ -not -path "*/node_modules*" \ -not -path "*/.git*" | sort | head -50-maxdepth 3是关键参数,很多源码包会把前端依赖一起打进去,不限深度能列出上千行,反而看不清主干。-not -path排除掉依赖目录,剩下的才是作者真正写的代码。跑完这条命令,你大概会看到下面几种形态之一:
| 目录特征 | 判断 | 对你的影响 |
|---|---|---|
src/main/java/.../controller+resources/templates | 单体服务端渲染 | 改页面要动模板,调试靠重启 |
src/main/java/.../controller+ 独立vue/ui目录 | 前后端分离 | 后端只关心接口,前端单独npm run dev |
根目录有多个xxx-admin、xxx-api模块 | Maven 多模块 | 启动类在 admin 模块里,别找错入口 |
根目录有generator、code之类目录 | 带代码生成器 | 新功能可以先生成再改,省一半时间 |
看到「多模块」这一行要特别注意。多模块的运输管理系统,启动类往往不在根目录,而在xxx-admin或xxx-web子模块里,application.yml也常常分散在多个模块的resources下。我第一次拆这类包时就吃过亏:在根目录跑mvn spring-boot:run,报「Unable to find a suitable main class」,折腾了半小时才发现入口在子模块。正确的做法是先grep -rn "SpringBootApplication" --include=*.java .,定位到启动类所在的模块,再在那个模块目录下启动。
2.2 三个必查项:JDK 版本、持久层、权限框架
目录看完,接下来读pom.xml。不用逐行读,只查三样东西,它们决定了你本地要装什么、配置文件长什么样、后面加功能往哪个方向写。
# 1. 看 Spring Boot 版本,直接决定 JDK 用 8 还是 17 grep -n "spring-boot-starter-parent" -A 2 pom.xml # 2. 看持久层:MyBatis、MyBatis-Plus 还是 JPA,附带看数据库驱动 grep -nE "mybatis|druid|mysql-connector|postgresql" pom.xml # 3. 看权限框架:Shiro 还是 Spring Security,两者的拦截器写法完全不同 grep -nE "shiro|spring-boot-starter-security|jjwt" pom.xml-A 2表示匹配行往后多打印两行,因为版本号通常写在<version>标签里,紧跟在 artifactId 下一行。这三条命令的输出,对应三个必须提前确认的事实:
第一,JDK 版本。Spring Boot 2.x 用 JDK 8 就能跑,3.x 强制 JDK 17 起。运输管理系统这类偏业务的项目,很多还停留在 2.x,本地装个 JDK 8 或 11 最稳。如果 pom 里写了<java.version>1.8</java.version>,你却用 JDK 17 去编译,大概率会遇到javax.annotation找不到、Lombok 版本过低这类连锁报错。
第二,持久层。MyBatis 的原生写法和 MyBatis-Plus 的写法差别很大:前者 XML 里写 SQL,后者大量用LambdaQueryWrapper。看运单列表怎么查的,就知道后面加查询条件该往哪写。顺带看一眼连接池,Druid 的话application.yml里会有一段druid:配置,改密码的位置和默认的spring.datasource不在一处,这是新手最容易改错的地方。
第三,权限框架。这是 Java 后台管理系统的骨架。Shiro 的配置通常在ShiroConfig类里,过滤器链用filterChainDefinitionMap定义;Spring Security 则是SecurityFilterChain加注解。搞清楚这一点,你才知道「给某个角色加一个菜单」要动几张表、几个类。
2.3 建库、改配置、启动:最小可运行闭环
三样都确认完,可以动手了。顺序是建库、导脚本、改配置、启动,中间任何一步跳过都会在启动时报错,而且报错信息往往指不到真正的原因。
# 建库:字符集必须 utf8mb4,运单备注里经常有生僻字和表情符号 mysql -uroot -p -e "CREATE DATABASE tms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入建表与初始化数据脚本,脚本一般在 sql/ 或 db/ 目录下 ls sql/ db/ doc/ 2>/dev/null mysql -uroot -p tms < sql/tms.sql # 确认表已经建出来,顺便看有多少张表 mysql -uroot -p tms -e "SHOW TABLES;" | wc -l字符集这里不要图省事用utf8。MySQL 里的utf8只支持三字节,运单备注里粘一个表情符号就会报Incorrect string value,而且这个错往往是在业务跑了几个月、有人填了一条特殊备注之后才爆出来。建库时就定成utf8mb4,一劳永逸。
数据库建好,改配置文件。下面是这类项目里必须逐个核对的四处,缺一处都可能起不来或者跑出诡异的时间问题:
# application.yml / application-dev.yml 里必须确认的配置 server: port: 8080 # 端口冲突就换,别跟本地其他服务抢 servlet: context-path: /tms # 有 context-path 的话,所有接口前缀都要带上 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/tms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: root password: 改成你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: time-zone: Asia/Shanghai # 不配这个,接口返回的时间会差 8 小时 date-format: yyyy-MM-dd HH:mm:ssserverTimezone=Asia/Shanghai和spring.jackson.time-zone这两处要一起配。只配前者,数据库里取出来的时间是对的,但接口序列化成 JSON 时又按 UTC 输出,前端看到的时间还是差 8 小时;只配后者,插入时又可能偏。这个坑我在三个不同项目上见过同样的表现,现象都是「列表页时间对,详情页时间错」。
启动命令看项目形态选:
# 单体项目,在含启动类的模块目录下执行 mvn clean spring-boot:run # 或者先打包再跑,这种更适合验证「换台机器能不能起来」 mvn clean package -DskipTests java -jar target/*.jar --spring.profiles.active=dev-DskipTests不是偷懒。很多源码包里的测试类依赖了作者本地才有的环境,打包时跑测试会直接失败,但主流程其实是好的。先跳过测试把服务拉起来,跑通主流程之后再回头单独看测试用例,这个顺序更省时间。
3. 数据模型是命门:运单、车辆、承运商三张主表怎么读
运输管理系统的复杂度不在页面数量,在数据模型。运单、车辆、承运商、司机、计费规则、结算单,这几张表之间的关联关系一旦设计歪了,后面加多少功能都别扭。读这套源码,建议直接从建表脚本入手,先把表清单和字段含义摸清楚,再回头看代码,效率比从 Controller 往下读高得多。
3.1 从建表脚本反推业务边界
把sql目录下的表名全列出来,按前缀分一下组,业务边界基本就出来了。一套完整的运输管理系统,表通常会落在下面这几块:
| 业务域 | 常见表名(以实际脚本为准) | 读表时重点看什么 |
|---|---|---|
| 运单 | tms_order、tms_order_item、tms_waybill | 状态字段、时间字段、金额字段 |
| 运力 | tms_vehicle、tms_driver、tms_carrier | 车辆载重体积、承运商结算方式 |
| 计费 | tms_fee_rule、tms_fee_detail | 规则维度、计费公式字段 |
| 结算 | tms_settlement、tms_settle_detail | 结算状态、对账标记 |
| 系统 | sys_user、sys_role、sys_menu、sys_role_menu | 数据权限范围字段 |
看到「系统」这一组,说明这套后台管理系统带了标准的 RBAC 权限模型。重点看sys_role上有没有类似data_scope的字段——有的话说明做了数据权限(比如分公司只能看自己的运单),没有的话就是纯功能权限,后面做多组织隔离得自己加。
tms_order和tms_waybill同时存在的情况也常见。这时候要看代码里哪个是主表:运单(order)是客户视角的委托单,运单(waybill)是承运视角的运输单,一张委托单可能拆成多张运输单。分不清这两个,后面做状态流转一定会错乱——客户签收了,但运输单还没到货,状态到底听谁的,是个必须先定的规则。
3.2 运单主表:业务字段和记账字段要分开看
打开运单主表,字段大致分三类:业务字段(发货方、收货方、货物名称、重量、体积)、流程字段(状态、调度车辆、司机、提货时间、签收时间)、记账字段(运费、附加费、结算状态)。读的时候重点确认两件事:状态字段用什么类型、时间字段用什么类型。
-- 1. 看索引:索引建在哪些字段上,反推系统里最常走的查询路径 SHOW INDEX FROM tms_order; -- 2. 看状态分布:枚举值实际用到哪几个,有没有写了但从没出现过的状态 SELECT status, COUNT(*) AS cnt FROM tms_order GROUP BY status ORDER BY cnt DESC; -- 3. 看字段类型:金额是 decimal 还是 double,时间戳是 datetime 还是 bigint SHOW COLUMNS FROM tms_order LIKE '%amount%'; SHOW COLUMNS FROM tms_order LIKE '%time%';SHOW INDEX的结果比任何文档都诚实。如果索引建在carrier_id + create_time上,说明列表页默认按承运商筛选加时间排序;如果没有order_no的唯一索引,说明这套代码在并发下可能重复插单。SHOW COLUMNS那两条尤其要看:金额字段如果是double,那么这套源码的结算模块基本不能直接用到生产,得先改成decimal(12,2);时间字段如果是bigint存毫秒,那所有 SQL 里的日期函数都要换算,跟datetime的写法完全是两套。
3.3 状态字段是分水岭:三种写法的区别
运单状态是整个系统里改动最频繁、也最容易出错的地方。读源码时留意它是哪种写法,直接决定了你后面加状态的成本。
第一种,魔法值硬编码。Service 里到处是if (order.getStatus() == 3),加一个「异常挂起」状态要全局搜索替换,改漏一处就是线上事故。第二种,常量类。OrderStatus.IN_TRANSIT = 40,比魔法值好,但流转规则还是散在各处的if-else里,谁都能从任意状态跳到任意状态。第三种,枚举加流转表,这是我唯一认可的做法:
public enum OrderStatus { CREATED(10, "已创建"), DISPATCHED(20, "已调度"), PICKED(30, "已提货"), IN_TRANSIT(40, "运输中"), SIGNED(50, "已签收"), SETTLED(60, "已结算"), CANCELLED(90, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } // 允许的流转关系集中在一处,新增状态只改这个 Map,不用全局搜 if-else private static final Map<OrderStatus, Set<OrderStatus>> FLOW = new EnumMap<>(OrderStatus.class); static { FLOW.put(CREATED, EnumSet.of(DISPATCHED, CANCELLED)); FLOW.put(DISPATCHED, EnumSet.of(PICKED, CANCELLED)); FLOW.put(PICKED, EnumSet.of(IN_TRANSIT, CANCELLED)); FLOW.put(IN_TRANSIT, EnumSet.of(SIGNED)); FLOW.put(SIGNED, EnumSet.of(SETTLED)); FLOW.put(SETTLED, EnumSet.noneOf(OrderStatus.class)); } public boolean canTransferTo(OrderStatus target) { return FLOW.getOrDefault(this, EnumSet.noneOf(OrderStatus.class)).contains(target); } public int getCode() { return code; } public String getDesc() { return desc; } }这段代码的价值在FLOW这个静态 Map:它把「哪些状态能跳到哪些状态」变成了一份可读、可测、可回滚的配置。调用方只写if (!current.canTransferTo(target)) { throw new BizException("状态不允许从" + current.getDesc() + "流转到" + target.getDesc()); },业务规则就不再散落。新增「异常挂起」状态时,只需在枚举里加一行、在FLOW里补两条边,所有调用点自动生效。
参数和边界要交代清楚:SETTLED我给了空集合,意味着结算完成后状态不可回退。这在实操中会带来一个问题——财务发现结算金额错了要退回重算,状态卡死。常见做法是留一个反向边SETTLED -> SIGNED,但只允许特定角色(财务主管)触发,并且记一条操作日志。这个例外要不要开,取决于你的业务能不能接受「已结算运单被修改」,我一般建议开,但强制留痕。
4. 调度与结算:把运力校验和计费规则从 Service 里拆出来
状态机管的是流程对不对,调度和结算管的是钱和车对不对。这两块是运输管理系统里最容易写成一坨 if-else 的地方,也是最值得在二次开发前先重构的部分。下面按「调度校验 → 计费计算 → 对账排查 → 数据隔离」四步走,每一步都给可以直接抄的逻辑。
4.1 调度:车辆可用性校验的三个必要条件
派车这件事,本质是判断「这辆车在这段时间里有没有冲突」。看起来简单,写起来容易漏条件。完整的校验至少三条:车辆状态可用(不是在维修、不是已停用)、司机准驾车型匹配、时间段不重叠。第三条最容易写错。
-- 判断某车在某时间段内是否已被占用 -- 关键:用左闭右开区间判断重叠,避免「上一单 18:00 结束、下一单 18:00 开始」被判成冲突 SELECT COUNT(1) FROM tms_order WHERE vehicle_id = #{vehicleId} AND status IN (20, 30, 40) -- 已调度 / 已提货 / 运输中 才占用运力 AND plan_start_time < #{endTime} -- 已有单的开始 早于 新单的结束 AND plan_end_time > #{startTime}; -- 已有单的结束 晚于 新单的开始这两行时间比较是整个调度模块的核心,写反一个不等号就会出现「明明车空着却派不出去」或者「同一辆车同一时间被派了两趟」。左闭右开的含义是:新单在 18:00 开始时,允许上一单在 18:00 结束,两单首尾相接不算冲突。如果你的业务要求司机必须有休息间隔,把plan_end_time > #{startTime}改成plan_end_time > DATE_ADD(#{startTime}, INTERVAL 30 MINUTE),就变成了强制 30 分钟缓冲。
状态过滤status IN (20,30,40)也不能少。已签收(50)、已取消(90)的运单虽然还有vehicle_id,但车早就空出来了,不排除掉会导致运力被历史数据永久占用,越用越派不出车。这个坑很隐蔽:系统刚上线时一切正常,跑三个月后调度员开始抱怨「系统说没车,实际车都闲着」,根因就在这里。
并发场景还要补一层。上面这条 SQL 在两个人同时点「确认派车」时会双双通过,因为查询和插入之间有时间窗口。常见做法是给车辆时间段加唯一约束(需要一张vehicle_schedule占用表),或者用乐观锁在更新时带版本号:
// 派车落库时带版本号,更新影响行数为 0 说明被别人抢先了 int rows = orderMapper.dispatch(orderId, vehicleId, driverId, expectedVersion); if (rows == 0) { throw new BizException("该车辆刚被其他调度占用,请刷新后重试"); }expectedVersion从页面传入或者从查询结果里带出来,dispatch的 SQL 里写WHERE id = #{orderId} AND version = #{expectedVersion},同时SET version = version + 1。这类乐观锁方案在调度量不大(一天几百单)的场景够用,真到抢运力的高峰,还是得上车辆占用表加唯一索引。
4.2 结算:金额一律 BigDecimal,计费规则外置到表
运费计算是这套系统里最不该硬编码的部分。零担按重量和体积取大、整车按趟、有起步价、有区域加价、有回单加收——这些规则如果全写在 Service 里,客户一改价目表就得改代码重新发版。
// 零担计费:重量价与体积价取大者,再加起步价兜底 BigDecimal weightFee = weight.multiply(rule.getPricePerTon()); BigDecimal volumeFee = volume.multiply(rule.getPricePerCube()); BigDecimal base = weightFee.max(volumeFee); // 起步价兜底:算出来比最低消费还低,按最低消费收 base = base.max(rule.getMinCharge()); // 附加费逐项相加,所有中间结果统一保留 6 位,最后一步再舍入到 2 位 BigDecimal total = base.add(rule.getPickupFee()) .add(rule.getDeliveryFee()) .add(rule.getReturnFee()); // 关键:divide 必须显式指定 scale 和舍入模式,否则除不尽直接抛 ArithmeticException BigDecimal tax = total.multiply(rule.getTaxRate()) .setScale(2, RoundingMode.HALF_UP); return total.setScale(2, RoundingMode.HALF_UP).add(tax);三个参数必须说清楚。第一,setScale(2, RoundingMode.HALF_UP)里的HALF_UP是四舍五入,而BigDecimal默认的HALF_EVEN是银行家舍入,0.125 会变成 0.12 而不是 0.13,财务对账时这一分钱的差异能吵一下午,所以必须显式指定。第二,中间结果保留 6 位、最后才舍入到 2 位,是因为逐项舍入会累积误差,一张单子差一分,一万张单子就是一百块。第三,divide必须带参数,BigDecimal.divide()无参重载在除不尽时会抛异常,运输场景里算单价、算分摊比例很容易除不尽。
规则本身落到表里,字段大致是:计费维度(重量/体积/趟次)、单价、起步价、适用区域、生效时间段、优先级。查询时按「区域 + 时间 + 优先级」取第一条命中的规则,这样运营改价只改数据,不动代码。
4.3 对账:两条 SQL 定位金额差异
结算跑完,财务发现总额对不上,这时候需要的是能定位到具体单号的查询,而不是在页面上翻页比对。
-- 1. 运单侧运费与结算明细不一致的单子 SELECT o.order_no, o.freight_amount, d.settle_amount, (o.freight_amount - d.settle_amount) AS diff FROM tms_order o JOIN tms_settle_detail d ON d.order_id = o.id WHERE o.freight_amount <> d.settle_amount ORDER BY ABS(o.freight_amount - d.settle_amount) DESC; -- 2. 已签收但没进结算批次的单子(漏结算,最常见的一类差异) SELECT o.order_no, o.sign_time, o.freight_amount FROM tms_order o LEFT JOIN tms_settle_detail d ON d.order_id = o.id WHERE o.status = 50 AND o.sign_time BETWEEN #{startTime} AND #{endTime} AND d.id IS NULL;第一条按差额绝对值倒序,先看差得最多的,通常前几条就能看出问题模式——是全差 0.01(舍入问题),还是差整倍数(规则命中错了)。第二条用LEFT JOIN ... IS NULL找漏结算的单子,这是对账时最高频的问题:签收了但结算批次没跑到,原因可能是结算任务按settle_time筛选而这张单的sign_time落在批次外,也可能是状态流转时漏了触发。
4.4 数据隔离:别让分公司看到彼此的运单
带多组织的运输管理系统,权限不只是「能不能点这个菜单」,还有「能看到哪些数据」。读源码时看sys_role上有没有数据范围字段,没有的话就得自己加。常见做法是在 Mapper 层统一拼条件,而不是在每个 Service 里手写。
// 数据权限拼接:按当前登录用户的数据范围,在查询条件里补一段 SQL public void applyDataScope(QueryWrapper<Order> wrapper, LoginUser user) { switch (user.getDataScope()) { case ALL: break; // 总部,不加限制 case COMPANY: wrapper.eq("company_id", user.getCompanyId()); break; case SELF: wrapper.eq("create_by", user.getUserId()); break; default: wrapper.eq("1", "0"); // 兜底:没配范围就查不到数据 } }default分支写1=0看着别扭,但很必要。权限配置漏了字段时,宁可查不到数据,也不能把全量数据放出去。这个兜底我在一个项目里加过,上线后确实拦住了两个没配数据范围的账号,否则就是两个分公司的运单互相可见。
5. 避坑清单:五个能让运单数据对不上的坑
这套源码跑起来不难,跑对不容易。下面五条是我拆这类 Java 运输管理系统时反复遇到的,每条按「现象 → 原因 → 解决」写,照着排查能省不少时间。
5.1 五个高频故障的定位路径
坑一:启动报Invalid bound statement (not found)。现象是服务能起来,一调运单列表接口就报这个,日志里连 SQL 都没打出来。原因是 Mapper 接口和 XML 没对上——要么 XML 放在src/main/java下的包目录里,而 Maven 默认不打包java目录下的非 class 文件;要么mapper-locations配的路径和实际路径差一层。解决办法是在pom.xml的<build><resources>里把src/main/java下的**/*.xml包含进去,或者干脆把 XML 统一挪到src/main/resources/mapper下,再配mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml。用classpath*:而不是classpath:,前者能扫到多个 jar 里的同名目录。
坑二:接口返回的时间差 8 小时。现象是数据库里查出来是2024-06-01 10:00:00,接口返回变成2024-06-01 02:00:00。原因就是 2.3 节说的,连接串的serverTimezone和 Jackson 的time-zone只配了一个。解决是把两处都设成Asia/Shanghai,并且确认实体类的时间字段用的是LocalDateTime而不是java.util.Date——Date在不同序列化配置下表现不一致,LocalDateTime更可控。
坑三:列表分页的总条数不对。现象是每页 10 条,实际 25 条数据,总条数却显示 40。原因是运单主表关联了货物明细表做LEFT JOIN,一条运单对应三条货物,COUNT(*)把明细也数进去了。解决是把 count 语句改成COUNT(DISTINCT o.id),或者干脆让分页插件自动生成的 count 走主表单独查。这个问题在带明细表的运输管理系统里几乎是必现的,看到总数偏大先怀疑 join。
坑四:备注里粘一个表情符号就报错。现象是保存运单时抛Incorrect string value: '\xF0\x9F...'。原因是数据库或表的字符集是utf8(三字节),存不下四字节字符。解决是把库、表、字段、连接串四处全改成utf8mb4,连接串里加characterEncoding=utf8就够(MySQL 驱动会自动协商到 utf8mb4),但字段的COLLATE要单独确认,历史表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4转一遍。
坑五:结算金额总有几分钱误差。现象是同一批单子,系统算出来的总额和财务用 Excel 算的差几毛。原因通常是三选一:实体类金额字段用了Double、divide没指定舍入模式、或者中间结果提前舍入。解决办法是把所有金额字段统一成BigDecimal+decimal(12,2),所有除法显式写setScale和RoundingMode,中间计算保留 6 位最后再舍。改完之后拿历史数据跑一遍对账 SQL,差额应该能归零。
5.2 改动前的自检表
在动第一行业务代码之前,把下面这张表过一遍,能避免大部分「改完发现方向错了」的情况。
| 检查项 | 怎么查 | 不合格的后果 |
|---|---|---|
| 金额字段类型 | SHOW COLUMNS看 amount 相关字段 | 用double必须全量改,否则对账永远差钱 |
| 状态流转是否集中 | 全局搜setStatus的调用点 | 散在各处,加状态必漏改 |
| 时间字段类型 | SHOW COLUMNS看 time 相关字段 | bigint和datetime的 SQL 写法不通用 |
| 是否有逻辑删除 | 看表里有没有del_flag/is_deleted | 漏加条件会把已删数据查出来 |
| 权限是否含数据范围 | 看sys_role有无data_scope | 多分公司场景直接串数据 |
| 是否有操作日志表 | 看sys_oper_log之类 | 出问题查不到是谁改的 |
6. 改完之后怎么验:运单状态机与结算的三层回归
改完状态机和计费逻辑,最怕的是「点几下看着正常」就上线。运输管理系统的错误往往藏在大批量数据里,单条测试根本跑不出来。我一般分三层验:状态机穷举、批量造数、对账回归。
第一层,状态机穷举。把枚举里所有状态两两组合,跑一遍canTransferTo,输出一张矩阵表,人工扫一眼有没有不该通的边。这段代码可以直接写成一个 JUnit 用例,几十行,跑一次几百毫秒:
@Test void printStatusMatrix() { // 打印全部状态两两之间的可达性,人工核对是否符合业务预期 for (OrderStatus from : OrderStatus.values()) { StringBuilder line = new StringBuilder(String.format("%-12s", from.name())); for (OrderStatus to : OrderStatus.values()) { line.append(from.canTransferTo(to) ? " 1" : " 0"); } System.out.println(line); } }输出是一张 N×N 的 0/1 矩阵,行是当前状态、列是目标状态。重点看三处:SETTLED那一行是不是全 0(终态不可再流转)、CANCELLED那一列是不是只有创建和调度阶段能进(运输途中不允许直接取消,得走异常流程)、以及每个状态能不能到自己(应该是 0,否则会出现原地重复提交)。
第二层,批量造数。手工点十单说明不了问题,用脚本灌一千单才能暴露并发和边界。我习惯直接写 SQL 造数,比调接口快得多:
-- 造 500 条随机状态的运单,用于验证列表分页、状态筛选和结算批次 INSERT INTO tms_order (order_no, company_id, vehicle_id, status, freight_amount, plan_start_time, plan_end_time, create_time) SELECT CONCAT('TEST', LPAD(n, 6, '0')), FLOOR(1 + RAND() * 3), FLOOR(1 + RAND() * 20), ELT(FLOOR(1 + RAND() * 6), 10, 20, 30, 40, 50, 90), ROUND(100 + RAND() * 4900, 2), DATE_ADD('2024-06-01 00:00:00', INTERVAL FLOOR(RAND() * 720) HOUR), DATE_ADD('2024-06-01 04:00:00', INTERVAL FLOOR(RAND() * 720) HOUR), NOW() FROM (SELECT @row := @row + 1 AS n FROM information_schema.columns, (SELECT @row := 0) t LIMIT 500) tmp;ELT按序号从后面的列表里取值,用来模拟真实的状态分布;plan_start_time和plan_end_time分别用不同的偏移量生成,避免出现结束早于开始的脏数据。灌完之后重点验三件事:列表分页翻到最后一页不报错、按状态筛选的条数和GROUP BY的结果对得上、结算批次跑完没有漏单(用 4.3 节第二条 SQL 查空)。
第三层,对账回归。拿一批历史真实数据(脱敏后)导进去,跑一遍计费,和财务手工算的结果比对。差异率超过千分之一就说明规则还有没覆盖到的情况,通常是某个区域的加价规则优先级配错了。这一步不能省,前面两层验的是代码逻辑,这一层验的是规则配置。
从那以后我每次改这类运输管理系统的状态或计费,都强制走一遍这三层,哪怕只改了一个if条件——因为出问题的从来不是改的那一行,是被那一行影响到的另外九十九行。希望帮到你。
本文还有配套的精品资源,点击获取