会计源码

Odoo 现金舍入为什么不是“尾差补一笔”:cash rounding 的两种策略和税分录边界讲透

很多人把现金舍入理解成发票最后补个零头,但 Odoo 官方源码其实把它建模成动态分录重算机制,而且有“新增发票行”和“并入最大税额”两套完全不同的会计策略。

会计
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人第一次看到 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 放进了 发票动态分录重算链,而且支持两种完全不同的策略:

  1. add_invoice_line:单独新增一条舍入行;
  2. biggest_tax:把舍入差异并入金额最大的税行。

这两种做法,代表的不是 UI 表现不同,而是 会计语义不同


一、为什么 Odoo 把现金舍入放进“动态行重算”而不是后置修补

源码开头的注释很清楚:

在某些国家,最小现金单位并不存在,比如 CHF 没有 0.01 的硬币,所以现金支付时,发票总额必须按可流通最小单位进行舍入。

关键在于:

Odoo 不是在付款时才临时补救,而是在发票动态行层面就把这种会计影响建模出来。

这有两个好处:

  1. 发票在展示、打印、税额汇总、总额校验时,始终是一致的;
  2. 后续过账、核销、税汇总都能看到一个已经完成舍入的完整结构,而不是外部补丁。

所以 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 account
  • tax_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。

这里说明两件事:

  1. 舍入不仅影响 journal items,也影响 tax totals summary;
  2. 不同策略下,舍入既可能表现为额外 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

评论区

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