会计源码

Odoo 周期性分录为什么不是“定时复制上一张”:auto_post、origin 与日期推进链路讲透

很多人把 Odoo 的周期性分录理解成固定频率复制模板,但官方源码真正做的是“原始分录锚点 + 周期推进 + 字段选择性继承”。本文拆解 account.move 里的 recurring entry 实现链路。

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

很多人第一次用 Odoo 的 Recurring Entries,会自然地把它理解成:

  • 找一张模板分录;
  • 每月自动复制一次;
  • 日期顺延;
  • 然后就完了。

但如果你看 /home/ubuntu/odoo-temp/addons/account/models/account_move.py,会发现官方实现要精细得多。

Odoo 不是简单地“拿上一张复制下一张”,而是维护了一套更稳定的递推机制:

  • auto_post 表示周期;
  • auto_post_origin_id 固定原始锚点;
  • _apply_delta_recurring_entries 来推进日期;
  • _get_fields_to_copy_recurring_entries 决定哪些字段应该被继承。

这套结构解决的核心问题是:

周期性分录不只是复制,而是要持续生成“下一期正确版本”,同时避免日期漂移和字段语义走样。


一、Odoo 为什么不按“上一张 + 一个月”粗暴递推

先看 _apply_delta_recurring_entries(self, date, date_origin, period)

源码的注释写得很清楚:

向前推进若干月,同时尽可能保持原始的月内日期。

这里最关键的是 date_origin

它不是拿当前这张分录自己去算下一张,而是拿 原始起点 去计算应该前进到哪一天。

例如:

  • 第一张在 1 月 31 日;
  • 下一张是 2 月;
  • 再下一张是 3 月。

如果你总是“上一张 + 1 个月”,很容易出现日期逐步漂移。

而 Odoo 的写法是:

  • 先计算当前记录相对原始日期已经过去了多少个月;
  • 再从原始日期继续加上目标周期。

这背后的思想非常像会计里的“固定计划表”,而不是链式复制。

也就是说,系统努力保留的是:

这是一条从原始制度安排推导出来的时间序列。

而不是“上一张碰巧复制出来的下一张”。


二、为什么要有 auto_post_origin_id

_copy_recurring_entries 里,Odoo 会先做一件很关键的事:

record.auto_post_origin_id = record.auto_post_origin_id or record

这行代码的意思是:

  • 如果这条周期分录还没有 origin,就把自己记为 origin;
  • 后续所有递推出来的分录,都沿用这个 origin。

这非常重要。

因为如果没有 origin,周期分录链会变成“儿子复制孙子、孙子复制曾孙”,最后系统只记得上一个节点,却忘了最初规则从哪里开始。

而有了 auto_post_origin_id,整条链就有了一个稳定锚点。

这样做至少有三个好处:

1)防止日期漂移

所有日期推进都能回到原始起点来算。

2)保留制度来源

这条周期链不是一堆互相复制的散点,而是一条有源头的递推链。

3)便于追踪与解释

源码里还会在消息中注明“this recurring entry originated from ...”,这就是为了让用户知道这张分录从哪条周期链派生而来。


三、真正的自动复制发生在什么时候

account_move.py 更后面的逻辑里,Odoo 会在分录过账后,对那些 auto_post 不是 no / at_date 的周期分录触发 _copy_recurring_entries()

这说明一个很重要的边界:

周期分录的“复制下一期”并不是独立任务先跑,而是和当前分录成功过账紧密相连。

这很合理。

因为会计上你通常希望表达的是:

  • 当前这期已经正式成立;
  • 因此下一期计划可以生成。

如果当前期还没真正过账,就贸然长出下一期,链路容易失真。

所以 Odoo 选择把“生成下一张”放在当前张被确认之后。


四、为什么它不是“全字段复制”

_get_fields_to_copy_recurring_entries(self, values)

这里 Odoo 明确地把一些字段重新指定给复制结果,例如:

  • auto_post
  • auto_post_until
  • auto_post_origin_id
  • invoice_user_id
  • 某些情况下的 invoice_date
  • 没有 payment term 时,按时间差推导 invoice_date_due

这说明官方非常清楚:

周期分录不是“整张记录原样 copy”就能成立。

因为有些字段默认 copy=False,但对 recurring entries 来说又必须保留; 有些字段则必须按新日期重算,不能直接照搬。

例如:

  • invoice_user_id 如果不保留,复制出来可能变成 OdooBot;
  • invoice_date 如果存在,就要按相同周期推进;
  • invoice_date_due 若没有 payment term,就要保持“到期日与记账日的相对间隔”。

这背后体现的是一个很成熟的产品判断:

周期分录的复制目标不是“复制历史”,而是“生成下一期同语义单据”。


五、auto_post_until 真正控制的是什么

_copy_recurring_entries 里,Odoo 会先算出 next_date,然后判断:

  • 如果 auto_post_until 没设,就继续;
  • 如果 next_date <= auto_post_until,就继续;
  • 否则停止生成。

这意味着 auto_post_until 控制的不是“当前这张是否有效”,而是:

这条周期链是否还允许继续长下一张。

这个区别很重要。

很多人误以为到截止日期之后,已有分录会被取消或状态改变。

不是的。

已有分录照样存在;auto_post_until 只是阻止未来继续递推。

所以它更像“生成边界”,而不是“分录生效边界”。


六、这套设计解决了什么问题

1)解决日期逐月漂移

依赖原始锚点推导,而不是永远以上一期为唯一依据。

2)解决周期链来源丢失

auto_post_origin_id 让整条链可追踪、可解释。

3)解决“该继承什么、该重算什么”这个最难的问题

通过 _get_fields_to_copy_recurring_entries,Odoo 避免了无脑复制造成的语义失真。

4)解决会计确认与下期生成的节奏关系

当前期过账成功,再推下一期,逻辑更稳。


七、新手最容易误解什么

误解 1:recurring entry 就是 cron 定时复制

不准确。

它更像“过账后按制度推进下一期”的会计链,而不是一个独立定时复制器。

误解 2:下一张一定基于上一张

源码显示它真正依赖的是 origin 锚点,而不是简单链式漂移。

误解 3:copy 就等于完整继承

也不是。

有些字段必须保留,有些字段必须按新日期语义重算。


八、实施和开发建议

实施上

  • 不要把 recurring entry 当纯模板工具;
  • 它更适合表达周期性、规则稳定、科目结构清晰的会计事项;
  • 截止日期、负责人、到期日规则都要提前设计清楚。

开发上

如果你扩展了 account.move 上一些 copy=False 字段,并希望它们在周期分录中延续,请优先考虑扩展 _get_fields_to_copy_recurring_entries()

这是官方预留的正确入口。

不要简单覆写 copy() 然后把整条递推逻辑搅乱。


九、最后总结

Odoo 的周期性分录,不是“定时复制上一张”这么简单。

它真正做的是:

  • auto_post 定义节奏;
  • auto_post_origin_id 固定源头;
  • _apply_delta_recurring_entries 保证日期推进稳定;
  • _get_fields_to_copy_recurring_entries 控制语义正确继承;
  • auto_post_until 划定递推边界。

所以如果你想看懂 recurring entry,不要把重点放在“复制”两个字,而要放在:

Odoo 如何稳定地生成下一期、同时保持这条会计链可解释。

那才是这套设计真正高明的地方。

DISCUSSION

评论区

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