先说结论
很多人把 Odoo 的 三单匹配 理解成:
- 采购单一张
- 收货单一张
- 供应商账单一张
- 三张单数字一样,于是系统就“通过”
这其实太表面了。
更准确地说,Odoo 真正在维护的是四层语义:
- PO(purchase order):公司对外下了什么采购承诺
- Receipt(收货):仓库事实上收到了什么
- Vendor Bill(供应商账单):供应商现在要求你确认并入账什么
- Payment / Payability(是否该付款):在控制规则下,这张账单现在应不应该被支付
所以三单匹配不是“三张单硬绑定”,而是:
Odoo 用采购行作为中间锚点,把“承诺数量”“实际收货数量”“已开票数量”串起来,再决定采购状态、开票状态、付款控制与部分库存估值。
这才是它真正的设计重点。
为什么这篇和“采购到收货链”不是同一题
站里已经有一篇讲过采购确认后,purchase.order -> stock.move -> stock.picking 是怎么串起来的。
那篇重点在:
- 采购确认后如何生成收货对象
- 收货单和库存 move 为什么会出现
- 采购链如何交给库存链继续执行
而这篇讨论的是另一层:
- 收到了货,为什么有的 PO 还不能开票,有的可以
- 开了 Vendor Bill,为什么并不等于“已经通过三单匹配”
- 三单匹配到底卡的是入账、付款,还是别的东西
- 账单和库存估值之间,到底谁先影响谁
也就是说,这篇讲的是 采购、收货、账单、付款控制、库存价值 之间的边界,而不只是“单据是怎么生成出来的”。
先把核心锚点找对:不是单头,而是 purchase.order.line
新手很容易盯着:
purchase.orderstock.pickingaccount.move
但源码真正拿来做匹配和计算的,很多时候不是这些单头,而是 采购行。
在 purchase.order.line 上,你会看到几组非常关键的字段:
qty_receivedqty_invoicedqty_to_invoiceinvoice_lines- 在
purchase_stock里还有move_ids
这意味着 Odoo 的思路不是:
“判断这三张单是不是彼此相等。”
而是:
“以每条采购行为单位,看这条采购承诺已经收了多少、已经开了多少、还应该再开多少。”
所以真正被匹配的,不是“单头对单头”,而是 行级业务事实。
qty_to_invoice 才是采购开票状态的核心,不是“有没有收货单”
在 purchase/models/purchase_order_line.py 里,_compute_qty_invoiced() 这段逻辑非常关键。
它大意是:
- 如果产品的
purchase_method == 'purchase' qty_to_invoice = product_qty - qty_invoiced- 否则
qty_to_invoice = qty_received - qty_invoiced
这句话非常值钱。
它说明 Odoo 并不是统一按收货数量开票,也不是统一按下单数量开票,而是看 采购控制策略。
两种常见语义
1. On ordered quantities
也就是按下单数量开票。
这时只要采购单确认了,就可能进入 to invoice,哪怕仓库还没收货。
所以你会看到:
- PO 已确认
invoice_status = to invoice- 但
qty_received = 0
这不是异常,而是设计如此。
2. On received quantities
也就是按收货数量开票。
这时系统真正关心的是:
- 已收多少
- 已开多少
- 还差多少没开
也正因为如此,很多人以为“三单匹配就是收完才能开票”,其实不完全对。
更准确地说:
只有当产品/场景采用按收货数量控制时,收货事实才直接进入开票数量计算。
qty_received 也不是一个“拍脑袋数字”
如果你只看界面,很容易觉得收货数量就是用户填出来的一个值。
但源码不是这么设计的。
在 purchase_order_line.py 里,基础版 purchase 对 qty_received 的处理是:
- service / consu 往往走
manual _prepare_qty_received()默认返回手工接收数量
而在 purchase_stock/models/purchase_order_line.py 里,逻辑被继续扩展:
- 对部分产品,
qty_received_method会变成stock_moves qty_received会从关联的move_ids中,按done的库存移动来累计- 供应商退货还会反向扣减
- 某些 dropship return 边界还会单独排除,避免重复计算
这意味着:
在接入库存模块后,采购行上的“已收数量”很多时候并不是表单上的一个普通字段,而是从真实库存 move 反推出来的结果。
所以三单匹配里的“收货”,在源码层其实更接近“已完成的库存事实”,不是“用户觉得自己收了多少”。
Vendor Bill 为什么能和采购链连起来:关键不是 purchase_id,而是 purchase_line_id
很多人第一次看界面,会以为 Vendor Bill 和 PO 的关联主要靠单头字段。
实际上,真正稳的关联锚点在账单行上。
在 purchase/models/account_invoice.py 里,account.move.line 被扩展了:
purchase_line_idpurchase_order_id(related 到purchase_line_id.order_id)
也就是说,Vendor Bill 不是只是“这张账单属于哪张采购单”,而是:
这张账单的哪一行,对应哪一条采购行。
这件事很重要,因为后面这些计算都要依赖它:
qty_invoiced统计- PO 的
invoice_status - 从采购单自动补账单行
- 从账单追溯来源 PO
- 库存估值在部分场景下追到账单价格
如果没有 purchase_line_id,很多链条都只能停留在“单头像是相关”,却做不到精确计算。
从 PO 自动生成 Vendor Bill,本质是在复制“可开票的采购行语义”
account.move 上有一个 _onchange_purchase_auto_complete()。
它做的事不是简单把 PO 编号抄过来,而是:
- 从 PO 准备账单抬头信息
- 找出还没被当前账单覆盖的采购行
- 调用
_add_purchase_order_lines(po_lines) - 为每条采购行生成对应的账单行
- 把
invoice_origin等来源信息也补上
这说明 Vendor Bill 从 PO 生成时,系统真正复制的不是“采购单壳子”,而是:
- 哪些采购行应该被带进账单
- 每条行保留对采购行的回挂
所以从业务上看,Vendor Bill 是会计单据;但从源码设计看,它仍然尽量保留采购链的来源语义。
invoice_status 为什么经常和你脑补的不一样
purchase.order 上的 invoice_status 是按 order_line.qty_to_invoice 算的。
逻辑并不复杂:
- 只要有行
qty_to_invoice != 0,就是to invoice - 全部
qty_to_invoice = 0且有invoice_ids,就是invoiced - 否则就是
no
所以你会看到一些让业务用户困惑的场景:
场景 1:明明已经收货,为什么还是 no?
可能因为:
- 该产品按 ordered quantity 控制,但尚未到采购确认状态
- 或者相关账单已冲销/取消后又回退
- 或者你看的不是能参与开票的产品行
场景 2:明明没收货,为什么已经 to invoice?
因为该产品采购控制策略是 purchase,也就是按订购数量开票。
场景 3:明明做了退货,为什么开票状态又变了?
测试 test_vendor_bill_delivered_return 已经很直白:
- 先收 10、开 10
- 后来把
qty_received从 10 变成 5 - 此时
qty_to_invoice会变成-5 - 再创建一张账单,实际语义就是供应商应退回或抵减这 5
这再次说明:
Odoo 看的是“应开票数量差额”,不是“有没有开过票”这个静态事实。
三单匹配到底“卡”在哪:更多是在付款控制层,而不是采购/库存基础算式层
在采购设置里,你会看到一个选项:
3-way matching: purchases, receptions and bills
帮助文案写得很直白:
只有在已经收到货的情况下,才支付供应商账单。
这句话特别值得细读。
它说的是 pay the invoice,不是 create the invoice,也不是 post the bill。
也就是说,三单匹配的业务目标不是把 Vendor Bill 从系统里“拦在门外”,而是:
在采购承诺、收货事实和账单请求不一致时,给付款动作加一道控制。
从这点你就能理解,为什么很多实现里会把三单匹配理解成“账单可入账,但付款前要再过一层检查”。
而核心 purchase / purchase_stock 源码本身,已经先把底层事实准备好了:
- 哪条采购行已收多少
- 哪条采购行已开多少
- 账单行挂在哪条采购行上
- PO 当前还差多少应开票
三单匹配模块只是把这些业务事实进一步用于付款控制。
Vendor Bill 的边界:它不是收货结果,也不是库存事实本身
这点是采购和会计联动里最容易混的。
Vendor Bill 表达的是:
- 供应商向你主张一笔应付
- 你准备把这笔应付放进会计系统
它不是:
- 仓库收货动作本身
- 实际库存数量本身
- 单纯的 PO 状态副本
所以你不能把它理解成“收货单的财务版”。
更准确地说:
- Receipt 解决“货有没有真的进来”
- Vendor Bill 解决“这笔采购成本现在要不要确认成应付”
两者高度相关,但不是一回事。
为什么“先收货后到票”与“先到票后收货”会影响库存估值理解
purchase_stock/models/stock_move.py 和 purchase_stock/models/account_invoice.py 里有一条很容易被忽略的边界:
库存 move 在估值时,可能先取采购报价,也可能在后续账单过账后再补价差或补估值语义。
尤其在自动估值、实时报价差、Anglo-Saxon / 标准成本等场景里,账单并不只是“财务记一笔应付”这么简单。
例如 purchase_stock/models/account_invoice.py 里 _stock_account_prepare_anglo_saxon_in_lines_vals() 就明确处理了:
- 供应商账单价格
- 已交付数量
- 价差科目
- 当前账单行金额的修正
它解决的问题是:
- 货已经先进库存了
- 但真正的供应商账单价格后来才知道,或者和采购价不一样
- 那库存价值与费用归属怎么补
这说明 Vendor Bill 的边界不是“纯财务,不碰库存”。
更准确地说:
Vendor Bill 不负责证明仓库已经收货,但在某些估值体系下,它会参与修正库存价值或价差归属。
一个特别容易踩坑的误解:看到“已开票数量 > 已收货数量”,就以为系统乱了
purchase_stock/tests/test_stockvaluation.py 里有个很好的测试:
- PO 分多次收货
- 每次收货各自开票
- 中间还做部分退货
- 于是出现某段时间里
invoiced_qty > received_qty
官方特意强调:
这不能被简单解释成“账单先于收货,因此库存估值逻辑应该改走另一套”。
换句话说,出现:
- 已开票数量大于当前已收数量
并不自动代表业务顺序一定是“先票后货”。
它还可能是:
- 之前收过、开过
- 后来又发生退货
- 当前净收货变少了
这类细节正是采购、库存、会计三边交叉时最容易误判的地方。
你真正该记住的边界
把这四句话记住,很多混乱会立刻变清楚。
1. PO 的边界:承诺
PO 表达的是:
- 向谁买
- 买什么
- 买多少
- 什么价格条件
- 预期什么时候到
它是采购承诺,不是库存事实,也不是应付事实。
2. Receipt 的边界:事实收货
Receipt / stock move 表达的是:
- 哪些货真的移动了
- 什么时候移动
- 移动了多少
- 是否有退货
它是仓库事实,不是供应商索款。
3. Vendor Bill 的边界:应付主张
Vendor Bill 表达的是:
- 供应商现在要你确认哪笔应付
- 账单行到底对应哪些采购行
- 这笔应付是否进入会计系统
它不是“收货成功证明”。
4. 3-way matching 的边界:付款控制
三单匹配表达的是:
- 当收货事实还不支持这张账单时,是否应该付款
它不是“只要开了账单就算流程闭环”。
实战里最常见的 6 个误判
1. 把三单匹配理解成“三张单号一一对应”
实际上真正匹配的是采购行、收货数量和账单行来源。
2. 把 invoice_status 当成付款状态
不是。
它表达的是采购侧“是否还有东西要开票”。
3. 以为有 Vendor Bill 就说明仓库已经收到了货
不一定。
取决于采购控制策略,也取决于公司是否强制三单匹配。
4. 以为收货完成就一定能自动解释财务差额
不一定。
价格差、汇率差、成本方法都会影响后续会计处理。
5. 以为账单只是 purchase_id 级关联
真正关键的是账单行上的 purchase_line_id。
6. 看到数量差异就急着改数据
很多差异是系统故意保留下来的业务事实,比如:
- 分批收货
- 部分退货
- 先按订购开票
- 后续补票/红字票
一套很实用的排查顺序
如果你遇到“采购、收货、账单对不上”,建议不要一上来就看单头,而是按这个顺序查:
1. 先看产品采购控制策略
看 purchase_method:
purchasereceive
这一步决定你后面看到的很多数量是不是正常。
2. 再看采购行
重点看:
product_qtyqty_receivedqty_invoicedqty_to_invoice
3. 再看库存 move
重点看:
move_ids- state 是否
done - 有没有退货 move
- 是否有 dropship / 特殊路线边界
4. 再看账单行
重点不是只看 bill header,而是看:
invoice_line_ids.purchase_line_id- 哪些行没挂采购行
- 是自动带入,还是手工补的
5. 最后才看付款控制
如果业务说“为什么这张账单不能付”,那已经是三单匹配和付款策略层的问题了,不是先去怀疑库存没做完。
一句话记忆法
Odoo 采购三单匹配,匹配的不是三张单表面上像不像,而是采购行上的承诺数量、库存链里的实际收货、账单行上的已开票事实,以及它们能不能共同支撑付款。
DISCUSSION
评论区