先说结论
Odoo 的库存搬移,不是把 stock.quant 的 location_id 直接改掉。
更准确地说,它在处理的是:
现有库存状态要如何在不破坏包裹结构、盘点语义和目标约束的前提下,被安全地搬去另一个位置。
所以搬移动作关注的不只是“从哪到哪”,还包括:
- 这批 quant 是不是属于完整包裹
- 目标是新库位、已有包裹,还是两者都有
- 是否存在盘点中的数量状态需要先清理
- 多来源库位时,目标该如何限制
这也是为什么官方会单独做一个 stock.quant.relocate wizard,而不是开放一个可随便编辑 quant 位置的表单。
为什么“直接改库位”听起来简单,实际却很危险
因为 quant 从来不只是一个“库存数量格子”。
它还可能同时背着这些维度:
- 产品
- lot / serial
- owner
- package
- company
- 当前盘点数量状态
如果你粗暴把 quant 的位置改掉,很容易出现两类问题:
1)破坏包裹一致性
一个包裹里的 quants 被你搬走一部分,但系统却还把它当成一个整体包裹。
2)破坏盘点语义
有些 quant 可能带着库存盘点上下文,如果你没先清干净,就会把“待确认的盘点事实”和“已发生的搬移动作”混在一起。
所以 Odoo 在搬移时,重点不是“能不能改位置”,而是:
如何让搬移后的库存状态仍然自洽。
源码抓手:addons/stock/wizard/stock_quant_relocate.py
这个 wizard 的关键点主要在:
_compute_is_partial_package_compute_is_multi_location_compute_dest_package_id_domain_compute_dest_package_idaction_relocate_quants
从这些方法就能看出,官方把库存搬移当成一个“带结构约束的库存调整动作”。
第一步:系统先判断你是不是在搬“半包”
_compute_is_partial_package 会看当前 quant_ids 里涉及到的 package_id,并检查:
- 某个包裹里的所有 quant,是不是都包含在本次搬移集合里
如果不是,就把它标记为:
is_partial_package = True
这非常关键。
因为在仓库现实里,“搬一个完整包裹”和“从包裹里拆一部分出来搬走”完全不是同一件事。
前者的语义是:
- 包裹整体转移
后者的语义是:
- 先拆包
- 再把其中一部分库存迁移出去
如果系统不先识别这个区别,就很容易把整包结构弄乱。
第二步:多来源库位时,wizard 不会假装目标包裹还能随便选
_compute_is_multi_location 会判断当前 quants 是否来自多个 location_id。
如果是,而且你还没指定 dest_location_id,就会标记 is_multi_location = True。
这背后的意思很实用:
当来源不止一个库位时,系统不能再假装“目标包裹域”天然明确。
因为包裹往往带着位置上下文。
如果你连目标库位都没定,系统就很难知道:
- 哪些目标包裹是合法候选
- 它们该属于哪个位置语义
所以 Odoo 宁可先把这层复杂性暴露出来,也不愿意给你一个看似方便、实则模糊的选择器。
第三步:目标包裹不是随便列,而是按公司 + 位置域收紧
_compute_dest_package_id_domain 很值得看。
它给目标包裹构造了一个 domain,大致包含:
- 公司要匹配当前 quant 所属公司,或者是无公司共享包裹;
- 如果选了
dest_location_id,目标包裹要么无固定位置,要么就在该位置; - 如果没选目标库位,但来源 quant 只来自单一位置,则目标包裹也要么无位置、要么就在该来源位置。
这说明 Odoo 对“包裹目的地”的理解不是纯粹容器选择,而是:
一个包裹是否是合法目标,要看它和位置语义是否兼容。
很多项目里之所以会出现“库存进了包,但包和库位关系奇怪”的脏数据,就是因为这层限制没守住。
第四步:如果原来选过的目标包裹不再合法,系统会自动清掉
_compute_dest_package_id 会检查当前选中的 dest_package_id 是否还满足最新 domain。
如果不满足,就清空。
这看似是小细节,其实非常重要。
因为搬移向导里经常会发生这种情况:
- 你先选了一个目标包裹
- 后来又改了目标库位
- 或者 quant 集合发生变化
- 于是原包裹其实已经不合法了
如果系统不自动清掉,就会把一个旧选择带进新语境,最后产生很难解释的数据。
第五步:真正执行搬移前,先 action_clear_inventory_quantity()
这是整篇文章里最值得注意的步骤之一。
action_relocate_quants 一上来就先做:
self.quant_ids.action_clear_inventory_quantity()
这说明 Odoo 非常在意:
搬移的是当前库存状态,而不是尚未清理干净的盘点中间态。
换句话说,如果 quant 上还带着 inventory quantity 相关状态,系统会先把这层盘点痕迹清掉,再去做位置迁移。
这避免了两种语义混淆:
- “我在盘点差异”
- “我在搬库存”
项目里如果有人定制 quant 搬移,完全跳过这一步,后面盘点异常通常很难查。
partial package 时为什么可能要先 unpack
如果本次是 is_partial_package,而且你又没有指定 dest_package_id,源码会先找出那些“包裹未完整入选”的 quants,然后:
move_quants(..., unpack=True)
这一步的业务语义很明确:
- 如果你不是把它们搬进另一个包裹
- 又只拿走原包裹中的一部分
- 那系统就必须先承认“原包裹结构被拆开了”
所以先 unpack,再搬。
这就是为什么 partial package 不是小问题,而是结构性问题。
它不只是数量少一部分,而是:
包裹作为操作单元的完整性已经不成立。
最后才是真正的 move_quants
把前面的限制、清理、拆包都做完后,wizard 才会调用:
move_quants(location_dest_id=..., package_dest_id=..., message=...)
这里的 message 其实也很实用。
它意味着搬移不是静默改数据,而是允许把“为什么搬”留下痕迹。
对库存治理来说,这很重要。因为很多仓库问题最后不是不知道搬了,而是不知道为什么搬。
为什么搬完后返回页面还会区分 lot / 单产品
源码最后会根据上下文决定跳去:
- lot 的 quant 视图
- 单产品 quant 视图
- 或通用 quant 列表
这说明 Odoo 认为库存搬移不是纯后台动作,而是一个需要继续复核的操作。
也就是说,搬移完成之后,系统默认你还要看:
- lot 视角是否合理
- 单产品库存切片是否合理
- 全局 quant 结果是否合理
这其实也是在提醒我们:
quant relocation 更像“受控库存重定位”,不是一笔无痕字段编辑。
实战里最容易踩的 5 个坑
1)把 quant relocation 当成位置字段编辑
这样很容易破坏 package、lot、owner 等边界。
2)忽视 partial package
半包搬移如果不先拆结构,后续 package 语义会乱。
3)目标包裹不校验位置合法性
最终经常会得到“包裹在 A 位,内容却像在 B 位”的脏状态。
4)跳过 inventory quantity 清理
盘点中的中间状态和搬移动作混在一起,很难收拾。
5)只关心数量搬过去了,没有留下原因
库存治理不只是结果对,还要可追溯。
小结
看完官方源码后,最实用的一句话总结其实是:
Odoo 的 quant relocation,不是在改 quant 的位置字段,而是在维护库存状态、包裹结构和盘点语义的一致性。
所以你如果在项目里做库存搬移相关开发,千万别把它想轻了。
一旦忽略:
- package 完整性
- 目标包裹合法性
- inventory quantity 清理
- 多来源库位边界
最后问题通常不会马上爆,而是会在盘点、追溯、整包操作时一起爆出来。
DISCUSSION
评论区