销售状态机

Odoo 报价单为什么不是“发出去就成交”:draft、sent、sale、锁定与有效期的状态机讲透

很多人把报价单理解成一张等待客户确认的 PDF,但在 Odoo 里,它其实是一台状态机:草稿、已发送、已确认、已取消、是否锁定、是否还在有效期内,都会改变后续签署、付款、取消与改单边界。本文把这套状态转换讲透。

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

先抓住核心

很多团队把 Odoo 报价单看成“发给客户的一张单”。

但从源码看,它更像一台可成交、可拒绝、可锁定、可取消的状态机。

你真正要分清的不是“报价单长什么样”,而是下面几件事:

  1. 订单当前是 draftsentsale 还是 cancel
  2. 客户还能不能在 portal 上签字 / 付款
  3. 订单确认后是不是已经被锁定
  4. 拒绝、取消、改单各自会不会影响下游对象

如果这几层没分清,团队就很容易把“已发送”“已签字”“已付款”“已确认”“已锁单”混成一回事。


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

核心入口主要在:

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

最关键的源码信号有这些:

  • state 明确区分 draft / sent / sale / cancel
  • action_quotation_sent() 只是在草稿上写入 sent
  • action_confirm() 才真正把订单写成 sale
  • validity_date 的帮助文本明确说:过期后不能再签字和付款
  • action_cancel() 会先检查 locked
  • portal 里的 acceptdecline,并不是简单改页面提示,而是会真正写签字信息或调用取消逻辑

这说明报价单不是“发个链接出去就结束”,而是一个有严肃状态转换约束的业务对象。


第一层:draftsent 差别到底是什么

很多人以为 sent 代表“客户已经收到了,所以几乎等于确认前一步”。

源码并不是这样设计的。

sale_order.py 里,action_quotation_sent() 做的事情非常克制:

  • 只允许当前状态是 draft
  • 然后把 state 写成 sent

也就是说,sent 的本质不是“成交流程已经推进了一大半”,而是:

销售团队确认这份草稿已经作为正式报价对外发出。

它解决的是销售动作的可追踪,而不是履约动作的开始。

所以:

  • draft:内部草稿,还在改
  • sent:正式对外,但还没成交
  • sale:商业承诺已经成立,后续履约可以启动

这三个层次一定不要混。


第二层:什么时候才真的从报价变成销售单

真正的分界点是 action_confirm()

在源码里,这一步至少做了四件关键事:

  1. 先跑 _confirmation_error_message(),确认订单状态和订单行都没问题
  2. 校验 analytic distribution
  3. 通过 _prepare_confirmation_values()state 改成 sale,并把 date_order 改成确认时间
  4. 调用 _action_confirm(),把后续模块的扩展动作接进来

而在 sale_stock 里,_action_confirm() 又会继续触发:

  • self.order_line._action_launch_stock_rule()

这就是关键分界。

只有进入 sale,系统才把它当成真正要履约的订单,而不是等待客户拍板的报价。

所以“已发送”和“已确认”绝不是轻微差别,它们决定了库存 / 补货 / 发货链是否应该启动。


第三层:有效期为什么不是装饰字段

validity_date 的帮助文本写得很直白:

过了这个日期,报价将不能再签字和付款。

这点非常容易被误解。

很多团队把有效期当成:

  • 给客户看的商务礼貌
  • 过了也无所谓
  • 顶多报表里显示一下

但 Odoo 的设计不是“软提示”,而是把它当成 portal 成交边界的一部分。

也就是说,有效期不是为了好看,而是在表达:

  • 这份价格承诺到什么时候为止
  • 客户还能不能基于这份报价继续完成在线确认
  • 销售是否应该重新发一轮新报价,而不是让老链接无限期有效

它的本质是报价承诺窗口,不是备注。


第四层:门户签字不是“写个图片”,而是在推进状态机

