先说结论
在 Odoo CRM 里,合并重复线索不是“做一个集合并集”,而更像:
先选一个主记录,再把其他记录的部分字段和依赖转移过来,最后删掉尾部记录。
这个结论一旦成立,很多现象就突然解释得通了:
- 为什么合并后只剩一个
campaign_id / medium_id / source_id - 为什么有些跟进历史、附件、日程还能保留
- 为什么丢单原因不会总是保留
- 为什么不同来源的线索一 merge,归因口径就开始模糊
所以这篇不讲“怎么点按钮”,而讲更本质的问题:
Odoo CRM 的 dedup / merge,到底保留了什么,又丢掉了什么。
一、Odoo 先怎么判断“可能重复”
源码里有两套相关逻辑,很多人会混在一起。
第一套:界面上的潜在重复提示
_compute_potential_lead_duplicates() 主要用来算:这条记录看起来是不是有潜在重复。
它用到的标准包括:
email_domain_criterion/ 邮箱域相关匹配phone_sanitized精确匹配- 同一个
commercial_partner体系
源码注释里写得很直白:
- 邮箱域精确匹配
- 清洗后的手机号精确匹配
- 同一商业实体
这套逻辑的定位更偏提醒和发现。
第二套:真正用于分配和去重的重复搜索
_get_lead_duplicates() 则更偏“拿来处理”的重复判断。
它主要基于:
email_normalizedpartner_id
然后再过滤:
- 默认只看
won_status = pending - 只看
active = True
也就是说,默认情况下:
- 已赢单的不参与这类重复清理
- 已归档、不可操作的记录也不纳入主要 dedup 结果
这就很业务。
因为 Odoo 想处理的是当前还在推进中的重复线索,而不是把历史全翻出来一起搅。
二、自动去重不是孤立动作,而是会嵌进线索分配流程
在 crm_team.py 里,销售团队分配 lead 时,会顺手做 deduplicate。
你能看到:
_allocate_leads_deduplicate()duplicates_cache_merge_opportunity()
这意味着 Odoo 的思路不是:
- 先把所有 lead 分完
- 以后再单独去重
而更像:
- 先判断候选 lead
- 在分配前看有没有明显重复
- 如有必要,直接 merge,再把结果交给团队
这个设计背后的业务直觉非常强:
如果不先去重,同一个客户可能同时被分给不同销售,CRM 脏得会非常快。
所以 dedup 在 Odoo 里不是可有可无的小工具,而是 pipeline 清洁度的一部分。
三、合并时,谁才是“活下来”的那条记录
源码里 _merge_opportunity() 不会随机挑一条记录当结果。
它先调用 _sort_by_confidence_level(reverse=True) 排序。
排序启发式大致是:
- 没丢的优先
opportunity比lead更可靠stage.sequence越靠后越优先probability越高越优先- 记录越新越优先
然后:
- 排序后的第一条记录,成为
opportunities_head - 其余都是
opportunities_tail
翻成人话:
Odoo 默认认为“更像已经被验证、更接近成交”的那条,最适合当主记录”。
这不是技术细节,而是 merge 结果的根。
因为后面大量字段保留逻辑,都依赖这个 head。
四、为什么合并后 UTM 只剩一套
最值得写进脑子里的地方在这里。
CRM_LEAD_FIELDS_TO_MERGE 里明确包含:
campaign_idmedium_idsource_id
而 _merge_data() 对大多数普通字段的策略是:
- 取排序后第一条记录里的第一个非空值
这就意味着,如果你把下面两条记录合并:
- A:Google Ads / Website / Search
- B:Newsletter / Email / Mailing
最终主记录里通常不会保留“两套来源”,而是:
- 先看排序后谁是 head
- 再保留 head 那条的非空 UTM 值
- 另一条来源语义只会留在历史消息或被删除记录里,不再作为主字段存在
这就是很多运营同学会觉得“合并以后归因变脏了”的根本原因。
不是报表写错,而是数据模型本来就不是多来源归因模型。
这件事最容易被误会成什么
很多人以为 merge 是:
- 联系人信息取最完整的
- 历史跟进都保留
- 来源归因也会自动综合
但源码并没有做“归因融合”。
它做的是:
字段层面以主记录优先,依赖层面尽量搬迁,尾记录最后删除。
所以 UTM 这类单值字段在 merge 之后天然会更像“主记录口径”,而不是“多触点历史口径”。
五、Merge 不是只搬字段,也会搬依赖
虽然主字段会偏向 head,但 Odoo 并不是把 tail 直接粗暴删掉。
源码里 _merge_dependences() 会继续处理:
- 历史消息 / activities
- 附件
- 日历事件
还有 _merge_followers(),会尽量把 followers 合并,避免重复 follower 关系。
所以合并的真实语义是:
- 主业务字段:以主记录为准
- 外围历史依赖:尽量迁移到主记录
这也是为什么你合并后常常会觉得:
- 沟通记录似乎还在
- 附件似乎也没丢
- 但来源字段、阶段、负责人这些主属性只剩一套
这是设计使然,不是偶发 bug。
六、为什么 lost reason 不一定保留
_merge_get_fields_specific() 里有一个特别有意思的规则:
- 如果排序后的第一条 lead
probability非 0,则lost_reason_id = False - 只有当头记录本身已经是概率为 0 的语义时,才会从被合并记录里挑一个 lost reason
翻成人话:
只要合并结果仍然是一个在推进中的机会,系统就不想让“丢单原因”挂在主记录上。
这非常合理。
因为 lost reason 属于失败语义,而 merge 结果如果是一个仍在推进的 opportunity,保留 lost reason 反而会制造歧义。
七、为什么 stage 也不一定照搬你以为的那个
还有一个容易忽视的细节。
合并之后,如果 merged_data 里带了 team_id,Odoo 会检查当前 stage_id 是否属于这个销售团队可用的 stage。
如果不属于,系统会:
- 重新换成该团队最前面的可用 stage
所以 merge 不只是字段保留问题,还涉及目标团队的管道合法性。
这意味着有些用户会看到:
- 明明原记录在某个较后阶段
- merge 后阶段却回到了团队下的另一个 stage
并不一定是丢数据,而是 merge 结果必须满足新的团队 stage 约束。
八、为什么官方还限制一次最多合并 5 条
_merge_opportunity() 里默认有 max_length=5,非超级用户超过这个数量会报错。
源码给的理由很直接:
- To prevent data loss
这其实非常诚实。
因为 Odoo 自己也知道,merge 不是数学上的无损合并,而是一个带优先级取舍的业务动作。
数量一旦太大:
- 主记录选择更难保证合理
- 非空值优先策略更可能误保留
- UTM / 负责人 / 阶段 / 联系信息越容易失真
所以这个限制不是保守,而是对数据语义的自我保护。
九、实战里最容易踩的 5 个坑
坑 1:把 merge 当成“无损归档整理”
不是。
它本质是“选主记录 + 迁依赖 + 删尾记录”。
坑 2:以为 UTM 会自动汇总
不会。
campaign_id / medium_id / source_id 是单值字段,merge 时通常主记录优先。
坑 3:把不同触点的线索太早合并
如果营销还需要看来源效果,太早 merge 可能直接把多来源信息压扁成一套主字段。
坑 4:以为 lost reason 一定会跟着保留
不一定。
只要 merge 结果还是活跃机会,lost reason 往往会被清掉。
坑 5:以为阶段不会受团队影响
会。
team 变化后,stage 还要重新校验是否属于该团队管道。
十、什么时候该 merge,什么时候别急着 merge
我自己的判断是:
适合 merge 的情况
- 明显同一客户、同一销售机会、只是重复录入
- 当前更想保证销售协同,不想让多人重复跟进
- 归因已经不再是核心分析对象
不要急着 merge 的情况
- 同一客户来自多个营销触点,你还想分析来源效果
- 你需要保留 first-touch / last-touch / multi-touch 的历史语义
- 线索尚处于甄别期,重复与否还没完全确认
因为 Odoo CRM 原生 merge 更偏销售主记录清理,不是营销归因仓库。
一句话记忆法
Odoo CRM 的 merge,不是把多条线索平等拼起来,而是选一个最可信的主记录;UTM、阶段、负责人等主字段以主记录优先,消息附件等外围依赖再尽量搬过去。
如果你用这句话去理解 dedup / merge,就不会再把它错当成“自动保留所有来源语义”的工具了。
DISCUSSION
评论区