先说结论
在 Odoo 里,退货不是“把原单倒着复制一张”这么简单。
更准确地说,退货流程同时在处理 3 件事:
- 数量反向:把货从原来的目的地退回来源地;
- 链路回连:告诉系统“这笔退货对应的是哪笔历史 move”;
- 下游影响:如果原 move 后面还挂着没完成的后续动作,要先考虑释放预留,避免链路继续误跑。
而 exchange(换货)又更特殊:
它先借用退货动作把旧货退回来,再额外创建一笔“新的补发”,但这笔补发不会继续被当成原 move 的从属返回。
所以,退货和 exchange 的重点,从来不只是“方向反过来”,而是语义要不要继承原链路。
很多人为什么会把退货理解浅了
因为从界面上看,用户只是在点 Return。
于是很容易以为系统只是做了下面这件事:
- 原来是 A → B
- 现在变成 B → A
方向上当然没错,但源码并不满足于“方向对了”。
Odoo 还要继续回答:
- 这笔退货是不是针对原来某一条
stock.move? - 原 move 后面还有没有没完成的后续 move?
- 退回去之后,系统是否还要保留供应链依赖关系?
- 如果这是换货,不是单纯退款,新的补发单该不该继续挂在旧链路上?
这些问题,决定了退货为什么会长成一个专门的 wizard,而不是简单复制。
源码抓手:addons/stock/wizard/stock_picking_return.py
从官方源码看,主入口在:
StockReturnPicking.default_getStockReturnPicking._compute_moves_locationsStockReturnPicking._create_returnStockReturnPickingLine._prepare_move_default_valuesStockReturnPickingLine._process_lineStockReturnPicking.action_create_exchanges
如果你想理解这套设计,最关键的是两层:
- 新 picking 怎么生成
- 新 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_idsmove_dest_ids
代码里的注释已经讲得很直白:
- 要把 return move 和原 move 连起来;
- 要把它和原 move 的兄弟 / 子链路中仍有效的相关 move 接起来;
- 同时避免一些错误的“直接跨层回连”。
通俗地说,Odoo 想表达的是:
退货不是把链路剪断重做,而是在原供应链网络里插入一条“带来历的反向边”。
这会带来两个实际好处:
- 后续追踪时,你还能看懂它从哪儿退回来;
- 系统不会因为完全断链而丢掉业务上下文。
action_create_returns_all 为什么不是直接取原 quantity
源码里 action_create_returns_all 会先算“可退剩余量”:
- 先取原 move 的
quantity - 再减掉那些已经基于该 move 创建出来的 return move 数量
所以它表达的不是“原来做了多少”,而是:
原来做了多少、其中已经退掉多少、现在还能再退多少。
这就是为什么 Odoo 的 return all 语义,接近“退掉剩余可退量”,而不是“无脑整单再来一次”。
exchange 为什么要先退,再故意断链
action_create_exchanges 的思路非常有代表性。
它不是直接凭空造一张补发单,而是:
- 先创建 return;
- 再基于 return picking 创建 exchange picking;
- 然后把 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_id、move_orig_ids、move_dest_ids,后续 traceability 和业务解释都会变差。
你在项目里该怎么用这套理解
如果你在做退货相关开发,我建议先问清楚这 3 个问题:
- 这是真正退回历史履约,还是只是新建一笔负向库存动作?
- 这笔动作要不要保留和原 move 的关系?
- 这是退货,还是“借退货起点做换货 / 重发”?
一旦这 3 个问题没分清,你就很容易把:
- return
- cancel
- reverse logistics
- exchange
- refund quantity update
全揉成一团。
小结
Odoo 退货之所以值得单独读源码,不是因为它“复杂炫技”,而是因为它把一个业务上看似简单的动作,拆成了几层很严谨的语义:
- 方向反转
- 历史绑定
- 下游预留释放
- 返回与换货分层
所以最实用的理解方式是:
stock.return.picking的任务,不是倒着复制单据,而是把“已经发生的库存事实”安全地组织成一条可追踪、可回滚、可继续演化的反向链路。
这才是它真正比“复制一张反向拣货单”高级的地方。
DISCUSSION
评论区