费用转嫁

Odoo 费用转嫁给客户为什么不是“报销单选个销售单”就结束:解析分录到独立销售行的桥接链讲透

很多人以为费用转嫁只是在报销单上选一张销售单;但标准 Odoo 真正做的是先用 expense_policy 决定是否可转嫁,再在过账时补齐 analytic_distribution,通过解析分录识别目标销售单,并强制一笔费用对应一条独立 sale.order.line。本文重点讲清这条“费用事实如何进入销售开票对象”的桥接链。

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

先抓主线

Odoo 里的“费用转嫁给客户”看上去像个很小的功能:

  • 报销单上选一张销售单
  • 费用过账
  • 客户最终被开票

但标准源码真正做的事情,比这个复杂得多。

完整主链路其实是:

  1. 先看费用产品的 expense_policy
  2. 只有允许 reinvoice 的费用,才保留 sale_order_id
  3. 费用过账时,确保 analytic distribution 存在
  4. 后续由会计分录/解析行进入销售转嫁链
  5. 系统识别目标销售单,并准备 sale order line 的值
  6. 对费用场景强制“一笔费用一条销售行”,避免混行
  7. 如果费用单被撤回、反冲或删除,对应销售行数量还会被重置

所以这条机制真正解决的问题不是“把费用记到客户头上”,而是:

如何在会计过账、分析分布、销售开票之间建立一条可逆、可追踪、可回滚的桥。


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

核心参考文件包括:

  • /home/ubuntu/odoo-temp/addons/sale_expense/models/hr_expense.py
  • /home/ubuntu/odoo-temp/addons/sale_expense/models/account_move_line.py
  • /home/ubuntu/odoo-temp/addons/sale_expense/models/account_move.py
  • /home/ubuntu/odoo-temp/addons/sale_expense/models/sale_order_line.py

最关键的方法是:

  • hr.expense._compute_can_be_reinvoiced()
  • hr.expense.action_post()
  • account.move.line._sale_can_be_reinvoice()
  • account.move.line._sale_determine_order()
  • account.move.line._sale_prepare_sale_line_values()
  • account.move.line._sale_create_reinvoice_sale_line()
  • hr.expense._sale_expense_reset_sol_quantities()
  • account.move.button_draft() / _reverse_moves() / unlink()

这几段连起来,才看得懂为什么标准 Odoo 在这个功能上特别在意“后悔药”。


第一层:不是所有费用都能转嫁,入口先被 expense_policy 卡住

hr.expense 上,can_be_reinvoiced 的计算逻辑非常直接:

  • 只有当 product_id.expense_policy in ['sales_price', 'cost']
  • 这笔费用才被视为可转嫁

反过来,如果不满足条件,_compute_sale_order_id() 会把:

  • sale_order_id
  • sale_order_line_id

一起清空。

这意味着标准设计并不接受“用户想转嫁就硬转”。能不能转嫁,是产品策略先决定的。

这里有两个常见含义:

  • cost:按实际成本转给客户
  • sales_price:按产品销售价/价目表逻辑转给客户

也就是说,费用转嫁并不是报销模块私有逻辑,而是产品定价策略先给边界。


第二层:为什么过账时还要管分析分布

hr.expense.action_post() 很关键。

标准 Odoo 在这里做的不是直接建销售行,而是先确保:

  • 如果费用指定了 sale_order_id
  • 但没有 analytic_distribution
  • 系统就用销售单的 _prepare_analytic_account_data() 创建一个分析账户
  • 然后把它塞进费用的 analytic_distribution

这一步特别像“多此一举”,其实不是。

因为后续 reinvoice 走的是分析/会计链,而不是只看报销单页面字段。没有合适的分析分布,这条桥会断。

所以标准逻辑是:

先把费用过账成一条带正确分析归属的会计事实,再让销售转嫁链去识别它。

这也是为什么很多“我明明选了销售单,怎么没转嫁过去”的问题,最后都要回头查 analytic distribution。


第三层:真正决定“这行能不能转嫁”的,是解析分录层

account.move.line._sale_can_be_reinvoice() 里,标准对费用场景单独开了一条分支。

如果 move line 来自费用,它会要求同时满足:

  • expense_id.product_id.expense_policy 允许转嫁
  • expense_id.sale_order_id 已经指定
  • 当前 move line 是 display_type == 'product'

这说明销售转嫁真正识别的对象,不是报销单按钮,而是过账后的分录行

业务含义也很清楚:

  • 不是所有会计行都该被转成销售行
  • 只有真正代表费用商品的那一行,才应该进入 reinvoice 逻辑

所以如果你在账务里看到税行、汇总行、说明行没被转嫁,那不是漏掉了,而是标准设计本来就只认 product line。


