人力资源

Odoo 年假不是“按月加一天”那么简单:看懂 Accrual Milestone、Carryover、失效期与等级切换

很多团队配置年假累计时,只盯着每月加几天。Odoo 源码真正复杂的地方在 milestone、transition_mode、carryover_date、carryover 上限以及 carryover 后的有效期。它管理的是一条会跨年度演化的额度时间线。

人力资源
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 26 阅读

先说结论

如果你把 Odoo 的年假累计理解成“每月自动加一天”,那只看到了最表面的一层。

源码里真正的结构是:

  • Accrual Plan:整套累计制度
  • Level / Milestone:员工进入这套制度后,在不同资历阶段适用不同规则
  • Transition Mode:升级到下一个 milestone 时,是立刻切换,还是等当前累计周期跑完再切换
  • Carryover:到了结转时间,没用完的额度是清零、全带走,还是只带走一部分
  • Accrual Validity:带过去的额度还能用多久

所以 Odoo 管的不是“加几天”,而是:

一条请假额度如何沿着员工资历和年度边界持续演化。


为什么 level 不是“高级设置”,而是核心建模

hr.leave.accrual.level 里有一批很关键的字段:

  • start_count
  • start_type
  • milestone_date
  • added_value
  • frequency
  • cap_accrued_time
  • action_with_unused_accruals
  • carryover_options
  • accrual_validity

这说明一个累计计划不是单条规则,而是一串按入职时长生效的里程碑

比如你完全可以配置:

  • 入职满 0 个月:每月 1 天
  • 入职满 24 个月:每月 1.5 天
  • 入职满 60 个月:每月 2 天

而且这些 level 不是摆设。源码里 sequence 会根据 start_count * start_type 自动排序,说明系统天然把它们当时间线上的阶段。


milestone_date = creation | after 到底在说什么

很多人第一次看这两个值会觉得只是界面文字差异。

其实不是。

  • creation:从 allocation 创建时就落在这个 level
  • after:要经过指定时间后才切到这个 level

源码还有约束:

  • 如果 milestone_date = afterstart_count 必须大于 0
  • 如果是 creationstart_count 必须是 0

这背后的逻辑很明确:

Odoo 不允许“从过去开始累计”。

也就是说,milestone 必须是当前起点或未来起点,而不是一个模糊回溯点。


transition_mode 为什么会让同一个员工“看起来升级了又没完全升级”

hr.leave.accrual.plan 里有个经常被忽略的字段:

  • immediately
  • end_of_accrual

这两个值决定了员工跨 milestone 时,规则切换发生在什么时候。

immediately

一旦到达新的资历门槛,后续累计直接按新 level 算。

end_of_accrual

即使资历已经够了,也要等当前累计周期结束,再切到新 level。

这对业务很关键。

举个例子:

  • 员工 7 月 15 日满两年
  • 当前规则是“每月月底累计”

如果是 immediately,7 月中后段后续计算就可能按新等级逻辑走; 如果是 end_of_accrual,可能要等本期跑完,8 月再整体切换。

所以别把 milestone 升级理解成一刀切的当天切换,它还要叠加周期边界


Carryover 不是只有“能不能结转”,而是 4 层问题

源码里的 carryover 至少有四层:

1)什么时候触发结转

carryover_date 支持:

  • 年初
  • allocation 日期
  • 自定义某月某日

系统会按 allocation 的起点和当前 nextcall 推算下一次结转日期。

2)结转后是清零还是保留

action_with_unused_accruals

  • lost
  • all

也就是:

  • 不用掉就失效
  • 可以带到下一个周期

3)能带多少

carryover_options

  • unlimited
  • limited

如果 limited,还要看 postpone_max_days

这说明 Odoo 不是只问“结不结转”,而是在问:

结转多少才算合规?

4)带过去的额度能活多久

如果 level 开了 accrual_validity,那 carryover 过来的额度不是永久的,它还有:

  • accrual_validity_count
  • accrual_validity_type = day | month

也就是说,结转成功不等于永久保留


为什么员工会问:“我余额没少,但为什么有一部分快过期了?”

因为 Odoo 的设计不是把余额当一个纯数字。

源码在 carryover 处理时,会同时关心:

  • 这部分额度属于哪个 level 产生
  • 它是在 carryover 前累计的,还是 carryover 后累计的
  • carryover date 和下一次 nextcall 哪个先到
  • 如果有有效期,expiration date 在哪里

这意味着一个员工看到的“总余额”,底层可能是几层来源叠起来:

  • 当期正常累计
  • 上周期结转过来但仍有效的余额
  • 即将因为 carryover validity 到期的余额

从业务角度看是“14 天余额”;从源码角度看,可能根本不是同一批 14 天。


为什么“结转日当天不再多给一天”是合理的

测试里还有一个很关键的场景:carryover date 本身不应该再额外累计一次

这反映出 Odoo 在避免“双重奖励”:

  • 先做 carryover
  • 又把这天当一次新的 accrual trigger

如果不这么拦,年初、自定义结转日或者 allocation 周年纪念日,就很容易重复加额度。

这也是为什么我说 Odoo 在这里管的是时间线一致性,不只是算术。


上限为什么分两种:总上限和年度上限

hr.leave.accrual.level 里有两种 cap:

  • cap_accrued_time / maximum_leave
  • cap_accrued_time_yearly / maximum_leave_yearly

这两者表达的不是一回事:

总上限

无论员工怎么累计,当前 allocation 的余额不能高过某个值。

年度上限

一年之内新增累计的额度不能超过某个值。

这两个上限叠在一起,才能比较真实地表达很多企业政策:

  • 余额不能囤太多
  • 但高资历员工年度增长也不能无限高

实施时最容易误判的 5 件事

误判一:只看 added_value

你只看“每月 1 天”,会忽略 milestone、切换时机、carryover 和 cap。

误判二:把 carryover 当成一次性拷贝

其实 carryover 后的额度还可能有限额和有效期。

误判三:没区分 immediatelyend_of_accrual

这会直接影响员工升级后的第一期结果。

误判四:以为结转日也该同时新增一期累计

源码专门绕开这种重复计算。

误判五:把余额看成一个没有来源的总数

一旦涉及结转和有效期,余额一定是分层来源的。


我会怎么和 HR 解释这套机制

如果 HR 问:“为什么系统这么复杂,不能就每月加几天吗?”

我会说:

因为企业的年假政策通常不是一个公式,而是一条跨资历、跨年度、跨结转规则的额度时间线。Odoo 只是把这条时间线显式建模了。


一句话记忆

Odoo 的 accrual plan 不是“月增几天”,而是 milestone、切换时机、结转规则、上限和失效期共同组成的额度生命周期。

DISCUSSION

评论区

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