很多人把 Odoo 的“注册付款”理解成一个很轻的动作:
- 点按钮
- 填金额
- 发票状态改成 Paid
但真正看 /home/ubuntu/odoo-temp/addons/account/wizard/account_payment_register.py 和 account_move.py,你会发现这条链路远比“打状态”严肃得多。
它本质上做了三件事:
- 先把待付分录按批次整理;
- 生成并过账
account.payment/account.move; - 再通过核销结果,让发票自己重算
payment_state。
所以:
payment_state不是向导直接写上的标签,而是核销后的会计结果。
一、注册付款的第一步不是改发票,而是挑出有效批次
在 _create_payments() 里,向导首先会遍历 self.batches。
它不会盲目全建,而会先检查:
- 对应收款 / 付款方式是否有可用银行账户;
- 伙伴银行账户是否需要人工验证;
- 当前批次是否满足付款创建条件。
这说明“注册付款”在 Odoo 里不是一个随便落记录的 UI 行为,而是一个带支付前校验的业务动作。
如果批次都无效,源码会直接抛 UserError,而不是先建一个半残 payment 再说。
二、向导先决定是 edit mode 还是分批创建
_create_payments() 里有个很关键的分叉:
edit_mode- 非
edit_mode
如果可以编辑且只涉及单条或允许 group payment,向导会走更像“当前这一笔付款”的路径; 否则会把待付行拆成多个子批次,一笔一笔生成 payment。
这一步的意义是:
- Odoo 不是把“注册付款”硬编码成永远一张 payment;
- 它会根据分组、分期、付款对象和批次特征决定生成结构。
所以你在界面里看到“同样是注册付款,这次合成一笔、下次拆成多笔”,往往不是随机,而是批次逻辑不同。
三、真正让发票进入支付链的是 _reconcile_payments()
向导不是建完 payment 就结束。
源码里 _reconcile_payments() 才是让发票状态开始变化的核心动作。
它会:
- 找出 payment move 上已过账、未核销、且属于有效付款账户类型的行;
- 取本次要核销的发票行;
- 按 account 分组;
- 对同科目的 payment lines + source lines 调用
.reconcile()。
这意味着 Odoo 的设计非常会计化:
不是“付款对象指向发票”就算付了,而是应收 / 应付行真正核销了才算进入支付结果。
这也是为什么有些开发者手工建 payment、手工关联 invoice,最后 payment_state 仍不对。
因为缺的不是“关系字段”,而是:
- 对的 journal items
- 对的科目
- 对的 reconcile 行为
四、payment_state 到底怎么从 not_paid 变成 partial / paid / in_payment
account_move.py 里的 _compute_payment_state() 是整条链路的判官。
它不是简单看有没有 payment 记录,而是综合判断:
amount_residual是否为 0;- 核销对手是不是 payment 或 statement line;
- 这些 payment 是否都 matched;
- 当前发票是不是仍有部分残额;
- 是否属于冲销形成的 fully reconciled 场景。
源码里几个关键结果可以这样理解:
not_paid
默认起点。
只要残额还在、没有足够核销结果,这就是最自然的状态。
partial
有核销,但残额还没清零。
也就是:
- 你确实付了一部分;
- 但从会计结果看,这张发票还没彻底结清。
paid
一般出现在:
- 残额为 0;
- 且对应 payment 都已 matched;
- 或者虽然没有 payment 记录,但核销结果已经把它彻底结平。
in_payment
这个状态最容易误解。
源码里 _get_invoice_in_payment_state() 的默认实现其实返回 paid``。
也就是说,在基础会计模块里,Odoo 默认并不坚持把 fully paid 但尚在支付流程中的发票显示成 in_payment。
这个方法的注释已经说得很清楚:
- 之所以做成 hook;
- 是因为只做 invoicing 的用户,不想看到
in_payment; - accountants 模块会 override 它,让会计场景可以启用
in_payment语义。
这意味着:
你看到的是
paid还是in_payment,不只取决于有没有付款,还取决于当前安装模块对会计状态语义的扩展。
reversed
如果发票因为冲销类分录被完整抵消,源码也会识别出这种 fully reconciled 但语义上更接近“已被反转”的状态。
所以并不是所有“残额归零”的发票都应该叫 paid。
五、为什么有时付款建好了,状态却没立刻变成 Paid
这是实战中最常见的困惑之一。
一般从源码逻辑看,常见原因有四类:
1)payment 建了,但没真正 reconcile 到应收 / 应付行
如果 payment line 和 invoice line 没走到 .reconcile(),状态当然不会如你预期变化。
2)核销了,但只是部分核销
那它就应该是 partial,而不是 paid。
3)支付流程语义仍停在 in_payment
尤其在启用更完整会计流程的环境里,fully paid 不一定立刻显示 paid。
4)你做的是“关系补线”,不是“会计结果补齐”
比如只回写了某些关联字段,没把 journal item 层真正接上。
六、这条链路解决的核心问题:让 UI 状态服从会计事实
从设计上看,Odoo 很刻意地避免这样一种危险实现:
- 点击按钮后,直接把发票写成 paid;
- 后面再想办法补 payment 和核销。
如果这么做,系统很快就会出现:
- UI 看起来已付款;
- 会计分录其实没平;
- 银行对账也说不通;
- 多币种差额、write-off、statement line 都会乱套。
所以 Odoo 反过来做:
- 先生成 payment;
- 再过账;
- 再核销;
- 最后由发票从会计事实中推导
payment_state。
这才是企业会计系统该有的方向。
七、调试建议:别先盯状态字段,先盯 journal items
如果现场出现“为什么还是 not_paid / partial / in_payment”,最稳的排查顺序通常是:
- 看 payment 是否真的创建并过账;
- 看 payment move 上哪些 line 属于有效付款账户类型;
- 看 invoice 的 receivable / payable line 是否与 payment line 真正 reconcile;
- 看残额是不是确实归零;
- 再看当前环境里
_get_invoice_in_payment_state()被谁 override。
这样查,比直接猜 UI 状态可靠得多。
结语
“注册付款”在 Odoo 里不是一个改标签的按钮,而是一条严格的会计链路。
它真正表达的是:
- 付款对象先被建出来;
- 会计分录先成立;
- 核销关系先成立;
- 发票状态再作为结果被计算出来。
所以以后再问“为什么点了 Register Payment 还是不是 Paid”,更精准的问题应该是:
这张发票的应收 / 应付行,真的已经按 Odoo 认可的方式被支付并核销了吗?
DISCUSSION
评论区