会计源码

Odoo 应收应付科目为什么有时不是伙伴科目:payment term 行的 account_id 选取链与 fiscal position 重映射讲透

很多人以为发票上的应收应付科目只看伙伴属性,但 Odoo 真正的选取链还会考虑历史 term line、公司兜底科目和 fiscal position 的 account remap。本文基于 `/home/ubuntu/odoo-temp/addons/account/models/account_move_line.py` 拆开讲透。

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

很多人第一次排查 Odoo 发票分录时,都会先看伙伴主数据:

  • 客户就看 property_account_receivable_id
  • 供应商就看 property_account_payable_id

然后一旦分录上的应收/应付科目和自己预期不同,就会觉得系统“乱选科目”。

但如果你去看 /home/ubuntu/odoo-temp/addons/account/models/account_move_line.pyaccount.move.line._compute_account_id() 的实现,会发现 Odoo 对 payment term 行 的科目选择远比“取伙伴属性”复杂。

它真正的优先链大致是:

  1. 同一张 move 里已有的 payment term 行沿用原科目
  2. 否则取伙伴的应收/应付属性科目;
  3. 再不行就退到公司自己的伙伴科目;
  4. 还不行再退到公司可用的 receivable/payable 兜底科目;
  5. 最后如果 move 上挂了 fiscal_position_id,还会再做一次 map_account() 重映射。

也就是说,发票上的应收应付科目不是一个“静态字段展开”,而是一条带继承、带兜底、还能被 fiscal position 改写的生成链。


一、为什么 payment term 行不是“随便找个应收科目”

Odoo 先区分:

  • display_type == 'payment_term' 的行
  • 普通产品行
  • tax 行 / rounding 行等其他动态行

payment term 行本质上不是商品收入或费用,它代表的是:

这张单最终落到谁的应收/应付,以及这些余额该在哪个往来科目上承载。

所以它的科目生成,不走产品收入/费用逻辑,而走往来账户逻辑


二、第一优先级:沿用同 move 已有的 payment term 科目

源码里先查的是同一个 account.move 里,是否已经存在别的 payment term 行。

这一步很关键。

它表达的是:

如果这张凭证已经出现过 payment term 行,那新增/重算出来的 payment term 行,优先保持和现有行一致。

这能避免两个很常见的问题:

  • 同一张发票拆成多期后,多个 term line 落到不同往来科目;
  • 重算动态行时,因为伙伴属性或上下文变化,导致同一张单内部分裂。

所以 Odoo 先求“单据内部一致性”,再求“主数据默认值”。


三、第二优先级:伙伴属性科目

如果 move 里没有已有的 term line 可参考,才回到大家熟悉的路径:

  • 销售单据用 property_account_receivable_id
  • 采购单据用 property_account_payable_id

这一步依然不是简单取 partner_id,而是取:

  • commercial_partner_id
  • 并且在 with_company(move.company_id) 的上下文里读取

这两个细节很重要:

1)为什么要用 commercial partner

因为 Odoo 记账时希望同一商业主体的往来余额尽量归到商业伙伴层,而不是某个联系人地址层。

2)为什么要切公司上下文

因为这些属性字段是 company-dependent 的。多公司环境下,不切对公司,拿到的可能根本不是当前账套应使用的科目。


四、第三和第四优先级:公司伙伴 + 公司兜底科目

如果伙伴身上根本没配应收/应付科目,Odoo 不会立刻报错,而会继续往后找:

  1. 先找 company.partner_id 上的应收/应付属性;
  2. 再找该公司下活跃的 receivable / payable 兜底科目。

这说明 Odoo 的设计目标不是“主数据不全就彻底停摆”,而是:

尽量把分录生成出来,但仍保证它落在语义正确的往来账户类型里。

所以你有时会看到:

  • 某个伙伴并没配往来科目;
  • 发票却仍然能出分录;
  • 最后落的是公司层默认或兜底科目。

这不是随机行为,而是源码里明确写死的 fallback 链。


五、最容易被忽略的一步:Fiscal Position 还能再改一次科目

很多人知道 fiscal position 会改税,但不知道它也能改科目。

在 payment term 行选出基础 account 后,源码还有一刀:

  • move.fiscal_position_id.map_account(...)

也就是说,最终应收/应付科目并不一定等于伙伴属性科目本身。

如果 fiscal position 里配置了 account mapping,那么 Odoo 会把“原本准备使用的往来科目”再映射成另一个科目。

这就解释了很多排查现场里的疑问:

  • 伙伴属性明明是 A;
  • 为什么分录最后挂到 B?

答案往往不是伙伴错了,而是:

  • 伙伴给出了基础科目;
  • fiscal position 又在最后一步把它改成了目标科目。

六、为什么这套链路很适合发票动态重算

发票在 Odoo 里不是“一次算完不再动”,而是会随着:

  • partner 改变
  • move type 改变
  • fiscal position 改变
  • payment term 改变

不断重算动态行。

如果科目选择没有这套层级,就很容易出现:

  • 同一张单前后重算结果不一致;
  • 多期付款行各自挂不同往来科目;
  • 多公司环境误用别家公司科目;
  • fiscal position 只能改商品收入费用,不能改往来逻辑。

Odoo 现在这套写法,本质上是在保证:

payment term 行无论怎么重算,都尽量先保持单据内部一致,再遵守公司与 fiscal position 规则。


七、排错时应该按什么顺序看

如果你发现发票上的 receivable/payable 科目“不对”,别只盯伙伴卡片,建议按这个顺序看:

  1. 这条行是不是 display_type = payment_term
  2. 同 move 里是否已有别的 payment term 行先占了科目;
  3. 当前公司下商业伙伴的应收/应付属性是什么;
  4. 公司自己的伙伴科目和兜底科目是什么;
  5. move 上的 fiscal_position_id 是否有 account mapping。

这个顺序比“打开伙伴表单看一眼”靠谱得多。


一句话记忆

Odoo 的 payment term 行科目,不是“直接拿伙伴属性”,而是“先保单据内一致,再走伙伴/公司 fallback,最后再让 fiscal position 改写一次”。

DISCUSSION

评论区

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