很多人第一次看到 Odoo 的 Cash Rounding,会觉得它只是一个“尾差处理小功能”:
- 发票总额是 10.02;
- 国家没有 0.01 的最小现金单位;
- 那就补到 10.00 或 10.05。
如果只停留在这个层面,会很容易误以为:
现金舍入就是最后额外加一笔差额。
但官方源码给出的实现,比这严谨得多。
在 /home/ubuntu/odoo-temp/addons/account/models/account_move.py 的 _recompute_cash_rounding_lines 里,Odoo 明确把 cash rounding 放进了 发票动态分录重算链,而且支持两种完全不同的策略:
- add_invoice_line:单独新增一条舍入行;
- biggest_tax:把舍入差异并入金额最大的税行。
这两种做法,代表的不是 UI 表现不同,而是 会计语义不同。
一、为什么 Odoo 把现金舍入放进“动态行重算”而不是后置修补
源码开头的注释很清楚:
在某些国家,最小现金单位并不存在,比如 CHF 没有 0.01 的硬币,所以现金支付时,发票总额必须按可流通最小单位进行舍入。
关键在于:
Odoo 不是在付款时才临时补救,而是在发票动态行层面就把这种会计影响建模出来。
这有两个好处:
- 发票在展示、打印、税额汇总、总额校验时,始终是一致的;
- 后续过账、核销、税汇总都能看到一个已经完成舍入的完整结构,而不是外部补丁。
所以 cash rounding 本质上不是“支付动作附带的小尾差”,而是 会计对象本身要承认的一种总额修正。
二、Odoo 先算的不是分录,而是“差额”
在 _recompute_cash_rounding_lines 里,内部先调用 _compute_cash_rounding:
invoice_cash_rounding_id.compute_difference(...)- 算出
diff_amount_currency - 再按汇率换算出
diff_balance
这个设计很关键。
它说明 cash rounding 的第一性问题不是“插哪条分录”,而是:
在发票币和公司币语义下,理论总额与可流通现金总额之间差了多少?
只有先把这个差额精确算出来,后面才谈得上用什么会计方式表达。
也因此,cash rounding 并不天然等于本币尾差,它可能同时涉及:
- 发票币金额;
- 公司币余额;
- 票据日期上的汇率转换。
三、为什么会有两种策略:其实是在回答“舍入差额算谁的”
策略 1:add_invoice_line
这条策略会新增一条独立的 rounding line。
源码里会给这条行设置:
display_type = 'rounding'account_id指向 profit/loss accounttax_ids清空
这表示什么?
表示 Odoo 把舍入差额视作一条 独立的非税务会计差额。
也就是说:
- 原商品金额还是原商品金额;
- 原税还是原税;
- 舍入影响单独作为一笔差异挂到指定盈亏科目。
这种做法最适合“我希望税保持原样,舍入影响单独追踪”的场景。
策略 2:biggest_tax
另一条策略完全不同。
源码会找出:
- 所有
tax_repartition_line_id对应的税行; - 其中 balance 绝对值最大的那一条;
- 把舍入差额直接并入该税行。
同时还会继承:
- 税科目;
- 税 repartition line;
- tax tags;
- tax ids。
这代表 Odoo 把舍入差异理解为:
总额上的微小调整,仍然属于税结构的一部分。
所以两种策略的真正差别不是“显示成一行还是不显示”,而是:
- 差额被视作独立会计差异;
- 还是被视作税结构内部的延伸。
四、为什么 biggest tax 不是“随便找个税行加上去”
很多人会觉得这个策略有点粗暴:
“凭什么并到最大税额那条?”
但从会计实现上看,它其实是一个非常现实的近似策略。
因为现金舍入通常很小,系统要解决的是:
- 不新增额外非税行;
- 又要让总额闭合;
- 同时维持税相关展示和会计结构尽量自然。
在这种前提下,把差额并到占比最大的税行,通常最能减少解释成本。
当然,这并不等于它在所有国家税务规则下都天然完美;它的意义是:
Odoo 提供了一种把舍入吸收到税结构内部的标准建模方式。
如果本地法规不允许这样做,那实施就该改策略,而不是误以为两个选项只是 UI 口味不同。
五、为什么源码会先删旧舍入行、再决定是否重建
_recompute_cash_rounding_lines 还有一个容易被忽略但很重要的动作:
- 如果策略改了,旧 rounding line 先删;
- 如果本次重算发现差额为 0,也删;
- 如果已有 rounding line 且金额没变,就不重复更新;
- 否则才创建或改写。
这说明 Odoo 不是把舍入行当成一个静态附件,而是把它视作:
由当前发票结构推导出来的动态结果。
也就是说,只要这些条件变化,舍入行就应该重新被推导:
- 发票行金额变了;
- 税变了;
- 币种/汇率语义变了;
- 舍入规则变了;
- 策略变了。
因此 cash rounding line 的正确理解,不是“我录了一条特殊行”,而是“系统为保证票据总额合法而自动生成的一条动态会计行”。
六、它和税总额汇总为什么关联得这么深
除了 account_move.py,/home/ubuntu/odoo-temp/addons/account/models/account_tax.py 的 _get_tax_totals_summary 也专门处理了 cash rounding。
这里说明两件事:
- 舍入不仅影响 journal items,也影响 tax totals summary;
- 不同策略下,舍入既可能表现为额外 base amount,也可能影响 tax amount 汇总。
这非常关键。
因为很多人以为 cash rounding 只是总账层修补,不影响发票税额显示。
实际上在 Odoo 的设计里,它就是发票总额结构的一部分,因此:
- 前端显示的税汇总;
- 发票总计;
- 动态分录;
必须一起协调。
七、这套设计解决了什么问题
1)解决“现金支付合法总额”与“发票明细结构”之间的张力
发票明细可能精确到分,但现实现金支付不一定允许。
2)解决舍入差额到底该如何会计表达
Odoo 没把它写死,而是给出两种策略:
- 独立差额;
- 并入税结构。
3)解决总额、税额、分录三者的一致性
因为舍入不是事后补丁,而是动态重算的一部分。
八、新手最容易误解什么
误解 1:cash rounding 等于 write-off
不对。
write-off 往往发生在付款/核销差异语境里;cash rounding 是发票总额合法化语境里的动态票据结构调整。
误解 2:两种策略只是展示不同
也不对。
它们表达的是不同会计边界:独立差额,还是税内吸收。
误解 3:舍入行录完就固定了
错。
它是发票当前结构推导出来的动态结果,任何关键金额或税结构变化都可能触发重算。
九、实施和开发建议
实施上
- 先确认本地法规对现金舍入的表达方式;
- 再决定使用
add_invoice_line还是biggest_tax; - 不要把这个选项当成“界面偏好”。
开发上
如果你自定义:
- 发票动态行;
- 税行重算;
- 本地化税总额展示;
- 舍入科目处理;
一定要把 _recompute_cash_rounding_lines 和税总额汇总逻辑一起看。
很多 bug 不是舍入本身算错,而是 舍入行、税汇总、总额校验三者没保持一致。
十、最后总结
Odoo 的 cash rounding,真正处理的不是“零头”,而是:
- 发票总额在现金支付场景下是否能合法落地;
- 这个差额应被表达为独立会计差异,还是税结构的一部分;
- 税汇总、动态分录和发票总额如何同步闭合。
所以它不是一个小尾差按钮,而是一套完整的 动态会计建模机制。
一旦理解这一点,你就不会再把 cash rounding 看成“最后补一笔”,而会把它看成 Odoo 对现实现金制度所做的结构性回应。
DISCUSSION
评论区