# 推荐箱型代码导读

> 现有按重量推荐箱型的代码入口、四处调用点、晚场重算与落库字段，附接手体积设箱（XYLZ-3678）要注意的坑

- 类型：业务口径
- 分类：仓库配货 / 打包
- 后端服务：admin
- 需求单：XYLZ-3678
- 更新：2026-11-02
- 网页版：https://doc.xfun.fun/box-suggest-code-guide

## 结论

推荐箱型目前**只按重量**算，核心算法集中在一个工具类 `PackSuggestUtil`。面单生成、城市仓合并面单、晚场重算、打单报给物流的件数，这四处都调用同一个 `computePlan`，所以面单张数和运单件数能对上。改算法（比如体积设箱）时，四处必须一起改。

业务口径（给产品 / 测试看的版本）见 [推荐箱型 / 设箱校验](/box-suggest-logic)。本篇只讲代码在哪、怎么串起来。

> [!NOTE]
> 本篇路径默认以 `xm-bulky-mall-admin/app/src/main/java/com/xunmeng/xmbulkymalladmin/` 为根，行号对应 2026-11-02 的 master（`b94751a387`），后续提交可能有偏移，按方法名搜索即可。

## 核心算法 PackSuggestUtil

文件：`utils/PackSuggestUtil.java`

| 方法 | 行号 | 作用 |
|----|----|----|
| `computePlan(totalWeight, specialWeight, ztoExpress, boxList)` | 49 | **唯一入口**。普通箱可装重量 = 总重 − 特殊花材重量，再按物流选算法；返回 `PackPlan`（推荐箱型列表、件数、是否兜底） |
| `splitByBox` | 71 | 非中通：先按最大箱容量整除放满，余量选能装下的最小箱 |
| `splitByBoxZto` | 108 | 中通：每件按 min(45kg, 剩余重量) 选能装下的最小箱，每轮固定扣 45kg，件数恒为 ceil(总重 / 45)；箱容量只用来选箱型 |
| `matchSpecialFlowerBox` | 136 | 特殊花材专用箱按箱名匹配（先全等，再包含），不要求配容量 |
| `specialFlowerBoxIds` | 142 | 当前配置下专用箱的 id 集合，晚场重算用它区分专用箱面单 |
| `availableBoxes` | 180 | 参与推荐的箱子：`flower_weight > 0`，按名称排除专用箱，按 `flower_weight` 从小到大 |

兜底：一个可用箱子都没有时，中通按 45kg、其他按 60kg 计件，不给推荐箱型，`PackPlan.fallback = true`，调用方打 warn 日志。

```text
例：非中通，可用箱容量 15 / 21 / 28.5 kg，可装重量 70kg
  整除：70 ÷ 28.5 = 2 箱，余 13kg
  余量：能装下 13kg 的最小箱 = 15kg 箱
  结果：28.5 + 28.5 + 15，共 3 张面单
```

### 特殊花材

- 定义在 `enums/OrderEnum.java`：`SPECIAL_FLOWER_BOX_NAME_MAP`（132 行）商品关键词 → 专用箱名，`NEED_EXTRA_WMS_PACKAGE_PRODUCT_NAME` 是它的 key 集合。
- 映射：蝴蝶兰 / 红掌 / 大花蕙兰 → 蝴蝶兰箱，蓝色妖姬 → 蓝妖箱。商品名**包含**关键词即算。
- 业务前提：特殊花材到仓时已装箱，只贴面单，所以重量不占普通箱额度，每种专用箱固定追加 1 张面单（`OrderEnum.distinctSpecialFlowerBoxKeywords` 按专用箱去重，映射到同一箱的只算 1 张）。
- 特殊花材重量：`utils/OrderUtil.calcSpecialFlowerWeight`（71 行），按订单行 `total_weight` 求和。

## 四处调用点

| 场景 | 位置 | 说明 |
|----|----|----|
| 发货单面单件数与推荐箱型 | `service/impl/OrderServiceImpl.java` 1335 行，方法 `handleAddSendOrderListDetail` | 结果写进 `SendOrderDto.expressPackageNum` / `suggestPackList`。特殊花材重量和种类由同文件 `addExtraExpressPackageNum`（1425 行）统计。**这个方法后台发货单列表、导出也在调** |
| 昆明→店（type=1）、仓→店（type=3）面单落库 | `service/impl/TaskServiceImpl.java` 2645 行 `generateXmWmsExpressPackage` | 不重新算，直接用发货单 DTO 上的结果；每个推荐箱型一行面单，专用箱面单排在普通面单后面（`pickSuggest`，2997 行） |
| 城市仓合并面单（type=2） | `TaskServiceImpl.java` 2838 行 `generateWareWmsExpressPackage` | 先 `groupSameWareExpressPackage`（3182 行）分组：繁花按货位、大客户按独立货位、其余普通单合一组；组内重量求和后调 `computePlan` |
| 打印运单报给物流的件数 | `logic/PrintOrderLogic.java` 479 行 → `utils/LogisticsUtil.java` 191 行 `getOrderNumByWeightAndConfigId` | 内部同样调 `computePlan`，再按专用箱种类加件数；补单（`isRepair = 1`）直接用请求传入的件数 |

中通判断有两种写法，口径一致：面单侧按物流名称包含「中通」，打单侧按 `configId` 是否为 `ZT_KY` / `ZT_LL` / `ZT_SMALL`。

## 定时任务与晚场重算

