很多人一提 Odoo 库存预留,脑子里就只有一条线:action_assign() → 找 quant → 锁库存 → 生成 move line。这个理解适合大多数普通收发货场景,但它有一个关键盲区:并不是所有 move 都应该真的去“预留库存”。
源码里这个分叉点很明确:stock.move._should_bypass_reservation()。
它回答的不是“现在有没有货”,而是一个更根本的问题:
这张 move 的源头,是否本来就不应该参与 quant 级别的锁货?
如果这个问题的答案是“是”,后面整条 assign 逻辑就会改写。系统仍然可能生成 move line,但不会去把 stock.quant.reserved_quantity 改掉。这正是很多人看到“单据 assigned 了,但怎么没锁库”时最容易误判的地方。
先记住一句人话
_should_bypass_reservation() 不是库存不足时的降级兜底,
而是 Odoo 对某些来源场景的结构性声明:
- 这里的货不是从普通可分配库存里拿;
- 这里不应该跟其他单据争抢 quant;
- 这里更像“允许通过”,而不是“先锁再走”。
源码里判断非常短:
- 源库位
location.should_bypass_reservation();或 - 产品不是
storable。
但这行判断背后的语义非常重。
为什么虚拟库位天然适合 bypass
很多虚拟库位的职责,本来就不是“真实仓位里的可争抢存量”。
比如:
- 供应商库位
- 客户库位
- 生产、盘点、报废等特殊用途库位
- 某些中转 / 逻辑位置
这类位置更像业务语义节点,而不是“仓库里那一格货架”。
当 move 的来源是这类位置时,系统若还硬按 quant 预留思路走,反而会制造很多伪问题:
- 你会试图去锁一个本来不代表自有可用库存的东西;
- 你会让链路误以为不同业务事实可以互相抢占;
- 你会把“业务许可”误读成“库内分配”。
所以 Odoo 的设计很克制:先看来源语义,再决定是否值得进入 reservation 机制。
bypass 不等于“什么都不做”
很多人一听“绕过预留”,就误解成 move 什么都不生成、什么都不检查。也不是。
在 _action_assign() 里,走 bypass 分支时,系统仍然会:
- 计算这张 move 还差多少数量;
- 在有上游 move 的情况下,尝试根据上游已完成的 move line 去继承 lot / package / owner 等维度;
- 必要时创建 move line;
- 把 move 标记成 assigned。
差别在于:
- 它不去变更 quant 的 reserved quantity;
- 它表达的是“这笔流转可以继续”,不是“我已经从库存池里抢到货了”。
所以你会看到一种很典型的现象:
- move 已 assigned;
- move line 也出来了;
- 但 quant 上没有那种普通库内预留痕迹。
这不是异常,而是这条链本来就没打算走 quant reservation。
有上游 move 时,bypass 更容易被看错
源码里有个很容易被忽略的细节:
当 move 有 move_orig_ids,而且当前 move 又属于 bypass reservation 场景时,Odoo 仍会去看上游已经带来的 move line,并把可继承的维度拎下来。
这意味着什么?
意味着 bypass 不是“凭空通行”,而是:
- 如果上游已经把 lot / package / owner / location 颗粒度带出来了,当前 move 可以沿着链继续传递这些信息。
这也是为什么一些跨步骤内部链路里,你会看到下游单据虽然没真正锁 quant,但 lot / package 等痕迹仍然能顺着链传下来。
因此,排查时别只盯 quant。你还要看:
- 有没有上游已 done 的 move line;
move_orig_ids/move_dest_ids是否连着;- 当前 assign 到底是“从库存找货”,还是“沿上游结果接力”。
bypass 与 reservation_method 不是一回事
另一个常见混淆,是把 bypass reservation 和 operation type 上的 reservation method 搞成一类问题。
二者层级不同:
reservation_method解决的是:什么时候开始尝试预留;_should_bypass_reservation()解决的是:这张 move 是否根本不该做 quant 预留。
也就是说:
at_confirm/manual/by_date决定“时机”;- bypass 决定“机制本身是否适用”。
这两个问题不能混着排。
如果一张 move 本来就 bypass,那么你去调 reservation date、操作类型预留方法,经常只是在错误层面上用力。
产品类型也会触发 bypass
源码还把 not self.product_id.is_storable 放进了 bypass 条件。
这背后的设计很朴素:
- 消耗品和服务型项目,本来就不适合进真实库存占用逻辑;
- 系统没必要对它们维持 quant 级库存竞争关系。
所以当你看到非 storable 产品的库存 move 没有正常 reservation 痕迹时,先别怀疑 assign 失效。多数时候,这正是系统有意为之。
为什么这套设计很重要
因为它把两类完全不同的事实分开了:
第一类:库存池竞争
这里的重点是:
- 同一批货会不会被多张单抢;
- 需要按 lot / owner / package / location 精确扣住哪一部分;
- quant 是否要体现 reserved_quantity。
第二类:业务语义流转
这里的重点是:
- 这步业务是否允许继续;
- 上一步带来的结果是否需要被沿链传递;
- 是否要保留来源与去向的因果关系。
普通仓库货架上的货,属于第一类。 很多虚拟来源、逻辑来源、非 storable 场景,更偏第二类。
Odoo 没把它们混在一起,是对的。
源码阅读顺序建议
如果你要真正吃透这块,推荐按这个顺序看:
stock.move._should_bypass_reservation():先建立边界感;stock.move._should_assign_at_confirm():再理解“时机”和“机制”的区别;stock.move._action_assign()里 bypass 分支:看它如何生成 move line 但不动 quant;stock.move.line/stock.quant相关同步逻辑:看普通 reservation 跟 bypass 的实际差异。
这样读,比一上来就钻 _update_reserved_quantity() 更不容易迷路。
实战排错:assigned 了但没锁库,先看什么
推荐顺序:
- 看源库位 usage / 语义:是不是虚拟来源、供应商、客户、盘点、报废等不该锁库的位置;
- 看产品类型:是不是非 storable;
- 看 move 有没有上游链路:是不是在承接上游 move line,而不是从 quant 里重新找货;
- 再看 reservation method:确认是不是“还没轮到预留”,而不是“根本不适用预留”;
- 最后再查 quant:别一开始就被 quant 视角绑架。
很多“系统没预留”的工单,到这一步就已经能解释清楚。
最后的判断标准
理解 bypass reservation,核心不是背下函数名,而是学会问一句:
这笔 move 的源头,到底代表“库存池中的可争抢现货”,还是“一个允许业务继续流动的逻辑节点”?
如果答案是后者,那么 Odoo 不去锁 quant,恰恰说明它没把业务语义和库存竞争搅成一团。
这不是偷懒,而是库存设计真正成熟的地方。
DISCUSSION
评论区