先说结论
如果你把 Odoo 的年假累计理解成“每月自动加一天”,那只看到了最表面的一层。
源码里真正的结构是:
- Accrual Plan:整套累计制度
- Level / Milestone:员工进入这套制度后,在不同资历阶段适用不同规则
- Transition Mode:升级到下一个 milestone 时,是立刻切换,还是等当前累计周期跑完再切换
- Carryover:到了结转时间,没用完的额度是清零、全带走,还是只带走一部分
- Accrual Validity:带过去的额度还能用多久
所以 Odoo 管的不是“加几天”,而是:
一条请假额度如何沿着员工资历和年度边界持续演化。
为什么 level 不是“高级设置”,而是核心建模
hr.leave.accrual.level 里有一批很关键的字段:
start_countstart_typemilestone_dateadded_valuefrequencycap_accrued_timeaction_with_unused_accrualscarryover_optionsaccrual_validity
这说明一个累计计划不是单条规则,而是一串按入职时长生效的里程碑。
比如你完全可以配置:
- 入职满 0 个月:每月 1 天
- 入职满 24 个月:每月 1.5 天
- 入职满 60 个月:每月 2 天
而且这些 level 不是摆设。源码里 sequence 会根据 start_count * start_type 自动排序,说明系统天然把它们当时间线上的阶段。
milestone_date = creation | after 到底在说什么
很多人第一次看这两个值会觉得只是界面文字差异。
其实不是。
creation:从 allocation 创建时就落在这个 levelafter:要经过指定时间后才切到这个 level
源码还有约束:
- 如果
milestone_date = after,start_count必须大于 0 - 如果是
creation,start_count必须是 0
这背后的逻辑很明确:
Odoo 不允许“从过去开始累计”。
也就是说,milestone 必须是当前起点或未来起点,而不是一个模糊回溯点。
transition_mode 为什么会让同一个员工“看起来升级了又没完全升级”
hr.leave.accrual.plan 里有个经常被忽略的字段:
immediatelyend_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:
lostall
也就是:
- 不用掉就失效
- 可以带到下一个周期
3)能带多少
carryover_options:
unlimitedlimited
如果 limited,还要看 postpone_max_days。
这说明 Odoo 不是只问“结不结转”,而是在问:
结转多少才算合规?
4)带过去的额度能活多久
如果 level 开了 accrual_validity,那 carryover 过来的额度不是永久的,它还有:
accrual_validity_countaccrual_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_leavecap_accrued_time_yearly/maximum_leave_yearly
这两者表达的不是一回事:
总上限
无论员工怎么累计,当前 allocation 的余额不能高过某个值。
年度上限
一年之内新增累计的额度不能超过某个值。
这两个上限叠在一起,才能比较真实地表达很多企业政策:
- 余额不能囤太多
- 但高资历员工年度增长也不能无限高
实施时最容易误判的 5 件事
误判一:只看 added_value
你只看“每月 1 天”,会忽略 milestone、切换时机、carryover 和 cap。
误判二:把 carryover 当成一次性拷贝
其实 carryover 后的额度还可能有限额和有效期。
误判三:没区分 immediately 和 end_of_accrual
这会直接影响员工升级后的第一期结果。
误判四:以为结转日也该同时新增一期累计
源码专门绕开这种重复计算。
误判五:把余额看成一个没有来源的总数
一旦涉及结转和有效期,余额一定是分层来源的。
我会怎么和 HR 解释这套机制
如果 HR 问:“为什么系统这么复杂,不能就每月加几天吗?”
我会说:
因为企业的年假政策通常不是一个公式,而是一条跨资历、跨年度、跨结转规则的额度时间线。Odoo 只是把这条时间线显式建模了。
一句话记忆
Odoo 的 accrual plan 不是“月增几天”,而是 milestone、切换时机、结转规则、上限和失效期共同组成的额度生命周期。
DISCUSSION
评论区