很多团队第一次用 Odoo 企业版的 Batch Payment,都会被一个现象绕晕:
- 批次里一共还是 10 笔付款;
- 批次总额
amount看起来没变; - 但
amount_residual却变少了; - 甚至批次状态已经是
sent,却还没到reconciled。
如果只看界面,很容易误会成:
Odoo 的批量付款金额是不是算乱了?
但去看 /home/ubuntu/odoo-temp/enterprise/account_batch_payment 源码会发现,官方其实是故意把 “批次规模”、“尚未匹配完的金额”、“发送/核销进度” 拆成三套口径。
也就是说,批量付款不是一个单一数字模型,而是一套并行状态机。
本文就基于:
models/account_batch_payment.pymodels/account_payment.pytests/test_account_batch_payment.py
把这条链讲透。
先说结论:在 Odoo 企业版里,批量付款的
amount回答“这一批原本有多大”,amount_residual回答“这一批还有多少流动性金额没匹配掉”,state回答“是否已发送、是否已完成匹配”。三者故意不会永远相等。
一、批量付款不是“重新造一笔总付款”,而是把多笔 account.payment 绑成一个容器
先看 models/account_payment.py。
企业版给 account.payment 加了一个字段:
batch_payment_id
create_batch_payment() 做的也不是“合并分录”,而是:
- 校验当前 payment 是否处于允许入批的状态;
- 拿第一笔 payment 的 journal、payment method、payment type 当默认骨架;
- 创建一个
account.batch.payment; - 把这些 payment 通过
payment_ids挂进去。
这件事非常关键。
因为它决定了一个基础事实:
Batch Payment 不是新的资金动作,而是多笔既有 payment 的编组对象。
所以你后面看到的金额、剩余金额、状态变化,都是围绕“这组 payment 的聚合观察”,不是围绕“一笔新总付款”的流水逻辑。
这也解释了为什么 Odoo 会强行限制同批 payment 的一致性。
在 account_batch_payment.py 的 _check_payments_constrains() 里,官方要求同一个 batch 内的 payment 必须满足:
- journal 相同;
payment_type相同;- payment method 相同;
- 要么都有会计分录、要么都还没有;
- 金额不能为 0;
- 不能重复出现在其他 batch 里。
这不是吹毛求疵,而是因为批次只是“聚合视图”。如果底层 payment 的业务语义不一致,批次上的聚合数就没有解释价值。
二、amount 统计的是“这一批的总体规模”,不是“还没处理完多少”
很多人最容易误会 amount。
在 _compute_from_payment_ids() 里,Odoo 会遍历 payment_ids,把每笔 payment 的流动性金额折算到 batch 货币,然后累加到:
amount
这里的关键点有两个。
1)它优先看 payment 对应的流动性分录
如果 payment 已经有 move_id,源码会先 payment._seek_for_lines(),然后拿 liquidity_lines 来算金额。
这说明 Odoo 关心的不是“业务表面上的付款单金额”,而是:
这笔 payment 在银行/现金流动性那一侧,当前对应了多少金额。
所以 batch 的 amount 本质上是一个流动性口径的批次规模。
2)amount 不会因为部分 payment 已匹配银行流水就自动归零
测试 test_change_payment_state() 与 test_change_payment_state_valid() 很能说明问题。
即使某些 payment 的状态变了,batch 的:
amount
仍然代表这批 payment 原始聚合后的金额规模;变化的主要是 residual,而不是 total。
换句话说:
amount更像“这包东西有多大”,不是“现在还剩多少没做完”。
这也是为什么用户看到“总额没变”时,不应先怀疑系统错了。
三、amount_residual 统计的是“流动性行还有多少没被匹配掉”
真正解释用户困惑的,是同一个计算函数里的另外两列:
amount_residualamount_residual_currency
它们跟 amount 一起算,但语义完全不同。
源码会先取一个允许的 payment state 列表:
['in_process', 'paid'] if _get_invoice_in_payment_state() == 'paid' else ['in_process']
也就是 _valid_payment_states()。
这句话背后有一个很重要的设计:
- 如果当前环境里发票“处理中”的口径最终落在
paid,那么 batch 可以把paid也当作有效状态; - 如果安装的是更严格的会计场景,只认
in_process,那么paid反而不再被视为仍应计入 residual 的“待跟踪状态”。
也就是说,amount_residual 不是一个纯数学字段,而是一个带状态语义的剩余金额。
residual 到底在回答什么?
不是“这批 payment 的原始金额是多少”,而是:
在当前会计状态规则下,这批 payment 对应的流动性金额里,还有多少没有完成匹配/清空。
所以当一部分 payment 被银行流水匹配后,你会看到:
amount不变;amount_residual下降。
这是正常现象,因为“批次规模”没变,但“待匹配余额”变少了。
为什么 Odoo 要分成公司币和批次币两个 residual?
因为 batch 自己有 currency_id,而公司也有 company_currency_id。
于是官方同时保留:
amount_residual:公司币口径amount_residual_currency:批次币口径
这能避免多币种场景里只看一个 residual 造成误读。
四、混合币种时,batch 不是简单求和,而是按批次货币重新折算
account_batch_payment 的一个很容易被低估的点,是它不是只为单币种支票袋设计的。
从测试看,官方专门覆盖了几类情况:
test_batch_payment_foreign_currencytest_batch_payment_move_different_currenciestest_foreign_currency_batch_paymenttest_batch_payment_journal_foreign_currency
这些测试证明一件事:
Batch Payment 的金额不是把 payment.amount 机械相加,而是会根据 batch/journal/company 的货币层级,动态换算。
源码里的计算顺序大致是:
- 如果流动性行币种与 batch 币种一致,直接取
amount_currency; - 如果 batch 币种等于公司币,则直接取
balance; - 否则,用
company_currency_id._convert(...)折算到 batch 币种。
这意味着:
- 两笔金额都叫 100,放进同一个 batch 后不一定等权;
- batch 列表上的 total,更像“站在这个批次货币视角下重述后的总规模”;
- residual 也是按相同逻辑重算,而不是偷懒沿用 payment 原币。
这对业务方意味着什么?
如果你们在看批量付款金额时:
- journal 本身有外币;
- payment 有公司币也有外币;
- 甚至 payment 的 move 货币和 batch 货币都不同;
那么 batch 上看到的金额,本来就不该被理解成某一列原币数字的简单相加。
它是 Odoo 替你做过一次“批次观察口径统一”之后的结果。
五、state 看的是发送与匹配进度,不是金额余额
再看 _compute_state()。
这里的逻辑非常克制:
- 如果所有未取消/未拒绝 payment 都
is_sent=True且is_matched=True,batch =reconciled - 否则,如果所有未取消/未拒绝 payment 都
is_sent=True,batch =sent - 否则,batch =
draft
注意,这里完全没有直接比较 amount 或 residual。
这说明官方根本不想把批次状态做成“按金额阈值推导”的模型,而是让它严格反映两个动作:
- 这些 payment 有没有被正式发送出去;
- 这些 payment 后来有没有和银行事实完成匹配。
所以你才会看到一个非常典型、但很容易误会的状态组合:
- batch 已经
sent - 但还没
reconciled - residual 也可能还不为 0
这不是卡住,而是在表达:
付款动作已经发起,但银行侧匹配闭环还没全部完成。
这其实比单纯用“已完成/未完成”更有会计现实感。
六、为什么 validate 时会先尝试 action_post(),再汇总错误而不是直接整批失败
validate_batch() 调 _check_batch_validity() 时,还有一个很值得抄作业的设计。
官方不会先粗暴拦截所有 draft payment,而是会调用 _check_and_post_draft_payments():
- 对每笔 draft payment 尝试
action_post(); - 某笔失败,就把
UserError收集起来; - 最后按错误原因分组,统一展示给用户。
这背后体现的是一种很成熟的批处理思路:
批次校验不是为了证明“整包都坏了”,而是为了尽量把可过的先过、把不可过的按原因归类。
所以 batch validation 的结果可能不是一条简单报错,而是:
- 只有一个错误时,用
RedirectWarning把你直接带到对应 payment; - 多个错误/警告时,弹 wizard 汇总展示。
这也解释了为什么企业版批量付款给人的感觉更像“财务操作工作台”,而不是一个一次性按钮。
七、为什么有些 payment 明明能看见,却不能建 batch
create_batch_payment() 里有一段容易被忽略但很重要的逻辑:
- 如果所选 payment 里有状态不在
_valid_payment_states()的,Odoo 不会直接建 batch; - 它会打开
account.create.batch.error.wizard; - 只把有效 payment 留给后续创建,非法 payment 交给你检查。
而且这个“合法状态”不是写死的。
它取决于:
- 当前环境是否把
paid也视作批量付款允许状态; - 当前会计安装层级是否更严格。
所以业务上常见的错觉是:
- “这几笔付款都已经存在了,为什么有些能入批、有些不行?”
源码给出的答案其实是:
Batch Payment 关注的不是 payment 是否存在,而是它是否还处于这个批处理流程可继续接管的状态。
一旦 payment 已超出这个状态边界,就不应再继续塞回同一批控制链里。
八、为什么企业版要把“已发送”和“已匹配”拆开,而不是直接一个 done
这是我觉得这个模块最有价值的地方。
很多系统会偷懒,把批量付款做成:
- 草稿
- 完成
看起来简单,但一到真实银行链路就不够用了。
因为从财务实际流程看,至少有三层状态:
- 我已经组织好这批 payment
- 我已经把这批 payment 发出去了
- 我已经在银行流水/对账层面把它们匹配闭环了
Odoo 企业版把这三层分别映射成:
draftsentreconciled
同时再用:
amountamount_residualamount_residual_currency
来表达金额视角。
这就形成了一个非常实用的组合:
- 状态字段 回答流程进度;
- 金额字段 回答财务余额;
- 两者不互相硬绑。
这也是为什么它比“状态=done 时金额自动清零”的模型更稳。
九、实战里最容易误解的 4 件事
1. 以为 batch amount 就是还没到账的金额
不对。
amount 更接近批次总体规模,不是待匹配余额。真正看“还没消掉多少”,要看 residual。
2. 以为 residual 下降就表示 batch 金额被改写了
不对。
通常只是部分 payment 已匹配,导致流动性剩余金额下降。
3. 以为 sent 就等于已经彻底完成
不对。
sent 只说明 payment 已发送,不代表银行侧匹配闭环已经完成。
4. 以为多币种 batch 的 total 应该和 payment 原币列表一眼对上
不对。
batch total 是站在 batch/journal 货币视角下重算过的统一口径。
十、如果你要做二开,最该尊重哪几个边界
如果你准备在 batch payment 上做定制,我建议先守住这几个边界:
1. 不要把 amount 和 amount_residual 合并成一个字段语义
这是两类信息:批次规模 vs 待匹配余额。混在一起,前台会更乱。
2. 不要把 sent 和 reconciled 折叠成一个“已完成”
你会失去银行发送与会计匹配之间最关键的过渡层。
3. 多币种场景不要偷懒只取 payment.amount 相加
要沿着官方的流动性行和 _convert() 逻辑走,否则 total/residual 都会失真。
4. 批量校验不要只做“第一笔失败就整体失败”
官方按 payment 逐笔尝试、按错误原因归类的做法,明显更适合财务工作流。
总结
account_batch_payment 最容易被误解的点,就是大家会本能地希望:
- 一个 batch 只有一个金额真相;
- 一个 batch 只有一个完成状态;
- 两者最好始终同步。
但 Odoo 企业版没有这么设计。
它把批量付款拆成了三组问题:
- 这批 payment 原本有多大? →
amount - 这批 payment 还有多少流动性金额没被匹配? →
amount_residual/amount_residual_currency - 这批 payment 目前处在发送完成,还是核销完成? →
draft/sent/reconciled
所以你看到“总额没变、residual 下降、状态还是 sent”,通常不是异常,而是 Odoo 在同时告诉你三件不同的事实。
如果把这一点看懂,后面无论你是在做财务实施、培训用户,还是二开批量付款导出/对账逻辑,都会顺很多。
DISCUSSION
评论区