先抓主线
在 Odoo CRM 里,从商机点 New Quotation,真正发生的事情并不是“打开一个空白销售单”。
标准源码做的是一条桥接链:
- 先判断这条
crm.lead有没有客户partner_id - 没客户时,先走补客户/选客户入口
- 有客户时,再跳到新建
sale.order动作 - 创建报价前,把商机上的销售上下文批量写进 action context
- 后续报价确认后,商机侧再汇总 quotation 数、订单数和已确认订单金额
- 在特定条件下,还会反向更新
expected_revenue
所以它解决的问题,不是“从 CRM 打开销售模块”,而是:
把 CRM 里的商业语境,尽量无损地桥接到销售报价里。
这也是为什么有些团队会觉得“明明只是点新建报价,为什么 team、salesperson、UTM、origin 都跟过去了”。因为标准设计本来就是这么干的。
这篇文章主要参考哪些源码
核心参考文件是:
/home/ubuntu/odoo-temp/addons/sale_crm/models/crm_lead.py
最关键的方法包括:
action_sale_quotations_new()action_new_quotation()_prepare_opportunity_quotation_context()action_view_sale_quotation()action_view_sale_order()_compute_sale_data()_update_revenues_from_so()
虽然文件不长,但信息密度很高。它把“从 CRM 创建报价”和“从报价反看 CRM 指标”两件事都串起来了。
第一层:有没有客户,决定你先走哪一个入口
源码里最容易被忽略的地方,是 action_sale_quotations_new():
- 如果
lead.partner_id为空,并不会直接创建报价 - 它会返回
sale_crm.crm_quotation_partner_action - 只有当商机已经挂上客户后,才会继续走
action_new_quotation()
这背后的业务逻辑非常朴素:
销售报价不是只认商机,还必须认客户。
如果客户主体都没确定,系统不应该让你直接落一张正式报价单。不然你后面会立刻遇到:
- 报价抬头是谁
- 地址、币种、税位从哪来
- 联系人和后续订单统计挂到谁身上
所以“先补客户,再建报价”不是 UI 多绕一步,而是数据模型在兜底。
第二层:真正的桥,不是按钮本身,而是 context
action_new_quotation() 最值得看的不是 action id,而是这句:
action['context'] = self._prepare_opportunity_quotation_context()
也就是说,商机转报价的真正桥接层,是 action context。
在 _prepare_opportunity_quotation_context() 里,标准 Odoo 会把这些默认值一起塞给销售单:
default_opportunity_iddefault_partner_iddefault_campaign_iddefault_medium_iddefault_origindefault_source_iddefault_company_iddefault_tag_ids- 有团队时再带
default_team_id - 有负责人时再带
default_user_id
这组字段非常说明问题。
它不是只传一个“我是从哪条商机来的”标记,而是在尽可能复用 CRM 的销售上下文:
- 负责人谁来接
- 销售团队归谁
- 线索来源是什么
- 营销活动是哪一波
- origin 要写什么
- 标签要不要延续
所以你看到新报价自动带出一堆默认值,不是偶然,而是 crm.lead -> sale.order 的标准设计。
第三层:为什么 origin 用的是商机名称,而不是客户名
源码里有个很细但很实用的点:
default_origin取的是self.name
这意味着商机建报价时,销售单 origin 默认不是 partner,也不是 lead id,而是商机标题本身。
这在业务上很有用,因为商机标题往往更贴近销售语义,比如:
- 某客户 2026 Q2 扩容项目
- 某渠道年度框架采购
- 某线索二阶段报价
如果 origin 只写客户名,很多单子会失去上下文;但写成商机名称,销售、项目、实施、财务在追单时都更容易知道这张单最初对应哪次商业机会。
所以这里不是随手填一个描述,而是在用最轻量的方式保留前链路语义。
第四层:报价视图和订单视图,标准就是按状态分家的
action_view_sale_quotation() 和 action_view_sale_order() 一起看,会更容易理解 Odoo 为什么把 quotation / order 分开展示。
源码里默认域是:
- quotation:
state in ('draft', 'sent', 'cancel') - sale order:
state not in ('draft', 'sent', 'cancel')
也就是说,在 CRM 视角里:
- 还没正式成交的,都算报价池
- 已经跨过报价阶段的,才算订单池
这件事的意义不只是列表分组,而是指标口径也跟着分了。
例如商机上:
quotation_count只看 draft/sentsale_order_count和sale_amount_total看的是已进入订单域的单据
所以你别把“我从商机能看到一张单”简单理解为同一个集合。标准设计本来就是按状态机把它拆成两个业务口径。
第五层:商机金额为什么常常比报价合计少
_compute_sale_data() 有两个细节特别关键:
1)只汇总已进入订单域的单子
它拿来算 sale_amount_total 的,是:
lead.order_ids.filtered_domain(self._get_lead_sale_order_domain())
也就是不含 draft / sent / cancel 的订单。
所以你看到商机上“Sum of Orders”没有把未确认报价算进去,这是标准行为,不是漏算。
2)汇总前先做币种换算
源码会把每张订单 amount_untaxed 通过:
order.currency_id._convert(...)
换到商机公司币种后再求和。
所以商机金额不是机械地累加原订单数字,而是:
按公司币种统一口径汇总确认订单未税金额。
这就解释了为什么跨币种销售里,商机上的金额看起来和单张报价总额“肉眼对不上”。
第六层:expected_revenue 不是实时等于订单金额,而是“条件更新”
_update_revenues_from_so() 非常值得产品经理和实施顾问看一眼。
它不是每次有销售单都强行覆盖商机 expected_revenue,而是有两个门槛:
- 只有当订单未税金额大于当前
expected_revenue - 且订单币种等于商机公司币种
才会把 expected_revenue 更新成订单金额,并记录一条日志。
这意味着标准 Odoo 的态度是:
- 它愿意用成交信息去抬高商机预期营收
- 但不轻易把人工判断覆盖掉
- 也不在跨币种条件不明时贸然改写
所以 expected_revenue 更像“机会判断值,可被高可信订单信号推高”,而不是“永远等于最近一张报价金额”。
新手最容易误解的 5 件事
1. 以为 New Quotation 一定直接新建销售单
不是。没有 partner_id 时,系统会先让你补客户。
2. 以为报价只是带一个 opportunity_id
不是。标准还会带 team、salesperson、campaign、source、tag、origin 等整组上下文。
3. 以为 CRM 里报价数和订单数是同一批单子
不是。它们是按状态拆分的两个集合。
4. 以为商机金额会把所有报价都算进去
不是。sale_amount_total 看的是已进入订单域的确认单,且先做币种转换。
5. 以为订单一创建就会硬覆盖 expected_revenue
不是。只有金额更大且币种口径安全时,标准才会回写。
实战里最该怎么排查
如果你发现“从商机创建报价时默认值不对”,建议按这条顺序查:
- 商机上有没有
partner_id - 触发的是
action_sale_quotations_new()还是后续action_new_quotation() _prepare_opportunity_quotation_context()里哪些default_*被带入- 团队、负责人、UTM、标签字段在商机上是否本来就为空
- 最终销售单是不是另有自定义 onchange / default 覆盖了 context
如果你发现“商机金额和报价不一致”,优先查:
- 报价是否仍处于 draft / sent
- 是否已经变成正式订单
- 币种是否不同
- 汇总看的是未税金额还是含税总价
- 是否期待的是
expected_revenue,但其实看的应是sale_amount_total
一句话记忆法
Odoo 里“商机转报价”真正桥接的是销售上下文,不只是打开一张 sale.order;而“订单回看商机”也不是简单计数,而是按状态和币种口径汇总并条件回写营收。
DISCUSSION
评论区