很多团队以为薪资模拟就是“输入预算,换算一个 gross/net 数字”。但 enterprise/hr_contract_salary_payroll/models/hr_contract_salary_offer.py 和 hr_version.py 展示的其实是一套受控试算流程:前台改预算,后台临时构造 version,再在 savepoint 里生成模拟 payslip,最后把试算记录当作一次性工件回收掉。
一、simulation offer 是独立对象,不是普通 offer 上多几个字段
default_get() 会在 default_is_simulation_offer 场景下单独生成 access_token,说明模拟 offer 从一开始就被当成可分享、可访问、但应与正式 offer 区分的对象。_onchange_simulation_employee_id() 则把当前版本的工资、工时日历、薪资结构带进来,确保模拟不是凭空计算,而是建立在真实员工版本之上。
这一步解决的是“模拟口径漂移”问题。只要结构类型、resource calendar 和当前 version 不一致,前台看到的成本就会与后续正式合同脱节。
二、真正的核心不是工资换算,而是 savepoint 下的 payslip 试算
_compute_salary() 先 flush_all(),再在 savepoint 中调用 version._generate_salary_simulation_payslip()。后者在 hr_version.py 里明确写了注释:不要在 rollback savepoint 之外调用。这句警告非常关键,因为 Odoo 这里不是为了“创建一张真的工资单”,而是为了借 payroll 规则引擎跑出 BASIC、NET、benefits、雇主成本这些结果。
源码甚至会处理小时工:_generate_salary_simulation_payslip() 在 hourly wage 合同下手工生成 worked_days_line_ids。这说明模拟结果不是静态公式,而是尽量复用正式薪资规则,连工时口径都要兼容。
三、前台预算变化,其实在驱动多层回算
_compute_monthly_wage() 会根据 final_yearly_costs 通过 version 的 _get_gross_from_employer_costs() 反推工资;_compute_salary() 再把 gross、net、monthly/yearly benefits、yearly/monthly employer cost 一起整理出来。也就是说,候选人改一个预算值,后台同时在做:
- 雇主成本到 gross 的换算;
- gross 到 payslip line 的工资规则展开;
- 福利成本的月度/年度聚合;
- 全职/非全职状态判断。
所以它不是“显示一个计算器结果”,而是“在不真正发工资的前提下,借 payroll 引擎做一次受控演练”。
四、临时记录如果不回收,模拟系统迟早会脏
action_cron_remove_simulation_offers() 会清理超过一个月的 simulation offers。这说明官方默认就承认:模拟记录会堆积,而且它们并不适合作为长期业务凭证保存。
实战里最容易忽略这点。很多企业把模拟当作沟通草稿,几轮谈薪下来会留下大量历史试算。如果不清理,后面用户会分不清哪些是正式 offer、哪些只是谈判过程中的临时版本。
五、落地时最需要防的不是“算错”,而是“口径不一致”
- 结构类型必须跟真实 payroll 结构一致:否则 BASIC/NET 只是在算另一套规则。
- resource calendar 要跟合同版本一致:尤其小时工和部分工时,工时口径直接影响模拟 payslip。
- 不要把模拟结果当正式承诺:它依赖当前 payroll 规则和上下文,规则一变结果也会变。
- 定期清理历史模拟:让 HR、候选人和经理看到的都是仍有业务价值的版本。
新手误区
- 误以为 gross/net 可以直接用前端公式算完,不必进 payroll。
- 误以为 simulation offer 和正式 offer 只是状态不同。
- 误以为小时工场景下模拟不需要 worked days。
- 误以为模拟记录可以无限保留,不会影响日常操作。
主要源码参考
enterprise/hr_contract_salary_payroll/models/hr_contract_salary_offer.pyenterprise/hr_contract_salary_payroll/models/hr_version.py
DISCUSSION
评论区