先说结论
如果你只看基础 purchase 模块,很容易形成一个印象:
- 服务和耗材采购的
qty_received_method多半是manual
但在 /home/ubuntu/odoo-temp/addons/purchase_stock/models/purchase_order_line.py 里,purchase_stock 明确把耗材产品的逻辑改了:
- 对
product_id.type == 'consu'的采购行 qty_received_method = 'stock_moves'
这意味着:
安装 purchase_stock 之后,耗材采购不再只是手工维护已收数量,而会重新挂回库存 move 驱动。
这正是很多项目里“同样是耗材,为什么这个库手工改不了 qty_received,那个库却能改”的根因。
为什么这条边界容易被误读
因为很多人记住的是“服务/耗材不是可库存产品,所以通常不走库存收货”。
这个印象在基础采购模块下并不算错。
但 purchase_stock 的立场更偏业务执行:
- 只要耗材采购已经纳入库存收货链
- 那已收数量就应该尽量由 move 事实驱动
这和“产品是否永久进库存估值”不是同一个问题。
也就是说,Odoo 在这里区分的是:
- 是否需要库存动作来表达收货事实
- 而不是简单按“是否 storable”一刀切
这带来的最大变化:qty_received 不再只是用户输入框
当耗材行走 stock_moves 后,源码会通过 _prepare_qty_received() 汇总相关 move,计算已收数量。
而且处理得并不粗糙,它会细分:
- 正常完成的收货 move → 加数量
- 采购退货且应退款 → 减数量
- 某些 dropship return 边界场景 → 避免重复计数
- 某些 purchase return 的回流场景 → 也有跳过条件
这说明 Odoo 在这里不是“简单把 move 数量相加”,而是在尽量让 qty_received 贴近真实业务含义。
为什么退货和代发回流会让人看不懂数量
源码里最值钱的部分,恰恰是那些 elif 分支。
它们表达的是:
- 并不是所有 done move 都应当计入“本次采购已收”
- 有些 move 是退货链的一部分
- 有些是 dropship 的回流边界
- 有些虽然是 done,但如果直接累加会把已收数量算重
这就是为什么现场里常见的抱怨:
- “收货都 done 了,为什么 qty_received 不是我以为的数?”
根因不在字段坏了,而在于 Odoo 正在尝试区分:
- 收到
- 退回
- 回流但不应重记
这些语义并不一样。
另一个容易忽略的影响:改数量会反过来动库存动作
purchase_stock 里采购行的 write()、create()、_create_or_update_picking() 也都围绕耗材做了处理。
这意味着:
- 你增减采购行数量
- 不只是 PO 行数字变了
- 相关 picking / move 也可能被创建、补齐、重算
所以在这个模块下,耗材采购已经不是“轻量采购记录”,而是和库存执行真正连起来了。
最容易误解的 5 个点
1. 以为耗材采购永远是 manual received
安装 purchase_stock 后,不成立。
2. 以为只有 storable 产品才会由 move 驱动已收数量
耗材在这里就是例外。
3. 以为 done move 全都应当直接累计
退货与回流场景有排除逻辑。
4. 以为 qty_received 不对就是界面字段问题
通常要先看 move 链和 return 语义。
5. 以为改采购行数量只是改采购
它还可能触发 picking / move 更新。
排错顺序
如果你遇到耗材采购已收数量“不对”,建议按这个顺序查:
- 是否安装并走到了
purchase_stock逻辑 - 该产品类型是否为
consu - 采购行当前
qty_received_method是manual还是stock_moves - 关联
move_ids哪些是 done、哪些是 return - 是否存在 dropship return 或 purchase return 回流边界
- 最近是否改过采购数量,导致 picking/move 被重建或重算
一句话记忆法
在 Odoo 的 purchase_stock 世界里,耗材采购不再只是手工记“收了多少”,而是尽量让库存 move 事实来回答这个问题。
DISCUSSION
评论区