先抓主线
Odoo 里的“费用转嫁给客户”看上去像个很小的功能:
- 报销单上选一张销售单
- 费用过账
- 客户最终被开票
但标准源码真正做的事情,比这个复杂得多。
完整主链路其实是:
- 先看费用产品的
expense_policy - 只有允许 reinvoice 的费用,才保留
sale_order_id - 费用过账时,确保 analytic distribution 存在
- 后续由会计分录/解析行进入销售转嫁链
- 系统识别目标销售单,并准备 sale order line 的值
- 对费用场景强制“一笔费用一条销售行”,避免混行
- 如果费用单被撤回、反冲或删除,对应销售行数量还会被重置
所以这条机制真正解决的问题不是“把费用记到客户头上”,而是:
如何在会计过账、分析分布、销售开票之间建立一条可逆、可追踪、可回滚的桥。
这篇文章主要参考哪些源码
核心参考文件包括:
/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_idsale_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.0product_uom_qty = 0.0expense_ids = [Command.clear()]
这意味着:
- 销售行不是不可逆凭证
- 它是费用转嫁链里的一个派生结果
- 上游费用事实被撤回时,下游销售行也要撤回语义
再看 account.move:
_reverse_moves()button_draft()unlink()
这三个动作都先调用 expense_ids._sale_expense_reset_sol_quantities()。
也就是说,无论你是:
- 冲销
- 退回草稿
- 删除会计单
标准都尽量先把那条销售行归零,防止“账上没了,销售里还留一条可开票数量”。
第七层:所以费用转嫁的核心不是“生成销售行”,而是“保持会计和销售一致”
把整条链连起来看,你会发现标准 Odoo 其实是在维护三件事的一致性:
- 费用产品策略:能不能转嫁,由
expense_policy决定 - 分析/会计事实:过账后必须能落到正确 analytic 归属
- 销售开票对象:生成 sale order line,但还能在上游撤回时同步清理
这就是为什么它不是“报销单点一下 -> 生成一条销售行”那么轻。
真正难的,是:
- 生成后怎么追踪
- 出错后怎么回滚
- 会计撤回后怎么防止销售端留脏数据
新手最容易误解的 5 件事
1. 以为只要选了 sale_order_id 就一定会转嫁
不是。还要满足 expense_policy、解析分录类型等条件。
2. 以为转嫁是在报销页面直接完成的
不是。真正起作用的是过账后的会计/分析链。
3. 以为多笔费用合并成一条销售行更“整洁”
标准恰恰反过来,宁可拆开,也要保证可回滚。
4. 以为费用转嫁生成的销售行不会再变
不是。上游单据被撤回、冲销、删除时,销售行会被重置。
5. 以为看到销售行还在,就说明费用仍然有效
不一定。你得看数量是否已被归零、expense link 是否还在。
实战调试顺序
如果你排查“为什么费用没有转嫁成销售行”,建议看:
- 产品
expense_policy是否允许 reinvoice - 费用上有没有
sale_order_id - 过账后是否存在正确的
analytic_distribution - move line 是否是
display_type == 'product' _sale_determine_order()最终把这条分录映射到哪张销售单- 是否有自定义逻辑把
force_split_lines或 sale line 生成逻辑覆盖掉
如果你排查“为什么销售行还在但金额/数量不对”,重点看:
- 费用单是否被退回草稿或冲销
- account move 是否执行了
_reverse_moves()/button_draft()/unlink() sale_order_line_id是否已被重置为 0 数量expense_ids关联是否已被清空
一句话记忆法
Odoo 费用转嫁不是“报销单选张销售单”而已,而是先把费用过账成可识别的分析/会计事实,再一笔费用生成一条 sale.order.line,并在上游撤回时把下游销售行同步归零。
DISCUSSION
评论区