# 体积设箱代码审查

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

- 类型：业务口径
- 分类：打包
- 后端服务：admin
- 状态：已上测
- 测试分支：bulky-test/admin
- 更新：2026-11-05
- 网页版：https://doc.xfun.fun/volume-pack-review

## 结论

审查对象：`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`）的普通面单，且同时满足：

- 实际箱型 `packageId` 查不到容量（箱型已删除，或是框子 / 其他这类 `flower_weight = 0` 的箱型）；
- `suggest_pack_id` 为空（未配置可推荐箱型时按 60kg 兜底生成的面单、历史面单等）。

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

**修复建议**：

- `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 的拆箱上限；
- 打印时单件重量 = 总重 ÷ 件数，可能超过德邦等物流的单件限重，导致下单失败或被加收超重费。

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