销售行边界

Odoo 销售行里的 display_type 为什么不是“排版字段”:section / note / down payment 如何绕开交付与开票计算讲透

很多人看到 sale.order.line 里的 display_type,会下意识把它理解成报价单上的 section 和 note 排版辅助。可一旦把它和 is_downpayment、qty_to_invoice、invoice_status 放在一起看,就会发现它其实定义了“哪些行参与业务计算、哪些行只负责结构呈现”的硬边界。本文结合 sale_order_line.py 把这条边界讲透。

Odoo 开发 销售
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

先说结论

sale.order.line.display_type 不是一个“为了让报价单更好看”的小字段。

在 Odoo 销售源码里,它真正划的是一条很重要的线:

  • 哪些行是可计价、可交付、可开票的业务行
  • 哪些行只是结构展示
  • 哪些行虽然不是普通商品行,但又要参与“开票状态”这类业务判断,比如 down payment

所以如果你把它简单看成 section / note 的 UI 标记,很快就会在这些地方掉坑:

  • qty_to_invoice 算不对
  • invoice_status 看起来反常
  • 自定义脚本把 note 行也当商品行处理
  • 预付款行明明没交付,却已经可以开票

源码先把边界写死在约束里

addons/sale/models/sale_order_line.py 顶部,Odoo 先用两组约束把边界钉住了。

对普通可记账业务行

如果 display_type IS NULL,又不是 is_downpayment,那它必须具备:

  • product_id
  • product_uom_id
  • 等一组“像正常销售行”的必需信息

对非业务结构行

如果 display_type 不为空,也就是 section / subsection / note 行,那么约束要求:

  • product_id IS NULL
  • price_unit = 0
  • product_uom_qty = 0
  • product_uom_id IS NULL
  • customer_lead = 0

这已经说明官方立场非常明确:

display_type 行不是“精简版商品行”,而是完全不同语义的记录。


为什么报价单明明是一张表,底层却不是“同类行”

销售单页面给人的感觉是:

  • section
  • note
  • 商品行
  • 预付款相关行

都混在一张列表里。

但 Odoo 在底层并没有把它们看成“只有显示方式不同”的同类对象。

更准确地说:

  • section / note 行:负责结构与叙述
  • 普通商品行:负责数量、价格、交付、开票
  • down payment 行:负责预收款类开票语义,但不走普通交付逻辑

这也是为什么同一个模型会同时有:

  • display_type
  • is_downpayment
  • qty_delivered
  • qty_to_invoice
  • untaxed_amount_to_invoice
  • invoice_status

这些字段并不是随便堆在一起,而是在表达同一个核心问题:

这条销售行到底属于哪类业务语义。


_compute_qty_to_invoice():只要有 display_type,就直接归零

_compute_qty_to_invoice() 里,逻辑非常直白。

只有满足这些条件的行,系统才会继续算待开票数量:

  • state == 'sale'
  • not line.display_type

一旦是 section / note / subsection 行:

  • qty_to_invoice = 0

这说明 display_type 的真正含义之一是:

把结构行彻底排除出“按数量开票”的主链路。

这也是很多自定义 bug 的根源:

  • 前端看着都在一张报价单里
  • 开发者就误以为都该参与统一计算

实际上 Odoo 从源码层就明确说了:不是。


那为什么 down payment 又是例外

预付款行最容易让人困惑。

它通常不是靠正常交付数量来决定是否能开票,但它又显然不是 section / note 那样的纯结构行。

所以 Odoo 没有用 display_type 来标记 down payment,而是单独用:

  • is_downpayment

这是一种很典型的 Odoo 设计:

  • display_type 解决“结构行 vs 业务行”
  • is_downpayment 解决“这是业务行,但语义不是普通商品行”

这也解释了为什么 _compute_invoice_status() 里会有专门判断:

  • 如果是 down payment,且 untaxed_amount_to_invoice == 0
  • 则直接视为 invoiced

也就是说,预付款不是按“交了多少货”来走,而是按“该开的预付款金额还有没有剩余”来走。


_compute_untaxed_amount_to_invoice():预付款与普通商品行其实走的是两套认知

源码里 untaxed_amount_to_invoice 的逻辑也很能说明问题。

普通商品行主要看:

  • 产品开票策略是按订单还是按交付
  • 已开票金额多少
  • 含税价是否要倒推未税金额

但 down payment 行在实际业务上关心的是:

  • 已经为这条预付款行开了多少
  • 还剩多少预付款额度未开

所以你会发现:

  • 它不依赖普通商品交付语义
  • 却仍然属于“能改变 invoice_status 的业务行”

这就是为什么把预付款理解成“特殊商品”常常不够准确。

更准确地说,它是:

销售模型里一类特殊的可开票业务行。


最容易出现的三个误区

误区 1:section / note 行也能参与业务统计

如果你自己做金额、数量、导出统计时没先排除 display_type,结果几乎一定会脏。

误区 2:预付款行应该像普通商品一样看 qty_delivered

不是。预付款的核心不是交付,而是预收/预开票语义。

误区 3:报价单上一张列表里的行,底层就应该用同一套逻辑处理

这正是很多错误自动化和 Studio 定制的起点。


实战开发时更稳的判断顺序

扩展销售行逻辑时,建议先问这四个问题:

  1. 这行有没有 display_type
  2. 这行是不是 is_downpayment
  3. 我要处理的是“交付逻辑”还是“开票逻辑”
  4. 我当前要遍历的,是全部行还是只有普通业务行

把这四个问题先问清楚,很多看似复杂的 bug 其实当场就能定位。


最后的判断句

在 Odoo 销售里,display_type 的本质不是排版,而是:

把结构行从交付、计价、开票主链路中明确剥离出去的模型边界。

is_downpayment 则补上另一层语义:

这不是普通商品行,但它仍然是一条需要参与开票判断的业务行。

把这两层分开看,销售行相关的很多“为什么状态不合直觉”的问题,基本就能解释清了。

DISCUSSION

评论区

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