企业排班

Odoo 企业版排班为什么不会“机械复制”:重复班次变成 Open Shift 的真实逻辑

很多人以为 Odoo Planning 里的重复班次就是把上一班原样复制到未来,但企业版源码做得更谨慎:只要落到非工作日、超出工作时段、叠加后超配,或者碰到合同边界,系统就可能保留班次却取消员工指派,把它变成 open shift。

人力资源 企业
进阶 开发者 3 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

先说结论

很多人看到 Odoo Planning 里的 Repeat,脑子里会自动把它理解成一个很朴素的动作:

  • 今天有一班
  • 以后每周再来一班
  • 系统把员工、时间、时长一并复制下去

但企业版源码并不是这样设计的。

/home/ubuntu/odoo-temp/enterprise/planning/models/planning_recurrency.py 里,planning.recurrency._repeat_slot() 做的不是“机械复制”,而是一次 带可分配性判断的生成

班次本身可以继续生成,但员工指派未必会被继续复制。

也就是说,未来那一班可能仍然存在,但 resource_id 会被清空,于是它不再是“某员工的已排班次”,而变成一个 open shift(未指派班次)

这背后的设计非常 Odoo:

  • 先保留业务需求:这个班还是需要有人上
  • 再尊重现实约束:原员工未必在那一天、那个时段、那个负载下还能接这班

所以如果你在项目里看到:

  • 重复班次照样生成了
  • 但有些未来班次忽然没人了
  • 或者明明第一次能排上,后面几次却变成 open shift

这通常不是 bug,而是源码有意做的 “保留需求、撤销错误分配”


源码里真正分成了两层判断

_repeat_slot() 里其实有两个关键问题:

第一层:这个未来班次能不能“生成出来”?

对应内部函数可以概括成 can_slot_be_generated(next_start)

它先判断未来日期是否适合生成这一班,重点看:

  • 是否落在公司工作日内
  • 员工是否属于 flexible 资源
  • 初始那一班是否本来就排在非工作日上

如果不满足这些条件,某些未来班次连生成都不会生成。

第二层:这个未来班次能不能“继续指派给同一个人”?

对应逻辑是 can_slot_be_assigned(next_start, next_end)

这一层才决定是否保留 resource_id

它会继续看:

  • 该员工在那个时间段是否算工作中
  • 那个时间段是否和已有班次重叠
  • 重叠后总分配工时是否超过可容纳时长
  • 员工是不是 fully flexible
  • 初始班次是不是本来就排在工作时段之外

如果答案是否定的,源码不会删除未来班次,而是在 slot.copy_data(...) 之后把:

slot_values['resource_id'] = False

这就是 open shift 的来源。

所以理解这段代码时,最关键的一句话是:

Odoo 重复的是“班次需求”,不是无条件重复“员工占位”。


为什么 Odoo 宁愿保留班次,也要撤掉员工

这恰恰说明 Odoo 把 Planning 看成两个层次:

层次 1:需求层

企业需要这段时间有人值班,这个需求不能因为原员工不再适合就直接消失。

层次 2:分配层

谁来接这个班,是在需求之上的第二层决策。

如果系统发现继续把班次压给原员工不合理,它更倾向于:

  • 保留班次
  • 去掉员工
  • 让管理者重新分配

这比“硬复制一个明知不合理的排班”更符合 HR 现场。

因为真实世界里最常见的问题不是“班次需不需要”,而是:

  • 这个人那天不上班
  • 这个人那个时段不出勤
  • 这个人已经被别的班挤满了
  • 这个人的合同已经结束

企业版这里的做法,本质上是在保护 排班需求的连续性人员安排的真实性


情况一:落到非工作日时,重复班次未必还能沿用原员工

源码先取公司工作区间:

  • company_calendar_working_days = company.resource_calendar_id._work_intervals_batch(...)

然后看未来日期是不是公司工作日。

正常情况下,如果未来那一天不在工作日里,这个班次本来就不该像普通工作日那样继续复制。

但这里有个非常关键的例外:

如果“初始班次”本来就排在非工作日上,Odoo 会认为这是业务有意为之

源码里有:

  • is_slot_outside_working_days

它会先检查最初那一班本身是否落在公司非工作日。

如果答案是是,那么未来班次即便也落在非工作日,系统仍然允许生成。

这一点在测试 test_recurrency_with_slot_on_closing_day() 里写得很清楚:

  • 第一班故意放在周日
  • 后续按天重复
  • 预期是后续班次都正常生成

这说明 Odoo 的思路不是“非工作日一律不许排”,而是:

