销售开票状态

Odoo 销售单为什么会卡在“可开票 / 已开票 / Upselling”之间:`invoice_status` 的真实计算逻辑

销售里最常见的误解之一,就是把 `invoice_status` 当成一个简单状态字段。实际上,从 Odoo 19 的 `sale_order_line.py` 和 `sale_order.py` 看,它是由 `qty_to_invoice`、产品开票策略、已交付数量、首付款逻辑,以及整单层的聚合规则共同推出来的结果。本文把这条链路讲清楚。

会计 销售
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

Odoo 里的 invoice_status 不是“用户点了开票后就顺手改一下”的状态。

它更像是一个从业务事实反推出来的结果字段

这些事实至少包括:

  • 订单是否已经确认到 sale
  • 这条产品按 ordered 还是 delivered 开票
  • qty_to_invoice 现在是多少
  • 是否已经超交付
  • 是否是首付款行
  • 整单里能开票的,是否只剩折扣/运费这类“不能单独开”的特殊行

所以它不是一个单点逻辑,而是一条链。


第一层:先算 qty_to_invoice,再谈 invoice_status

sale_order_line.py 里,_compute_invoice_status() 并不是直接从天上判断状态。

它先依赖 _compute_qty_to_invoice() 的结果。

_compute_qty_to_invoice() 的规则很清楚:

按 ordered 开票

如果产品 invoice_policy == 'order',则:

  • qty_to_invoice = product_uom_qty - qty_invoiced

按 delivered 开票

否则:

  • qty_to_invoice = qty_delivered - qty_invoiced

这其实已经解释了很多业务现象:

“能不能开票”首先不是看你想不想开,而是看系统认定还有没有数量差额可开。


第二层:行级 invoice_status 的优先级顺序很重要

源码里行级顺序大概是:

  1. 订单行不在 saleno
  2. 是首付款行,且待开票金额为 0 → invoiced
  3. qty_to_invoice 不为 0 → to invoice
  4. 按 ordered 开票,且实际交付大于订购 → upselling
  5. 已开票数量 >= 订购数量 → invoiced
  6. 其他情况 → no

这个顺序本身就值得记。

因为它说明:

  • to invoice 的判断优先级高于 upselling
  • upselling 不是“多交付就立刻出现”,而是要先满足“当前已没有正常待开票数量”

为什么会出现 upselling

很多人第一次看到 upselling 会以为:

  • 系统在鼓励销售加单
  • 或者是某个 CRM 机会提醒

但从源码语义看,它更像一个业务提醒:

这条线按订购数量开票,可你已经交付得比原订购量更多了。

最典型的是服务类场景:

  • 原本报价 10 小时
  • 实际做了 12 小时
  • 因为产品按 ordered 开票,所以正常待开票数量已经用完
  • 但交付事实比订单承诺更高

这时系统给出 upselling,意思不是“自动多收钱”,而是:

  • 这里存在额外价值交付
  • 业务上要不要改单、补单、增售,需要你自己决定

所以 upselling 本质上是商业提醒,不是会计动作。


首付款行为什么是特例

源码对 is_downpayment 做了单独判断:

  • 只要 untaxed_amount_to_invoice == 0
  • 就直接当作 invoiced

这说明首付款行的逻辑核心不是普通商品数量,而是金额余额。

也就是说,invoice_status 虽然经常看起来像“数量状态”,但在首付款场景它其实会回到金额语义


第三层:整单状态不是简单取“最差的一条线”

sale_order.py_compute_invoice_status() 很值得看。

它会先排掉:

  • is_downpayment = False
  • display_type = False

然后用 _read_group() 聚合订单行状态,再决定整单状态。

规则大意是:

  • 只要任何一条有效行是 to invoice,整单通常就是 to invoice
  • 如果所有有效行都是 invoiced,整单才是 invoiced
  • 如果所有有效行都在 invoiced / upselling 之间,整单会显示 upselling

这说明整单状态是一个聚合后的业务结论,不是某条 line 字段的简单镜像。


一个很容易忽略的细节:不是所有“可开票行”都能单独把整单变成可开票

源码里还有一段特别实战:

当整单里既有 to invoice,又有 no 时,系统会额外检查:

  • 当前那些可开票行,是不是其实都属于不能单独开票的特殊行

例如:

  • 折扣行
  • 运费/促销这类依附型行

如果整单剩下的可开票内容全是这类“单独开出来没有业务意义”的行,订单状态会被压回 no,而不是盲目显示 to invoice

这就是为什么有些单子看着似乎还有一点点金额差异,但订单头上仍然不是“待开票”。

系统不是算错了,而是在做更贴近业务语义的过滤。


排查销售开票状态时,别只盯订单头

遇到状态不符合预期时,我建议按这个顺序排:

  1. 先看每条 line 的 product.invoice_policy
  2. 再看 qty_delivered / qty_invoiced / qty_to_invoice
  3. 判断是不是首付款行
  4. 判断是不是折扣 / delivery 之类特殊行
  5. 最后再看整单聚合为什么变成 no / to invoice / invoiced / upselling

大多数“状态怪怪的”问题,都不是订单头字段自己出毛病,而是底层 line 事实本来就长那样。


最后一句话

Odoo 的 invoice_status 本质不是手工状态,而是:

把订购、交付、已开票、首付款和特殊行语义综合起来后,对“这张销售单现在还能不能正常开票”给出的业务解释。

理解这点后,to invoiceinvoicedupselling 看起来就不再像三个神秘标签,而是一套很工整的推导结果。

DISCUSSION

评论区

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