采购收货链

Odoo 采购收货为什么不是“一行采购一张收货单”:已有 picking 复用、move 挂接与 qty_received 真实链路

很多人以为采购确认后,Odoo 会按采购行机械地一行生成一张收货单。但从 purchase_stock 的 _create_picking、_create_or_update_picking 与 _prepare_qty_received 看,系统真正维护的是“订单级收货容器 + 行级 stock.move + 已完成 move 反推收货数量”的模型。

库存 采购
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 7 阅读

很多实施同学第一次看 Odoo 采购收货,会默认带着一个 ERP 直觉:

  • 一张采购单有三行;
  • 系统就该生成三张或至少三段完全独立的收货对象;
  • 每行的“已收货数量”也应该只是那一行自己的字段累加。

/home/ubuntu/odoo-temp/addons/purchase_stock/models/purchase_order.pypurchase_order_line.py 里的实现不是这种“一行一世界”的思路。

它更像三层分工:

  1. 采购单负责准备或复用收货容器,也就是 stock.picking
  2. 采购行负责生成或补挂具体的库存移动,也就是 stock.move
  3. qty_received 并不是人工主字段,而是根据已完成 move 反推出来的结果。

所以你看到“新增采购行没有再生一张新收货单”“改数量后单据还是挂在原来的 receipt 上”“已收货数量会被退货再冲回去”,这些都不是异常,反而正是源码设计本意。

一、采购单先准备的是 picking,不是逐行收货结果

_prepare_picking() 很直白,它返回的是一张收货单级别的数据:

  • picking_type_id
  • partner_id
  • origin
  • 源库位 / 目标库位
  • company_id
  • reference_ids

注意这里没有“某一行采购行”的概念。

这说明在 Odoo 的采购收货模型里,picking 是订单级或批次级容器,先表达“这批货是从哪个供应商、往哪个目标位置、以什么入库类型进来”,再让后续的 move 往里面挂。

也就是说,receipt 先回答的是:

这是一趟怎样的入库动作?

而不是:

每一行采购行单独长什么样?

二、_create_picking() 的默认心智模型是“优先复用未完成收货单”

purchase.order 上的 _create_picking() 会遍历状态已经是 purchase 的订单,然后先找:

  • 当前订单已有的 picking_ids
  • 且状态不在 done / cancel 的未完成收货单

如果找到了,就直接复用第一张; 如果没找到,才调用 _prepare_picking() 新建一张 picking。

这就是为什么很多现场会出现一种“看着像没新建”的错觉:

  • 你新增了一行;
  • 你提高了数量;
  • 你重新确认或修改订单;
  • 但系统没有给你再生一张新收货单。

原因不是漏触发,而是源码本来就优先把新增需求塞进同一张仍然开放的 receipt 容器里。

这背后的业务含义很合理:

  • 同一张 PO 的到货通常希望在同一个入库业务上下文里处理;
  • 仓库更关心“这一趟收货单”而不是“每行都独立成单”;
  • 后续 message、回溯、操作确认也更集中。

三、真正和采购行绑定的,是 stock.move

单据容器找好之后,_create_picking() 调的是:

  • order.order_line._create_stock_moves(picking)

也就是说,行级粒度并不是靠 picking 切分,而是靠 move 表达。

后续它还会:

  • 过滤掉已完成和已取消 move;
  • _action_confirm()
  • move.date 重排 sequence;
  • _action_assign()
  • 最后把受 push rule 影响的后续 picking 一起 action_confirm()

这里很关键的一点是:

一张 picking 里可以挂多条 move,而采购行和库存动作的真实对应关系主要在 move 上。

所以如果你把“收货单”理解成“行级实体”,很多现象都会觉得反直觉; 但如果你把它理解成“入库批次容器”,就顺了。

四、采购行后续变动时,源码依然优先“补挂到现有 picking”

更能说明设计意图的,其实是 purchase.order.line_create_or_update_picking()

它处理的是这种场景:

  • 采购单已经确认;
  • 后面又新增采购行;
  • 或者改了数量;
  • 系统要决定把新的库存需求放哪。

源码顺序大致是:

  1. 先看当前行已有 move 对应的 picking 里,有没有仍未完成、且目标位置是内部/中转/客户的 picking;
  2. 没有的话,再看整张订单下有没有可复用的 open picking;
  3. 还没有,且 product_qty > qty_received 时,才真正新建 picking;
  4. 然后通过 _create_stock_moves(picking) 往里补 move,并 _action_confirm()._action_assign()

这套顺序非常说明问题:

  • 优先挂回行自己的现有收货上下文
  • 不行就挂回订单已有收货上下文;
  • 实在没有才新建。

所以“为什么改数量后还是进老收货单”这个问题,源码答案就是:

因为系统默认认为这是同一笔采购履约链上的增量,不是必须拆成新的业务容器。

五、qty_received 不是你想象中的“手工累计字段”

_prepare_qty_received() 更值得看。

qty_received_method == 'stock_moves' 的场景下,它不是简单读某个人工维护数值,而是遍历 _get_po_line_moves() 的结果,针对 state == 'done' 的 move 重新累计。

而且它还处理了几个很实战的边界:

  • 采购退货时,数量要扣回去;
  • 某些 dropship / returned 边界不能重复计数;
  • 需要把 move 的 UoM 换算到采购行自己的 product_uom_id

这说明:

采购行上的“已收货数量”更像是库存执行结果的投影,而不是独立主数据。

因此你调试时如果只盯着采购行字段,很容易误判; 真正该看的往往是:

  • 这行对应了哪些 move;
  • 哪些 move done 了;
  • 哪些是退货 move;
  • 有没有 UoM 换算;
  • 有没有被特殊链路排除。

六、新手最容易误解的三个点

1)“采购行 = 收货单行 = 一定独立成单”

不对。

采购行更接近需求来源,真正执行层是 stock.movestock.picking 是承载这些 move 的业务容器。

2)“改采购数量后没新建 receipt,就是系统没刷新”

也不对。

很多时候不是没刷新,而是命中了源码里的复用 open picking 策略。

3)“qty_received 直接改一下就完事”

这也危险。

在 stock move 驱动的场景里,qty_received 本质上是执行结果投影。你如果只想改显示结果,不改底层 move,后面往往还会被重新算回来。

七、实施和开发时最该注意什么

我更建议把排错顺序固定成下面这样:

  1. 先看订单是否已有未完成 picking
  2. 再看采购行生成了哪些 move
  3. 确认 move 的 state、quantity、UoM 与退货关系
  4. 最后再看采购行上的 qty_received 是否只是被动反映。

如果要做定制,也最好想清楚你到底想改哪一层:

  • 想改变“收货单怎么分组/复用” → 更偏 picking 层;
  • 想改变“某行生成什么库存动作” → 更偏 move 层;
  • 想改变“已收货数量怎么看” → 要从 move 到 qty_received 的映射链去改。

总结

Odoo 采购收货的关键,不是“一行采购生成一张收货单”,而是:

  • 采购单先准备或复用 picking 容器
  • 采购行往容器里生成或补挂 stock.move
  • qty_received 再根据已完成 move 进行 结果反推

如果只记一句,就记这句:

在 Odoo 采购收货里,真正细粒度执行的是 move,picking 只是容器,qty_received 则是库存执行结果的投影。

DISCUSSION

评论区

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