库存预留边界

Odoo 为什么有些库存 move 天生不走预留:_should_bypass_reservation、虚拟库位与“直接放行”边界

很多人把 Odoo 里的 assign 理解成“所有 move 都应该去锁 quant”。其实不是。只要源库位本身就不参与预留,或者产品压根不是 storable,系统就会改走另一条链。本文把 _should_bypass_reservation 的设计目的、常见触发条件和排错顺序讲透。

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

很多人一提 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 分支时,系统仍然会:

  1. 计算这张 move 还差多少数量;
  2. 在有上游 move 的情况下,尝试根据上游已完成的 move line 去继承 lot / package / owner 等维度;
  3. 必要时创建 move line;
  4. 把 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 没把它们混在一起,是对的。

源码阅读顺序建议

如果你要真正吃透这块,推荐按这个顺序看:

  1. stock.move._should_bypass_reservation():先建立边界感;
  2. stock.move._should_assign_at_confirm():再理解“时机”和“机制”的区别;
  3. stock.move._action_assign() 里 bypass 分支:看它如何生成 move line 但不动 quant;
  4. stock.move.line / stock.quant 相关同步逻辑:看普通 reservation 跟 bypass 的实际差异。

这样读,比一上来就钻 _update_reserved_quantity() 更不容易迷路。

实战排错:assigned 了但没锁库,先看什么

推荐顺序:

  1. 看源库位 usage / 语义:是不是虚拟来源、供应商、客户、盘点、报废等不该锁库的位置;
  2. 看产品类型:是不是非 storable;
  3. 看 move 有没有上游链路:是不是在承接上游 move line,而不是从 quant 里重新找货;
  4. 再看 reservation method:确认是不是“还没轮到预留”,而不是“根本不适用预留”;
  5. 最后再查 quant:别一开始就被 quant 视角绑架。

很多“系统没预留”的工单,到这一步就已经能解释清楚。

最后的判断标准

理解 bypass reservation,核心不是背下函数名,而是学会问一句:

这笔 move 的源头,到底代表“库存池中的可争抢现货”,还是“一个允许业务继续流动的逻辑节点”?

如果答案是后者,那么 Odoo 不去锁 quant,恰恰说明它没把业务语义和库存竞争搅成一团。

这不是偷懒,而是库存设计真正成熟的地方。

DISCUSSION

评论区

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