很多人看到“销售单改数量”时,脑子里会自动担心一件事:
会不会又重新生成一套发货单?
Odoo 的答案是:不会直接重来。它会先算已经履约了多少,只对差额继续发起 procurement。
这正是 sale_stock 里最值得学的一段防重复逻辑。
先看入口:不是每次写入都会全量重算
在 sale.order.line.write() 里,Odoo 只有在销售行确实处于 sale 状态时,才会继续往下走,而且它会先把旧数量记下来,再把 previous_product_uom_qty 传给 _action_launch_stock_rule()。
这一步的用意很直接:
- 不是“看到改动就重建”;
- 而是“对比新旧数量,只补真正缺的部分”。
这也是为什么你改一笔订单的数量,常常不会看到全新的 move 链,而是看到已有链路继续被补齐。
_get_qty_procurement() 先统计“已经推进了多少”
这段逻辑的核心很朴素:
- 找出这条 sale order line 的 outgoing moves;
- 再找出会抵消数量的 incoming moves;
- 把已经履约、已退回、已反向处理的量一起折算进来;
- 得出当前这条行还有多少“净需求”。
所以它看的不是“订单上写了多少”,而是:
这条行在真实库存链路里,已经被推进了多少。
这就是防重复的关键。
如果你只盯着 product_uom_qty,很容易误判;但 Odoo 是拿它去和已履约数量做差,再决定是否继续发起 procurement。
_action_launch_stock_rule() 只处理差额
算完净需求后,Odoo 只对差额发起后续动作:
- 先准备 procurement values;
- 再把差额换算成正确的 UoM;
- 最后交给
stock.rule.run()。
也就是说,它的真实策略不是“整条行重发”,而是:
只把还没覆盖的部分送进库存规则系统。
这对修改数量、补单、部分交付后的再次编辑特别重要。
为什么这套逻辑对业务很友好
假设一条销售行原来是 10 件:
- 系统已经为 6 件建过 move;
- 你把数量改成 12 件;
Odoo 不会把 10 件全删掉再来一次,而是倾向于计算:
- 已经覆盖了 6 件;
- 现在还差 6 件;
- 只把这 6 件继续推向 procurement。
这样就能避免:
- 重复生成 move;
- 重复占用库存;
- 重复打乱后续 picking。
_prepare_procurement_values() 负责把“这条差额”讲清楚
差额不是裸数字,而是带上下文的需求包。_prepare_procurement_values() 会带上:
originsale_line_idwarehouse_idpartner_idroute_idsdate_planneddate_deadlinesequence
这意味着后续 rule 看到的不是“一个 6 件”,而是:
某张销售单上的某一行,还差 6 件,需要按当前仓库、路线和交货时间继续处理。
新手最容易踩的坑
1)把 write() 当成“普通字段保存”
在销售单上,write() 可能会触发库存链路重算。二开时如果你绕过这条逻辑,就容易漏掉差额或重复创建 move。
2)只看订单,不看既有 move
真正决定是否继续发货的,不是订单数字本身,而是已有 move 的状态和数量。
3)忽略 rounding
Odoo 会用 float_compare 和 UoM 精度来判断差异。很多“看起来只差 0.0001”的问题,都会在这里被吞掉或保留。
排错时最值得问的三个问题
- 这条 sale line 现在到底已经履约了多少?
- 新旧数量差额是多少?
stock.rule.run()看到的是整单,还是只是一部分差额?
只要把这三个问题答清楚,重复发货、重复补货、数量改动后链路混乱的问题,通常就能定位到八九不离十。
一句话总结
Odoo 处理销售改数量时,不是“重新来一遍”,而是:
先用
_get_qty_procurement()算出已履约数量,再让_action_launch_stock_rule()只处理差额,所以发货链路不会轻易重复造轮子。
DISCUSSION
评论区