企业版薪酬包

Odoo 企业版 Salary Package Offer 为什么不是“发个 Offer 链接”而已:savepoint 模拟、候选人假员工与 countersign 生命周期讲透

很多人以为 Odoo 企业版 Salary Package Offer 只是给候选人发一个可调薪酬包的链接,再点签字完成入职。源码里真正复杂的地方在于:offer 页面其实跑在 savepoint 里的“模拟版本”,候选人阶段会先造一个 inactive 假员工承接配置,签字后再按单签/会签状态切换版本、激活员工并触发 benefit 相关活动与后续签署。

人力资源 企业
进阶 开发者 4 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多人第一次接触 Odoo 企业版 Salary Configurator / Salary Package Offer,会把它想成一个比较直线的流程:

  • HR 选一个合同模板;
  • 发候选人一个链接;
  • 对方在线调整福利;
  • 签个字;
  • 转成正式员工。

这个总结有表层结果,但没讲到源码里真正高明的部分。

如果你把 /home/ubuntu/odoo-temp/enterprise/hr_contract_salary 里的 models/hr_contract_salary_offer.pymodels/hr_applicant.pymodels/sign_request.pycontrollers/main.py 串起来看,会发现官方真正要解决的是这些难题:

  1. 候选人还不是员工时,薪酬包页面要挂在哪个对象上模拟?
  2. 候选人在网页上改福利时,为什么不是直接改数据库里的正式合同版本?
  3. offer 签署为什么区分 open / half_signed / full_signed / expired / refused / cancelled 这么多状态?
  4. 只有候选人先签、HR 还没 countersign 时,为什么员工不能立刻完全生效?
  5. 签完 offer 以后,为什么还有 benefit 活动和额外 sign request 会继续触发?

所以 Salary Package Offer 的本质不是“发个链接让人签”,而是“用一套可回滚模拟 + 渐进式签署生效 + 版本切换”来安全承接候选人入职和员工续签”。

这也是它比普通电子 offer 链接复杂得多的原因。


一、先说结论:offer 不是合同本体,而是“签署中的版本入口”

hr.contract.salary.offer 这个模型名很容易让人误以为它就是合同记录。

其实不是。

它更像一个中间协调层,负责把下面几件事绑在一起:

  • 接收人是谁:applicant_idemployee_id
  • 基于哪个合同模板:contract_template_id
  • 给谁发签署请求:sign_request_ids
  • 当前签到了哪一步:state
  • 当前公开访问凭证是什么:access_token
  • 页面入口地址是什么:url

也就是说,offer 不是正式运行中的合同版本,而是“让某个人查看、模拟、签署某个版本方案”的承载对象。

这点很重要。

因为它决定了:

  • 你在网页里看到的数字,不一定已经写进正式合同;
  • 你在签署前做的调整,很多其实只是模拟态;
  • 真正的合同版本切换,发生在签署链的后段。

二、为什么候选人阶段要先造一个 inactive 员工,而不是直接拿 applicant 顶上去

_get_version() 是整条链最值得读的函数之一。

如果 offer 面向的是已有员工,逻辑相对简单:

  • 取员工现有 version 或模板;
  • 带上 salary_simulation=True 上下文;
  • 进入配置页面。

但如果 offer 面向的是 applicant,官方没有偷懒把 applicant 直接当员工用。

它会先:

  • 创建一个 hr.employee
  • 写入姓名、私人电话、私人邮箱、公司、国家;
  • 且把这个 employee 标成 active=False
  • 再基于模板生成/复制一个 hr.version 给它做模拟。

这一步很多人第一次看都会疑惑:

候选人还没入职,为什么系统要先建 employee?

答案很现实:

因为薪酬包配置器最终操作的是“员工版本语义”,不是“招聘漏斗语义”

薪酬包页面会涉及:

  • 薪资结构类型;
  • 福利项目;
  • 合同模板;
  • sign template;
  • employee 相关字段;
  • 后续 benefits 活动与签署责任人。

这些对象天然都围绕 employee / version 转,不围绕 applicant 转。

所以官方做法不是把招聘对象硬塞进薪酬模块,而是:

先造一个不激活的“模拟员工壳”,让薪酬与签署链路有稳定对象可挂。

这是一种很典型的企业系统桥接方式。


三、为什么 offer 页面要跑在 savepoint 里的 simulation,而不是边改边落正式数据

控制器 /salary_package/simulation/offer/<offer_id> 里有一句特别关键的注释:

  • THE REST OF THE TRANSACTION WILL BE ROLLED-BACK
  • This is just a simulation.

然后它会:

  • flush_all()
  • 进入 with request.env.cr.savepoint(flush=False) as sp:
  • 在 savepoint 内构造 version;
  • 在页面交互时不断用 version 计算展示值。

