先说结论
Odoo 里销售单显示 To Invoice / Invoiced / No / Upselling,并不是只看“有没有开发票”,而是分两层算:
- 先算每一条
sale.order.line的qty_to_invoice和invoice_status - 再把整张
sale.order的状态聚合出来
而影响这件事的关键,不只是发票,还包括:
- 产品的 invoice policy(按订购 / 按交付)
qty_deliveredqty_invoiced- down payment 行
- 是否是折扣/运费/奖励等“不能单独开票”的特殊行
sale_stock对已完成 stock move 的特殊兜底逻辑
所以很多人看到的“状态忽左忽右”,其实不是随机,而是多层规则叠在一起。
一、先看订单级状态:整张销售单不是自己算的,是从订单行聚合出来的
在 addons/sale/models/sale_order.py 里,sale.order._compute_invoice_status() 的逻辑很清楚:
- 非
sale状态的订单,直接是no - 只要有任一正常订单行是
to invoice,订单通常就是to invoice - 如果所有相关行都
invoiced,订单就是invoiced - 如果所有相关行都在
invoiced / upselling,订单就是upselling - 否则就是
no
而且它明确过滤了:
lines_domain = [('is_downpayment', '=', False), ('display_type', '=', False)]
也就是说,订单级状态在聚合时,会先排除:
- down payment 行
- display_type 行(章节标题、备注等)
这已经解释了第一个常见现象:
明明有些行在动,为什么整张订单状态看起来没跟着变?
因为订单级状态看的不是“所有行”,而是“参与正式聚合的业务行”。
二、行级状态才是关键:真正决定方向的是 qty_to_invoice
sale.order.line 的 invoice_status 本质上不是“我有没有发票”,而是“我现在还剩多少可以开票”。
源码里核心判断是:
elif not float_is_zero(line.qty_to_invoice, ...):
line.invoice_status = 'to invoice'
这句话很重要。
它说明:
行只要还有
qty_to_invoice,优先就是to invoice。
所以真正要先盯的是 qty_to_invoice 怎么算,而不是先盯 invoice_status。
三、qty_to_invoice 又取决于 invoice policy:按订购和按交付完全是两套口径
在 sale 模块里,核心分支是:
- 按订购(
invoice_policy == 'order') qty_to_invoice = product_uom_qty - qty_invoiced- 按交付
qty_to_invoice = qty_delivered - qty_invoiced
这就是为什么同样一张销售单,不同产品会表现完全不同。
按订购产品
确认销售单后,即使还没发货,只要还没开票,就可能立刻显示 To Invoice。
按交付产品
即使订单已经确认,如果 qty_delivered 还是 0,那么 qty_to_invoice 也可能是 0,于是状态不是 To Invoice。
所以很多业务同学说:
“单都确认了,为什么还不能开票?”
答案往往不是权限,而是:
这条产品按交付开票,系统口径还没认定你有可开票数量。
四、qty_delivered 不是手写的,它常常来自 sale_stock 的 stock move 统计
到了 sale_stock,消耗型/可库存产品会把交付口径切到 stock_move。
在 addons/sale_stock/models/sale_order_line.py 里:
_compute_qty_delivered_method()会把合适的产品设为stock_move_prepare_qty_delivered()会统计 outgoing moves 和 incoming moves- done 的 outgoing 加数量
- done 的 incoming 减数量
也就是说:
- 发出去多少,
qty_delivered往上加 - 退回来多少,
qty_delivered往下减
所以它看的不是“你主观觉得发了多少”,而是已完成库存移动的净效果。
这也解释了几个高频现象:
1)部分交付后,按交付产品只出现部分可开票
因为 qty_to_invoice = qty_delivered - qty_invoiced。
2)退货后,原先可开票数量会回落
因为 incoming move 会把 delivered 数往回扣。
3)库存 move 没 done,状态就还不到位
因为 sale_stock 统计的是已完成 move,而不是草稿或已分配 move。
五、为什么有时明明部分交付,状态却直接显示 Invoiced
sale_stock 里还补了一段很实战的特殊逻辑:
当满足这些条件时:
- 行在
sale - 当前基础计算结果是
invoice_status == 'no' - 产品是实体商品
- 产品按交付开票
- 有 move
- 并且这些 move 全部已经
done或cancel,且至少有一个done
系统会把它强制认定成:
line.invoice_status = 'invoiced'
这段逻辑是为了解决一个现实问题:
某些按重量、按实际拣货、按包装差异出货的商品,实际交付量不一定和订购量精确相等,但业务上已经结束,不想让它永远挂在一个暧昧状态里。
这也是为什么你偶尔会看到:
- 数量没严格对齐
- 但状态却已经是 Invoiced
它不是算错,而是 sale_stock 明确做了业务兜底。
六、down payment 行是另一套语义,别和普通销售行混着理解
行级状态里还有一个特殊判断:
elif line.is_downpayment and line.untaxed_amount_to_invoice == 0:
line.invoice_status = 'invoiced'
这说明 down payment 行不是按普通 qty_to_invoice 的思路走,它更偏向“金额语义”,而不是“实物数量语义”。
所以:
- 押金/预付款行
- 普通商品行
虽然都挂在销售订单行上,但 invoice_status 的判断口径并不完全一样。
这也是为什么如果你把预付款、折扣、运费和普通商品混在一起肉眼判断,很容易误会系统状态。
七、为什么订单级状态有时会从 To Invoice 又掉回 No
这是用户最容易困惑的一类现象。
常见原因有三个:
原因 1:按交付产品的 qty_delivered 回落了
例如发生退货或修正,导致 qty_to_invoice 回到 0。
原因 2:能开票的只剩特殊行
源码里有一段很细的判断:如果当前只剩一些不能单独开票的特殊行(比如折扣/配送/奖励类),订单级状态会避免把整单标成 To Invoice。
换句话说:
不是只要某行 technically 可开票,整单就一定显示 To Invoice。
系统还会判断这些剩余行是不是“有意义单独开”。
原因 3:订单已经不在 sale 聚合口径里
例如状态变化、取消、非 sale 场景等。
八、最常见的排错顺序
如果业务问你“为什么这张单不是 To Invoice / 为什么又变回 No”,最有效的排查顺序不是先看发票,而是按下面来:
第一步:看订单行,不要先看订单头
订单头只是聚合结果,真正的源头在 sale.order.line。
第二步:看这几个字段
product_id.invoice_policyqty_deliveredqty_invoicedqty_to_invoiceinvoice_statusis_downpaymentdisplay_type
第三步:如果是库存产品,再看 move
重点看:
move_ids.state- 是 outgoing 还是 incoming
- 有没有 done
- 有没有退货把 delivered 数扣回去
第四步:判断是不是特殊行在误导你
例如:
- delivery fee
- discount line
- reward line
- down payment
这些行的存在,常常会让“肉眼看应该能开票”的直觉失准。
九、一个最实用的心智模型
可以把销售开票状态理解成三层:
第一层:业务口径
产品到底按订购开票,还是按交付开票?
第二层:行级数量
- 订购了多少
- 交付了多少
- 已经开了多少
- 现在还剩多少可开
第三层:订单级聚合
- 普通业务行里有没有
to invoice - 剩下的是不是只能单独开一些“意义不大”的特殊行
- 是否该显示
invoiced或upselling
只要按这三层看,invoice_status 的大多数异常都能拆开。
结语
Odoo 的 invoice_status 不是一个“发票有/无”的简单灯泡,而是销售、库存、预付款和特殊业务行共同作用后的结果。
真正的主轴不是“有没有发票”,而是:
- 这条行按什么口径可开票
- 现在还有没有
qty_to_invoice sale_stock有没有根据真实库存完成情况调整交付口径- 订单头在聚合时又有没有把特殊行排除或降权
理解这条链之后,再遇到 “To Invoice / No / Invoiced” 来回切换,就不会觉得它神神叨叨了——大多数时候,它只是比我们肉眼判断更严格,也更业务化。
DISCUSSION
评论区