先说结论
在 Odoo 里,“有货” 和 “这张单据现在能 reserve 到货” 不是一回事。
很多人看到产品页还有 on hand,就会默认这张拣货单理应 assign 成功。但源码真正问的问题更像是:
在当前 location、lot、package、owner、tracking、UoM 与 reservation 规则下,这个 move 还能不能合法拿到足够数量。
所以“明明有货却预留失败”,大多数时候不是 bug,而是你在看总量,系统在看当前动作可合法占用的那一小块量。
入口看起来简单,真正难点在后面
库存预留主入口还是熟悉的:
stock.picking.action_assign()stock.move._action_assign()stock.quant._get_reserve_quantity()
但一旦进入 stock.quant,逻辑就不再是“仓里有多少就拿多少”,而是先回答几个更细的问题:
- 这次要不要 bypass reservation
- 要按宽松匹配还是
strict=True - lot / package / owner 有没有卡边界
- serial 产品能不能按整数保留
- 当前 quant 里是不是混着负数 / 负预留残留
很多“看着有货却分不到”的真相,都藏在这些细节里。
第一类误判:你看的 on hand,不是这次 move 能用的 available
最常见的误区是把下面两句话当成同义词:
- 仓里还有货
- 这张单据能拿到货
其实不是。
_get_available_quantity() 与 _get_reserve_quantity() 都支持:
lot_idpackage_idowner_idstrict
也就是说,Odoo 看的不是“总库存”,而是带条件的库存。
举个最典型的例子:
- 总库存 100
- 其中 60 属于某个 owner
- 20 在特定 package 里
- 20 已被别的 move reserve 了
你在产品页看到“还有货”,并不代表当前这张 move 在自己的边界里还有货。
第二类误判:strict 语义和人脑直觉不一样
stock.quant._get_gather_domain() 很值得看。
当 strict=False 时,Odoo 会更宽松地找 quant,例如:
location_id child_of 当前库位- lot 可能允许
[指定 lot, False] - owner / package 只在有值时参与约束
但当 strict=True 时,条件会明显收紧:
- location 必须完全相等
- package / owner 必须完全相等
- lot 也会按精确边界来算
这就是为什么很多链路里你会遇到一种反直觉情况:
同样是“查库存”,宽松时看起来够,真正落到具体 move line 或链式 move 时却又不够。
因为系统已经从“广义上有没有”切换到了“这一条具体明细能不能严丝合缝地匹配”。
第三类误判:serial 产品不是“有零头也能先分一点”
源码里 _get_reserve_quantity() 有一个很关键的约束:
- 如果产品 tracking 是
serial - 而可保留数量不是整数
- 那么数量会直接归零
这背后的意思很简单:
序列号产品的 reservation 不是“先凑一点”,而是必须按一件一号的离散单位来成立。
所以你可能看到:
- 界面上还有 0.6、0.8 这类数量
- 甚至 UoM 换算后感觉“差不多够了”
但对 serial 产品,差一点就是不行。
第四类误判:UoM 和 rounding 会把“看起来够”变成“系统不敢分”
源码里在 reserve 前会做 UoM 换算,并且尽量避免“超分配”。
这一步会考虑:
- move 的 UoM
- product 的基础 UoM
- rounding precision
- DOWN / HALF-UP 之类的换算方式
所以某些场景下,人脑算的是:
- 反正就差一点点,分了也没事
系统算的是:
- 如果按 rounding 规则一换算,会超出允许边界,那我宁可不分
这类问题在包装单位、采购单位和库存单位不完全一致时特别常见。
第五类误判:负 quant / 负预留会让“可用量”比你想的更小
_get_reserve_quantity() 里还有一段非常实战的逻辑:
- 它会先识别
quantity - reserved_quantity < 0的 quant - 把这些负的残留先冲掉
- 再决定真正还能分多少
这说明 Odoo 不是单纯把所有正数加总就完事,而是会先处理历史上已经存在的异常形态。
这类异常可能来自:
- 之前的强制操作
- 特殊链路下的库存修正
- inventory adjustment 把链式 move 的供给打断
- 某些 done / unreserve / revert 操作后的临时不平衡
所以你看到“表面数量够”,系统却 reserve 不满,有时不是当前单据的问题,而是历史 quant 状态先把一部分可用量抵掉了。
第六类误判:上游 move 带来的“可用量”也不一定真的还在 quant 里
_action_assign() 对有 move_orig_ids 的 move,会先看父 move 带来了多少可分配的 move line,再去 double-check quant 自身是否还可用。
源码里甚至直接写了一个很重要的注释:
- 这可能是 inventory adjustment 把原来那部分量全部或部分拿掉了
意思是:
上游链路说“理论上这批货应该流到你这里”,不代表 quant 层现在还真的保有这批货。
所以链式补货 / MTO / 多步物流里,预留失败不能只看 move 关系,还要落回 quant 事实层确认。
最值得先排的 6 个点
如果你遇到“明明有货却 assign 不上”,最省时间的排查顺序是:
1. 看产品是不是 storable,库位是不是 bypass reservation
有些动作本来就不走普通 reservation 语义。
2. 看 move 需要的边界
重点确认:
- location
- lot
- package
- owner
- tracking
3. 看可用量是不是已经被别的 move reserve 掉了
不是 on hand,而是 quantity - reserved_quantity。
4. 看 serial / UoM / rounding 有没有把数量打成 0
尤其是序列号产品与包装换算。
5. 看 quant 里有没有负残留
这会先吞掉可保留量。
6. 看是不是链式 move,被 inventory adjustment 或别的动作中途改了底层事实
“上游说有”不等于“量现在还在”。
新手最容易犯的判断错误
1. 把产品页 on hand 当成 reservation 成功保证
不是。
2. 以为 assign 失败一定是 location 没货
也可能是 lot / owner / package 不匹配。
3. 以为 serial 产品能像普通产品那样先拆点零头
不行。
4. 以为链式 move 的可用量来自上游关系,而不是来自 quant 事实
关系链只是线索,不是最终库存事实。
5. 看到数量够就忽略 rounding
系统不会因为“差一点点”就网开一面。
一句话记忆法
Odoo 的 reservation 失败,很多时候不是“没货”,而是“在当前 strict 边界、tracking 规则、UoM 精度和 quant 真实状态下,没有足够可合法占用的货”。
理解这句话,你以后看到“明明有货却预留失败”,就不会再只盯着 on hand 发呆。
DISCUSSION
评论区