编号链

Odoo 会计编号为什么不是“取下一个序号”这么简单:SequenceMixin 的 reset、正则拆段与并发抢号讲透

很多人把 Odoo 编号理解成“找到最后一条,再加一”。但从 sequence_mixin.py 看,真正困难的不是 +1,而是先判断这串编号按月重置、按年重置还是永不重置,再在并发事务里安全地抢到下一个号,还不能把日期和编号链打乱。

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

很多人第一次看 Odoo 会计编号,都会把它脑补成一个很朴素的流程:

  1. 查最后一张凭证;
  2. 把末尾数字加 1;
  3. 写回去。

这个理解只在“单线程、纯数字、永不换年换月、没人改前缀”的世界里成立。

/home/ubuntu/odoo-temp/addons/account/models/sequence_mixin.py 真正解决的是一个复杂得多的问题:

同一套编号既要允许人类读懂,又要允许按日期重置,还要在多人同时过账时不撞号。

所以 SequenceMixin 真正的重点,不是“加一”,而是三件事:

  • 先识别这条编号属于哪一种重置制度;
  • 再从旧编号里拆出 prefix、year、month、seq;
  • 最后在并发事务里安全地产生唯一的下一个号码。

一、Odoo 先关心的不是数字,而是“这条链按什么制度重置”

_deduce_sequence_number_reset() 里,源码会按几组正则去判断当前编号属于哪类序列:

  • year_range_month
  • month
  • year_range
  • year
  • never

这一步非常关键。

因为 Odoo 不是先拿到一个数字再决定怎么展示,而是反过来:

先从现有编号长相里,推断它的时间语义。

比如:

  • INV/2026/03/0007 很像月度重置;
  • INV/2026/0007 很像年度重置;
  • 0007 则更像固定不重置。

这解释了一个实战里很常见的误区:

为什么“我只改了前缀”,后面整条编号链却开始怪了?

因为你改的不是装饰文本,而是序列格式本身的证据

一旦格式变了,Odoo 之后再看这条编号时,推断出来的 reset 周期可能就变了; 再往后取“上一条”时,搜索域和格式化结果也可能跟着变。

二、正则拆段不是为了炫技,而是为了把旧编号还原成一张模板

_get_sequence_format_param(previous) 的作用,不是简单解析字符串,而是把上一条编号拆成:

  • prefix
  • year / year_end
  • month
  • seq
  • suffix
  • 各字段长度

然后再生成一个 Python format 模板。

可以把它理解成:

  • 旧编号是一个样本;
  • Odoo 把样本拆成“结构 + 变量”;
  • 下一条编号是在这个模板上替换年份、月份和 seq 之后得到的。

这就是为什么源码不只是保存 sequence_number,还会保存:

  • sequence_prefix
  • sequence_number

因为它后面要按“前缀链”找最后一条,也要按数字比较大小,而不是只靠整串字符串硬算。

三、为什么 _get_last_sequence() 不只是“找最大数字”

_get_last_sequence() 里最容易被忽略的一点是:

它并不是全表找一个最大的 sequence_number,而是先按 domain 找到最近的前缀链,再在这条链里取最大序号。

源码里的说明写得很直白:

如果你把最后一条从 INV/2026/0002 改成 FACT/2026/0001,下一次未必会继续从 FACT/2026/0002 开始; 因为对数据库来说,前缀字母顺序本身会影响“最后那条链”是谁。

这意味着:

前缀不是 UI 皮肤,而是排序与选链条件的一部分

因此在同一期间中途换前缀,往往是危险操作。

更准确地说,SequenceMixin 的思路不是:

  • “无论你怎么改,我都能聪明接上。”

而是:

  • “请尽量保持一段期间内的链稳定;如果之前改乱了,用 resequence 明确修复。”

这也是 _constrains_date_sequence() 存在的意义:

  • 日期和编号链不匹配时,不让你继续假装这是一条正常链;
  • 想修,先清空编号或走 resequence。

四、为什么 Odoo 要校验“日期和编号是否匹配”

_sequence_matches_date() 会把编号重新拆开,再检查:

  • 年份是否和记录日期一致;
  • 月份是否和记录日期一致;
  • year range 场景里起止年份是否合理。

这一步不是形式主义,而是为了防止两类真实事故:

1)人为改日期,不改编号

比如一张 2026 年 3 月的分录,被改到了 4 月,但名字还是 INV/2026/03/xxxx

2)人为改编号,不顾日期语义

比如把 2026 年的凭证手工改成 2025 前缀,只因为“想补一个空号”。

在会计上,这类行为最大的问题不是“显示不好看”,而是:

时间维度的编号链会失真,后续过账、审计、重排都很难判断什么才是正常顺序。

五、真正最难的部分,其实是并发抢号

如果系统只有一个人过账,SequenceMixin 根本不需要这么复杂。

真正麻烦的是:

  • 两个人同时过账;
  • 或者一个批任务批量过账几十张;
  • 或者 webhook / cron / 异步流程同时生成需要占号的记录。

