退货链路

Odoo 退货为什么不是“原单倒着走一遍”:stock.return.picking、链路回连和 exchange 流程讲透

很多人以为 Odoo 退货只是把原来的拣货单反向复制一遍,但源码里真正难的地方在于:要不要回连原 move、要不要先释放下游预留、exchange 为什么又要故意断开依赖。本文把这条链路讲清楚。

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

先说结论

在 Odoo 里,退货不是“把原单倒着复制一张”这么简单

更准确地说,退货流程同时在处理 3 件事:

  1. 数量反向:把货从原来的目的地退回来源地;
  2. 链路回连:告诉系统“这笔退货对应的是哪笔历史 move”;
  3. 下游影响:如果原 move 后面还挂着没完成的后续动作,要先考虑释放预留,避免链路继续误跑。

而 exchange(换货)又更特殊:

它先借用退货动作把旧货退回来,再额外创建一笔“新的补发”,但这笔补发不会继续被当成原 move 的从属返回。

所以,退货和 exchange 的重点,从来不只是“方向反过来”,而是语义要不要继承原链路


很多人为什么会把退货理解浅了

因为从界面上看,用户只是在点 Return

于是很容易以为系统只是做了下面这件事:

  • 原来是 A → B
  • 现在变成 B → A

方向上当然没错,但源码并不满足于“方向对了”。

Odoo 还要继续回答:

  • 这笔退货是不是针对原来某一条 stock.move
  • 原 move 后面还有没有没完成的后续 move?
  • 退回去之后,系统是否还要保留供应链依赖关系?
  • 如果这是换货,不是单纯退款,新的补发单该不该继续挂在旧链路上?

这些问题,决定了退货为什么会长成一个专门的 wizard,而不是简单复制。


源码抓手:addons/stock/wizard/stock_picking_return.py

从官方源码看,主入口在:

  • StockReturnPicking.default_get
  • StockReturnPicking._compute_moves_locations
  • StockReturnPicking._create_return
  • StockReturnPickingLine._prepare_move_default_values
  • StockReturnPickingLine._process_line
  • StockReturnPicking.action_create_exchanges

如果你想理解这套设计,最关键的是两层:

  1. 新 picking 怎么生成
  2. 新 return move 和旧 move 怎么重新挂关系

第一步:不是所有 picking 都能 return

_compute_moves_locations 里先做了一件很关键的过滤:

  • 只能 return Done 的 picking;
  • 已取消的 move 不进来;
  • 目标库位是 inventory 的 move 也会跳过;
  • wizard 默认把每条可退 move 生成为一条 stock.return.picking.line,初始数量是 0。

这说明 Odoo 的态度很明确:

退货不是对“计划中的动作”做反向更改,而是对“已经发生的库存事实”创建一笔有来历的反向库存事实。

这也是为什么未完成单据不能直接走这里。


第二步:新 return picking 的方向确实会反过来,但不是盲反

_prepare_picking_default_values_based_on 会构造新的 picking。

核心逻辑是:

  • 新 picking 的 location_id 取原单的 location_dest_id
  • 新 picking 的 location_dest_id 一般回到原单 location_id
  • 但如果 operation type 配了 return_picking_type_id,且它是 incoming,则优先用该类型的默认目标库位

这背后很实用:

  • 对普通退货来说,确实是“从原目的地退回原来源地”;
  • 但系统也允许企业把退货导向专门的退货收货流程,而不是盲目回原位。

所以 return 的本质不是“反方向”,而是:

以原 move 为依据,创建一条受退货 operation type 控制的新流向。


第三步:真正的关键是 origin_returned_move_id

_prepare_move_default_values 里最关键的字段之一是:

  • origin_returned_move_id = self.move_id.id

这个字段告诉系统:

这条新 move,不是普通新需求,而是“某条旧 move 的返回”。

这会影响很多后续判断:

  • return all 时如何扣除已退数量;
  • 后续链路追踪如何看待它;
  • 某些 push / procurement 逻辑是否应该再次触发。

如果没有这层标记,Odoo 就很难分清:

  • 这是业务新需求
  • 还是历史履约的回滚动作

第四步:退货前先 unreserve 下游,是为了不让旧链继续“误占货”

_create_return 一开始会先遍历原 move 的 move_dest_ids,把未 done / 未 cancel 的后续 move 做 _do_unreserve()

这是很多人第一次看源码时会忽略,但实际非常重要的一步。

原因很简单:

  • 原来的货已经发出并驱动了后续链路;
  • 现在你要退货,相当于在回滚部分履约事实;
  • 如果后面那些依赖原 move 的动作还继续保留预留,系统就会出现一种“上游事实已变,下游还在按旧世界占资源”的错位。

