销售主链路

Odoo 销售确认后到底发生了什么:出库、拣货与开票为什么会分成两条链

很多人以为销售单一确认,系统就会顺着一条线自动走到发货和开票。但从 sale、sale_stock 源码看,Odoo 实际上把‘履约链’和‘开票链’拆开了:一条去生成 procurement / picking / move,另一条等 qty_to_invoice 条件成熟后再进 account.move。

会计 库存 销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 17 阅读

很多人看 Odoo 销售流时,会下意识把它想成一条直线:

  • 销售单确认;
  • 系统生成出库;
  • 然后顺手生成发票。

但源码视角下,这个理解不够准确。

更真实的说法是:

销售确认后,Odoo 会把业务拆成两条彼此相关、但不完全同步的链。

  • 履约链:去解决“货怎么交付”
  • 开票链:去解决“现在能不能开票、该开多少”

这也是为什么你经常会看到这些“看起来矛盾,实际上正常”的状态:

  • 订单已确认,但还不能开票
  • 已有 picking,但 invoice_status 还是 no
  • 已经开票了,但货还没出完

不是系统乱,而是 Odoo 故意把“物流事实”和“财务确认”拆开管理。

一、action_confirm() 真正做的是把订单推进到可执行状态

addons/sale/models/sale_order.py 里,action_confirm() 先做三件核心事:

  1. 校验订单是否允许确认
  2. write() 把订单状态推进到 sale
  3. _action_confirm(),让后续扩展模块去生成下游单据

这一步很关键。

它并不是“直接创建所有后续业务单”,而是:

先把销售订单从报价,升级为一个已经承诺要履行的业务对象。

所以确认之后,订单本身进入执行态;但执行态之后到底要不要建库存单、怎么建、何时能开票,要交给后续链路分别决定。

二、库存履约链真正从 _action_launch_stock_rule() 开始发力

很多人以为 sale order 一确认,系统直接“建一张出库单”。

源码里其实更抽象。

sale_stock/models/sale_order_line.py 里,真正重要的是:

  • 每条销售行先判断自己是否需要走库存履约
  • 再通过 _action_launch_stock_rule() 生成 procurement
  • 最后交给 stock.rule.run() 决定后面走 pull / buy / manufacture 哪条分支

也就是说,销售行并不是直接说“给我一张 picking”。它更像是在说:

这里有一笔需求,请库存规则系统决定如何满足。

这也是 Odoo 设计上很漂亮的一点:

  • 销售负责提出需求
  • 库存规则负责决定 fulfilment strategy

所以销售确认后,下游可能不只是普通出库,还可能是:

  • 仓内调拨
  • 采购补货
  • 制造单
  • dropship

你如果把这条链看成“确认销售 = 创建出库单”,就会在复杂路线下很快看不懂。

三、为什么确认后会看到 picking,但不代表已经能开票

_action_launch_stock_rule() 之后,stock.rule.run() 会驱动 move / picking 逐步落地。

于是前台最容易看到的现象是:

  • 销售单上出现了交货 smart button
  • 下游有 picking 了
  • 甚至 picking 已经 assigned 了

这时很多新手会自然推断:

既然都开始发货了,那应该也能开票了吧?

不一定。

因为库存履约链解决的是“货有没有被安排和交付”,而开票链盯的是另一组字段,尤其是:

  • qty_delivered
  • qty_invoiced
  • qty_to_invoice
  • invoice_status

如果产品开票策略是按已交付数量,那么 delivery 的推进当然会影响能否开票; 但如果还没真正 done,或者交付数量还没进入可开票条件,哪怕你已经看到 picking,也可能依然不能开票。

四、开票链不是从 picking 出发,而是从“哪些行现在可开”出发

sale/models/sale_order.py 里,_create_invoices() 的核心思路不是“只要订单确认就开票”,而是:

  1. 先找出 invoiceable lines
  2. 再把这些销售行转换成 account.move.line 的 vals
  3. 最后创建 account.move

这一步的重心是“可开票性”,而不是“有没有库存单”。

