很多实施同学第一次看 Odoo 采购收货,会默认带着一个 ERP 直觉:
- 一张采购单有三行;
- 系统就该生成三张或至少三段完全独立的收货对象;
- 每行的“已收货数量”也应该只是那一行自己的字段累加。
但 /home/ubuntu/odoo-temp/addons/purchase_stock/models/purchase_order.py 和 purchase_order_line.py 里的实现不是这种“一行一世界”的思路。
它更像三层分工:
- 采购单负责准备或复用收货容器,也就是
stock.picking; - 采购行负责生成或补挂具体的库存移动,也就是
stock.move; qty_received并不是人工主字段,而是根据已完成 move 反推出来的结果。
所以你看到“新增采购行没有再生一张新收货单”“改数量后单据还是挂在原来的 receipt 上”“已收货数量会被退货再冲回去”,这些都不是异常,反而正是源码设计本意。
一、采购单先准备的是 picking,不是逐行收货结果
_prepare_picking() 很直白,它返回的是一张收货单级别的数据:
picking_type_idpartner_idorigin- 源库位 / 目标库位
company_idreference_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()。
它处理的是这种场景:
- 采购单已经确认;
- 后面又新增采购行;
- 或者改了数量;
- 系统要决定把新的库存需求放哪。
源码顺序大致是:
- 先看当前行已有 move 对应的 picking 里,有没有仍未完成、且目标位置是内部/中转/客户的 picking;
- 没有的话,再看整张订单下有没有可复用的 open picking;
- 还没有,且
product_qty > qty_received时,才真正新建 picking; - 然后通过
_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.move;
stock.picking 是承载这些 move 的业务容器。
2)“改采购数量后没新建 receipt,就是系统没刷新”
也不对。
很多时候不是没刷新,而是命中了源码里的复用 open picking 策略。
3)“qty_received 直接改一下就完事”
这也危险。
在 stock move 驱动的场景里,qty_received 本质上是执行结果投影。你如果只想改显示结果,不改底层 move,后面往往还会被重新算回来。
七、实施和开发时最该注意什么
我更建议把排错顺序固定成下面这样:
- 先看订单是否已有未完成 picking;
- 再看采购行生成了哪些 move;
- 确认 move 的 state、quantity、UoM 与退货关系;
- 最后再看采购行上的
qty_received是否只是被动反映。
如果要做定制,也最好想清楚你到底想改哪一层:
- 想改变“收货单怎么分组/复用” → 更偏 picking 层;
- 想改变“某行生成什么库存动作” → 更偏 move 层;
- 想改变“已收货数量怎么看” → 要从 move 到 qty_received 的映射链去改。
总结
Odoo 采购收货的关键,不是“一行采购生成一张收货单”,而是:
- 采购单先准备或复用 picking 容器;
- 采购行往容器里生成或补挂 stock.move;
qty_received再根据已完成 move 进行 结果反推。
如果只记一句,就记这句:
在 Odoo 采购收货里,真正细粒度执行的是 move,picking 只是容器,
qty_received则是库存执行结果的投影。
DISCUSSION
评论区