先说结论
很多人把销售开票问题理解成:
- 产品设成按订购开票,还是按交付开票
这当然重要,但还不够。
真正决定结果的往往是另一层:
系统到底准备用什么方法去计算
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 = 0invoice_status = no- 用户觉得系统“明明做完了却不开票”
这类问题常见根因包括:
- 服务产品没接 timesheet / analytic 链
- 商品没真正完成出库
- 退货把 delivered qty 抵掉了
- UoM 换算后数量与你肉眼预期不同
- 只改了展示状态,没有改到底层 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 这条链没成立。
实战排查顺序
如果用户说“这条销售行明明交付了却不能开票”,我建议按下面顺序排:
- 产品
invoice_policy - 当前
qty_delivered_method qty_delivered的具体值- stock move / analytic line 是否真实存在且已完成
qty_invoicedqty_to_invoiceinvoice_status
你会发现,大多数问题在第 2 到第 4 步就已经暴露了。
一句话记忆法
按交付开票并不等于“系统懂你的业务已经交付”,它只是在说:可开票数量完全信任
qty_delivered,而qty_delivered又取决于 manual、analytic、stock move 等不同计量体系。
DISCUSSION
评论区