先说结论
很多人看到 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 企业版这里的做法更像“带审查的复制”:
- 先保留未来需求
- 再检查员工还能不能自然接住这班
- 接不住,就把人拿掉,不把班删掉
所以它不是单纯提升录入效率,而是在做 排班质量控制。
从 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
评论区