| 任务 | 入口 | 查哪些发货单 |
|----|----|----|
| 白场生成 | `task/order/GenerateWmsExpressPackageJob`（SchedulerX）→ `TaskServiceImpl.generateWmsExpressPackage`（2569 行） | 当轮、创建时间早于 18:05 |
| 晚场生成 / 重算 | `task/order/GenerateWmsExpressPackageNightJob` → `generateWmsExpressPackageNight`（2584 行） | 当轮全部 |

`MyTask` 里旧的 `@Scheduled` 已注释，两个任务都走 SchedulerX。两者最终进入 `generateWmsExpressPackageBySendOrderList`（2600 行），按 `wareId` 和城市仓类型拆成 type=1 / 3 / 2 三路。

晚场时已存在面单的单子会重算：

- type=1 / 3：`replanSendOrderSuggests`（2737 行），按发货单（同一货架当天同一个 sendNo）重算。
- type=2：只有存在 18:05 之后创建的发货单才重算，走 `renightWareGroupSuggests`。
- 两条路径都进 `replanWareGroupSuggests`（3018 行，static，有单测），结果由 `applyReplanResult` 落库。

`replanWareGroupSuggests` 的规则：

1. 已出库（`expressStatus ≥ 20`）的普通面单不动，从总重里扣掉它们的箱容量：优先实际箱型 `package_id`，没有再用推荐箱型 `suggest_pack_id`，都取不到按 0 并打 warn。
2. 剩余重量重新 `computePlan`，按 `serialNum` 顺序覆盖未出库普通面单的推荐箱型；件数变多就追加，变少多余的不处理。
3. 特殊花材：白场每种专用箱 1 张，晚场新增发货单再出现的每种再 1 张，与已有专用箱面单比差额补齐。
4. 所有未出库面单同步更新 `total_weight`、`package_num`、`item_num`。

## 落库与下游

| 表 / 字段 | 说明 |
|----|----|
| `xm_bulky_wms_express_package.suggest_pack_id` / `suggest_pack_info` | 推荐箱型 id 和名称，每张面单一行 |
| `xm_bulky_wms_express_package.package_id` / `package_info` | 仓库实际设的箱 |
| `xm_bulky_pack_config.flower_weight` | 箱子可装花材重量（kg），0 不参与推荐 |

供应商端（仓库打包）读 `suggest_pack_id` 做设箱校验：`xm-bulky-mall-supplier/supplier-core/.../logic/PackageLogic.java`（111 行附近判断实际箱与推荐不一致，`calcBoxMatchStatus` 算匹配状态）。

## 单测

`xm-bulky-mall-admin/app/src/test/java/com/xunmeng/xmbulkymalladmin/` 下：

| 测试 | 覆盖 |
|----|----|
| `utils/PackSuggestUtilTest` | 拆箱算法、中通、兜底、专用箱匹配 |
| `service/impl/TaskServiceImplReplanTest` | 晚场重算（已出库扣容量、追加、专用箱补差） |
| `service/impl/OrderServiceImplSpecialFlowerStageTest` | 特殊花材白场 / 晚场拆分 |
| `utils/PackConfigTestFixtures` | 测试用箱型配置 |

## 接手体积设箱（XYLZ-3678）的注意事项

> [!IMPORTANT]
> 需求第 4 条「件数一致」指的就是「四处调用点」那张表里的四处。只改其中一处，面单张数和运单件数会对不上。

1. **已有体积字段和数据**：提交 `499aa5cf43`（2026-09-13，按体积推荐对比功能）已给 `xm_bulky_pack_config` 加 `flower_volume`（cm³），给 `xm_bulky_class_second_rank_sku` 加 `volume`（cm³/扎，当前默认 3000）。DDL 在 `xm-bulky-mall-admin/docs/sql/2026-09-13-package-volume-compare.sql`，里面有各箱体积的初始值。
2. **`utils/PackVolumeSuggestUtil` 不能直接用**：它是验证用副本，只按体积拆、不看承重，算法是「最大箱整除 + 余量」，和需求要求的「重量、体积都满足才算装得下，装不下先放最大箱再循环」不同。
3. **可复用的查询**：`mapper/dbBulky/PackageVolumeCompareMapper.listRankSkuVolume` 按 secondId 批量查等级体积。
4. **实体缺字段**：admin 的 `XmBulkyPackConfig` 实体还没有 `flowerVolume`，要补。
5. **箱型配置有 Redis 缓存**：`XmBulkyPackConfigServiceImpl.findAll()` 缓存 key `PACK_CONFIG`，过期参数 100；上线时旧缓存里没有体积字段，改完箱型配置可调 `delCache()`。
6. **订单行查询要补字段**：`OrderServiceImpl.handleAddSendOrderListDetail` 里查明细的 `select` 只取了部分列，没有 `secondId`、`rankNum`；`PrintOrderLogic` 用的 `getOrderItemsBySendNo` 是 `select c.*`，不用改。
7. **试点判断要的字段都在**：`SendOrderDto` / `XmBulkySendOrder` 上有 `province` 和 `deliveryType`（2 = 自提）；`province_area` 表按省份名直接匹配，`TaskServiceImpl` 刷采购商大区时就是这么用的（`ProvinceArea::getProvince` 做 key）。
8. **城市仓合并面单**：一组发货单的重量是求和得到的（`buildWareGroupPackageContext`），体积也要按同一分组求和。
9. **晚场重算**：`replanWareGroupSuggests` 目前只建了 id → `flower_weight` 的容量表，体积模式要再建一张 id → `flower_volume`，已出库箱同时扣两种容量。
10. **记录推荐方式**：新字段要在生成（`buildXmExpressPackage`、`buildWareExpressPackage`）和重算（`applyReplanResult` 的追加行、未出库行更新）两处都写，专用箱面单记成和同单普通面单相同的值。
