很多人第一次用 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_postauto_post_untilauto_post_origin_idinvoice_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
评论区