很多人在 Odoo 里看到付款单时,会自然把它理解成:
- 前台是
account.payment - 后台就是一张
account.move - 两者一一对应
- 改哪个都差不多
但 /home/ubuntu/odoo-temp/addons/account/models/account_payment.py 的实现说明,事情并没有这么简单。
相关关键方法至少包括:
account.payment._synchronize_to_moves()account.payment._get_trigger_fields_to_synchronize()account.payment._generate_journal_entry()account.payment._generate_move_vals()account.payment._prepare_move_line_default_vals()
把这些放在一起看,Odoo 的真实设计其实是:
account.payment是业务语义对象,account.move是会计事实对象。二者既不能彼此脱钩,也不能粗暴地互相覆盖,所以系统需要一条显式同步链。
一、为什么 Odoo 不直接只保留 move
如果只是记账,从纯会计角度看,一笔付款最终落到的的确是一张 journal entry。
但业务上,付款还带着很多并不等于“分录字段”的语义:
- 付款方向
payment_type - 伙伴类型
partner_type - 付款方式行
payment_method_line_id - 银行账户
partner_bank_id - memo / payment reference
- outstanding account 与 destination account 的推导
这些字段并不是简单地存在 account.move 上就能表达完整业务意图。
所以 Odoo 需要 account.payment 这个业务容器。
但同一时间,真正入账又必须是 account.move + account.move.line。
于是问题变成:
业务层 payment 改了,底层 move 如何持续跟上?
这正是 _synchronize_to_moves() 存在的理由。
二、不是所有字段都会触发同步,只有“改变会计含义”的才会
_get_trigger_fields_to_synchronize() 列出来的字段很有意思:
dateamountpayment_typepartner_typepayment_referencecurrency_idpartner_iddestination_account_idpartner_bank_idjournal_id
这说明 Odoo 的同步策略不是“payment 一改就整张 move 重写”,而是:
只有那些会改变 journal entry 结构或语义的字段,才值得触发同步。
这是一种很克制的设计。
否则你会得到两个坏结果:
- 不必要的反复重算;
- 用户以为改了一个业务备注,却无意间把底层分录重建了一遍。
三、为什么 posted move 不同步
_synchronize_to_moves() 一开始就会跳过:
pay.move_id.state == 'posted'
这条边界非常重要。
它意味着:
payment 和 move 的“自动一致性维护”,只发生在 move 还处于可编辑阶段时。
一旦底层分录已经 posted,Odoo 不再把它当成可以随 payment 任意揉捏的草稿结构。
这和整个会计系统的一致哲学完全一致:
- 草稿可以同步
- 已过账不能随便回写
否则 payment 端一次小改动,就可能把正式入账事实悄悄改掉。
四、同步不是“改几个字段”,而是先重新找 liquidity / counterpart / write-off
同步时,系统先调用 _seek_for_lines(),把现有 move lines 拆成三类:
liquidity_linescounterpart_lineswriteoff_lines
这一步很关键。
它表明 Odoo 从来没把付款分录当成一团平铺的 lines,而是有明确角色分层:
1)liquidity line
代表钱实际进出哪一个流动性账户。
2)counterpart line
代表这笔付款真正冲向哪个应收/应付或目标科目。
3)write-off lines
代表额外差额解释,比如小额差异、手续费或其他补充分录。
也就是说,payment 同步不是“把 line_ids 原样复制”,而是在尝试重建这三种角色之间的平衡关系。
五、为什么 Odoo 要先保留 write-off 金额,再重建 line
源码里还有一个特别好的细节:
如果现有 move 里同时存在 liquidity / counterpart / write-off lines,Odoo 会先把 write-off 聚合成 write_off_line_vals,再交给 _prepare_move_line_default_vals()。
这表达的是一个非常现实的取舍:
同步 payment 时,系统允许重新生成结构,但不应该悄悄把用户已经确认过的 write-off 含义丢掉。
所以 Odoo 不是“把旧 write-off 行保留原样不动”,而是:
- 先抽取金额和关键字段;
- 再在新的付款结构里重建 write-off 行;
- 同时删除旧 write-off 行。
这样做的好处是:
- 能适应 amount、journal、currency 等变化;
- 不会把过时的旧 line 结构继续带着跑;
- 又能保住 write-off 的业务语义。
这比“全删重建”更稳,也比“全部沿用旧行”更一致。
六、真正生成 line 结构的是 _prepare_move_line_default_vals()
_synchronize_to_moves() 自己并不直接算借贷分录,它把任务交给 _prepare_move_line_default_vals()。
这说明 payment 同步的设计分层很明确:
_synchronize_to_moves()负责判断要不要同步、如何组织命令;_prepare_move_line_default_vals()负责生成这笔付款在当前上下文下“应该是什么 lines”。
这也解释了为什么 payment 看起来像一个业务对象,但其实始终与会计结构深度耦合。
因为只要:
- amount 变了
- payment_type 变了
- destination account 变了
- partner / currency / journal 变了
理论上的 line 结构就都可能跟着变。
七、journal_id 为什么特殊:改 journal 时连 name 都要回到 /
同步逻辑里还有一个非常容易被忽略的细节:
如果 journal_id 在 changed fields 里,系统会额外写:
name: '/'journal_id: pay.journal_id.id
为什么?
因为在 Odoo 里,分录编号通常依赖 journal 的序列规则。
如果你把付款从 A journal 切到 B journal,但保留旧 name,就可能出现:
- 编号前缀不属于新 journal;
- 序列逻辑失真;
- 后续过账或审计链不一致。
所以改 journal 不只是换个外键,而是意味着:
这张 move 的编号语义也应该重新交给新 journal 的序列体系。
'/' 在这里就是“允许重新命名”的信号。
八、为什么 _generate_journal_entry() 和 _generate_move_vals() 说明 payment 不是 move 的附属品
如果 payment 还没有 move_id,_generate_journal_entry() 会调用 _generate_move_vals() 去创建新的 move。
_generate_move_vals() 准备的字段包括:
move_type: 'entry'ref: self.memodatejournal_idcompany_idpartner_idcurrency_idpartner_bank_idline_idsorigin_payment_id
最值得注意的是 origin_payment_id。
它说明 move 不是孤零零生成出来的,而是明确知道:
- 自己是从哪一笔 payment 派生来的;
- 这不是普通杂项分录,而是付款链的一部分。
换句话说,Odoo 不是“先建 move,再套个 payment 壳”,而是:
payment 驱动 move 的生成,move 承载 payment 的会计结果。
九、这套同步机制最容易暴露哪些实施误区
误区 1:直接改 move lines,期待 payment 自己理解
通常不会。
payment 只会在它自己的触发字段变化时,把业务对象同步到 move; 不是反向把所有 move line 手工改动都自动吸收回来。
误区 2:posted 后还想继续把 payment 当草稿单改
posted move 已经越过了自动同步边界。
误区 3:把 write-off 当普通附加行,随便插在 move 上
同步重建时,它们可能被重新归并或重生。
误区 4:切换 journal 只看界面显示,不考虑序列和 name
这会让后续编号逻辑出问题。
十、排查 payment / move 不一致时,最有效的顺序
如果你遇到:
- payment 页面金额改了,分录没跟着变;
- 分录行结构怪;
- write-off 消失或重建了;
- journal 换了之后 name 变了;
建议优先按这个顺序排查:
1)底层 move 是否已经 posted
posted 就不会走自动同步。
2)修改的字段是否在 _get_trigger_fields_to_synchronize() 列表内
不在就不该期待触发会计重建。
3)现有 move line 是否还能被 _seek_for_lines() 正确分成 liquidity / counterpart / write-off
角色识别失败,后续同步自然会怪。
4)是否有自定义代码直接改写了 line_ids
最常见的脏问题来源。
5)改 journal 时是否理解 name 被置回 / 是预期行为
这通常不是 bug。
结论
account.payment 和 account.move 的关系,既不是一张表套一张表,也不是谁完全从属于谁。
Odoo 的真实设计是:
payment负责承载业务付款语义;move负责承载正式会计结果;_generate_move_vals()负责首次把付款翻译成分录;_synchronize_to_moves()负责在草稿阶段持续保持二者一致;posted状态则明确切断这种可随时回写的关系。
所以“改了 payment 为什么还要回写 move”这个问题,本质上不是技术细节,而是 Odoo 在业务对象与会计对象之间维持一致性的核心设计。
理解这条链,很多付款、核销、write-off 和 journal 切换问题都会清楚得多。
DISCUSSION
评论区