商机转报价

Odoo CRM 里的“新建报价”为什么不是点个按钮就完:商机到报价的上下文继承、分流入口与营收回写讲透

很多人以为 CRM 里的 New Quotation 只是从商机页跳去 sale.order 新建表单;但标准 Odoo 真正做的是把 partner、team、salesperson、campaign、source、origin 等销售上下文一起注入,并且把“无客户”和“已有客户”的入口分开处理,后续还会按确认订单回写商机营收。本文把这条桥接链讲透。

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

先抓主线

在 Odoo CRM 里,从商机点 New Quotation,真正发生的事情并不是“打开一个空白销售单”。

标准源码做的是一条桥接链:

  1. 先判断这条 crm.lead 有没有客户 partner_id
  2. 没客户时,先走补客户/选客户入口
  3. 有客户时,再跳到新建 sale.order 动作
  4. 创建报价前,把商机上的销售上下文批量写进 action context
  5. 后续报价确认后,商机侧再汇总 quotation 数、订单数和已确认订单金额
  6. 在特定条件下,还会反向更新 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_id
  • default_partner_id
  • default_campaign_id
  • default_medium_id
  • default_origin
  • default_source_id
  • default_company_id
  • default_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/sent
  • sale_order_countsale_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

不是。只有金额更大且币种口径安全时,标准才会回写。


实战里最该怎么排查

如果你发现“从商机创建报价时默认值不对”,建议按这条顺序查:

  1. 商机上有没有 partner_id
  2. 触发的是 action_sale_quotations_new() 还是后续 action_new_quotation()
  3. _prepare_opportunity_quotation_context() 里哪些 default_* 被带入
  4. 团队、负责人、UTM、标签字段在商机上是否本来就为空
  5. 最终销售单是不是另有自定义 onchange / default 覆盖了 context

如果你发现“商机金额和报价不一致”,优先查:

  1. 报价是否仍处于 draft / sent
  2. 是否已经变成正式订单
  3. 币种是否不同
  4. 汇总看的是未税金额还是含税总价
  5. 是否期待的是 expected_revenue,但其实看的应是 sale_amount_total

一句话记忆法

Odoo 里“商机转报价”真正桥接的是销售上下文,不只是打开一张 sale.order;而“订单回看商机”也不是简单计数,而是按状态和币种口径汇总并条件回写营收。

DISCUSSION

评论区

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