这背后的设计思想非常清楚:

候选人在网页上调薪酬包,本质是“试算”,不是“直接改正式合同”。

为什么必须这么做?

如果不这样做,会立刻出现三类问题:

  1. 页面半填状态污染正式数据 - 候选人点开链接看一眼,数据库就已经被改脏。
  2. 多次刷新或回退导致版本历史紊乱 - 每次试算都可能产生新的中间版本。
  3. HR 和候选人还没最终确认前,后台就看见一堆未生效工资数据

所以 Odoo 用 savepoint 的方式,把整个 configurator 页面放进“可回滚的沙盒”。

页面里看到的许多计算结果都是真实算出来的,但并不立即承诺成为正式生效版本。

这是这套设计里最专业的地方之一。


四、为什么 offer 访问控制同时支持 HR、员工本人和 token 三种入口

check_access_to_salary_configurator() 明确写了三种访问方式:

  1. HR Manager 组内用户
  2. 被 offer 的员工本人
  3. 携带 token 的公开访问

这三种入口其实分别对应三个现实场景:

场景 1:HR 内部调整和复核

HR 需要能直接打开页面看内容、帮忙修改、重发。

场景 2:已有员工的调薪/续签

如果 offer 面向已有员工,对方可能直接用系统账号查看。

场景 3:候选人尚未有账号

候选人通常只能通过公开 token 链接进入。

同时它还会检查:

  • offer 是否已 expired / refused
  • offer_end_date 是否已过期;
  • token 是否匹配;
  • 若 offer 绑定的是已有员工账号,其他用户不能冒充访问。

所以这个页面虽然是 public route,但不是“匿名开放页”,而是“允许 token 访问的受控协作页”。


五、为什么 access_token 不是所有 offer 都必须有

_compute_token() 里有个细节:

  • 只有当 offer 没 token,且 没有员工账号可直接登录 时,才会生成 token。

这说明官方并不是强制所有 offer 都走“公开链接”模型。

如果接收人本来就是系统里的员工用户,那么:

  • 系统账号本身已经是身份凭证;
  • 就没必要再额外暴露公开 token。

这点很像 Frontdesk 那种“按场景选择最小可用授权面”的设计。

对实施的启发

很多团队会条件反射地给所有流程都发外链。

但源码在这里表达得很克制:

能走内部身份,就别额外扩大公开 token 暴露面。


六、为什么签署状态要有 half_signed

hr.contract.salary.offer.state 里最容易被忽略、但最有业务意味的状态,就是:

  • half_signed

很多轻量产品只会做:

  • draft
  • signed
  • refused

但 Odoo 明显认为这不够。

因为在薪酬包和劳动合同场景里,常见的不是“一个人签完就生效”,而是:

  • 候选人先签;
  • 公司代表后签;
  • 或双方都有各自签署动作。

_compute_is_half_sign_state_required() 里也写得很直接:

  • 如果 signatories 不止 1 个,就需要考虑半签状态。

这背后的业务含义是:

签署流程不等于状态切换;它可能是一个分阶段承诺过程。

所以 half_signed 回答的是:

  • 候选人已经表达接受;
  • 但公司侧尚未完全 countersign;
  • 此时一些动作可以先准备,但不应把所有效果都当成最终完成。

七、签字后到底发生了什么:不是“把 PDF 标已签”这么简单

控制器里继承了 sign 模块的签署入口。

一旦 sign() 成功,系统会:

  • 找到对应 sign.request.item
  • 反查关联 hr.version
  • 反查关联 offer
  • 校验 offer 没过期/没被拒绝;
  • 再根据签署人数与模板角色,调用 _update_version_on_signature()

这里真正复杂的是 _update_version_on_signature()

第一阶段:候选人/员工先签

如果当前是第一个签署人完成:

  • 记录签署时要应用的 wage;
  • 如果是 applicant,就把 applicant 和 employee 建立更实关系;
  • 如果还有其他待签人,offer 进入 half_signed
  • 同时创建 benefits 相关活动。

第二阶段:所有签署人都完成

nb_wait == 0 时:

  • 当前 version 被真正激活;
  • 旧 version 可能被结束或归档;
  • employee 被激活;
  • applicant 被推进到 hired stage;
  • work contact 被激活;
  • benefit 相关 sign request 和 activity 继续触发;
  • offer 进入 full_signed

也就是说:

签署动作在 Odoo 里不是“文档已签”单点事件,而是“驱动合同版本切换和员工身份生效”的业务触发器。


八、为什么旧版本不是直接覆盖,而是会做归档/替换和日期调整

在全签完成后,源码会去看 current_employee_version

如果当前员工已经有运行中的 version:

  • 旧版本的 contract_date_end 可能被设为新版本开始日前一天;
  • 如果新旧版本时间冲突,还会通过调整 date_version 和 active 状态来完成切换;
  • 某些情况下旧版本被归档,新版本转为 active。

