采购三单匹配

Odoo 采购三单匹配别理解成“三张单硬绑定”:PO、收货、Vendor Bill 到底谁决定能付、谁决定入账

这篇不再只讲“采购到收货链”,而是把采购订单、收货、Vendor Bill、付款控制和库存估值边界一起讲清。你会真正看懂 Odoo 里的三单匹配到底在匹配什么。

Odoo 开发 会计 库存 采购
进阶 开发者 3 分钟阅读
0 评论 0 点赞 0 收藏 31 阅读

先说结论

很多人把 Odoo 的 三单匹配 理解成:

  • 采购单一张
  • 收货单一张
  • 供应商账单一张
  • 三张单数字一样,于是系统就“通过”

这其实太表面了。

更准确地说,Odoo 真正在维护的是四层语义:

  1. PO(purchase order):公司对外下了什么采购承诺
  2. Receipt(收货):仓库事实上收到了什么
  3. Vendor Bill(供应商账单):供应商现在要求你确认并入账什么
  4. Payment / Payability(是否该付款):在控制规则下,这张账单现在应不应该被支付

所以三单匹配不是“三张单硬绑定”,而是:

Odoo 用采购行作为中间锚点,把“承诺数量”“实际收货数量”“已开票数量”串起来,再决定采购状态、开票状态、付款控制与部分库存估值。

这才是它真正的设计重点。


为什么这篇和“采购到收货链”不是同一题

站里已经有一篇讲过采购确认后,purchase.order -> stock.move -> stock.picking 是怎么串起来的。

那篇重点在:

  • 采购确认后如何生成收货对象
  • 收货单和库存 move 为什么会出现
  • 采购链如何交给库存链继续执行

而这篇讨论的是另一层:

  • 收到了货,为什么有的 PO 还不能开票,有的可以
  • 开了 Vendor Bill,为什么并不等于“已经通过三单匹配”
  • 三单匹配到底卡的是入账、付款,还是别的东西
  • 账单和库存估值之间,到底谁先影响谁

也就是说,这篇讲的是 采购、收货、账单、付款控制、库存价值 之间的边界,而不只是“单据是怎么生成出来的”。


先把核心锚点找对:不是单头,而是 purchase.order.line

新手很容易盯着:

  • purchase.order
  • stock.picking
  • account.move

但源码真正拿来做匹配和计算的,很多时候不是这些单头,而是 采购行

purchase.order.line 上,你会看到几组非常关键的字段:

  • qty_received
  • qty_invoiced
  • qty_to_invoice
  • invoice_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 里,基础版 purchaseqty_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_id
  • purchase_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 编号抄过来,而是:

  1. 从 PO 准备账单抬头信息
  2. 找出还没被当前账单覆盖的采购行
  3. 调用 _add_purchase_order_lines(po_lines)
  4. 为每条采购行生成对应的账单行
  5. 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.pypurchase_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

  • purchase
  • receive

这一步决定你后面看到的很多数量是不是正常。

2. 再看采购行

重点看:

  • product_qty
  • qty_received
  • qty_invoiced
  • qty_to_invoice

3. 再看库存 move

重点看:

  • move_ids
  • state 是否 done
  • 有没有退货 move
  • 是否有 dropship / 特殊路线边界

4. 再看账单行

重点不是只看 bill header,而是看:

  • invoice_line_ids.purchase_line_id
  • 哪些行没挂采购行
  • 是自动带入,还是手工补的

5. 最后才看付款控制

如果业务说“为什么这张账单不能付”,那已经是三单匹配和付款策略层的问题了,不是先去怀疑库存没做完。


一句话记忆法

Odoo 采购三单匹配,匹配的不是三张单表面上像不像,而是采购行上的承诺数量、库存链里的实际收货、账单行上的已开票事实,以及它们能不能共同支撑付款。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。