业务口径打包
体积设箱代码审查
体积设箱试点(822547a2eb)代码审查结论:2 处必须修的空指针、3 处需与产品确认的口径风险、3 处一般问题
Markdown 原文(给 AI 用):https://doc.xfun.fun/volume-pack-review.md
结论
审查对象:origin/LTM/admin 分支提交 822547a2eb「feat(管理端):体积设箱试点」(与 bulky-test/admin 上的 407bf66261 改动一致),共 22 个文件、+1462 / -49 行。
| 级别 | 数量 | 说明 |
|---|---|---|
| 必须修 | 2 | 已用临时单测复现空指针,影响范围不限于试点大区 |
| 需确认 | 3 | 口径风险,需要和产品对齐后决定改不改 |
| 一般 | 3 | 加固、权限、DDL 核对 |
验证情况:mvn -pl xm-bulky-mall-admin/app -am 编译通过;提交自带的 PackVolumeUtilTest、PackSuggestUtilVolumeTest、PackSuggestUtilTest、TaskServiceImplVolumeReplanTest、TaskServiceImplReplanTest 共 38 个用例全部通过。「必须修」两条另写了复现用例,两条都抛 NullPointerException,复现代码见「复现用例」。
必须修
晚场重算:已出库面单无推荐箱型时空指针
位置:TaskServiceImpl.shippedCapacity(本次新抽出的方法)
BigDecimal capacity = capacityMap.get(shippedRow.getPackageId());
if (Objects.isNull(capacity)) {
capacity = capacityMap.get(shippedRow.getSuggestPackId().longValue()); // suggestPackId 为 null 时 NPE
}原因:抽方法前的代码有 shippedRow.getSuggestPackId() != null 判断,抽出来时丢了,属于回归。按重量重算的分支也改为调用这个方法,所以未开试点的大区同样受影响。
触发条件:同一组里有已出库(expressStatus >= 20)的普通面单,且同时满足:
- 实际箱型
packageId查不到容量(箱型已删除,或是框子 / 其他这类flower_weight = 0的箱型); suggest_pack_id为空(未配置可推荐箱型时按 60kg 兜底生成的面单、历史面单等)。
后果:外层 try/catch 只打一条 error 日志,这一组的晚场重算整体失败:晚场新增的货不追加面单,未出库面单的推荐箱型也不改写。现场表现为晚场加单没有面单可贴。
修复建议:补回空判断即可。
if (Objects.isNull(capacity) && Objects.nonNull(shippedRow.getSuggestPackId())) {
capacity = capacityMap.get(shippedRow.getSuggestPackId().longValue());
}计算花材体积:订单行字段为空时空指针,影响全部发货单
位置:PackVolumeUtil.calcPackVolume
item.getProductName()::contains、item.getItemNum() <= 0、BigDecimal.valueOf(item.getTotalWeight()) 都直接使用字段值。同口径的 OrderUtil.calcSpecialFlowerWeight 对 productName、totalWeight 都做了判空,PrintOrderLogic 里也单独判过 productName 为空,说明线上出现过这类数据。
影响范围比看起来大:只要 xm_bulky_common 里 type=88 的配置行存在(哪怕开关是关的),PilotContext.isComputeVolume() 就为 true,OrderServiceImpl.handleAddSendOrderListDetail 会对所有发货单计算体积,不只是试点货区。一行明细字段为空,整个发货单列表查询就会抛异常,所有大区都无法生成面单。
同类问题:PackVolumePilotLogic.parseConfig 解析配置 JSON 失败时 JacksonUtil.parse 会抛 BusinessException;手工改库写出 "zones": null 时,PilotContext 构造里的 new HashSet<>(zones) 也会空指针。两种情况的后果同上。
修复建议:
calcPackVolume对productName、itemNum、totalWeight判空,口径与calcSpecialFlowerWeight保持一致;loadContext解析失败或字段缺失时打 error 日志,然后按PilotContext.disabled()降级,不要影响按重量的主流程。
需与产品确认
面单张数与物流运单件数可能不一致
位置:PrintOrderLogic 打印运单、PackVolumePilotLogic.calcPackVolume
打印运单时会按当时的规格体积(xm_bulky_class_second_rank_sku.volume)和折算系数重新计算件数。这次同时上线了批量改体积接口 /rankSku/batch/setVolume 和 Excel 导入 /rankSku/batch/importVolume,注释写的是「改完对之后新生成的面单生效」,但打印侧并不使用已生成面单的结果。
例子:白天按体积生成 3 张面单后,数据组导入了新的规格体积,打印时重新算出 2 件,物流运单件数是 2,仓库手上却有 3 张面单。
建议(二选一):
- 发货单已有按体积生成的面单时,打印直接用有效普通面单张数加特殊花材张数,不再重算;
- 或者约定只在场次结束后修改规格体积和折算系数。
按体积推荐完全不看重量
位置:PackSuggestUtil.splitByVolume、PrintOrderLogic 中 volumeMode 的单件重量
按体积推荐「只比体积,不看重量」,这是有意的设计,但会带来两个后果:
- 体积小、分量重的花材可能被装进一个箱子,单箱重量远超原来 60kg 的拆箱上限;
- 打印时单件重量 = 总重 ÷ 件数,可能超过德邦等物流的单件限重,导致下单失败或被加收超重费。
例:某发货单普通花材 120kg,规格体积合计 400000 cm³
体积口径:最大箱 459000 cm³ 装得下 → 1 件,单件 120kg
重量口径:60kg 一件 → 2 件建议:确认是否加兜底规则:按体积拆出的箱数少于 ceil(普通花材重量 ÷ 最大箱重量容量) 时,取两者中较大的箱数。
城市仓普通组按第一张发货单的货区判定
位置:TaskServiceImpl.buildWareGroupPackageContext
context.volumeMode = pilotContext.volumeMode(
PackVolumeUtil.packZone(oneWareSendOrder.getShelfSn(), oneWareSendOrder.getZone()), ...);注释写的是「同组同一货位」,但 groupSameWareExpressPackage 的普通组(separateNormalWareSendList)收的是同一城市仓下所有带子货位、非繁花的发货单,不一定是同一个货位。如果组内发货单分布在试点和非试点货区,或者第一张恰好未分货位(shelfSn = 0,packZone 返回 null),整组走体积还是走重量取决于列表顺序。
需确认:普通城市仓的发货单是否一定共用同一个货位。是的话只需要改注释;不是的话要改成明确的规则(比如组内任意一张在试点货区就走体积)。
一般问题
试点配置保存接口无权限点
POST /system/save/packVolumePilot 只加了不带权限点的 @AuthToken,任何能登录运营后台的账号都能开关试点、改折算系数。写法和现有箱型配置接口一致,但这个开关直接影响面单生成和运单件数,建议加专门的权限点。
核对 suggest_pack_id 是否允许为空
仓库里没有找到 xm_bulky_wms_express_package.suggest_pack_id 的 DDL。代码里多处判断 getSuggestPackId() != null,说明它可能为空。建议在 DMS 上执行 SHOW CREATE TABLE xm_bulky_wms_express_package 确认,这决定了「晚场重算:已出库面单无推荐箱型时空指针」在线上的触发频率。
DDL 执行确认
docs/sql/2026-11-02-pack-volume-pilot.sql 第 1 步给约 360 万行、按年分区的面单表加 suggest_mode 列,MySQL 5.6 下需要重建表。文件里写了「正式库 2026-11-04 核对:1、2 已执行」,建议找 DBA 确认已执行完、没有遗留锁或影子表。第 4 步停用「小件70*20」箱型的 SQL 仍是注释状态,等业务确认后再执行。
已确认没问题
| 项 | 结论 |
|---|---|
| 重量单位换算 | totalWeight 是克,÷1000 后乘 cm³/kg,正确 |
| 剔除口径 | 虚拟商品、特殊花材的剔除与 calcSpecialFlowerWeight 一致 |
| 箱型体积字段 | flower_volume 为 int unsigned NOT NULL DEFAULT 0,不会空指针 |
| 排除场景 | 中通、自提、补单都不走体积 |
| 前后口径一致 | 晚场重算、打印运单都沿用面单记录的 suggest_mode,配置改动只影响新生成的面单 |
| 历史面单 | suggest_mode = 0 按重量处理,不会被批量改写 |
| 批量改体积 | 有 oldVolume 并发校验、dryRun 试运行,可通过对调新旧值回滚 |
复现用例
放在 xm-bulky-mall-admin/app/src/test/java/com/xunmeng/xmbulkymalladmin/service/impl/ 下,执行 mvn -pl xm-bulky-mall-admin/app -am test -Dtest='ReviewRepro*' -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false。修复前两个用例都失败(抛 NullPointerException),修复后应全部通过,可以直接改名后留作回归用例。
package com.xunmeng.xmbulkymalladmin.service.impl;
import com.xunmeng.xmbulkymalladmin.domain.dbBulky.XmBulkyOrderItem;
import com.xunmeng.xmbulkymalladmin.domain.dbBulky.XmBulkyWmsExpressPackage;
import com.xunmeng.xmbulkymalladmin.utils.PackVolumeUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import static com.xunmeng.xmbulkymalladmin.utils.PackConfigTestFixtures.box;
class ReviewRepro_ShippedNullSuggestTest {
@Test
void weightReplanShippedRowWithoutSuggest() {
XmBulkyWmsExpressPackage shipped = new XmBulkyWmsExpressPackage();
shipped.setId(1L);
shipped.setSerialNum(1);
shipped.setExpressStatus(20);
shipped.setDataStatus(0);
shipped.setPackageId(999L); // 实际箱型不在配置里
shipped.setSuggestPackId(null); // 无推荐箱型
Assertions.assertDoesNotThrow(() -> TaskServiceImpl.replanWareGroupSuggests(
Collections.singletonList(shipped), new BigDecimal("50"), BigDecimal.ZERO, BigDecimal.ZERO,
false, false, Collections.emptyList(), Collections.emptyList(),
Arrays.asList(box(1, "A", "10", 100, 1), box(2, "B", "20", 200, 2)), null));
}
@Test
void calcVolumeNullProductName() {
XmBulkyOrderItem item = new XmBulkyOrderItem();
item.setProductName(null);
item.setItemNum(1);
item.setProductId(1L);
item.setTotalWeight(1000);
Assertions.assertDoesNotThrow(() -> PackVolumeUtil.calcPackVolume(
Collections.singletonList(item), Collections.emptyMap(), new BigDecimal("6600")));
}
}