先抓住核心
很多团队把 Odoo 报价单看成“发给客户的一张单”。
但从源码看,它更像一台可成交、可拒绝、可锁定、可取消的状态机。
你真正要分清的不是“报价单长什么样”,而是下面几件事:
- 订单当前是
draft、sent、sale还是cancel - 客户还能不能在 portal 上签字 / 付款
- 订单确认后是不是已经被锁定
- 拒绝、取消、改单各自会不会影响下游对象
如果这几层没分清,团队就很容易把“已发送”“已签字”“已付款”“已确认”“已锁单”混成一回事。
这篇文章主要参考哪些源码
核心入口主要在:
/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 / cancelaction_quotation_sent()只是在草稿上写入sentaction_confirm()才真正把订单写成salevalidity_date的帮助文本明确说:过期后不能再签字和付款action_cancel()会先检查locked- portal 里的
accept和decline,并不是简单改页面提示,而是会真正写签字信息或调用取消逻辑
这说明报价单不是“发个链接出去就结束”,而是一个有严肃状态转换约束的业务对象。
第一层:draft 和 sent 差别到底是什么
很多人以为 sent 代表“客户已经收到了,所以几乎等于确认前一步”。
源码并不是这样设计的。
在 sale_order.py 里,action_quotation_sent() 做的事情非常克制:
- 只允许当前状态是
draft - 然后把
state写成sent
也就是说,sent 的本质不是“成交流程已经推进了一大半”,而是:
销售团队确认这份草稿已经作为正式报价对外发出。
它解决的是销售动作的可追踪,而不是履约动作的开始。
所以:
draft:内部草稿,还在改sent:正式对外,但还没成交sale:商业承诺已经成立,后续履约可以启动
这三个层次一定不要混。
第二层:什么时候才真的从报价变成销售单
真正的分界点是 action_confirm()。
在源码里,这一步至少做了四件关键事:
- 先跑
_confirmation_error_message(),确认订单状态和订单行都没问题 - 校验 analytic distribution
- 通过
_prepare_confirmation_values()把state改成sale,并把date_order改成确认时间 - 调用
_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() 的逻辑很值得看。
它会做这些动作:
- 先校验
access_token能否访问这张订单 - 检查订单当前是否还处在“需要客户签字”的状态
- 把
signed_by、signed_on、signature写到订单上 - 如果订单已经不要求支付,就直接
_validate_order() - 生成带签字结果的 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. 以为锁单就是“已确认”的另一种叫法
不是。锁单是在确认之后进一步收紧改动权限。
实战里怎么排查“报价为什么没有顺利成交”
我建议按这个顺序看:
- 当前
state到底是什么 validity_date是否已过期- 是否要求
require_signature - 是否要求
require_payment - 是否因为 portal 动作已经写入
signed_by / signed_on - 是否自动锁单导致后续取消 / 修改被拦住
- 是否已经进入
sale并触发了库存链
这个顺序比只盯 portal 页面报错更有效,因为它是按状态机在看问题。
一句话记忆法
Odoo 报价单不是“等待回复的一张 PDF”,而是一台从 draft 到 sent、sale、cancel,并受有效期、签字、付款与锁单共同约束的成交状态机。
DISCUSSION
评论区