会计付款

Odoo 付款为什么改了 payment 还要回写 move:_synchronize_to_moves、line 重建与付款单/分录双向一致性讲透

很多人把 `account.payment` 当成 `account.move` 的壳,但 `/home/ubuntu/odoo-temp/addons/account/models/account_payment.py` 说明事实恰好更复杂:payment 是业务对象,move 是会计落账对象,二者必须通过 `_synchronize_to_moves()` 和 `_generate_move_vals()` 保持结构一致,而不是谁单方面覆盖谁。

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

很多人在 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() 列出来的字段很有意思:

  • date
  • amount
  • payment_type
  • partner_type
  • payment_reference
  • currency_id
  • partner_id
  • destination_account_id
  • partner_bank_id
  • journal_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_lines
  • counterpart_lines
  • writeoff_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.memo
  • date
  • journal_id
  • company_id
  • partner_id
  • currency_id
  • partner_bank_id
  • line_ids
  • origin_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.paymentaccount.move 的关系,既不是一张表套一张表,也不是谁完全从属于谁。

Odoo 的真实设计是:

  • payment 负责承载业务付款语义;
  • move 负责承载正式会计结果;
  • _generate_move_vals() 负责首次把付款翻译成分录;
  • _synchronize_to_moves() 负责在草稿阶段持续保持二者一致;
  • posted 状态则明确切断这种可随时回写的关系。

所以“改了 payment 为什么还要回写 move”这个问题,本质上不是技术细节,而是 Odoo 在业务对象与会计对象之间维持一致性的核心设计。

理解这条链,很多付款、核销、write-off 和 journal 切换问题都会清楚得多。

DISCUSSION

评论区

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