很多人第一次接触 Odoo 企业版 Salary Configurator / Salary Package Offer,会把它想成一个比较直线的流程:
- HR 选一个合同模板;
- 发候选人一个链接;
- 对方在线调整福利;
- 签个字;
- 转成正式员工。
这个总结有表层结果,但没讲到源码里真正高明的部分。
如果你把 /home/ubuntu/odoo-temp/enterprise/hr_contract_salary 里的 models/hr_contract_salary_offer.py、models/hr_applicant.py、models/sign_request.py 和 controllers/main.py 串起来看,会发现官方真正要解决的是这些难题:
- 候选人还不是员工时,薪酬包页面要挂在哪个对象上模拟?
- 候选人在网页上改福利时,为什么不是直接改数据库里的正式合同版本?
- offer 签署为什么区分
open / half_signed / full_signed / expired / refused / cancelled这么多状态? - 只有候选人先签、HR 还没 countersign 时,为什么员工不能立刻完全生效?
- 签完 offer 以后,为什么还有 benefit 活动和额外 sign request 会继续触发?
所以 Salary Package Offer 的本质不是“发个链接让人签”,而是“用一套可回滚模拟 + 渐进式签署生效 + 版本切换”来安全承接候选人入职和员工续签”。
这也是它比普通电子 offer 链接复杂得多的原因。
一、先说结论:offer 不是合同本体,而是“签署中的版本入口”
hr.contract.salary.offer 这个模型名很容易让人误以为它就是合同记录。
其实不是。
它更像一个中间协调层,负责把下面几件事绑在一起:
- 接收人是谁:
applicant_id或employee_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 计算展示值。
这背后的设计思想非常清楚:
候选人在网页上调薪酬包,本质是“试算”,不是“直接改正式合同”。
为什么必须这么做?
如果不这样做,会立刻出现三类问题:
- 页面半填状态污染正式数据 - 候选人点开链接看一眼,数据库就已经被改脏。
- 多次刷新或回退导致版本历史紊乱 - 每次试算都可能产生新的中间版本。
- HR 和候选人还没最终确认前,后台就看见一堆未生效工资数据
所以 Odoo 用 savepoint 的方式,把整个 configurator 页面放进“可回滚的沙盒”。
页面里看到的许多计算结果都是真实算出来的,但并不立即承诺成为正式生效版本。
这是这套设计里最专业的地方之一。
四、为什么 offer 访问控制同时支持 HR、员工本人和 token 三种入口
check_access_to_salary_configurator() 明确写了三种访问方式:
- HR Manager 组内用户
- 被 offer 的员工本人
- 携带 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 标成
refused或cancelled; - 调
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
评论区