先说结论
在 Odoo 里,package 的当前库位并不足以表达 package 的业务历史。
这就是为什么源码里单独存在 stock.package.history。
它要解决的问题不是:
- 包裹现在在哪。
而是:
- 这个包从哪来;
- 它中途进过哪些父容器;
- 它这次搬运关联了哪些 move line;
- 它属于哪张调拨;
- 它的目标外层容器链是什么。
所以这套模型真正表达的是:
包裹不是一个静态位置对象,而是一条可追溯的容器演化链。
为什么“当前库位”不足以描述 package
如果仓库只做最简单的装箱发货,当前库位好像已经够看了。
但一旦出现这些场景:
- 小箱装进大箱;
- 大箱放到托盘;
- 托盘整包转移;
- 部分包裹被重新拆出再装;
- 一次调拨中多层容器共同移动。
你就会发现:
- 当前库位只能告诉你“它现在停在哪”;
- 却无法告诉你“它是怎么变成现在这样的”。
而在追溯、售后、异常复盘里,后者往往比前者更重要。
stock.package.history 这个模型到底记什么
从 addons/stock/models/stock_package_history.py 看,这个模型核心字段包括:
location_id/location_dest_idmove_line_idspackage_idpackage_namepackage_type_idparent_orig_id/parent_orig_nameparent_dest_id/parent_dest_nameoutermost_dest_idpicking_ids
请注意这组字段的味道:
- 它不是在复制
stock.package的全部状态; - 它在保存一次包裹形态和流转关系的快照。
尤其是:
- 来源父容器;
- 目标父容器;
- 最外层目标容器;
- 关联调拨;
- 参与的 move lines。
这些都说明它关心的是 lineage,而不是单一时点属性。
历史是谁写进去的:_prepare_package_history_vals()
关键抓手在 stock_move_line.py 的 _prepare_package_history_vals()。
这个方法会:
- 从
result_package_id出发; - 取出
_get_all_package_dest_ids()涉及的整条目标包裹链; - 对链上的每一个 package 生成 history vals;
- 把与该 package 对应的 move lines、pickings、父包裹、外层包裹一并写进去。
这有两个非常重要的含义。
含义一:history 不是从“当前 package 单体”推导,而是从“本次结果容器链”生成
也就是说,Odoo 认为包裹历史的形成时刻,是:
- 一次 move line 处理把货放进某个结果包裹时。
不是你回头打开 package 详情页时现算。
含义二:它天然支持“包中包”的层级记录
因为它不是只记 result_package_id 这一个包。
它会把这条目的容器链一起展开。
这就是为什么 outermost_dest_id 会出现。
result_package_id 为什么在这里这么关键
很多人把 result_package_id 只理解为“这行货最后进了哪个包”。
这话没错,但还不够。
在 package history 语义里,它更像是:
触发容器轨迹落账的锚点。
因为有了 result_package_id,系统才知道:
- 这批货现在属于哪个结果容器;
- 该容器有没有父包;
- 父包上面还有没有更外层包;
- 哪些 move line 应该被归到这个历史片段里。
所以 package history 的成立,和 result_package_id 关系非常深。
为什么 history 里同时存“来源父容器”和“目标父容器”
字段里既有:
parent_orig_idparent_dest_id
这不是重复。
它在表达两件不同的事情:
- 原来这个包属于哪个上层容器;
- 处理之后它被塞进了哪个上层容器。
如果没有这对字段,你只能知道“这个包现在在托盘 B 里”,却不知道:
- 它是不是原来在托盘 A;
- 它是不是刚从散包状态被装进 B;
- 它是不是经历了一次重新套箱。
所以这对字段在售后排查、错装复盘、包裹重组分析里特别关键。
_get_complete_dest_name_except_outermost() 暗示了显示层的真实需求
stock.package_history.py 里有个方法:
_get_complete_dest_name_except_outermost()
逻辑很简单,但信息量很大:
- 如果没有目标父容器,返回空;
- 如果目标父容器就是最外层包,返回当前 package 名;
- 否则返回
complete_name去掉最外层之后的内部路径。
这说明 Odoo 在展示包裹链时,不只是想告诉你“属于哪个最外层包”,还想告诉你:
- 在最外层包之内,这个包的内部嵌套路径是什么。
也就是说,UI 关心的不是单点,而是层级路径。
这和普通 location 树显示完全不一样。
为什么 history 里要存 picking_ids
按直觉,你可能会说:
- 既然已经有 move line 了,调拨不是能反查出来吗?
理论上是,但直接把 picking_ids 落到 history 里,能让这张历史表更像一份“追溯索引”。
它意味着你可以很快回答:
- 这只包跟哪些 transfer 有关;
- 某张 picking 下改动过哪些包;
- 这次异常包裹关系应该回看哪几张单。
这和纯粹依赖关系链回溯相比,查询和理解都会轻很多。
action_show_package() 的设计也说明:history 不是替代 package
action_show_package() 最后还是把你带回 stock.package 的 form view。
这说明 Odoo 的设计边界很清楚:
stock.package是对象本体;stock.package.history是对象在流转中的历史片段。
history 不负责替代当前包对象。 它负责把“当前对象是怎么走到这一步的”补全。
实施里最容易踩的几个误区
1. 只看 package 当前库位做追溯
这会漏掉:
- 父子容器变化;
- 同一包关联多次调拨;
- 中途重组或换外箱。
2. 把 package history 理解成日志表
它不是无脑流水日志。 它更像是围绕结果包裹链构造出来的业务快照。
3. 认为 package 追溯只和 package 自己有关
不对。 它和:
result_package_idmove_line_idspicking_ids- 父包裹链
是一起工作的。
调试包裹轨迹时建议怎么查
如果你遇到“这个包怎么会跑到这里”之类问题,建议顺序是:
- 先看相关 move line 的
result_package_id; - 再看
_prepare_package_history_vals()逻辑能不能解释当前层级; - 再看 history 里的
parent_orig_id/parent_dest_id是否发生了换套; - 最后再结合
picking_ids回到具体调拨单。
这样排查会比只盯 package 当前页高效很多。
一句话总结
Odoo 的 stock.package.history 不是为了告诉你“包裹现在放在哪里”,而是为了保存:
- 这只包与哪些 move line / picking 相连;
- 它从哪个父容器来;
- 又进入了哪个父容器;
- 最终落在怎样的外层包裹链里。
最准确的理解是:
package 是当前对象,package history 是对象的容器谱系。
只有把这两层分开,你才真的读得懂 Odoo 的包裹追溯模型。
DISCUSSION
评论区