销售改单边界

Odoo 销售单为什么不能想取消就取消:unlock、cancel 与发货/发票下游边界讲透

很多人把取消销售单理解成“状态改回去就行”,但 Odoo 标准逻辑里,锁单、草稿发票、未完成出库、已触发的库存规则都会一起参与。本文把 unlock、action_cancel 与下游回撤边界讲透。

销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说最重要的判断

在 Odoo 里,销售单确认之后再取消,通常不是“把状态改一下”这么简单。

因为从源码视角看,一张订单一旦进入 sale,它就可能已经把影响扩散到:

  • 发票草稿
  • 出库单 / 拣货单
  • 库存规则触发链
  • 活动日志与责任提醒

所以取消销售单,本质上是在问:

这条已经向下游扩散的商业承诺,现在还能撤回到什么程度?

这和“我在界面上不想要这张单了”完全不是一个层级的问题。


这篇文章主要参考哪些源码

核心参考包括:

  • /home/ubuntu/odoo-temp/addons/sale/models/sale_order.py
  • /home/ubuntu/odoo-temp/addons/sale_stock/models/sale_order.py

最关键的源码信号有:

  • action_unlock() 只是把 locked 设回 False
  • action_cancel() 会先阻止取消 locked 订单
  • 基础 sale 的 _action_cancel() 会先取消草稿发票,再写 state = cancel
  • sale_stock_action_cancel() 会取消未完成 picking,并记录数量下降相关日志

这说明取消逻辑是分层叠加的,不是 UI 按钮的一次性动作。


第一层:为什么要先区分“已确认”和“已锁定”

很多用户会说:

  • 这张单已经确认了,怎么还不能取消?

源码给出的答案是:

  • 因为确认和锁定不是一回事
  • 但锁定会改变取消边界

sale_order.py 里:

  • action_lock()locked 设成 True
  • action_unlock() 把它设回 False
  • action_cancel() 一开头就检查是否有 locked 订单

只要是 locked,系统就直接报错:

  • 不能取消,请先解锁

这层设计在表达:

商业上已成立的订单,不代表可以被任何人随时反向操作。

锁单的意义,就是把“还能不能随手改 / 随手撤”的门槛再抬高一层。


第二层:解锁不是回到草稿,而只是重新开放修改边界

action_unlock() 的实现其实很简单:

  • 只是 locked = False

这正说明一个常见误区:

  • 很多人把 unlock 理解成“回退流程”

其实不是。

unlock 并不会:

  • sale 改回 draft
  • 回退已生成的 picking
  • 回退已生成的发票
  • 自动清除已经触发的下游对象

它只是重新允许你对订单做后续修改或取消。

所以 unlock 是放开编辑边界,不是业务时间倒流


第三层:基础 sale 的取消逻辑为什么先盯草稿发票

sale_order.py_action_cancel() 里,系统先做这件事:

  • 找出 invoice_ids 里 state 为 draft 的发票
  • button_cancel()
  • 然后才把订单写成 cancel

这很有代表性。

它说明 Odoo 的标准态度是:

  • 还没正式入账的会计对象,可以跟着销售单一起撤回
  • 但不是所有发票都能被销售层随意带走

也就是说,这里明确保护的是“还在草稿中的会计草案”,而不是对全部会计后果一概回滚。

这也是为什么已过账发票、已付款发票等场景通常会需要更严肃的后续动作,而不是直接靠销售取消解决。


第四层:为什么 sale_stock 会继续取消未完成 picking

到了 sale_stock,取消逻辑进一步扩展:

  • 找到非 done 状态的 picking
  • 执行 action_cancel()

这说明标准设计承认一种合理回撤:

如果仓库履约还没真正完成,那么与这张销售承诺对应的物流动作也应该一起撤回。

但它同时也非常克制:

  • done 的 picking 不会被直接抹掉

这才符合真实业务。

已经完成的物流动作,不可能因为销售想取消就当没发生过。


第五层:为什么源码还要记录 ordered quantity decrease

sale_stock 在取消时还会构造 documents,并调用 _log_decrease_ordered_quantity(...)

这一步很多人会忽略,但它很重要。

因为系统不仅在撤对象,还在保留审计线索:

  • 哪些履约相关文档受影响
  • 谁应该注意到订单数量下降或被取消
  • 哪些活动提醒需要重新被看见

这意味着取消不是“悄悄删痕迹”,而是带审计语义的回撤


第六层:为什么很多“取消不了”本质上不是权限问题,而是下游已经太深

业务现场常会把这类情况归结为:

  • 用户权限不够
  • 按钮灰了
  • 系统太死板

其实标准设计真正想保护的是下游一致性。

只要订单已经连接到:

  • 已完成出库
  • 已过账发票
  • 已发生付款 / 核销

那你想回撤的就不再只是销售单,而是一整串已经生效的业务事实。

这时正确做法通常不是硬取消,而是:

  • 先逆向处理库存
  • 再逆向处理会计
  • 最后让销售对象与现实状态重新一致

所以很多“不能取消”其实是在提醒你:

你现在面对的是完整业务链,不是一个表单状态。


第七层:确认后改单到底该怎么判断

我自己的判断顺序是这样的:

可以相对轻量处理的场景

  • 订单已确认但未锁
  • 没有已过账发票
  • 没有已完成出库
  • 下游动作都还停留在可取消状态

这时解锁、修改、取消往往还比较顺。

需要非常谨慎的场景

  • 已锁单
  • 已完成部分或全部出库
  • 已有发票过账
  • 甚至已经有付款、核销、对账

这时你要思考的已经不是“能不能取消按钮按下去”,而是如何做一套业务上可审计的逆向链路


新手最容易误解的 5 件事

1. 以为 unlock 会把订单回退成草稿

不会,它只取消锁定。

2. 以为取消销售单一定会自动回滚所有下游

不会,标准只处理可安全回撤的那部分。

3. 以为已完成出库也能跟着销售取消一起抹掉

标准不会这么做。

4. 以为“取消不了”只是权限问题

很多时候是业务事实已经发生。

5. 以为取消动作不需要留痕

标准 sale_stock 明确会记录相关日志与活动语义。


实战排查顺序

如果用户说“这张销售单怎么取消不了”,建议按下面顺序看:

  1. 是否 locked
  2. 是否先需要 action_unlock()
  3. 有没有 draft invoice
  4. 有没有 posted invoice
  5. picking 是否已有 done
  6. 下游库存 / 会计对象到底处在哪个状态
  7. 是否应该走逆向业务流程,而不是强行 cancel

一句话记忆法

Odoo 销售单取消不是状态回退,而是对一条已向发票、库存与审计链扩散的商业承诺做可控回撤;unlock 只是重新开放入口,不是让时间倒流。

DISCUSSION

评论区

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