第四层:目标销售单怎么决定?费用场景直接覆盖默认判断

account.move.line._sale_determine_order() 在费用场景里不会完全沿用通用逻辑。

它先调用父类,再把:

  • move_line.id -> expense.sale_order_id

这一层映射盖上去。

也就是说,费用转嫁时,目标销售单不是靠一般性的推断,而是优先尊重费用上明确指定的 sale_order_id

这就是为什么费用转嫁在业务上可控:

  • 你选哪张销售单,就主要挂去哪张销售单
  • 它不会像一些泛化的解析逻辑那样只靠 partner 或 analytic 模糊猜测

第五层:为什么标准要强制“一笔费用一条销售行”

_sale_create_reinvoice_sale_line() 是这个功能最重要的设计信号之一。

源码里明确写了:

  • 对 expensed lines,要 force_split_lines = True
  • 强制每一笔 reinvoiced expense 拥有自己独立的 sale order line

原因也写得很明白:

  • 否则后面无法正确编辑数量
  • 也不利于回滚

这件事非常关键。

如果多笔费用被合并进同一条销售行,你后面一旦:

  • 撤回其中一笔报销
  • 修改一笔金额或数量
  • 冲销其中一张费用单

系统就很难精确知道该把哪一部分从销售行里减掉。

所以标准宁愿行数变多,也要保住一对一映射。


第六层:销售行并不是“生成完就永远不动”,标准还设计了回滚通道

hr.expense._sale_expense_reset_sol_quantities() 很值得多看两遍。

当费用或其会计 move 被重置回未完成状态时,标准会 sudo 去改对应 sale_order_line_id

  • qty_delivered = 0.0
  • product_uom_qty = 0.0
  • expense_ids = [Command.clear()]

这意味着:

  • 销售行不是不可逆凭证
  • 它是费用转嫁链里的一个派生结果
  • 上游费用事实被撤回时,下游销售行也要撤回语义

再看 account.move

  • _reverse_moves()
  • button_draft()
  • unlink()

这三个动作都先调用 expense_ids._sale_expense_reset_sol_quantities()

也就是说,无论你是:

  • 冲销
  • 退回草稿
  • 删除会计单

标准都尽量先把那条销售行归零,防止“账上没了,销售里还留一条可开票数量”。


第七层:所以费用转嫁的核心不是“生成销售行”,而是“保持会计和销售一致”

把整条链连起来看,你会发现标准 Odoo 其实是在维护三件事的一致性:

  1. 费用产品策略:能不能转嫁,由 expense_policy 决定
  2. 分析/会计事实:过账后必须能落到正确 analytic 归属
  3. 销售开票对象:生成 sale order line,但还能在上游撤回时同步清理

这就是为什么它不是“报销单点一下 -> 生成一条销售行”那么轻。

真正难的,是:

  • 生成后怎么追踪
  • 出错后怎么回滚
  • 会计撤回后怎么防止销售端留脏数据

新手最容易误解的 5 件事

1. 以为只要选了 sale_order_id 就一定会转嫁

不是。还要满足 expense_policy、解析分录类型等条件。

2. 以为转嫁是在报销页面直接完成的

不是。真正起作用的是过账后的会计/分析链。

3. 以为多笔费用合并成一条销售行更“整洁”

标准恰恰反过来,宁可拆开,也要保证可回滚。

4. 以为费用转嫁生成的销售行不会再变

不是。上游单据被撤回、冲销、删除时,销售行会被重置。

5. 以为看到销售行还在,就说明费用仍然有效

不一定。你得看数量是否已被归零、expense link 是否还在。


实战调试顺序

如果你排查“为什么费用没有转嫁成销售行”,建议看:

  1. 产品 expense_policy 是否允许 reinvoice
  2. 费用上有没有 sale_order_id
  3. 过账后是否存在正确的 analytic_distribution
  4. move line 是否是 display_type == 'product'
  5. _sale_determine_order() 最终把这条分录映射到哪张销售单
  6. 是否有自定义逻辑把 force_split_lines 或 sale line 生成逻辑覆盖掉

如果你排查“为什么销售行还在但金额/数量不对”,重点看:

  1. 费用单是否被退回草稿或冲销
  2. account move 是否执行了 _reverse_moves() / button_draft() / unlink()
  3. sale_order_line_id 是否已被重置为 0 数量
  4. expense_ids 关联是否已被清空

一句话记忆法

Odoo 费用转嫁不是“报销单选张销售单”而已,而是先把费用过账成可识别的分析/会计事实,再一笔费用生成一条 sale.order.line,并在上游撤回时把下游销售行同步归零。

DISCUSSION

评论区

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