这时,最危险的写法就是:

  1. 读最后一个号;
  2. 本地 +1;
  3. 再写回。

因为两个事务可能同时读到同一个“最后号码”。

_locked_increment() 在解决什么

源码里的 _locked_increment() 做了一件非常关键的事:

  • 通过对受唯一约束保护的行执行 UPDATE,拿到数据库层的锁;
  • 如果发现新号码已被别的事务抢走,就在 savepoint 内回滚重试;
  • 一旦当前事务拿到这条序列的锁,后续同事务的连续取号优先走事务级缓存。

这背后体现的是很典型的 Odoo 风格:

不要自己发明并发控制,尽量借数据库唯一约束和事务锁来保证编号唯一。

这也是 init() 里会警告缺少 unique index 的原因。

源码甚至明确提醒:

  • 如果 sequence.mixin 相关唯一索引缺失,重载场景下就可能出现重复编号。

换句话说,SequenceMixin 的正确性,一半在 Python,另一半在 PostgreSQL 的唯一约束与锁语义里。

六、为什么还要搞一个 transaction cache

_get_sequence_cache() 乍看像性能优化,实际上它同时解决了性能和稳定性。

原因是:

  • 每取一个号都走 savepoint,成本很高;
  • 大量 savepoint 在高并发下会明显拖慢事务;
  • 但完全不做保护,又会撞号。

所以 Odoo 的折中方案是:

  1. 第一次取号时,先通过数据库锁拿稳;
  2. 拿稳后,把当前格式串与 sequence index 作为 cache key;
  3. 在同一事务里连续取后续号码时,优先走缓存累加。

这个设计说明了一点:

SequenceMixin 不是“每次都重新算一次最新值”,而是“先取得一段事务内的占号权,再顺着往下发”。

因此它既保住了唯一性,也尽量避免重复 savepoint 带来的性能损耗。

七、_set_next_sequence() 为什么还要同时改 ORM 和数据库

_set_next_sequence() 的注释很值得看。

它强调:

  • 编号必须同时在 ORM 和数据库层被设置;
  • 因为后续找“上一条编号”是直接走 SQL 查询;
  • 如果 ORM 状态和数据库状态不同步,下一次求号就可能拿到过期数据。

这就是为什么它不是简单 self.name = new_name,而是:

  • 先调用 _locked_increment() 拿到新号码;
  • 再通过上下文控制避免误清缓存;
  • 然后补跑受该字段触发的 compute 字段;
  • 最后重算 sequence_prefixsequence_number

也就是说,SequenceMixin 不是“改字符串字段”而已,它还在维护:

  • 序列链索引;
  • 后续依赖字段;
  • 供 SQL 查询继续使用的一致状态。

八、为什么 resequence 不能被理解成“补个空号”

很多团队看到中间断号,就会本能地想:

  • 把后面的号码手工往前挪一下。

但从 SequenceMixin 的设计看,resequence 真正处理的是三层问题:

  • 日期与编号语义重新对齐;
  • 同一前缀链重新排序;
  • 保持未来继续生成新号时仍能接在正确链上。

所以 resequence 不只是补一个空缺,它实质上是在重建一条时间和格式都可继续使用的链

如果你只是手动改几张名字,看起来像修好了,实际上可能只是把问题推迟到下一次过账。

九、给实施和开发的几个实战建议

1)不要在同一会计期间里频繁改前缀

尤其不要边过账边改。

因为 _get_last_sequence() 很依赖前缀链的稳定性。

2)日期错了,优先修日期或走 resequence,不要直接硬改名字

否则 _constrains_date_sequence() 迟早会把你拦下来,或者更糟: 短期没拦住,但之后整条链开始不可信。

3)高并发场景一定确认唯一索引真实存在

源码已经明说,少了 unique index,重载时可能重复编号。

4)不要自己写“查最后一条 +1”的土办法覆盖官方逻辑

那样最容易在批量过账、异步任务、并发操作下撞号。

5)把编号理解成“受日期语义约束的链”,不是普通显示字段

这会直接改变你处理问题的顺序。

总结

SequenceMixin 最值得记住的一点是:

Odoo 会计编号的难点从来不是数字加一,而是让“可读格式、日期重置、数据库唯一性、并发安全”四件事同时成立。

所以当你遇到这些现象时:

  • 编号看起来跳了;
  • 改个前缀后接不上;
  • 换月后编号不对;
  • 批量过账时偶发重复号;

不要只盯着“最后一个数字”。

真正该看的,是这四层:

  1. 当前编号格式属于哪种 reset 制度;
  2. 日期和编号是否仍匹配;
  3. 前缀链是否被人为打断;
  4. 数据库唯一约束和并发锁是否真正生效。

把这四层看清之后,SequenceMixin 就不再像一团正则黑魔法,而是一套很务实的“可编辑编号 + 会计审计 + 并发安全”折中方案。

DISCUSSION

评论区

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