销售开票边界

Odoo 销售为什么总有人说“明明交付了却不能开票”:invoice_policy、qty_delivered_method 与 delivered qty 边界讲透

很多人知道 Odoo 有按订购数量和按已交付数量两种开票策略,却没意识到真正决定结果的往往是 qty_delivered 怎么算。手工交付、库存 move、分析行、服务工时,各自都会改变 qty_to_invoice 和 invoice_status 的来源。本文把这层边界讲透。

销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 4 阅读

先说结论

很多人把销售开票问题理解成:

  • 产品设成按订购开票,还是按交付开票

这当然重要,但还不够。

真正决定结果的往往是另一层:

系统到底准备用什么方法去计算 qty_delivered

因为在 Odoo 里,qty_to_invoice 的公式虽然看起来简单:

  • 按订购开票:ordered - invoiced
  • 按交付开票:delivered - invoiced

但如果 delivered 本身来自不同机制,后面的开票状态就会完全不同。


这篇文章主要参考哪些源码

核心入口包括:

  • /home/ubuntu/odoo-temp/addons/sale/models/sale_order_line.py
  • /home/ubuntu/odoo-temp/addons/sale_stock/models/sale_order_line.py
  • /home/ubuntu/odoo-temp/addons/sale_timesheet/ 相关 delivered qty 扩展思路

最关键的源码信号有这些:

  • sale 基础模块先定义 qty_delivered_method
  • 基础逻辑里,费用报销型行会走 analytic
  • 只有装了 sale_stock,可消耗 / 可库存商品才会把 delivered qty 改成按 stock_move
  • qty_to_invoice 并不直接判断“你业务上觉得交付了没有”,而是机械依赖 qty_delivered
  • 对 delivery policy 的物理产品,sale_stock 还补了一层“move 都结束了也可视为 fully invoiced”的特判

也就是说,这块不是一个字段决定全部,而是交付来源 + 开票策略 + 状态修正共同作用。


第一层:invoice_policy 只决定公式,不决定交付来源

sale_order_line.py 里,_compute_qty_to_invoice() 的逻辑非常明确:

  • invoice_policy == 'order'product_uom_qty - qty_invoiced
  • 否则 → qty_delivered - qty_invoiced

这一步看起来很简单,所以很多人会误会:

  • 只要把产品改成按交付开票
  • 系统自然就会知道“什么时候算交付了”

其实不会。

invoice_policy 只是在说:

开票数量是参考下单数量,还是参考交付数量。

至于“交付数量是谁、怎么来的、用什么对象累计”,是 qty_delivered_method 在决定。


第二层:基础 sale 模块里的 delivered qty 其实很克制

sale 模块自己的 _compute_qty_delivered_method() 已经把边界写得很清楚:

  • expense 场景 → analytic
  • 其他 service / consu(在仅 sale 模块下)→ manual

这说明基础销售模块并不会神奇地自动知道所有交付。

如果你只有 sale,没有 stock、没有 timesheet 等扩展,那么很多“已交付”语义其实是要手工维护或由别的模块接管的。

这点非常重要,因为很多实施项目会默认以为:

  • 我在销售单上用了服务产品
  • 那么已交付数量应该会自己长出来

标准基础逻辑并不支持这种想当然。


第三层:装了 sale_stock 后,商品类交付数量才真正挂到库存 move 上

sale_stock/models/sale_order_line.py 里,Odoo 把可消耗品的 qty_delivered_method 扩展成 stock_move

对应 _prepare_qty_delivered() 的逻辑也非常直接:

  • 找 outgoing moves
  • done 的出库量加上去
  • done 的 incoming moves(退货)减回来

这一步特别关键,因为它说明:

物理商品的已交付数量,本质上不是看销售单本身,而是看它关联的库存 move 最终完成了多少净数量。

所以如果业务说“明明已经交付了”,技术上你应该追的不是销售单界面,而是:

  • move 有没有生成
  • move 是不是 done
  • 有没有退货抵回去
  • UoM 换算后净值是多少

