很多实施和二开同学第一次碰库存明细改写,都会低估 stock.move.line 的重量。
界面上看,它只是一行:
- 换个 lot;
- 改个 owner;
- 从一个 package 改到另一个 package;
- 把数量从 5 改成 3。
看起来像改表格,实际上如果这条 line 已经参与了 reservation,Odoo 做的根本不是“单纯 update 一行记录”,而是一套 先拆旧库存关系、再重建新库存关系 的同步动作。
这也是很多人遇到以下问题的根因:
- 改完 lot 以后预留数量忽然变少;
- 改完 package 以后单据状态变成 partially_available;
- 改 done move line 居然影响到下游单据;
- 自定义
write()以为只是补字段,结果把 quant 弄乱了。
先建立一个正确认识
在 Odoo 里,已预留 move line 不是“描述库存”的文本。 它更像一份 当前库存占用关系的声明。
这份声明至少包含这些关键维度:
- product
- location
- lot
- package
- owner
- quantity
只要这些维度变了,系统就不能假装“还是原来那笔占用”。
所以源码在 stock.move.line.write() 里干脆把这件事处理成:
- 判断你是不是改了 reservation 相关触发字段;
- 如果是,就把旧特征对应的预留先释放;
- 再尝试按新特征重新预留;
- 如果新特征下可用量不足,只保留最大可能值;
- 必要时重算 move 状态。
这套设计,比“硬改字段”安全得多。
哪些字段会触发真正的库存同步
源码里这组字段尤其关键:
location_idlocation_dest_idlot_idpackage_idresult_package_idowner_idproduct_uom_idquantity
但要注意,result_package_id 的语义跟别的字段不完全一样。
为什么 result_package_id 相对特殊
package_id 表示货现在从哪个包里来,
result_package_id 表示这次操作后要进哪个包。
所以改 result_package_id 更多是在改“目的包装结果”,
而改 lot_id / package_id / owner_id / location_id 则是在改“源侧占用条件”。
源码里专门把它们区分开,就是因为:
- 改源侧特征,往往必须释放并重建 reservation;
- 改结果包装,不一定等价于改源侧占用。
核心动作:先 unreserve,再 reserve
这是这篇最重要的一句话。
当一条已预留 move line 被改写时,Odoo 的思路不是“尝试原地修补 quant”,而是:
旧关系先清干净,新关系再按新条件重建。
源码里会先调用同步逻辑,把旧的 quantity_product_uom 从旧特征对应的 quant reservation 中扣掉;然后再按新的 lot / package / owner / location 去做一次 reservation。
这一步非常关键,因为库存系统最怕的就是“表面字段改了,底层 reservation 没跟着迁移”。
一旦出现那种半改半没改的状态,你看到的就会是:
- move line 上写着 lot B;
- quant 却还在 lot A 上挂着 reserved;
- 后面任何 assign / validate 都会越来越诡异。
Odoo 正是为了避免这种裂缝,才把写入做得很重。
为什么改完数量,预留可能反而减少
很多人会疑惑:
- 我只是把数量改成了 10;
- 为什么最后 move line 上只剩 6;
- 为什么状态也从 assigned 变 partial?
原因是源码并不承诺“你改成多少,就一定抢到多少”。
它承诺的是:
在新特征条件下,尽量保留最大可预留数量。
举个典型例子:
- 原来这条 line 预留的是无 lot 库存;
- 你把它改成某个指定 lot;
- 但该 lot 真正可用只有 6。
那么系统就会:
- 释放原来那笔更宽松条件下的 reservation;
- 再尝试按指定 lot 重新预留;
- 最终只能拿回 6,而不是你表面写的 10。
这不是 write 丢数据,而是你把约束收紧以后,可占用池变小了。
done move line 为什么更危险
源码里还有一层很多人没意识到:
如果你改的是已经 done 的 move line,影响不只在当前单据。
为什么? 因为已完成 move line 不只是“预留过”,它已经是实际库存流转事实的一部分。
这时系统会:
- 撤销原先对目的库位 / 来源库位造成的 quant 影响;
- 按新值重新同步可用量;
- 找出相关的下游
move_dest_ids; - 让后续 move 重新面对真实的可用情况。
换句话说,改 done line,本质上是在改链路历史。
所以这类改写很容易牵动:
- 下游 move 的 reserved availability;
- picking 状态;
- 链式补货 / 履约判断;
- 追溯报表的一致性。
如果你在二开里直接批量写 done move line,又没理解这层语义,后果通常比你想的严重得多。
_synchronize_quant() 才是真正的底层铰链
源码里最值得盯的,不只是 write(),而是它最终借力的 _synchronize_quant()。
这个方法做了两件核心事:
action="available"时,改可用量;action="reserved"时,改预留量。
同时它还会把:
lotpackageowner
这些维度一起带下去。
也就是说,move line 的“改一行”之所以很重,是因为它最后会落到 quant 粒度的真实同步,而不是停留在文档层。
lot / owner / package 为什么是同一类问题
很多人习惯把它们分开理解:
- lot 是追溯;
- owner 是寄售;
- package 是装箱。
业务语义上当然不同,但在 reservation 粒度上,它们是一类问题:
它们都在缩小“哪一份库存才算这条 line 可占用的库存集合”。
一旦你改这些字段,就等于在问系统:
“请不要再按原来的那批货算了,请改按另一组更精确的库存边界来占用。”
那当然不可能只靠一条 SQL update 结束。
实战里最容易犯的二开错误
1)把 move line 当成普通明细表批量 update
这是最常见的坑。
如果你的脚本跳过了 ORM 语义,只在数据库层硬写 lot / package / owner,前端也许看着变了,但 quant 层基本注定乱。
2)在 write() 里补字段时忽略库存语义
有些扩展会重载 write(),以为只是补个 trace 或同步外部系统。问题是,只要你顺手把 reservation 相关字段也碰了,系统就不是在“补信息”,而是在“重建库存关系”。
3)改 done move line 却没意识到会波及下游
done 不代表安全,反而代表它已经进入因果链。历史被改,后续当然要重算。
推荐的排错顺序
如果你遇到“改完 line 后库存更乱了”,建议按这个顺序看:
- 改的是不是
lot_id/package_id/owner_id/location_id/quantity这类触发字段; - 这条 line 改之前是否已经 reserved;
- 改之后的新特征下,quant 是否真的有足够可用量;
- 这条 line 是否已经 done;
- 它有没有
move_dest_ids或链式后续动作; - 自定义代码是否绕过了 ORM 同步路径。
这套顺序能帮你把“表层字段变化”和“底层库存关系变化”分开看。
最后一句总结
在 Odoo 里,已预留 move line 不是一行可随便修的表单数据。 它是 库存占用关系在文档层的外显。
所以你一旦改它,系统就必须去问:
- 旧关系要不要拆;
- 新关系能不能重建;
- quant 要不要同步;
- 下游链路要不要跟着重算。
理解了这一点,你就会明白:
Odoo 对 move line 改写之所以“反应很大”,不是因为它脆弱,而是因为它在认真维护库存事实的一致性。
DISCUSSION
评论区