很多团队第一次用 Batch Payment,注意力都放在“怎么把多笔 payment 聚成一个 batch”。
但真正到了财务落地阶段,最容易出问题的往往不是建批次,而是 批次进入银行对账之后会发生什么。
常见误解大概有三种:
- 批量付款只是导出银行文件的壳子,后面对账和它无关;
- 一旦银行流水匹配成功,batch 自己就等于最终凭证;
- 如果撤销对账,删掉 statement line 上那条已核销行就行,不会影响 payment 本身状态。
如果你去读 /home/ubuntu/odoo-temp/enterprise/account_accountant_batch_payment,会发现企业版真正补强的并不是“再造一个更大的付款单”,而是:
让 batch 成为银行对账界面里的一个可回放、可撤销、可回写 payment 集合。
这意味着它要处理的不只是“把几笔付款装进袋子”,还包括:
- 对账时怎样把 batch 对应的 move line 塞进 statement line;
- 某些 payment 还没有会计分录时,什么时候自动补验证;
- 删除已核销行之后,payment 为什么会退回
in_process; - 发票已经被付款影响了 residual / payment state 时,为什么还要被强制 draft 再 post 一遍。
这篇就专门讲这条 “batch → bank rec → rollback” 链,而不重复上一篇关于 amount / amount_residual 的口径问题。
一、企业版真正加的不是新账,而是“批次可进入银行对账”的桥
先看 models/account_batch_payment.py。
这里最核心的方法不是创建 batch 的动作,而是:
_get_amls_from_batch_payments(domain)_get_amls_for_reconciliation(st_line)
它透露出一个重要事实:
批量付款进入银行对账时,系统关心的是它最终能贡献哪些 account.move.line。
_get_amls_from_batch_payments() 会先把已有 move_id 的 payment 取出来,调用 payment._seek_for_lines(),只抓流动性相关的 liquidity_lines。然后它做两件事:
- 把这些已存在的 move line 收集起来;
- 再用
_get_aml_values(...)反向生成一组准备塞入 statement line 的数据。
这里的 balance=-move_line.balance、amount_currency=-move_line.amount_currency 很关键。
这说明银行对账里追加的行,不是把原 payment 再复制一遍,而是为了在 statement line 上构造一个 可抵消、可勾稽 的对向行。
同时,企业版还给 account.move.line 加了一个技术字段:
payment_lines_ids
这个字段表面不起眼,但它直接决定了后续“撤销已核销行时,系统还能顺藤摸瓜找到原 payment”。
换句话说,企业版不是单纯让 batch 出现在银行对账候选里,而是提前布好了 回写路径 和 回滚路径。
二、选中一个 batch 时,真正落地的是 statement line 的补线与 payment 校验
再看 models/account_bank_statement_line.py 里的:
set_batch_payment_bank_statement_line(batch_payment_id)
它的主链路很短,但信息量很大:
- 取出
account.batch.payment; - 调
batch_payment._get_amls_for_reconciliation(self),拿到要追加的 move lines; - 用
_add_move_line_to_statement_line_move(amls_to_create)把这些行塞到当前银行流水分录里; - 如果某些 payment 还没有
move_id,但状态已经属于_valid_payment_states(),就额外执行action_validate()。
这里最容易被忽略的是第四步。
很多人以为“batch 进入对账”只是在前端 widget 里做匹配展示。但企业版其实承认一种现实:
- 有些 payment 此时已经在业务上成立;
- 但它们还没有正式会计分录;
- 一旦你决定把它们与实际银行流水勾上,系统就必须把这层会计形态补齐。
所以 set_batch_payment_bank_statement_line() 的意义不是“标记这个 batch 已匹配”,而是:
把 batch 里的 payment 真正投影进 statement line 所代表的会计世界。
这也解释了为什么银行对账不是只读视图,而是会改变 payment 生命周期的入口。
三、有分录和无分录两种 payment,进入对账后的落账口径并不一样
测试文件 tests/test_batch_payment.py 很能说明企业版在意的边界。
1)有会计分录的 payment:直接勾到 liquidity / outstanding 侧
在 test_bank_rec_widget_batch_payment_with_entries() 里:
- 先创建 payment;
- 再
create_batch_payment(); - 然后把 batch 绑到 statement line。
最后断言的结果是:
- statement line 上会有一条银行默认科目行;
- 还有一条来自 payment method outstanding account 的反向行;
- 这条行会直接与 payment
move_id中资产流动性相关的 line 建立reconciled_lines_ids。
这说明对已有分录的 payment,企业版更偏向做 现成分录之间的勾稽。
2)无会计分录的 payment:先补出能进对账的行,再视需要验证
在 test_partner_account_batch_payments_without_journal_entry() 和 test_bank_rec_widget_batch_payment_without_entries() 里,可以看到另一条逻辑:
- 没有 journal entry 的 payment,系统不会凭空装作已经存在 liquidity line;
- 它会按收款/付款方向,临时转成应收或应付侧的 move lines;
- 如果这批 payment 后续被确认要与银行流水绑定,才进一步
action_validate()。
所以“同样是 batch 进银行对账”,底层并不是统一一条机械逻辑。
Odoo 实际在回答两个不同问题:
- 已有分录的 payment,该怎样被 statement line 正确勾住?
- 尚未生成分录的 payment,什么时候必须补成正式会计对象?
这正是企业版场景复杂于社区版常见理解的地方。
四、删除已核销行不是简单 undo,而是一次 payment 与 invoice 的联动回滚
真正体现企业版细致程度的,是:
delete_reconciled_line(move_line_ids)
它在 super() 删除 statement line 上指定已核销行之后,还会继续做两段回滚。
第一段:payment 回到 in_process
代码注释写得非常直白:
不去碰 batch 本身,因为 batch 只是 payment 的 envelope。
这句话值得单独记住。
官方明确把 batch 定义成:
- 一个容器;
- 一个会计工作流的入口;
- 但不是“主账实体”。
所以撤销某条已核销行时,真正应该回退的是 payment,而不是 batch。
于是它会:
payments.action_draft()payments.action_post()
最后 payment 回到 in_process。
第二段:如果 payment 已经影响发票,还要把发票也重过一遍
若 payments.invoice_ids 存在,代码会:
move_linked.button_draft()move_linked.action_post()
为什么这么麻烦?
因为付款与发票 residual / payment state 已经联动过。如果你只是把 statement line 上一条已核销行删掉,而不重算发票,界面就会出现非常危险的假象:
- payment 已经退回处理中;
- 但 invoice 还停留在看似已付款或 residual 不准确的状态。
所以这里不是“多余重过账”,而是为了强制把发票恢复到正确的 in_payment / residual 口径。
test_bank_rec_widget_batch_payment_without_entries_link_to_move() 就验证了这件事:删除对账行之后,payment 回到 in_process,invoice 的 payment_state 也回到 in_payment。
五、batch 的状态不是对账分录的 owner,而是 payment 集合的观察结果
如果你只看界面,很容易把 batch 当成“这批付款的最终状态单”。
但企业版源码一直在避免这个误解。
最典型的证据就是删除已核销行时那句注释:
we don't touch the batch payment itself since it's just an envelope of payments
意思是:
- batch 可以从
sent变成reconciled,也能因为底层 payment 回退再显示成未完成; - 但它不是 statement line 的法律主体,也不是 invoice residual 的原始来源;
- 它更像 payment 集合在某个阶段上的 包装、投递与匹配状态摘要。
这和很多团队的直觉正好相反。
很多实施项目把 batch 当成“导出银行文件后就封存的业务单”。
源码告诉你的却是:
只要银行对账还能继续回写或撤销,batch 就仍处在可回放的会计链路里。
因此,排查问题时不要只盯 batch 状态本身,而要顺着这条顺序看:
payment_ids是否都处于可入对账的状态;- 这些 payment 有没有
move_id; - statement line 上追加的行是否带了正确的
payment_lines_ids/reconciled_lines_ids; - 删除时是否把 payment 与 invoice 一并回退。
六、实战里最容易踩的 4 个坑
坑 1:把 batch 当成最终凭证编号
batch 名称可以出现在 bank statement 的 payment_ref,但它本身不是最终会计分录。
真正决定 residual、reconcile、invoice payment state 的,还是底层 payment 与 statement move lines 的关系。
坑 2:只测“勾上成功”,不测“撤销成功”
很多定制只在 happy path 下看起来没问题:
- 选中 batch;
- statement line 勾上;
- 状态变了。
但一旦用户撤销对账,如果你自己的扩展没有尊重 payment_lines_ids、action_draft() / action_post() 这套回滚方式,就很容易留下脏状态。
坑 3:忽略“无分录 payment”分支
企业版明确支持 payment 在某些安装组合下先没有 move,再在对账时补验证。
如果你做定制时默认“所有 payment 必有 move_id”,对账阶段就会出错,而且通常只在生产数据里才暴露。
坑 4:只看 batch,不看 invoice 被重算后的状态
用户往往报的是“批量付款撤销后发票怎么还像付过款”。
这类问题别只查 batch,要同步看:
- payment.state
- invoice.payment_state
- invoice line residual
- statement line 是否已删除对应 reconciled line
七、这条源码链真正回答的问题是什么?
account_accountant_batch_payment 在企业版里,回答的不是“如何把几笔 payment 打包发送给银行”。
它真正回答的是:
当一组 payment 被包装成 batch 后,它们怎样进入银行对账、怎样把会计影响写回 statement line、又怎样在撤销时安全回滚到底层 payment 与 invoice。
所以如果你要给财务团队一句最实用的总结,我会这样说:
Odoo 企业版里的 Batch Payment 不是付款终点,而是 payment 集合进入 Bank Reconciliation 的中间桥。真正的风险点不在导出那一刻,而在后续回写与撤销是否还能保持 payment、statement line 与 invoice 三边一致。
这才是读完这组源码后,最值得记住的地方。
DISCUSSION
评论区