而销售行如何变成发票行,关键又落到 sale_order_line._prepare_invoice_line()

这意味着:

  • 库存链关注的是 move / picking / rule
  • 开票链关注的是 sale order line 当前的 invoiceable 数量

两条链会互相影响,但不会互相替代。

五、为什么 Odoo 要故意拆成两条链

因为业务世界里,“交付”和“开票”本来就不总是同一步。

最常见的几种情况:

1)先确认,后发货,再开票

这是很多实物商品场景。

2)先确认就能开票

如果产品按订购数量开票,系统并不要求先等交货完成。

3)已经有交货动作,但暂时还不能开票

比如部分交货、异常退货、数量还没汇总到可开票条件。

4)已经开了票,但下游物流还在继续

在预付款、阶段性交付、按订购数量开票等场景里都很常见。

所以从架构上讲,Odoo 不是偷懒没把它们写成一条线,而是:

它承认销售履约和财务确认不是同一个事实。

六、排错时应该先问“卡在哪条链上”

实际排错时,很多人一上来就在销售单里乱点,效率很低。

更稳的顺序是先分链。

情况 A:问题像是“为什么没有出库 / 出库不对”

优先看履约链:

  • action_confirm() 后订单状态是否进入 sale
  • 相关销售行是不是 storable / consu,是否满足走库存规则
  • _action_launch_stock_rule() 有没有真正跑起来
  • route / rule 是否把需求导向了正确分支
  • picking / move 当前是什么状态

情况 B:问题像是“为什么不能开票 / 开票数量不对”

优先看开票链:

  • 该行产品的 invoicing policy 是什么
  • qty_deliveredqty_invoicedqty_to_invoice 分别是多少
  • _get_invoiceable_lines() 有没有把这行纳入
  • _prepare_invoice_line() 生成的 line vals 是否符合预期

情况 C:用户说“明明发货了还不能开票”

先不要怀疑按钮坏了。

先判断:

  • 是不是按 delivered quantities 开票
  • picking 是否真的 done
  • qty_delivered 是否正确回写
  • 是否只是部分交货

很多“开票失效”问题,根子其实在交付事实还没成立。

七、最容易误解的点:看到 picking,不代表履约已经完成

这是实施现场特别常见的错觉。

很多人把这些状态混成一个词:

  • 有 picking
  • picking assigned
  • picking done
  • delivered qty 已更新
  • qty_to_invoice 已变成正数

实际上,这五件事不是一个层级。

最粗暴但很实用的理解是:

  • 有 picking:系统已经开始安排履约
  • picking assigned:库存资源已尝试分配
  • picking done:物流事实被确认
  • qty_delivered 更新:销售层开始承认已交付数量
  • qty_to_invoice > 0:财务层开始承认可以开票

每一层都比前一层更接近“能收钱”。

八、实战开发时该注意什么

1)不要在自定义里把库存链和开票链硬绑死

比如一确认销售就强制建发票,往往会破坏按已交付数量开票的语义。

2)扩展确认逻辑时,优先挂在 _action_confirm() 周边

因为 action_confirm() 是入口,真正给模块扩展后续行为的通常是 _action_confirm() 语义。

3)排查数量问题时,不要只看订单头

头部状态大多只是聚合结果,根因常在行上:

  • sale.order.line
  • stock.move
  • picking state
  • qty_delivered / qty_to_invoice

4)别把“生成 picking”误当成“路线已经正确”

生成了 picking 只是说明有下游动作,不代表路线、仓库、来源位置、供应策略全都对。

最后总结

理解 Odoo 销售确认后的行为,最重要的不是记住多少个方法名,而是建立一个正确心智模型:

确认销售后,系统会同时启动“履约链”和“开票链”;它们互相关联,但不是一条线。

所以以后再看到“已确认但未开票”“已有出库但状态别扭”“开票和交付不同步”时,不要急着说系统有 bug。

先问自己一句:

这次卡住的,到底是库存履约链,还是销售开票链?

这一步分清了,后面排错会快很多。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。