第四层:analytic delivered qty 不是“项目工时的别名”,而是另一种交付计量体系

sale 基础模块里,analytic 路径会读取 account.analytic.line

  • 分组到 so_line
  • unit_amount 汇总
  • 再转成销售行 UoM

这说明 analytic delivered qty 的本质,不是库存履约,而是:

用分析行 / 工时 / 费用记录去证明已经提供了多少可计费服务。

所以它和 stock move 的区别非常大:

  • stock_move 证明“货物流动”
  • analytic 证明“服务消耗 / 工时产出 / 报销转嫁”

两者都能喂给 qty_delivered,但业务语义完全不同。


第五层:为什么“按交付开票”最容易踩坑

因为“按交付开票”看起来最业务一致,但对底层数据要求也最高。

只要 qty_delivered 这条链哪一步没打通,就会直接出现:

  • qty_to_invoice = 0
  • invoice_status = no
  • 用户觉得系统“明明做完了却不开票”

这类问题常见根因包括:

  1. 服务产品没接 timesheet / analytic 链
  2. 商品没真正完成出库
  3. 退货把 delivered qty 抵掉了
  4. UoM 换算后数量与你肉眼预期不同
  5. 只改了展示状态,没有改到底层 move / analytic 数据

所以 delivery-based invoicing 不是一个产品字段,而是一整条可证实交付的数据链。


第六层:为什么 sale_stock 还要补一个 invoice status 特判

sale_stock 里有一段很容易被忽略的逻辑。

对于:

  • 物理商品
  • 按交付开票
  • 所有 move 都已处于 done / cancel
  • 且至少有一个 done

即便基础逻辑算出来 invoice_status = no,模块也可能把它修正成 invoiced

这段设计很实用,尤其是按重量销售、实际交付数量很难和原始订购量完全一致的场景。

它在表达:

底层履约已经结束,这张行不该永远挂成“好像还没开完票”的悬空状态。

也就是说,Odoo 在这里不是纯数学,而是在补一层业务完成语义。


第七层:退款为什么不会无脑把一切都“退回到可重开票”

_compute_qty_invoiced() 里还有一个很重要的点:

  • 只有和销售单行明确关联的发票行 / 退款行
  • 才会反向影响 qty_invoiced

而且退款并不是“系统见到 refund 就自动恢复可开票数量”的简单逻辑,它强调的是:

只有在销售链路内被识别为这条行的正向 / 反向发票数量,才参与回算。

这也是 Odoo 很典型的设计:

  • 不靠口头业务理解
  • 只靠结构化关联对象来回推状态

新手最容易误解的 5 件事

1. 以为 invoice_policy 决定了一切

不是。它只决定公式,不决定 delivered qty 的来源。

2. 以为服务产品一定会自动增长已交付数量

不是。没有对应扩展时,很多服务仍然是 manual 或 analytic 口径。

3. 以为商品交付只要销售单确认就算完成

不是。标准交付数量要看 stock move 的 done / return 净值。

4. 以为退货不影响销售交付数量

会影响。incoming move 会把已交付净数量减回来。

5. 以为开不了票就是按钮问题

通常不是,是 qty_delivered 这条链没成立。


实战排查顺序

如果用户说“这条销售行明明交付了却不能开票”,我建议按下面顺序排:

  1. 产品 invoice_policy
  2. 当前 qty_delivered_method
  3. qty_delivered 的具体值
  4. stock move / analytic line 是否真实存在且已完成
  5. qty_invoiced
  6. qty_to_invoice
  7. invoice_status

你会发现,大多数问题在第 2 到第 4 步就已经暴露了。


一句话记忆法

按交付开票并不等于“系统懂你的业务已经交付”,它只是在说:可开票数量完全信任 qty_delivered,而 qty_delivered 又取决于 manual、analytic、stock move 等不同计量体系。

DISCUSSION

评论区

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