业务口径打包

体积设箱代码审查

体积设箱试点(822547a2eb)代码审查结论:2 处必须修的空指针、3 处需与产品确认的口径风险、3 处一般问题

后端服务
admin
状态
已上测
测试分支
bulky-test/admin
更新
2026-11-05

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(本次新抽出的方法)

java
BigDecimal capacity = capacityMap.get(shippedRow.getPackageId());
if (Objects.isNull(capacity)) {
    capacity = capacityMap.get(shippedRow.getSuggestPackId().longValue()); // suggestPackId 为 null 时 NPE
}

原因:抽方法前的代码有 shippedRow.getSuggestPackId() != null 判断,抽出来时丢了,属于回归。按重量重算的分支也改为调用这个方法,所以未开试点的大区同样受影响。

触发条件:同一组里有已出库(expressStatus >= 20)的普通面单,且同时满足:

后果:外层 try/catch 只打一条 error 日志,这一组的晚场重算整体失败:晚场新增的货不追加面单,未出库面单的推荐箱型也不改写。现场表现为晚场加单没有面单可贴。

修复建议:补回空判断即可。

java
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) 也会空指针。两种情况的后果同上。

修复建议:

需与产品确认

面单张数与物流运单件数可能不一致

位置: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 的单件重量

按体积推荐「只比体积,不看重量」,这是有意的设计,但会带来两个后果:

text
例:某发货单普通花材 120kg,规格体积合计 400000 cm³
体积口径:最大箱 459000 cm³ 装得下 → 1 件,单件 120kg
重量口径:60kg 一件 → 2 件

建议:确认是否加兜底规则:按体积拆出的箱数少于 ceil(普通花材重量 ÷ 最大箱重量容量) 时,取两者中较大的箱数。

城市仓普通组按第一张发货单的货区判定

位置:TaskServiceImpl.buildWareGroupPackageContext

java
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),修复后应全部通过,可以直接改名后留作回归用例。

java
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")));
    }
}