这说明 Odoo 不把合同更新理解成“改一行工资字段”。

它理解成:

员工在时间线上经历了一次版本更替。

所以系统保留的不是“最新值”,而是:

  • 哪个版本曾生效;
  • 何时结束;
  • 哪个版本接上来。

这对审计、人力历史、后续 payroll 都很关键。


九、为什么 applicant 拒绝、签署取消会反过来清理模拟对象

hr.applicant.archive_applicant()sign.request.cancel() 和 offer 拒绝逻辑里,都能看到一条共同思路:

  • 把 offer 标成 refusedcancelled
  • unlink_archived_versions()
  • 取消关联 sign request;
  • 删除或清理前面为 applicant 造出来的归档 version / employee 痕迹。

这说明官方非常清楚:

前面为了支撑配置器和签署,创建过一些“桥接对象”。

如果 offer 最后没成,这些对象不能长期留在系统里污染 HR 数据。

所以 candidate flow 不是一路只增不减,而是“成功时转正,失败时回收模拟残留”。

这也是前面先造 inactive employee 而不是直接用正式员工的另一个好处:可以安全回收。


十、benefits 为什么不是签完 offer 就结束,而是可能继续排活动/发二次签署

_create_activity_benefit()_send_benefit_sign_request() 很值得研究。

很多人以为 salary package 只是:

  • 选工资;
  • 选福利;
  • 签完收工。

但在真实企业里,某些福利项会带来后续动作,比如:

  • 配车;
  • 手机/设备申请;
  • 保险单独签字;
  • 特定 benefit 需要 HR responsible 再跟进。

源码就把这种后续动作参数化了:

  • 某 benefit 是否要创建 activity;
  • running / countersigned / onchange / always 哪些状态创建;
  • 是否需要额外 sign.template
  • 这些 sign request 分别发给 employee signatory 或 job responsible。

这说明 salary package offer 并不是链路终点,而是:

一旦签署完成,就把“薪酬选择结果”继续扇出到设备、福利、合规文件等后续流程。

这才符合大型企业的人事现实。


十一、实战里最容易误解的 5 个点

误区 1:候选人页面改的就是正式合同

不是。

大部分浏览和试算发生在 savepoint 里的 simulation 语义中。

误区 2:offer 就等于 hr.version

不是。

offer 是签署入口与状态协调层;version 才是实际合同版本对象。

误区 3:候选人一签,员工就一定完全生效

不一定。

如果需要 countersign,就会先进入 half_signed,很多效果要等全签完成。

误区 4:生成 employee 太早是脏设计

恰恰相反。

这是为了让薪酬、合同、签署、benefit 全部挂到统一语义对象上,同时又能保持 inactive / 可回收。

误区 5:签署完成后没别的事了

也不对。

benefit activities、额外 sign requests、版本归档切换都可能随后发生。


十二、对二开的真正启发

如果你要基于这套能力做扩展,最应该学的不是页面按钮,而是下面四个边界:

1)配置页面优先用“试算态”,不要直接写正式对象

尤其是福利组合、预算推导、多步骤表单场景。

2)把 applicant 到 employee 的桥接设计成可回收壳对象

不要让失败的 offer 直接污染正式员工主数据。

3)签署链和业务生效链要能分阶段

“我签了”不一定等于“公司已承诺生效”。

4)把 benefit 的后续动作设计成可配置扇出

这样薪酬签署才能真正接入 IT、行政、合规等协同流程。


总结

hr_contract_salary 源码串起来以后,你会发现 Odoo 企业版并不是做了一个“发外链签 offer”的花哨功能。

它真正做的是一套很完整的生命周期控制:

  • offer 作为签署入口和状态协调层;
  • inactive employee + version 承接 applicant 阶段的薪酬语义;
  • savepoint simulation 保证网页试算不污染正式数据;
  • token / 内部账号 / HR 权限 三种入口控制访问;
  • half_signed / full_signed 区分承诺阶段;
  • version 归档与切换 表达合同时间线;
  • benefit activity / extra sign request 把薪酬包结果继续扇出。

所以 Odoo 企业版 Salary Package Offer 的本质,不是“在线签 Offer”,而是“把招聘、合同版本、电子签署和入职后续动作安全串成一条渐进式生效链”。

这才是这套企业版源码最值得学的地方。


参考源码 - enterprise/hr_contract_salary/models/hr_contract_salary_offer.py - enterprise/hr_contract_salary/models/hr_applicant.py - enterprise/hr_contract_salary/models/sign_request.py - enterprise/hr_contract_salary/controllers/main.py - enterprise/hr_contract_salary/tests/test_applicant_salary_configurator.py

DISCUSSION

评论区

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