别把核销理解成一个布尔值
很多人看 Odoo 核销,只盯两个表面结果:
- 单据是不是 paid
- 分录是不是 reconciled
然后就很容易把脑内模型简化成:
- 没核销
- 已核销
但从源码看,真正发生的事情更像一个图逐步闭合的过程。
核心角色至少有三层:
account.move.line:被匹配的分录行account.partial.reconcile:一条“借方行 ↔ 贷方行”的匹配边account.full.reconcile:当整组边和节点都达到完整收口后,给整组打上的“完全核销”标记
所以最关键的一句不是“核销了没”,而是:
现在只是建了一条 partial 边,还是整组已经满足 full 的条件了?
reconcile() 并不是直接把一切改成 fully reconciled
在 addons/account/models/account_move_line.py 里,reconcile() 本身很短:
- 它直接调用
_reconcile_plan([self])
这意味着真正复杂的逻辑不在入口函数,而在后面的计划执行里。
这种写法的好处是,Odoo 可以把核销当成一个批处理计划,而不是对每两行做一次机械更新。
partial 的本质:先创建“匹配边”
在 account.partial.reconcile 模型里,最核心的字段非常直白:
debit_move_idcredit_move_idamountdebit_amount_currencycredit_amount_currencyfull_reconcile_id
这说明 partial reconcile 不是“一个状态”,而是一条连接关系记录。
它表达的是:
- 哪条借方分录
- 和哪条贷方分录
- 以多大金额被匹配起来了
所以部分付款、分次收款、拆分核销这些现实业务,才有地方落地。
如果没有 partial 这层,系统只能表达“全有或全无”,那会非常笨。
matching number 为什么有时是 P...,有时又像一个完整编号
account_partial_reconcile.py 里的 _update_matching_number() 很值得看。
它会把所有 partial 关系看成一组图:
- partial 是边
- move line 是节点
然后为每个连通分量生成 matching number。
逻辑大意是:
- 如果这组 line 还没有 full reconcile,就写成
P开头 - 如果
full_reconcile_id已经存在,就直接写 full reconcile 的 id 文本
这就解释了为什么有时候你在界面上看到的是:
P123
有时候却像一个完整的核销号。
因为前者表示:
- 这组线已经有关联,但还只是 partial 图
后者表示:
- 这组线已经被系统确认收口为 full reconcile
什么时候 partial 才会升级成 full
account_move_line.py 在 _reconcile_plan() 后段有一段非常关键的逻辑:
- 先把涉及的分录按 matching 图聚合成
full_batches - 再判断每组是不是
is_fully_reconciled - 满足条件的,才组装
full_reconcile_values_list - 最后统一
create(account.full.reconcile)
这里最重要的不是“创建了 full reconcile”,而是创建之前那层判断。
源码会检查:
- 相关 line 是否已经真的归零
- 多币种场景下该看公司币 residual,还是外币 residual
- 这组 partial 图是不是已经闭合到可以视为完整收口
也就是说,full reconcile 不是“多建一张表方便查”,而是:
当系统确认整组分录在会计意义上已经彻底对完,才补上的那层整组标记。
为什么 partial 特别适合现实业务
因为现实世界里的收付款,本来就经常不是一步到位:
- 一张发票分两次付款
- 一笔收款同时冲多张发票
- 汇率差异要到最后才能补齐
- 现金制税、汇兑差异、尾差处理都可能插进来
如果系统只有 full reconcile,那这些中间态就没法优雅表达。
partial reconcile 的价值就在于:
- 允许系统先承认“已经匹配了一部分”
- 但不假装这已经是最终结论
而当所有残余金额真正归零后,再由 full reconcile 给整组盖章。
这套两层结构非常像会计上的现实过程。
为什么“已付款”不等于“你已经懂了核销”
很多实施或排错时,大家只会看发票状态是不是 paid。
但 paid 只是业务面上的读法。
源码层真正让系统站得住的是:
- 哪些
account.move.line被哪些 partial 连接起来 - 这些 partial 所形成的图是否已经闭合
- 是否已经创建 full reconcile
- 过程中有没有触发汇兑差异、现金制税、反向冲销等附带动作
所以你如果只盯 paid,很容易漏掉两类关键问题:
1)为什么看起来“已经差不多对完了”,却还没 full
因为某个 residual 还没真正归零,或者多币种条件下还有残差。
2)为什么取消核销后,系统像回滚了一串东西
因为 account.partial.reconcile.unlink() 里不只是删边,它还会:
- 处理 full reconcile 的解绑
- 回退 matching number
- 反转或删除现金制税/汇兑差异分录
- 回写 payment 状态
这说明 partial 不只是中间痕迹,它还是后续很多会计动作的触发点。
最容易误判的 4 件事
误判 1:partial 就是不完整版 full
不准确。partial 是边,full 是整组收口标记,它们不是同一层概念。
误判 2:核销是两条分录的一对一动作
现实里它更像一个图,可能一条边边相连,最后收成一组。
误判 3:matching number 只是展示字段
它背后其实反映了图结构当前处于 partial 还是 full 状态。
误判 4:取消核销只是把状态改回去
不对。它可能连带现金制税、汇兑差异和 payment 状态一起回滚。
排查核销问题时的正确视角
以后你再遇到这些问题:
- 发票怎么还没 fully paid
- 为什么这组分录还是 partial
- 为什么取消核销影响了一串分录
- 为什么 matching number 变了
建议优先按这条线看:
- 哪些
account.move.line在参与 - 生成了哪些
account.partial.reconcile - 这组 line 的 residual 是否真的都归零
- 有没有生成
account.full.reconcile - 有没有伴随汇兑差异或现金制税动作
这个视角比只看单据状态靠谱得多。
最后一句话
Odoo 核销机制最重要的,不是“是否 paid”,而是:
系统先用
account.partial.reconcile把分录一步步连起来,等整组金额与残余真正收口后,再用account.full.reconcile给这张关系图盖上完全核销的章。
把这个过程想成“图逐步闭合”,你对核销、反核销、多次付款和汇率差异的理解都会清晰很多。
DISCUSSION
评论区