先说结论
在 Odoo CRM 里,客户卡片上的 Opportunities, 默认不是“这个 partner 自己直接挂了几条商机”这么简单。
源码更偏向一个 客户层级视角:
- 先递归找出这个 partner 及其所有子联系人
- 把这些联系人名下关联的 opportunity 一起纳入统计
- 再把数量沿父级链条往上回卷
- smart button 打开列表时,也用同一套层级 domain
所以当你在一家公司主档上看到商机数偏大, 往往不是算错了, 而是:
Odoo 想让“公司客户”看到的是整个账号体系的销售机会,而不是某一个联系人名下的孤立记录。
一、源码为什么先抓 partner hierarchy
res.partner._fetch_children_partners_for_hierarchy() 会用:
('id', 'child_of', self.ids)
把当前 partner 下整棵联系人树都抓出来。
这个动作很关键。 它说明 Odoo 在 CRM 统计上, 默认不把公司、部门联系人、子联系人切成互相隔绝的小岛。
如果一个公司下面有:
- 总部公司档案
- 采购联系人
- 技术联系人
- 法务联系人
这些联系人各自挂的商机, 在“客户账户视角”下本来就应该能聚到一起看。
二、opportunity_count 为什么不是简单 count(partner_id)
_compute_opportunity_count() 用 _read_group() 聚合的是:
_get_contact_opportunities_domain()返回的层级 domain
也就是先把整棵伙伴树算进来,
再按 partner_id 聚合数量。
但源码没有停在这里。
它还会把统计结果沿 parent_id 链继续往上加。
这意味着:
- 子联系人自己的机会数会保留
- 上级公司也会把这些机会一起累计进去
于是你在顶层公司看到的 opportunity_count,
其实是“整棵客户树的商机总量”。
三、为什么 smart button 打开列表时,也会包含子联系人机会
action_view_opportunity() 并没有只过滤:
partner_id = 当前 partner
而是直接复用了 _get_contact_opportunities_domain()。
再加上它默认打开的搜索上下文里, 还会勾上:
- won
- ongoing
- lost
active_test=False
这说明这个 smart button 的设计目标是:
给你一个完整账户视角的机会档案,而不是只看当前联系人直接挂了什么。
所以它既看进行中, 也看赢单和丢单, 甚至包含 archived 数据。
四、为什么这种设计很适合 B2B,不一定适合所有实施口径
对于典型 B2B 销售来说, 这套层级汇总非常合理。
因为一个企业客户的机会, 本来就可能分散在多个联系人上:
- 业务联系人在跟方案
- 财务联系人在跟付款条件
- 技术联系人在跟接口评估
如果每个联系人只看自己名下机会, “这个客户整体还在谈什么”就很难判断。
但这也意味着实施时要统一口径:
- 你们看的是联系人视角,还是账号视角?
- 顶层公司卡片上的数量,是否允许天然大于任一联系人?
如果团队默认以为这是“当前联系人的直属机会数”, 就会误判系统重复统计。
五、为什么权限组也会影响你能不能看到这个数
opportunity_count 带了销售组权限:
groups='sales_team.group_sale_salesman'
也就是说,这个汇总不是所有人都该看到。
从业务上也很好理解:
- 机会数量属于销售数据
- 它不仅反映客户热度,也反映 pipeline 资产
因此 Odoo 把它作为销售人员视角下的账户指标, 而不是人人可见的联系人装饰信息。
六、这为什么能解释很多“客户上 3 条,联系人上 1 条”的现场疑问
当用户说:
- “为什么这家公司显示 3 条机会,但当前联系人只有 1 条?”
源码给出的答案其实很直接:
- 因为另外 2 条挂在同一客户树里的别的联系人上
- 顶层公司做了 roll-up
- smart button 打开的列表本来就按 hierarchy 域看
所以这不是计数 bug, 而是 Odoo 对“客户账户”的默认定义更偏向公司维度。
一句话记忆法
Odoo CRM 的客户机会数默认是账号树汇总,不是单联系人直连数;主公司看到的是整个 partner hierarchy 的机会全景。
DISCUSSION
评论区