先说结论
在 Odoo 里,取消采购单绝不是“把状态改成 cancel”这么浅。
/home/ubuntu/odoo-temp/addons/purchase_stock/models/purchase_order.py 和 purchase_order_line.py 的逻辑表明,取消 PO 时系统会同时考虑:
- 采购行对应的收货 move 要不要取消
- 下游
move_dest_ids要不要一起取消 - 如果不一起取消,是否应该回退成
make_to_stock - 已完成的收货单怎么办
- 多条采购行共同指向同一 downstream move 时怎么解绑
所以采购取消,本质上是在重写一部分供应链承诺关系。
第一层:为什么系统先拦“锁定单”和已入账单
基础 purchase.order.button_cancel() 就已经有两道硬门槛:
- 锁定的采购单不能直接取消
- 相关 Vendor Bill 不是草稿/取消状态时,也不能直接取消
这说明 Odoo 很清楚:
- 一旦采购单已经被当作稳定业务事实
- 你想撤销,不能只从采购视角出发
先处理账单和锁定状态,目的是防止“采购撤销了,但财务与履约还挂着”。
第二层:purchase_stock 真正复杂的,是下游 move 处理
在 purchase_stock 里,取消 PO 会遍历采购行,并处理两类 move:
move_ids:采购行自己生成的收货 movemove_dest_ids:依赖这笔采购供给的下游需求 move
重点就在第二类。
因为采购单常常不是孤立存在的,它背后可能服务于:
- MTO 销售
- 补货规则
- 生产需求
- 跨仓补给
取消采购,不等于这些需求一起消失。
propagate_cancel 才是真正决定“要不要一锅端”的开关
采购行上有一个字段:
propagate_cancel
当它为真时,下游 move 会跟着取消;为假时,系统会把下游 move 改成:
procure_method = 'make_to_stock'
然后重新计算状态。
这背后的业务含义特别重要:
propagate_cancel = True
表示这笔下游需求明确依赖这次采购,一旦采购没了,下游也应该一起终止。
propagate_cancel = False
表示“采购这条供应来源取消了,但需求本身还在”,系统要把它重新交还给库存/补货体系继续解决。
这就是你看到某些需求在 PO 取消后没有消失,而是重新等待补货的根因。
为什么源码还要处理“共享 downstream move”
有些 move_dest_ids 不是一对一,而是可能被多条采购行共同指向。
源码会先找:
len(m.created_purchase_line_ids.ids) > 1
这种情况下,它不是粗暴把 move 干掉,而是先解绑当前采购行。
这是非常关键的保护。
否则你取消一条采购线,可能会把别的仍然有效的供应承诺一起误杀。
已完成收货单为什么不会被硬取消
代码里对已完成 picking 的处理很克制:
- 不强行取消 done picking
- 只在其上留言,说明关联采购单已取消
这其实是在尊重“已经发生的仓储事实”。
货已经收了,就不能靠取消 PO 让历史物理动作消失。
这和很多新手想象的“采购单是总开关”完全不同。
在 Odoo 里,单据之间更像相互引用、相互修正,而不是一张单无条件统治全部事实。
最容易误解的 5 个点
1. 以为取消采购单只影响采购模块
实际上会波及库存与补货链。
2. 以为取消后下游需求一定消失
还要看 propagate_cancel。
3. 以为没取消的下游 move 就是异常残留
它可能是被故意回退为 make_to_stock。
4. 以为 done picking 会被一并撤销
源码明确不会这么做。
5. 以为一条采购行对应一条独立 downstream move
共享引用场景下,系统会先解绑再处理。
排错顺序
当你遇到“PO 取消了,为什么还有 move/需求/收货记录”,建议按这个顺序排:
- 看采购单是否被锁定或已有有效 Vendor Bill
- 检查采购行
propagate_cancel的值 - 看
move_ids与move_dest_ids各自状态 - 确认 downstream move 是否被多个 created_purchase_line_ids 共享
- 看未取消的 move 是否已被改成
make_to_stock - 对 done picking,不要期待系统会替你抹掉历史事实
一句话记忆法
Odoo 取消采购单,取消的不只是单据状态,而是这笔采购对下游需求“是否继续供给”的承诺关系。
DISCUSSION
评论区