很多团队第一次看 Odoo 企业版 Employee Referral,都会先看到最表面的动作:
- 员工分享岗位链接;
- 朋友投递;
- HR 跟进;
- 招到人后给推荐人一点奖励。
如果只停在这个层面,就很容易把它当成一个“带 tracking 的招聘分享页”。
但把 /home/ubuntu/odoo-temp/enterprise/hr_referral 串起来看,Odoo 其实设计得更细:
内推不是一条链接,而是一套“可追踪来源 + 可结算进度 + 可消费奖励”的业务账本。
也就是说,系统真正想解决的不是“谁转发了岗位”,而是下面三件事:
- 这个候选人到底归因给哪位员工;
- 候选人在招聘流程里推进或回退时,推荐积分该如何同步;
- 员工拿到积分以后,等级成长、礼品兑换和多公司边界怎么分账。
这篇就只基于企业版 hr_referral 官方源码,把这三层讲透。
一、第一层不是“公共链接”,而是员工级 source + 职位级 campaign
在 wizard/hr_referral_link_to_share.py 里,hr.referral.link.to.share._compute_url() 做的第一件事,并不是直接拼一个职位 URL,而是先补齐营销归因对象:
- 员工维度:如果当前用户没有
utm_source_id,就先为这个员工创建一个专属utm.source; - 岗位维度:如果职位没有
utm_campaign_id,就为这个岗位创建一个专属utm.campaign; - 渠道维度:再根据
direct / facebook / twitter / linkedin选择不同utm.medium。
也就是说,Referral 这套机制默认把一条分享链接拆成三维:
- 谁分享的(source)
- 分享的是什么岗位(campaign)
- 通过什么渠道发出去(medium)
这和很多实现方式很不一样。
很多系统只会在链接上挂一个“推荐人 ID”,最多再记一个渠道字段。Odoo 做得更像一套标准营销追踪模型:内推在它眼里不是孤立招聘动作,而是一种可分析的获客来源。
所以 models/hr_job.py 里才会继续提供:
direct_clicksfacebook_clickstwitter_clickslinkedin_clicks
这些点击数不是全站总点击,而是当前员工自己的 source 在当前岗位 campaign 下、按 medium 拆开的点击表现。
这意味着 Odoo 不只是知道“岗位有没有被分享”,还知道:
- 哪个员工真的在传播;
- 他主要靠哪个渠道带来点击;
- 某个岗位的内推传播是否有实际触达。
这就是为什么 test_referral_share_is_new() 和 test_search_or_create_referral_links() 会特别验证两件事:
- 不同员工必须拿到不同链接;
- 同一员工在不同渠道也必须拿到不同链接。
因为一旦链接不能稳定映射到 source + campaign + medium,这套归因模型就会塌掉。
二、Odoo 为什么强行保住 UTM source / campaign 不让你删
如果内推只是一个短期活动,UTM 对象按理说可以随时清理。
但 models/utm_source.py 和 models/utm_campaign.py 明确做了删除保护:
- 只要某个
utm.source还被推荐员工绑定,就不允许删; - 只要某个
utm.campaign还被岗位绑定,就不允许删。
测试 test_utm_consistency_hr_job() 与 test_utm_consistency_res_users() 也专门保证了这一点。
这背后的业务含义很重要:
Referral 的链接不是临时营销素材,而是长期可追溯凭证。
为什么要这么保守?因为一旦删掉:
- 旧链接的来源归因会断;
- 招聘报表里的渠道统计会失真;
- 历史候选人到底是谁推荐的,也可能无法稳定回溯。
所以 Odoo 选择的不是“方便清理”,而是“宁可保留对象,也要守住历史归因”。
这对实施很有启发:
- 不要把 source / campaign 当成随手可删的营销垃圾数据;
- 如果要改命名或重构渠道,优先新增而不是覆盖历史对象;
- Referral 上线后,UTM 就进入了审计资产范畴。
三、候选人上的 ref_user_id 才是“内推归属”的核心锚点
很多人会以为:既然链接已经带了 source,那候选人归属就完全靠 UTM 了。
但 models/hr_applicant.py 的设计更谨慎。
在 hr.applicant 上,企业版额外挂了两个关键字段:
ref_user_id:真正的推荐人;source_id:外显的来源。
并且它们不是彼此独立的:
_compute_source_id():如果有ref_user_id,就把source_id统一算成系统级的utm_source_referral;_compute_ref_user_id():如果当前source_id不是这个 Referral 专用 source,就把ref_user_id清掉;default_get():如果新建申请时读到了某个员工专属 UTM source,会自动反解出ref_user_id,再把source_id改写回统一的 Referral source。
这套设计非常值得注意。
它说明 Odoo 并没有让“员工专属 source”长期停留在候选人主数据上,而是做了一个拆分:
- 专属 source 用来做链接归因入口;
- 统一 source + ref_user_id 用来做申请对象上的业务表达。
这么做的好处有两个。
1)申请表的业务语义更稳定
候选人一旦进入 HR 视角,系统更关心的是:
- 这是不是 Referral 来源;
- 具体是哪位员工推荐的。
如果直接把每个员工都塞成一个 source,申请表会变成“来源字段既表示来源类型,又表示具体人”,语义很容易混乱。
而现在的拆法更清楚:
source_id = Referral说明这是一种来源类型;ref_user_id = 张三才说明是谁推荐的。
2)后续积分、通知、报表都能直接挂在推荐人上
后面的积分流水、奖励兑换、通知消息,全部都围绕 ref_user_id 计算,而不是围绕原始 UTM source 计算。
所以 Referral 在候选人对象上真正的主锚点,其实不是 source,而是 ref_user_id。
四、积分不是“最终发奖”,而是按招聘阶段持续记流水
Referral 最容易被误解的地方,是把奖励理解成“招到人了才一次性给”。
_update_points() 说明完全不是这样。
Odoo 的积分逻辑是:
- 招聘阶段
hr.recruitment.stage上可以配置use_in_referral和points; - 候选人每推进到一个纳入 Referral 的阶段,就生成一笔
hr.referral.points; - 如果跨阶段跳转,会把中间所有应该拿到的阶段积分一次补齐;
- 如果流程回退,则会生成负积分行,把原来已经得到的部分冲回去。
注意,它不是改一条累计值,而是始终记流水。
hr.referral.points 里每一行都带着:
applicant_idref_user_idstage_idpointscompany_id- 以及可选的
hr_referral_reward_id
这意味着 Referral 的积分模型,本质上就是一张轻量账本。
为什么这种做法重要?
因为招聘流程不是单向直线。
现实里经常会发生:
- 候选人先推进,再被放回早期阶段;
- 中间穿过一个不计积分的 parking 阶段;
- HR 修正推荐人归属;
- 一个岗位流程里既有“展示给员工看的阶段”,也有“内部处理阶段”。
如果系统只存一个累计分数,这些变化就很难解释。现在用流水方式,才可以把“加分”“冲回”“兑换扣减”统一放在一张表里。
五、为什么回退阶段时要写负积分,而不是重算总分
test_referral_no_hired_stage() 很能体现 Odoo 的思路。
测试里专门构造了几类阶段:
use_in_referral = True的有效阶段;use_in_referral = False的 parking / 中间处理阶段;- 候选人在这些阶段之间前进、回退、跳跃。
官方断言的不是“总分最终对不对”而已,而是:
- dashboard 上哪些阶段该被视为 done;
- 跳过不计分阶段时是否只补应得积分;
- 从高阶段退回时是否用负积分准确冲销;
- 再前进一次时是否重新补回对应积分。
这就说明 Referral 的目标不是“做一个漂亮累计数字”,而是:
让内推进度既能给员工正反馈,又能忠实反映招聘流程的真实往返。
所以当阶段回退时,Odoo 没有偷懒去“重新求和覆盖写回”,而是保留了回滚痕迹。
这种设计的价值在于:
- 报表更容易解释;
- 调试时能看到每一次状态变化造成的分数影响;
- 如果未来要接入更多奖励规则,也更容易扩展。
六、不是所有招聘阶段都应该给分
hr.recruitment.stage 上除了 points 之外,还有个非常关键的布尔值:use_in_referral。
这意味着 Odoo 在建模上明确区分了两类阶段:
- 招聘内部流程阶段:服务 HR 管理,不一定要让推荐人感知;
- 内推可见里程碑阶段:会展示在 Referral Dashboard 上,也会触发积分。
这很符合真实业务。
很多公司招聘流程里都有一些内部状态,例如:
- 待分配
- 暂缓
- 内部复核
- 编制确认
- 预算冻结等待
这些状态对 HR 很重要,但不适合直接拿来激励推荐人。否则员工会看到一堆无法理解的状态变化,积分也会莫名其妙波动。
Odoo 的做法是让你显式声明哪些阶段属于 Referral 旅程。
所以实施时一个常见误区是:把所有招聘阶段都勾上 use_in_referral。
这么做的后果通常有两个:
- Dashboard 节点过多,员工根本看不懂;
- 积分节奏失真,稍微移动一下流程就疯狂加减分。
更合理的做法,是只保留少量对推荐人有业务意义的节点,比如:
- 简历通过
- 面试通过
- Offer 阶段
- 已录用
七、奖励消费和等级成长,其实是两本不同的账
如果只看表面界面,很多人会以为:
- 有积分;
- 用积分换礼品;
- 积分变少了,等级可能也降。
但 retrieve_referral_welcome_screen() 和 upgrade_level() 把这件事拆得很清楚。
系统至少同时维护两种口径:
point_received:只统计未绑定 reward 的积分,表示累计获得过多少成长积分;point_to_spend:统计全部积分流水净额,表示当前还剩多少可花积分。
同时,升级逻辑 upgrade_level() 用的是:
- 推荐人所有 未绑定 reward 的积分总和,即成长口径;
- 而不是扣除礼品消费后的可花余额。
这代表一个非常明确的产品决策:
花掉积分,不应该让你的 Referral 等级倒退。
这很像很多游戏或会员体系里的“经验值”和“钱包余额”分离:
- 等级看历史成就;
- 消费看当前余额。
这也是为什么 hr.referral.points 这张表里,兑换礼品时不会改原积分,而是额外插一条负分记录,并且带上 hr_referral_reward_id。
于是系统自然就能同时回答两个问题:
- 你这一路一共贡献过多少有效内推成长;
- 你现在账户里还剩多少积分可以买礼品。
八、多公司不是共享积分池,而是按公司分账
test_referral_multi_company() 则说明,Referral 还有一个容易被忽略的边界:公司维度分账。
hr.referral.points、hr.referral.reward、hr.applicant 都挂了 company_id。
测试里明确验证:
- 一个岗位属于公司 A;
- 候选人也投到公司 A;
- 那推荐积分就只记在公司 A;
- 即使同一个用户还属于公司 B,也不会自动跨公司共用这些积分。
奖励购买时也同理。
hr.referral.reward.buy() 会先按当前 reward 所属公司,重新计算用户在该公司的可用积分;如果不够,就直接报错。
这背后的原则非常清楚:
- 内推是围绕某个公司的岗位发生的;
- 奖励成本也是某个公司在承担;
- 所以积分和礼品都不能随意跨公司串账。
如果你的实施场景是集团多法人,这一点尤其重要。别把 Referral 想成一个全集团统一钱包,源码压根不是这么设计的。
九、推荐人变更时,系统为什么要先删旧流水再重算
test_referral_change_referrer() 还暴露出另一层细节:
- 候选人已经挂给 Richard,积分也发给了 Richard;
- 后来 HR 把
ref_user_id改成 Steve; - 系统会先删除旧推荐人的积分记录,再按当前阶段把积分重建给新推荐人。
这说明 Odoo 不接受“历史上 Richard 拿过,所以就留着”这种宽松处理。
它的态度是:
Referral 的积分归属必须与当前有效推荐人严格一致。
这对数据治理也很关键。
如果企业内部允许事后修正推荐归属,那么最怕出现的就是:
- 候选人改挂给了新推荐人;
- 旧推荐人的奖励没回收;
- 新推荐人又再拿一遍;
- 最后同一个人头被算了两次。
Odoo 直接通过“删旧流水 + 按当前状态重建”的方式,把这个口子堵死了。
十、最终你会发现:Referral 本质上是“招聘归因账 + 激励账”的耦合体
把这些代码放在一起看,Employee Referral 的真实结构大概是这样的:
1)入口层:链接归因
- 员工有自己的
utm_source_id - 岗位有自己的
utm_campaign_id - 渠道有自己的
utm.medium link.tracker负责生成稳定短链
2)业务层:候选人归属
- 申请进入系统后,不再只靠原始 source
ref_user_id成为推荐归属主键source_id被规范回统一的 Referral 来源语义
3)结算层:积分流水
- 进入可计分阶段就加分
- 回退就冲回
- 跳阶段就补记
- 奖励兑换再插负分流水
4)展示层:Dashboard / 等级 / 奖励
- Dashboard 看的是可见里程碑完成情况
- 等级看的是历史成长积分
- 可消费余额看的是当前净积分
- 多公司场景下再按公司切分账本
所以 Odoo 企业版 Referral 根本不是一个“招聘海报分享小工具”。
它更接近:
把招聘获客归因、流程进展激励和礼品兑换结算,压缩进同一套轻量模型里的企业内推系统。
十一、实施这套模块时,最容易踩的 5 个坑
坑一:把 Referral 只当成 marketing link
如果上线时只做“生成分享链接”,不配置:
- 哪些阶段算 Referral milestone;
- 每阶段多少积分;
- 礼品按哪个公司承担;
- 推荐归属修正由谁负责;
那模块很快就会沦为“有点击、没激励、也没人信分数”的半成品。
坑二:所有招聘阶段都参与积分
这会让 Referral Dashboard 变成 HR 内部流程镜像,而不是员工可理解的激励旅程。
坑三:把等级和消费余额混在一起
如果你后续做二开,把礼品消费直接影响等级,会很容易跟官方心智冲突,也会让员工觉得“换个礼物怎么还掉级了”。
坑四:忽略多公司边界
集团场景下,奖励预算、岗位归属、HR 管辖本来就分公司。源码已经按公司切账,实施时别再人为合并。
坑五:手工改归属却不理解回滚逻辑
如果运营同事不知道改 ref_user_id 会触发积分重建,就可能以为系统“突然扣分”是 bug。其实那是在修正账本归属。
十二、结论
如果只看界面,你会觉得 Odoo 企业版 Referral 很像一个带小游戏皮肤的招聘分享功能。
但源码告诉我们,官方真正做的是三件更严肃的事:
- 用
source + campaign + medium守住分享归因; - 用
ref_user_id + hr.referral.points守住候选人与积分账本; - 用“成长积分”和“消费积分”分离,守住激励体系的一致性。
所以对企业来说,Referral 最有价值的地方,并不是“员工能不能发链接”,而是:
- 你能不能稳定追溯推荐来源;
- 你能不能把招聘进展转成可信的激励流水;
- 你能不能在多公司环境里把奖励账算清楚。
这三件事做好了,Employee Referral 才会从“海报转发器”变成真正能跑起来的企业内推机制。
源码依据
enterprise/hr_referral/models/hr_applicant.pyenterprise/hr_referral/models/hr_job.pyenterprise/hr_referral/models/hr_referral_points.pyenterprise/hr_referral/models/hr_referral_reward.pyenterprise/hr_referral/models/hr_referral_level.pyenterprise/hr_referral/models/res_users.pyenterprise/hr_referral/models/utm_source.pyenterprise/hr_referral/models/utm_campaign.pyenterprise/hr_referral/wizard/hr_referral_link_to_share.pyenterprise/hr_referral/report/hr_recruitment_report.pyenterprise/hr_referral/tests/test_referral.pyenterprise/hr_referral/tests/test_referral_links.pyenterprise/hr_referral/tests/test_utm.py
DISCUSSION
评论区