如果第一班已经证明你是有意识地在非常规日期排班,后续重复就按这个意图继续。

这对零售、门店、展会、活动值班很合理。


情况二:落到员工非工作时段时,班次会生成,但员工可能被拿掉

这部分更容易让实施团队误判。

很多人会想:

  • 周一 15:00-16:00 给 A 排了一班
  • 设置每天重复 5 次
  • 那就应该周二到周五都还是 A

但源码不是只看“时间格式一样”,而是看 这个员工在那一天那个时段是否真的有可用工作区间

can_slot_be_assigned(next_start, next_end) 会取资源可用区间:

  • resource._get_valid_work_intervals(...)

然后判断未来班次是否与这些有效工作区间相交。

如果未来那一班落在员工非工作时段:

  • 班次本身仍然可能被创建
  • resource_id 会被清空
  • 于是它变成 open shift

测试 test_recurrency_outside_working_hours() 就是这个场景:

  • 周一下午 15:00 的班次是合法的
  • 员工改成一个周二、周四下午不工作的 part-time 日历
  • 结果周二、周四那两班不会继续指派给该员工
  • 但班次不会消失,而是变成未指派班次

这其实非常符合业务直觉:

未来还有这个班要上,但不能假装这个员工那时段也能上。


情况三:如果初始班次本来就排在工作时段之外,Odoo 会把它当成“特批模式”继续复制

这里又有一个很容易忽略的例外。

源码里先算出:

  • is_slot_outside_working_hours

也就是:最初那一班是否完全落在员工正常工作区间之外。

如果初始班次本来就这样安排,后续重复班次即便继续落在工作时段之外,系统也允许继续保留原员工。

测试 test_recurrency_with_slot_outside_working_hours() 说明得很直接:

  • 第一班故意安排在晚上 18:00-20:00
  • 员工正常工作时段并不覆盖这里
  • 后续按天重复
  • 预期是所有重复班次都继续保留员工

这背后的业务逻辑很重要。

Odoo 在说:

  • 如果第一次排到非标时段是偶然错误,那应该改第一班
  • 但如果第一班已经被确认存在,系统倾向于理解为“这是业务允许的特殊班”
  • 那么重复时就沿着这个意图继续,而不是每次都强行打回 open shift

所以它不是单纯地“尊重日历”,而是同时尊重 历史事实


情况四:真正让重复班次变成 open shift 的核心,不只是重叠,而是“重叠后是否超配”

这部分是整段源码里最值得单独讲透的地方。

很多人看到排班冲突,脑子里只有一个粗暴规则:

  • 只要时间重叠,就是冲突
  • 只要冲突,就不能排

但 Odoo 这里没有这么简化。

can_slot_be_assigned() 里,它先收集所有重叠班次:

  • 现有 occurring slots
  • 再加上当前准备生成的这一个 recurring slot

然后计算:

  • 最早开始时间 earliest_start
  • 最晚结束时间 latest_end
  • 所有重叠班次的 allocated_hours 总和
  • 该重叠窗口的总时长 total_hours_in_overlap

最后用一个非常关键的判断:

如果资源不是 fully flexible,那么 只有当计划工时总和大于重叠窗口总时长 时,才认定资源真的忙不过来。

也就是说,Odoo 判断的不是“有没有重叠”,而是:

重叠后,总分配工时是否把这段时间挤爆了。

这和很多实施顾问口中的“班次冲突”其实不是一个概念。

为什么这个设计更合理?

因为 allocated_hours 不一定等于自然时长。

举个近似例子:

  • 08:00-14:00 一班,分配 2 小时
  • 07:00-10:00 一班,分配 1 小时
  • 12:00-16:00 一班,分配 1 小时
  • 新重复班次 08:00-14:00,分配 2 小时

这些班时间上互相覆盖,但如果合并后的重叠窗口总长足够大,总分配工时未必超 100%。

测试 test_recurrency_resource_partially_busy_below_100() 就验证了这一点:

  • 虽然有重叠
  • 但总工时没有挤爆窗口
  • 所以后续重复班次仍然保留员工

反过来,test_recurrency_resource_partially_busy_above_100() 则说明:

  • 如果重叠窗口只有 3 小时
  • 总分配工时却达到 4 小时
  • 那这个未来班次就不会继续保留给该员工
  • 它会被生成成 open shift

这说明 Odoo 在这里做的是 容量判断,不是简单的日历碰撞判断。


fully flexible 员工为什么更“宽容”

源码里对 flexible 资源是区别对待的。

如果资源是 fully flexible,那么上面那个“是否挤爆”判断会明显放宽。