所以 Odoo 先把相关预留松开,再创建 return picking。

这不是多余动作,而是先止血,再回退


第五步:为什么 _process_line 那么复杂?因为退货不是只复制字段,还要重接依赖网

这是整份源码里最值得看的部分。

_process_line 在复制新 move 后,不只是反写来源/去向,还会重算:

  • move_orig_ids
  • move_dest_ids

代码里的注释已经讲得很直白:

  • 要把 return move 和原 move 连起来;
  • 要把它和原 move 的兄弟 / 子链路中仍有效的相关 move 接起来;
  • 同时避免一些错误的“直接跨层回连”。

通俗地说,Odoo 想表达的是:

退货不是把链路剪断重做,而是在原供应链网络里插入一条“带来历的反向边”。

这会带来两个实际好处:

  1. 后续追踪时,你还能看懂它从哪儿退回来;
  2. 系统不会因为完全断链而丢掉业务上下文。

action_create_returns_all 为什么不是直接取原 quantity

源码里 action_create_returns_all 会先算“可退剩余量”:

  • 先取原 move 的 quantity
  • 再减掉那些已经基于该 move 创建出来的 return move 数量

所以它表达的不是“原来做了多少”,而是:

原来做了多少、其中已经退掉多少、现在还能再退多少。

这就是为什么 Odoo 的 return all 语义,接近“退掉剩余可退量”,而不是“无脑整单再来一次”。


exchange 为什么要先退,再故意断链

action_create_exchanges 的思路非常有代表性。

它不是直接凭空造一张补发单,而是:

  1. 先创建 return;
  2. 再基于 return picking 创建 exchange picking;
  3. 然后把 exchange move 上的: - origin_returned_move_id - move_orig_ids 清掉。

这里的设计很妙。

因为 exchange 虽然业务上“起因于退货”,但它不能继续被系统理解成:

  • 原 move 的返回
  • 或原供应链的被动延续

否则你会得到一条非常混乱的链:

  • 旧 move 的返回
  • 返回的再返回
  • 以及补发动作继续继承老依赖

所以 Odoo 刻意把 exchange 从“返回语义”里剥离出来,让它变成一笔新的履约动作。

一句话:

退货要保留历史关系,换货要借退货起步,但最终得重新独立。


sale / stock_account 扩展也说明:退货不只是库存问题

官方扩展里还有两个很值得注意的点:

  • sale_stock 会在 _get_proc_values 里优先复用销售行的 procurement values;
  • stock_account 给 return line 增加了 to_refund,决定是否回写 SO/PO 的已交付 / 已接收数量。

这说明 return wizard 在 Odoo 里并不是孤立库存动作,它同时牵涉:

  • 销售交付语义
  • 采购接收语义
  • 后续财务与数量回写边界

所以项目里如果有人说“退货不就是仓库自己的事”,大概率低估了它。


实战里最容易误解的 4 件事

1)退货不是取消原单

原 move 已经 done 了,return 是新增一笔反向事实,不是把历史 done 改没。

2)退货数量不能脱离已退历史单独理解

同一条 move 可能分多次 return,所以“还能退多少”必须扣掉历史 returned move。

3)exchange 不是普通 return

exchange 需要重新变成独立履约链,否则会把原依赖关系越缠越乱。

4)只改界面数量,没重连 move 关系,后续追踪会坏

很多定制把 return 当成“复制 move + 反向库位 + 数量”。这通常不够。

如果不处理 origin_returned_move_idmove_orig_idsmove_dest_ids,后续 traceability 和业务解释都会变差。


你在项目里该怎么用这套理解

如果你在做退货相关开发,我建议先问清楚这 3 个问题:

  1. 这是真正退回历史履约,还是只是新建一笔负向库存动作?
  2. 这笔动作要不要保留和原 move 的关系
  3. 这是退货,还是“借退货起点做换货 / 重发”?

一旦这 3 个问题没分清,你就很容易把:

  • return
  • cancel
  • reverse logistics
  • exchange
  • refund quantity update

全揉成一团。


小结

Odoo 退货之所以值得单独读源码,不是因为它“复杂炫技”,而是因为它把一个业务上看似简单的动作,拆成了几层很严谨的语义:

  • 方向反转
  • 历史绑定
  • 下游预留释放
  • 返回与换货分层

所以最实用的理解方式是:

stock.return.picking 的任务,不是倒着复制单据,而是把“已经发生的库存事实”安全地组织成一条可追踪、可回滚、可继续演化的反向链路。

这才是它真正比“复制一张反向拣货单”高级的地方。

DISCUSSION

评论区

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