很多人第一次看 Odoo 销售履约,脑子里都会自动补成一句话:
销售单确认后,系统生成发货单。
这句话作为业务层口语没有问题,但如果你在做开发、排错或二开,它就太粗了。
因为从 /home/ubuntu/odoo-temp/addons/sale_stock/models/sale_order.py、sale_order_line.py 到 addons/stock/models/stock_rule.py 的真实链路看,Odoo 的思路并不是:
- 销售模块自己直接把 picking 一把建完;
而是:
- 销售单确认后,订单行先判断还有多少数量需要履约;
- 订单行准备一包
procurement values; - 把需求交给
stock.rule.run(); - 再由规则系统决定是 pull、buy 还是 manufacture;
- 如果走 pull,就创建 stock move,再由 move / picking 确认链继续推进。
换句话说,销售模块更像“发起需求”,库存规则系统才是“决定如何履约的人”。
一、入口其实非常短:sale.order._action_confirm()
sale_order.py 里的关键入口只有一行:
self.order_line._action_launch_stock_rule()
这行代码短得几乎容易被忽略,但它其实非常说明设计哲学。
它告诉你:
- 销售单头部不是最终执行者;
- 真正被送进履约系统的是订单行。
为什么是订单行,而不是订单头?
因为决定履约路径的关键上下文都在行上:
- 产品
- 数量
- 单位
- 路线
- 仓库
- 收货地址
- 这条行已经被履约了多少
如果把这些决策都塞到订单头,扩展能力会很差。
二、真正的需求发射器:sale.order.line._action_launch_stock_rule()
这一步才是整条链真正启动的地方。
源码里它先做几件很务实的事:
1)跳过不该处理的行
比如:
- 不是
sale状态 - 订单已锁定
- 产品类型不参与这条库存履约逻辑
这说明系统不是“确认单据就全量推库存”,而是有明确过滤条件。
2)先算“还有多少没被履约”
_get_qty_procurement() 会先看已有的 outgoing / incoming moves,算出这条销售行已经被推进了多少。
然后真正待发射的数量是:
product_uom_qty - qty
这一步非常关键,因为它解释了为什么 Odoo 在修改销售数量、部分履约、退货后,后续动作不会简单粗暴地整单重建。
系统先看现实,再发新需求。
三、procurement values 才是后续规则判断的“上下文包”
很多人知道 _action_launch_stock_rule() 会调库存规则,但没认真看 _prepare_procurement_values()。
其实这一步非常重要,因为它准备的不是装饰性数据,而是后续规则系统做判断时真正依赖的上下文。
源码里典型会塞进去这些值:
originsale_line_iddate_planneddate_deadlineroute_idswarehouse_idpartner_idlocation_final_idcompany_idsequencepackaging_uom_id
你可以把这包 values 理解成一句完整的业务指令:
这条销售需求来自谁、要什么时候交、往哪交、按什么路线走、属于哪个公司、对应哪条销售行,请你根据规则接着处理。
这也是 Odoo 可扩展性的关键原因之一。
如果将来你要做:
- 特殊路由
- 多公司差异
- 特定仓库策略
- 某些自定义分单/分仓逻辑
很多扩展点都不是去改销售确认入口,而是去补这包 values 或影响 rule 选择逻辑。
四、为什么销售模块不直接“自己建 picking”
因为 Odoo 要解决的不是“生成一张发货单”这么单一的问题,而是“如何满足这条需求”。
到了 stock.rule.run(),源码会做两件事:
1)给 procurement 找 rule
系统会根据:
- 产品路线
- 仓库路线
- 包装路线
- 目标位置
- 公司上下文
去找最合适的 rule。
2)按 action 分流
如果找到了 rule,Odoo 并不是统一走一条固定逻辑,而是根据 rule 的 action 决定后续路径:
pullbuymanufacture- 以及
pull_push映射后的 pull 处理
这就是为什么“销售确认后会生成什么”这个问题,不能脱离路线和规则配置单独回答。
对某些产品,结果可能是:
- 直接生成库存 move
- 触发采购
- 触发制造
- 串出多段 move 链
销售模块如果直接硬编码“创建 picking”,就会把这些灵活性全部抹掉。
五、如果命中 pull,真正创建的是 stock.move
stock_rule.py 里的 _run_pull() 很直接:
- 先检查 rule 的来源位置是否完整;
- 调
_get_stock_move_values()组装 move values; - 按公司批量创建
stock.move; - 对新建 move 调
moves._action_confirm()。
这一步很能说明层次关系:
pull 规则首先关心的是“生成库存移动”,不是先关心 UI 上那张 picking 单据长什么样。
对 Odoo 来说,move 才是库存执行语义里的基础对象;picking 更像对一组 move 的业务承载和操作容器。
六、_get_stock_move_values() 解释了 move 是怎么“带着业务上下文出生”的
这一步不是简单拷几个字段。
从源码看,它会综合考虑:
date_planned/date_deadlinepartnerwarehouse_idprocure_methodmove_dest_ids- 以及从 procurement values 往下传的各种附加信息
所以库存 move 从一开始就不是孤零零的一条物料动作,而是:
- 知道自己从哪来
- 知道要去哪里
- 知道属于哪个业务来源
- 知道后面是否还挂着链式目的 move
这也解释了为什么很多履约问题最终要追到 move,而不是只停留在 sale order 或 picking 界面。
七、为什么创建 move 后还要再确认 picking
sale_order_line.py 最后还有一段经常被忽略的代码:
- 收集订单相关 pickings
- 过滤掉
cancel/done - 再执行
pickings_to_confirm.action_confirm()
源码注释也写得很坦白:
当前调度器触发点还依赖 picking confirmation,而不只是 stock.move confirmation。
这句话非常关键。
它说明现实系统里,链路不是“move 出来就万事大吉”,而是:
- 需求先进入规则系统
- 规则产出 move
- 然后某些后续调度还要靠 picking 的确认链来继续接力
所以如果你排查问题时只盯着“move 已经创建了”,但没看 picking 是否进入正确状态,很容易误判为“库存没继续跑”。
八、开发和实施时最容易踩的坑
坑 1:把问题全怪到 sale 模块
很多“销售确认后没出单”的问题,根因其实在:
- route 配置
- warehouse 配置
- rule 没命中
- procurement values 缺上下文
而不是 sale 本身没执行。
坑 2:以为 procurement 是最终产物
procurement 只是需求表达,不是最终单据。
真正的后续对象要看 rule 决定。
坑 3:忽略“已履约数量”的扣减逻辑
如果已有 move、部分交付、退货、补单混在一起,不先看 _get_qty_procurement(),你很容易误判系统为什么没有再建新 move。
坑 4:只盯 picking,不追 move 和 rule
picking 是结果展示层之一,rule 和 move 才是这条链的骨架。
九、排查销售履约问题的推荐顺序
遇到“确认销售单后库存动作不对”,我建议按这个顺序查:
- 订单行是不是已经进入
sale状态; _get_qty_procurement()结果是不是说明其实没有新增需求;_prepare_procurement_values()是否带上了正确 route / warehouse / partner / deadline;stock.rule.run()命中了哪条 rule;- 对应 action 是 pull、buy 还是 manufacture;
- 如果走 pull,
stock.move是否成功创建并确认; - 相关 picking 是否又被正确 confirm,后续调度是否接上。
照这个顺序看,你会比“为什么没发货单”这种问法更接近真实问题。
总结
Odoo 销售确认后的库存链路,真正的重点不是“sale 直接建 picking”,而是:
- 订单行先计算还需要履约多少;
- 再构造 procurement values 作为规则系统的上下文输入;
- 由
stock.rule.run()决定到底走 pull、buy 还是 manufacture; - 如果走 pull,就先生成并确认 stock move;
- 最后再通过 picking confirmation 把后续调度接起来。
理解这条主线后,你会发现很多销售履约问题其实不是“销售没工作”,而是需求已经被发射出去了,只是后面的规则链没有按你以为的方式落地。
DISCUSSION
评论区