先说结论
采购退货不是一条“库存反向回滚”这么简单的链路。
它至少会同时影响三件事:
- 库存:退回多少货
- 采购行:
qty_received怎么算 - 会计:是否应该再开 vendor refund / credit note
所以你看到的“退货单”只是表面。真正复杂的地方,是 Odoo 要判断这笔退货到底算不算“已经收过、又退回来”,以及是否要让后续账务跟着反映。
源码里,qty_received 不是简单求和
在 purchase_stock/models/purchase_order_line.py 里,_prepare_qty_received() 不是把所有相关 move 的数量直接加起来,而是先区分:
- 普通入库 move
- 采购退货 move
- dropship 相关的特殊退货
- 是否已经绑定
origin_returned_move_id - 是否要走
to_refund
核心逻辑很清楚:
- 采购退货如果满足条件,会从
qty_received里扣掉 - 某些特殊退货不会重复扣两次
- 有些场景只退库存,不一定立刻把采购收货量再减一次
也就是说,qty_received 是一个业务解释后的结果,不是“所有 move 数量的机械总和”。
to_refund 的意思,比“退货”更关键
很多人看到退货就默认“应该退款”。其实 Odoo 还要看 to_refund。
在退货场景里:
- 有退货:说明货物回去了
- 有
to_refund:说明这笔退货应该影响供应商账单
如果只有库存层面的退货,没有 to_refund,那它未必会变成供应商退款逻辑。
这就是为什么同样是“退货”,有时只是仓库动作,有时却会触发后续的 vendor refund 流程。
退货单创建时,Odoo 会把采购链带回来
在 purchase_stock/models/stock.py 里,stock.return.picking 被扩展了。
当退货的地点是 supplier 时,Odoo 会尝试把:
purchase_line_idpartner_id
从原始链路里重新找回来。
这样做的目的很直接:
退货不是一张孤立的库存单,它要知道自己属于哪张采购单。
只有把采购链找回来,后续的数量回写、账务联动、责任人追踪才不会断。
stock.move 里,退货还能反向影响采购单
在 purchase_stock/models/stock_move.py 中,_action_synch_order() 会在某些已完成 move 上,反向创建或绑定采购行。
这里有两个关键点:
- 不是所有 move 都会同步到 PO
- 如果是 supplier / transit 方向的退货,数量会被视为负向
这说明采购与库存之间不是单向写入,而是会根据 move 的方向、状态和来源,决定要不要回写到采购单。
会计层的联动:不只是“退了货”,还要看“账开没开”
在 purchase/models/account_invoice.py 和 purchase_stock/models/account_invoice.py 里,Odoo 还会把采购单、供应商账单和退款单串起来。
典型逻辑包括:
- 从采购单或旧 bill 反向自动补全 invoice
- 匹配
in_invoice/in_refund - 根据已开票与已收货差异,计算后续应不应该提醒用户申请 refund
- 在 valuation 里,把 refund 的金额和数量一起算进去
所以,采购退货真正关心的不是“仓库里少了一箱货”这么简单,而是:
这箱货少了以后,采购收货、待开票数量、成本和供应商退款是否还一致。
最常见的误解
误解 1:退货 = qty_received 一定直接归零
不对。它会根据 move 类型、origin_returned_move_id 和 to_refund 分别处理。
误解 2:退货单一定等于 vendor refund
也不对。库存退货和账务退款是两层逻辑。
误解 3:只要把库存退掉,采购链就自动完美回滚
也不对。Odoo 还要同步采购行、发票行和成本链路。
实战建议
如果你在排查采购退货问题,按这个顺序看:
- 退货 move 的方向是不是 supplier
- 有没有
origin_returned_move_id to_refund是否开启- 采购行的
qty_received和qty_to_invoice是否一致 - 是否已经有关联 bill / refund
把这五步看完,基本就能知道问题卡在哪一层。
结尾
采购退货的本质不是“倒着走一遍入库”,而是同时重算库存、采购和会计三条链。
理解这一点,qty_received、to_refund、vendor refund 这些看似零散的字段,就会变成一条完整的业务故事。
DISCUSSION
评论区