先说结论
在 Odoo 里,员工今天多上了几小时班,不等于系统马上就得到了一条可以直接进工资单的加班工资。
官方源码至少拆了三层语义:
- Attendance:员工实际打卡了多久
- Overtime Line / Extra Hours:这些时长里,哪些部分被规则认定为额外工时
- Work Entry / Payroll:这些额外工时最终要不要变成可发薪、按什么类型发薪、按什么倍率发薪
这三层故意没有揉成一层。
所以你在界面里看到 worked_hours、overtime_hours、validated_overtime_hours,不要以为最后一个就已经等于“工资系统认定的加班工资”。它更像是:
考勤模块先把“额外工时候选结果”算出来,至于是否进入薪资口径,还要看后续 work entry / payroll 设计。
为什么官方不把“打卡时长”和“可发薪加班”直接绑死
因为这两件事在业务上根本不是同一层。
一个员工晚上多待了 2 小时,可能有几种完全不同的解释:
- 真的是应该发钱的加班
- 只是晚走,但公司不认定为加班
- 需要主管审批后才算
- 可以换调休,不一定发工资
- 属于弹性工时,不应直接算加班
- 只是考勤异常,之后还要人工修正
所以 Odoo 在 hr_attendance 里没有偷懒地做成“多打卡 = 多发钱”。
它先解决的是:
- 员工实际工作了多久
- 相对排班来说,多出来或少掉了多少
- 这些时长是否要审批
- 规则命中了哪一种 overtime rule
而薪资怎么吃这些结果,被留在更后面的层。
这就是边界感。
hr.attendance 里那 3 个小时字段,分别在说什么
源码里最容易让人误会的是这几个字段:
worked_hoursovertime_hoursvalidated_overtime_hours
1)worked_hours:你实际上班了多久
这个最朴素。
在 hr.attendance 里,worked_hours 来自签到签退区间,并且在非弹性日历下会把午休区间扣掉。
它回答的问题是:
这条考勤记录实际覆盖了多少工作时长?
它不关心这是不是加班。
2)overtime_hours:规则识别出来的额外工时
overtime_hours 不是简单的 worked_hours - 标准工时。
源码里它是把 linked_overtime_ids 的 manual_duration 累加出来。也就是说,真正起作用的是 overtime line,不是考勤本身。
换句话说:
考勤只是原材料,加班时长是规则计算后的结果。
3)validated_overtime_hours:已批准的额外工时
这个字段只统计状态为 approved 的 overtime line。
所以它回答的问题也不是“应该发多少钱”,而是:
在考勤模块内部,已经被批准通过的额外工时有多少。
请注意这个措辞:在考勤模块内部。
它仍然是 HR/Attendance 语义,不是最终 payroll 语义。
Extra Hours 真正落地的核心对象,其实不是 Attendance,而是 hr.attendance.overtime.line
如果只盯着 hr.attendance,你会误以为系统直接在考勤表上算加班。
但源码真正单独建了一个模型:hr.attendance.overtime.line。
这个模型里有几个特别关键的字段:
employee_iddatestatusdurationmanual_durationtime_starttime_stopamount_raterule_ids
这说明官方认定“额外工时”不是一列附属数字,而是一种可被审批、可被规则命中、可携带倍率信息的独立结果对象。
这里有两个很重要的信号。
信号一:它有 status
状态可能是:
to_approveapprovedrefused
这就说明 extra hours 不是天然有效,而是可能经过管理动作。
信号二:它有 amount_rate
amount_rate 不是纯展示字段,它表达的是“这段额外工时可能对应什么倍率”。
但更关键的是,这个模型里还有一条非常耐人寻味的注释:
# in payroll: rate, work_entry_type# in time_off: convertible_to_time_off
这几乎已经把官方设计意图直接写在脸上了:
当前这个 overtime line 只是中间结果,后面可以往 payroll 方向长,也可以往 time off 方向长。
也就是说,extra hours 在这里还没有被锁死成“工资条上的钱”。
加班行是怎么生成的:不是增量补丁,而是“删掉重算”
hr.attendance 里的 _update_overtime() 很值得注意。
它的做法不是“发现一条新打卡,就只补一点差值”。
相反,它会:
- 先根据员工和日期范围找到相关 overtime lines
- 把这些 overtime lines 删掉
- 重新收集这一段时间内的考勤
- 按版本、规则集、排班区间重新计算
- 再统一创建新的 overtime lines
也就是说,官方把 extra hours 当成一种派生结果,而不是主数据。
这在实施上很关键。
很多人会想:
- 我能不能手改某条考勤上的 overtime_hours?
- 我能不能直接把某条加班数字写死?
源码告诉你:别把派生结果当主入口。
因为只要考勤、员工、签到签退或规则变了,这些 overtime line 很可能会被整段重算。
所以更稳定的入口通常是:
- 修考勤原始记录
- 修 overtime rule
- 修 ruleset / schedule / version
- 再让系统重算
而不是硬改最终汇总数字。
Odoo 不只会算“正向加班”,它还会算“负向差额”
这是这套设计里最容易被忽略、但很体现深度的一点。
在 quantity rule 的计算里,如果公司开启了 absence_management,并且结果小于员工容差,系统会返回 undertime,也就是负向额外工时。
这说明官方不是在算一个简单的“超过 8 小时就是加班”。
它在表达的是:
相对预期工时,这个人今天究竟是超了、刚好、还是少了。
所以 extra hours 在源码里的本质,更像“相对预期工时的偏差管理”,而不只是“奖励性加班”。
这也解释了为什么它不能直接等于 payroll。
因为 payroll 往往只接收某种被认可的、可计薪的结果;但 attendance 这里先处理的是更广义的工时偏差。
Rule 为什么分 quantity 和 timing
hr.attendance.overtime.rule 里,官方把规则分成两类:
quantitytiming
这也是很多实施会忽略的设计点。
quantity:多出来多少
这种规则关注的是:
- 一天/一周工作总量有没有超
- 预期工时来自合同排班还是固定值
- 员工容差、公司容差是多少
也就是“量”的问题。
timing:多出来的时间落在什么时段
这种规则关注的是:
- 是否发生在工作日 / 非工作日
- 是否发生在请假期间
- 是否发生在特定排班之外
- 是否落在指定时段内
也就是“时间位置”的问题。
这两类规则叠加后,系统不只是知道“多了几小时”,还知道:
- 这些小时为什么算 extra
- 属于哪种规则命中
- 应该套什么倍率
所以 extra hours 其实已经是一种规则解释过的中间层结果。
为什么 overtime line 的 time_start/time_stop 还会等于整条考勤区间
这里也很容易让开发者困惑。
在新版生成逻辑 _generate_overtime_vals_v2() 里,最终写入 overtime line 时,time_start 和 time_stop 用的是整条 attendance 的 check_in/check_out,真正拆分后的额外工时长度则放在 duration 里。
这说明什么?
说明这条 overtime line 更像:
“这条考勤记录,在某一天、按某组规则,贡献了多少额外工时”
而不是“数据库里必须保存最精细的额外工时切片”。
所以你不能简单把 overtime line 看成一个精确的物理时间段,它更像是考勤结果上的规则归属记录。
这也再次说明:它距离最终 payroll line,还有一层业务翻译。
为什么 validated_overtime_hours 也不等于 Work Entry
因为 work entry 解决的是另一件事。
在 hr_work_entry 里,核心模型是 hr.work.entry,字段包括:
work_entry_type_iddurationamount_ratestateversion_id
而在 hr.version 里,work entry 的默认生成来源是 work_entry_source = 'calendar'。
也就是说,基础工作录入的生成逻辑首先在处理:
- 员工在这个期间理论上应该上什么班
- 哪些区间是正常出勤
- 哪些区间是 leave
- 对应应该落成什么
work_entry_type
官方数据里确实预置了:
AttendanceOvertime HoursOut of Contract- 各种 leave type
但在当前你能看到的基础源码里,Attendance 的 overtime line 并没有直接把自己自动变成 payroll-ready work entry。
它只是已经准备好了两个很关键的钩子:
amount_rate- 注释里提到的
work_entry_type
这说明官方是在给后续薪资或本地化模块留接口,而不是在 hr_attendance 里直接做完所有发薪逻辑。
所以边界可以总结成一句话:
validated extra hours = 已批准的考勤额外工时;work entry = 薪资与工时制度真正消费的标准化输入。
两者相关,但不是同一个层。
对实施和开发来说,最容易踩的 4 个坑
坑一:把 worked_hours - 标准工时 当成最终加班
这几乎一定过于粗糙。
因为还有:
- 午休扣减
- 时段规则
- 周规则
- 容差
- 请假区间
- 审批状态
坑二:看到 validated_overtime_hours 就直接发工资
这通常只在非常简单的制度里才勉强可行。
只要企业有下面任一情况,就应该再加一层映射:
- 不同类型加班有不同工资项
- 加班可转调休
- 周末与节假日倍率不同
- 部分加班需要落成特定 work entry type
坑三:手改汇总字段,不改规则来源
因为 _update_overtime() 会重算,很多“修过但又被冲掉”的问题就是这么来的。
坑四:搞不清 attendance 视角和 payroll 视角
Attendance 在回答:
- 实际发生了什么
- 相对预期偏差多少
- 哪些偏差被批准
Payroll 在回答:
- 哪些时长可计薪
- 用什么工资项或 work entry type 计薪
- 按什么倍率结算
两个问题相关,但并不相同。
如果你要做二开,正确切入点通常是什么
如果你的目标是“让已批准的 extra hours 进入薪资”,更稳的思路通常不是改 worked_hours 或硬写工资单,而是明确补齐这层转换:
- 从
hr.attendance.overtime.line出发 - 依据
status、rule_ids、amount_rate判断哪些可计薪 - 映射到明确的
work_entry_type - 再进入 payroll 或本地化薪资逻辑
如果你的目标是“加班可换调休”,也不应该直接从 attendance 总数硬扣,而应围绕 overtime line 的审批结果去做转换。
因为官方已经把这里设计成分叉点了。
调试这类问题时,我建议按这个顺序看
-
先看 attendance 原始记录 -
check_in-check_out-worked_hours -
再看 ruleset 和 rule -
quantity还是timing- 容差是多少 - 是否按合同排班取 expected hours - 是否启用 absence management -
再看 overtime line - 生成了没有 -
status是什么 -duration/manual_duration是多少 -amount_rate从哪条规则来 -
最后才看 work entry / payroll - 有没有做映射 -
work_entry_type是什么 - 是否真的进入薪资消费链路
这样你会比一上来盯工资单,排错快很多。
一句话记忆法
Attendance 记录事实,Overtime Line 记录规则解释后的额外工时,Work Entry / Payroll 才决定这些工时如何被制度化消费。
把这三层分开,你看 Odoo 人力源码会清楚很多。
DISCUSSION
评论区