测试 test_fully_flexible_employee_recurring_shifts_with_conflicts() 体现得很直接:

  • 员工没有固定日历
  • 系统把他视为 fully flexible
  • 即使存在时间上的重叠,重复班次仍可继续分配给他

这说明 Odoo 认为 fully flexible 资源的约束模型不同:

  • 普通员工受具体工作区间约束
  • fully flexible 员工更像“总量型资源”或“高度弹性资源”

所以你不能把所有员工都用同一套冲突逻辑解释。

一旦某个员工的资源模型是 flexible,重复班次的生成与指派就会变得明显更宽松。


合同边界也会截断重复逻辑,但逻辑同样很讲究

planning_recurrency.py 里还有一个容易被忽略的方法:

  • _get_misc_recurrence_stop()

它会把员工合同结束日期纳入重复班次的生成边界。

默认思路是:

  • 如果员工合同会在未来结束
  • 那重复班次不应该无限越过合同边界继续生成

所以系统会取一个更早的 stop datetime,把未来生成范围截断。

但这里仍然保留了一个和前面一致的例外思路:

如果初始班次本来就排在合同结束之后,系统会把它理解成一种“已被接受的特殊事实”,后续就不再用合同边界去否定这组重复。

这和前面“初始班次在非工作日”“初始班次在非工作时段”的设计逻辑是一致的:

  • 先判断标准规则
  • 再判断初始班次是否已经构成业务例外

也就是说,Odoo 在重复班次问题上并不是只有死规则,它一直在尝试区分:

  • 正常约束
  • 与被确认过的例外

为什么这套机制比“复制上周排班”高级得多

很多企业排班系统都有“复制上一周”“复制模板”的能力。

但那类功能经常有一个问题:

  • 复制得很快
  • 错也复制得很快

比如:

  • 员工周二下午原本就不上班
  • 复制后仍被塞进去了
  • 员工合同已到期
  • 复制后班次还挂在他头上
  • 一段时间里已有其他班次
  • 复制后形成虚假的满负荷冲突

Odoo 企业版这里的做法更像“带审查的复制”:

  1. 先保留未来需求
  2. 再检查员工还能不能自然接住这班
  3. 接不住,就把人拿掉,不把班删掉

所以它不是单纯提升录入效率,而是在做 排班质量控制

从 HR 视角看,这样的收益很实际:

  • 你不会因为复制逻辑太死而漏掉未来需求
  • 也不会因为复制逻辑太粗而制造一堆假分配
  • 管理者只需要处理真正需要重新分配的 open shift

对实施和二开的几个关键提醒

1. 不要把 repeat 理解成“复制 resource_id”

更准确的理解应该是:

  • 复制排班结构
  • 再尝试继承员工

resource_id 只是第二阶段的结果,不是第一阶段的前提。

2. 看到 open shift,不要第一反应就是“系统丢数据”

很多时候不是丢,而是源码主动撤销了不合理的员工指派。

先检查:

  • 那天是不是工作日
  • 那个时段是不是员工有效工作时段
  • 是否存在已有重叠班次
  • allocated_hours 叠加后是否超过窗口容量
  • 员工是不是 flexible / fully flexible
  • 合同是不是已经到边界

3. 如果你要做二开,别只改界面上的 conflict 提示

真正决定未来班次是否继续带着员工走的是 recurrence 生成逻辑本身。

如果只改前端颜色、warning、甘特图标记,而不改 _repeat_slot(),很多“为什么自动变成 open shift”的问题根本不会消失。

4. allocated_hours 比你想象得更重要

这里不是简单比较自然时长重叠,而是比较 计划工时总和重叠窗口容量

如果你的二开改了:

  • allocated_hours 计算方式
  • flexible 资源规则
  • 工作区间计算方式

那重复班次是否保留员工,结果都可能变化。


最后总结

Odoo 企业版 Planning 对重复班次的理解,远比“把上一班复制到下周”更成熟。

它真正做的是:

重复生成未来班次需求,再基于工作日、工作时段、资源弹性、负载容量和合同边界,判断原员工是否还能继续占这个班。

所以未来班次变成 open shift,并不代表系统失败了。

恰恰相反,这往往代表系统成功避免了一次错误的人力指派。

如果只用“有没有重复生成”来评估排班质量,你会误解这段源码。

更准确的评价应该是:

  • 需求有没有被保留下来
  • 员工分配有没有遵守现实约束

而 Odoo 在这件事上的答案是:

需求尽量保留,指派宁可收回。

这正是企业排班里很难得的一种克制。

DISCUSSION

评论区

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