sale/controllers/portal.py 里,portal_quote_accept() 的逻辑很值得看。

它会做这些动作:

  1. 先校验 access_token 能否访问这张订单
  2. 检查订单当前是否还处在“需要客户签字”的状态
  3. signed_bysigned_onsignature 写到订单上
  4. 如果订单已经不要求支付,就直接 _validate_order()
  5. 生成带签字结果的 PDF,并留言到 chatter

这说明在线签字不是“前台画一笔图像上传一下”。

它真正表达的是:

客户已经在一个受控入口里完成了对报价的正式接受动作。

如果公司配置成“签字后即可确认”,那么签字本身就会直接推动订单进入确认链。


第五层:拒绝报价为什么会直接走取消逻辑

portal_quote_decline() 更有意思。

很多人直觉上会以为“客户拒绝”只是:

  • 留一句备注
  • 或者把报价打上 lost / rejected 标记

但标准 sale portal 的实现更直接:

  • 如果当前订单还处在需签字状态,并且客户提交了拒绝原因
  • 系统会调用 order_sudo._action_cancel()
  • 然后把拒绝留言写进 chatter

这意味着标准逻辑里,客户拒绝不是“中性状态”,而是直接把商业机会从当前订单对象上结束掉。

所以如果你们想保留“被拒绝但不取消”的语义,那通常已经属于业务定制,而不是标准逻辑。


第六层:锁单为什么和确认是两回事

action_confirm() 后,源码里还有一句很关键:

  • self.filtered(lambda so: so._should_be_locked()).action_lock()

也就是说:

  • 确认订单
  • 不一定立即锁定
  • 是否自动锁定取决于功能开关

action_cancel() 又明确检查:

  • 只要有 locked = True
  • 就不能直接取消,必须先解锁

这说明锁单表达的是另一层语义:

这张订单虽然已经成立,但现在不希望被随手改动或撤销。

所以“已确认”和“已锁定”不是重复状态。

前者是商业成立,后者是修改边界收紧。


第七层:取消订单到底会影响什么

在基础 sale 模块里,_action_cancel() 会先取消草稿发票,再把订单写成 cancel

到了 sale_stock,逻辑继续加深:

  • 非完成状态的 picking 会被 action_cancel()
  • 系统还会记录已订购数量下降的活动日志

这就解释了为什么取消销售单往往不是“改个状态”而已。

它可能连着影响:

  • 草稿发票
  • 待处理出库单
  • 补货 / 调拨跟踪
  • 操作日志与责任提醒

所以确认后的取消,本质上是撤回一条已经开始外溢到下游的承诺链


新手最容易误解的 5 件事

1. 以为 sent 基本等于“准销售单”

不是。sent 只是正式发出,不代表履约已经开始。

2. 以为过期只影响显示,不影响动作

不是。标准帮助文本已经明确把它和签字 / 付款边界绑在一起。

3. 以为在线签字只是附件

不是。它可能直接推动订单确认。

4. 以为客户拒绝只是备注

标准 portal 下,拒绝会直接走取消逻辑。

5. 以为锁单就是“已确认”的另一种叫法

不是。锁单是在确认之后进一步收紧改动权限。


实战里怎么排查“报价为什么没有顺利成交”

我建议按这个顺序看:

  1. 当前 state 到底是什么
  2. validity_date 是否已过期
  3. 是否要求 require_signature
  4. 是否要求 require_payment
  5. 是否因为 portal 动作已经写入 signed_by / signed_on
  6. 是否自动锁单导致后续取消 / 修改被拦住
  7. 是否已经进入 sale 并触发了库存链

这个顺序比只盯 portal 页面报错更有效,因为它是按状态机在看问题。


一句话记忆法

Odoo 报价单不是“等待回复的一张 PDF”,而是一台从 draft 到 sent、sale、cancel,并受有效期、签字、付款与锁单共同约束的成交状态机。

DISCUSSION

评论区

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