很多人提到 Odoo 会计“防篡改”时,脑子里会立刻浮现两个词:
- posted 之后不能乱改
- 编号不能断号
这些理解不算错,但还是太表层了。
如果你去看 /home/ubuntu/odoo-temp/addons/account/models/account_move.py 里这几个方法:
account.move._get_integrity_hash_fields()account.move._is_move_restricted()account.move._get_chain_info()account.move._get_chains_to_hash()account.move._hash_moves()account.move._calculate_hashes()
你会发现 Odoo 真正要构造的不是“过账后编号固定”这么简单的一条规则,而是一条按序列前缀、按 journal、按前序 hash 串起来的审计链。
Odoo 的不可篡改设计重点不在“某一张单不能改”,而在“同一链上的单据必须能证明自己前后相连、字段未被悄悄重写”。
这就是 integrity hash 的真正意义。
一、为什么光有 posted 还不够
posted 只表达一件事:
- 这张分录已经从草稿变成正式会计事实。
但从审计角度看,仅仅“正式”还不够。
还需要回答:
- 这张正式分录后来有没有被改过关键字段?
- 如果改过,系统能不能看出来?
- 它和前一张、后一张同序列分录之间有没有断裂?
- 某些编号是不是被插空、跳号、补号?
也就是说,posted 解决的是“状态边界”,而 hash chain 解决的是“时间链可信度”。
二、先别急着算 hash,Odoo 先判断这张 move 是否属于受限链
_get_move_hash_domain() 和 _is_move_restricted() 先定义了一个边界:
- move 必须是
state = 'posted' - 默认还要
restrict_mode_hash_table = True - 或者在
force_hash=True的上下文里强制纳入
这说明 Odoo 的 hash 机制不是对所有分录一刀切,而是先回答:
这张 move 是否属于需要进入不可篡改链的那一类正式凭证。
这一步非常关键,因为它把“会计可追溯性要求”从普通业务编辑逻辑中独立出来了。
不是所有草稿、不是所有临时状态,都必须立刻进 hash chain; 但一旦进入受限范围,它就开始承担更强的审计要求。
三、hash 的对象不是单张 move,而是同一 journal + sequence_prefix 的 chain
_get_chain_info() 里有一个特别核心的约束:
- 同一条链要求属于同一个
journal_id - 同一个
sequence_prefix
这意味着 Odoo 不是把所有会计分录混在一起算一个全局 hash,而是按编号链组织审计单位。
这样做非常合理,因为实际审计上,最关心的是:
- 这个 journal 的序列有没有断;
- 这个前缀下的编号有没有被插改;
- 某一段编号链是否保持连贯。
所以 hash 的真实语义不是“每张单自己有个签名”,而是:
每张单都带着前一张单的影子,从而形成一整段顺序不可随便重写的链。
四、为什么 Odoo 特别在意 last hashed move
_get_chain_info() 会去找:
- 当前链上最后一张已哈希的 move
- 以及它的
inalterable_hash
然后只对后续未哈希、且编号不超过当前最后目标 move 的那段记录做处理。
这背后的设计非常精细。
它要解决的是:
- 已经 secure 过的历史链不能反复重算;
- 但新加入链尾的 move 必须接在旧 hash 后面;
- 如果 journal 是后来才开启 restricted mode,是否要把更早的未哈希 move 补进来,也是一个单独选择。
所以 hash 不是一次性全表扫描,而是增量续链。
这和区块链思路很像,但实现上更贴近会计序列审计场景。
五、为什么 gap 会被当成严重问题
_get_chain_info() / _get_chains_to_hash() 会显式检查 gap:
- 如果首尾 sequence number 与记录数量对不上
- 就认为存在 gap
然后在 raise_if_gap=True 时直接报错。
这说明在 Odoo 看来,序列 gap 不是“编号有点难看”,而是:
审计链可能已被破坏,系统无法再证明这段链是连续且完整的。
也就是说,gap 的问题不只是 UI 排序问题,而是可信性问题。
你可以理解为:
- 少了一张;
- 或中间插过东西;
- 或有已编号单据后来被删、作废、重排但没有正确修复链。
所以当系统说 gap 是错误,不是在吹毛求疵,而是在保护整段 hash chain 的可证明性。
六、为什么未对账的 bank statement line 也会阻止某些链上锁
_get_chain_info() 里还会检测 unreconciled:
- 只要待哈希的 moves 里关联到
account.bank.statement.line - 且
is_reconciled = False - 就会把它视为 warning
而 _get_chains_to_hash() 在默认行为下会因此报错:
“All entries have to be reconciled.”
这点非常有代表性。
说明 Odoo 认为某些和银行流水直接挂钩的会计事实,如果尚未完成 reconciliation,就不应贸然固化为不可篡改链的一部分。
也就是说,系统保护的不只是分录字段本身,还保护它所处流程的完成度。
七、真正参与 hash 的字段不是全部字段,而是精心挑过的字段
_get_integrity_hash_fields() 根据 hash version 返回字段:
- v1:
date,journal_id,company_id - v2 / v3 / v4: 再加上
name
而 _get_integrity_hash_fields_and_subfields() 还会把 line 上的 integrity fields 一起纳入。
这说明 Odoo 没打算把所有数据库列都塞进 hash,而是只选择那些足以表达会计身份与金额结构的关键字段。
这是一种非常实际的做法。
如果把所有字段都哈希进去:
- 很多纯展示、纯辅助字段改动也会导致 hash 失效;
- 审计价值不一定提升;
- 维护成本却会显著上升。
所以 Odoo 选的是“足以证明关键会计事实”的字段集,而不是“数据库快照签名”。
八、_calculate_hashes() 真正做的是前序 hash + 当前记录序列化
_calculate_hashes() 会:
- 取上一条 hash,作为
previous_hash - 收集当前 move 的关键字段
- 再收集每条 move line 的关键字段
- 用
json.dumps(..., sort_keys=True, separators=(',', ':'))序列化 - 把
previous_hash + current_record喂给sha256
这一步最核心的含义是:
当前 hash 不是只由当前记录决定,而是同时依赖前一条链的 hash。
所以只要链上某一张历史分录被改过,后面整段 hash 都会失去一致性。
这正是“链式不可篡改”的来源。
不是靠权限提示,不是靠前端禁用按钮,而是靠后续每一条都承认前一条存在且未被改写。
九、为什么 _hash_moves() 还会发消息、甚至可能激活安全组
_hash_moves() 在写入 inalterable_hash 后,还会:
- 给这些 move 批量记 message:
This journal entry has been secured. - 如果链中有 journal 本身未启用
hash on post,还可能激活account_secured相关组
这说明 Odoo 把“secure”视为一个明确事件,而不只是后台悄悄打标。
这对审计和实施都很有用:
- 用户能看到这张分录已经进入 secure 状态;
- 权限侧也能随之调整。
换句话说,hash chain 不只是算法,也是一种产品状态。
十、实施里最常见的误解
误解 1:只要 posted 就等于不可篡改
不是。posted 是状态,hash chain 是审计保障层。
误解 2:只要编号连续就够了
也不够。还要保证关键字段与前序链一致。
误解 3:gap 只是显示问题
不是。gap 会破坏链可证明性。
误解 4:hash 是按单张分录独立算的
不是。它依赖 previous hash,是链式结构。
十一、排查 secure / inalterable 问题时最有效的顺序
如果你遇到:
- move 不能 secure;
- 提示 gap;
- 提示 unreconciled;
- 开了 restricted mode 但还有未哈希条目;
建议优先按这个顺序查:
1)确认 move 是否属于 restricted 范围
先看 _is_move_restricted() 逻辑。
2)看 sequence_prefix 和 journal 是否形成预期中的链
链分组不对,后面全会乱。
3)查 last hashed move 之后是否存在 gap
这通常是最致命的问题。
4)查相关银行流水是否已 reconciled
未完成流程会阻止 secure。
5)最后再看 hash version 和字段集差异
版本升级或报告对比时才尤其重要。
结论
Odoo 会计不可篡改的核心,从来不只是“posted 后别改”或“编号别断号”。
它真正建的是一条由以下要素组成的审计链:
- 只纳入受限范围内的正式 move;
- 按 journal + sequence_prefix 分链;
- 以上一条 hash 串接下一条;
- 检查 gap 和未完成对账;
- 最终把 move 标记为 secured。
所以 integrity hash 的意义,不是给单张分录盖个章,而是证明一整段会计序列在时间上连续、在结构上可校验、在审计上不易被悄悄改写。
这比“编号固定”要严格得多,也更接近真正的会计防篡改要求。
DISCUSSION
评论区