先说结论
采购收货完成,不是把一张收货单状态改成 done 就结束。
真正发生的是一条连续动作:
- 采购单确认后生成或复用入库 picking
- 采购行生成
stock.move,并把purchase_line_id绑到 move 上 - 仓库在收货单里录入实收数量并点击 Validate
stock.picking.button_validate()进入标准完成链路purchase_stock对stock.picking._action_done()做扩展,先对采购单执行action_acknowledge()- 采购行的
qty_received再根据已完成 move 重新计算
所以你在界面里看到的“已收货”“部分收货”“已完成”,本质上都不是独立字段各玩各的,而是围绕 采购行 ↔ 库存 move ↔ picking 完成 这条主线回写出来的结果。
第一段:采购单确认后,为什么会冒出收货单
在 purchase/models/purchase_order.py 里,button_confirm() 主要负责把采购单从草稿推进到确认流程。真正把库存单据建出来的关键扩展,在 purchase_stock/models/purchase_order.py:
button_approve()里会调用_create_picking()_create_picking()会为采购单准备入库 picking- 然后让
order_line._create_stock_moves(picking)生成 move
也就是说,采购确认和库存执行并不是两套平行系统:
采购单一旦进入已确认采购,库存入库载体就要被建出来。
这也是为什么你常会看到采购单和收货单天然关联在一起。
第二段:采购行到底把什么信息交给了库存 move
在 purchase_stock/models/purchase_order_line.py 里,_prepare_stock_moves() / _prepare_stock_move_vals() 很关键。
这里准备的不是“界面展示数据”,而是之后所有回写的锚点:
purchase_line_idpicking_idproduct_uom_qtylocation_id/location_dest_idmove_dest_idspropagate_canceldate_deadline
最重要的是 purchase_line_id。
它意味着这条库存 move 不是一条孤立的入库动作,而是明确地说:
我是这条采购行的履约结果之一。
后面采购行要计算 qty_received,本质上就是沿着这根线去回看自己关联的 move。
第三段:Validate 按钮真正进入的是标准库存完成链
很多人会误以为“采购收货”有一套特殊入口。
其实收货单校验走的仍然是 stock/models/stock_picking.py 里的标准 button_validate():
- 先补齐 draft picking 的确认
- 做 sanity check
- 跑
_pre_action_done_hook() - 处理 backorder 分支
- 最后进入
_action_done()
这段设计很重要,因为它说明:
- 采购收货不是旁路逻辑
- 它是 标准库存完成链 上的一个业务场景
- 采购模块只是把“采购上下文”挂接到这条库存主链上
所以排查问题时,别只盯采购模块。 很多“采购收货异常”,根子其实在 stock 的 backorder、done 数量、return、move 状态切换里。
第四段:为什么采购单会被标记为已确认收到
purchase_stock/models/stock.py 里对 stock.picking._action_done() 做了一个很轻但很关键的扩展:
self.purchase_id.sudo().action_acknowledge()
return super()._action_done()
意思很直白:
- 这个 picking 若能反查到
purchase_id - 那么在真正完成库存动作前,先把采购单做 acknowledge
这一步不是数量计算本身,但它会影响采购单上的业务状态感知:
- 供应商已交付的确认感
- 采购协同流程上的“已被仓库承认收到”
- 后续统计口径里的确认时间/回执语义
所以收货单 done,并不只是库存层的动作;它也在顺手推进采购单的业务语义。
第五段:qty_received 为什么会自动变动
真正让采购行“已收数量”变化的关键,在 purchase_stock/models/purchase_order_line.py 的 _prepare_qty_received()。
这段逻辑不是读一张 picking 的状态,而是逐条看本采购行关联的 move:
- 只统计
state == 'done'的 move - 普通入库,数量累加
- 采购退货,按条件扣减
- 某些 dropship / return 边界场景要跳过,避免重复计算
这说明一个经常被忽略的事实:
采购行的已收数量,本质上是根据“完成的库存 move”重算出来的,不是人工同步写进去的。
因此你会看到这些现象:
- 只收一部分,
qty_received就部分增加 - 做退货,
qty_received可能回退 - move 没真正 done,只是 reserved / assigned,不会计入已收
这也是很多“为什么明明有收货单但采购单没显示已收”的答案:
因为决定口径的是 done move,不是“有单据存在”本身。
第六段:为什么有时采购单还是显示部分收货
因为 picking done 和采购 fully received 不是同一个判断层级。
一个 picking 完成,只代表:
- 这一次收货动作结束了
但采购单是否“全部收完”,还要回到采购行层面看:
- 每条采购行的
product_qty - 每条采购行的
qty_received - 是否存在 backorder / 后续补收
也就是说:
- picking 是执行单据
- purchase order line 是履约口径
执行动作已经 done,不等于履约义务已经全部完成。
实战里最容易误解的 4 件事
1. 以为 Validate 只改 picking 状态
错。它还会触发 move done、回写采购行已收数量,并推进采购单确认语义。
2. 以为 qty_received 是采购模块自己维护的
不准确。它在 stock_moves 模式下,本质上是由库存 move 结果倒推出的。
3. 以为看到收货单就一定算已收
不对。只有进入 done 的 move 才进入统计。
4. 以为部分收货异常一定在采购单上修
很多时候真正该查的是:
- move 数量是否录对
- 是否走了 backorder
- 是否发生 return
- 关联的
purchase_line_id是否正确
你排错时该按什么顺序看
如果现场出现“采购单数量没回写”“收货后状态不对”,我通常按这个顺序排:
- 采购行是否真的生成了
purchase_line_id关联 move - move 是否
done - done 数量是不是你以为的数量
- 是否有 return / backorder 抵消了结果
- picking 是否能正确反查到
purchase_id - 再去看采购单页面上的状态展示
顺序不要反过来。 先盯页面状态,往往越看越乱;先盯 move 链路,通常很快就能定位。
最后一句话
Odoo 采购收货的设计并不是“采购单改一次、收货单再改一次”。
它更像是:
- 采购单负责提出履约需求
- picking / move 负责记录真实执行
- 采购行状态再根据真实执行结果回写出来
所以想真正看懂“收货完成后系统为什么这样显示”,关键不是背页面字段,而是抓住这条主线:
采购行有没有被正确绑定到库存 move,而这些 move 又是否真的完成。
DISCUSSION
评论区