1. 工厂日历不是一张表,而是一套时间语义系统
很多人第一次接触工厂日历,脑子里浮现的就是一张Excel排班表——哪天上班、哪天休息,标个颜色就完事了。真到写代码的时候才发现,事情远没有这么简单。工厂日历本质上是一套时间语义系统,它要回答的问题包括:某个时间点是不是工作日?两个时间点之间有多少个有效工作时间?给定一个起始时间和一段工作时长,结束时间落在哪里?跨班次、跨节假日、跨周末的边界怎么处理?
我在多个生产排程和MES对接项目里反复踩过这块的坑,最深的体会是:工厂日历的难点从来不在"日历"两个字,而在"工厂"两个字。工厂意味着多班次、多产线、多厂区、调休、临时停产、设备检修窗口,这些因素叠加起来,用简单的日期判断根本兜不住。
这篇文章围绕几个跟工厂日历相关的核心函数展开,把工作日判定、工作日偏移、工作时长计算、时间窗口求交这几类函数的实现逻辑、参数设计、边界处理和实战坑点讲透。适合正在做排程系统、设备OEE统计、工单交期计算、考勤与产能核算的开发者参考,也适合刚接触这块业务、想搞清楚"为什么日历函数这么难写"的朋友。
先给一个结论:不要试图用一个函数解决所有日历问题。工厂日历至少需要拆成四类函数,各自职责单一,组合起来才能覆盖真实场景。下面逐个拆解。
2. 工作日判定函数:别再用isWeekend糊弄了
2.1 为什么dayOfWeek判断一定会翻车
最常见的错误写法是这样:拿到一个日期,判断它是不是周六周日,不是就算工作日。这段代码在纯双休场景下能跑,但放到工厂里立刻出问题。
工厂的休息日不等于周末。我见过太多实际排班:有的厂周日固定休、周六轮休;有的厂因为订单旺季连续三周不休息,之后调休两天;有的厂分大小周,这周休一天下周休两天。更麻烦的是法定节假日调休——某个周日可能因为调休变成工作日,某个周三可能因为放假变成休息日。
所以工作日判定函数的正确输入不应该是"日期",而应该是"日期 + 日历版本"。日历版本这个概念很关键,后面会反复提到。
2.2 工作日判定函数的参数设计
我推荐的工作日判定函数签名大致是这样:
def is_workday(ts, calendar): """ ts: 待判断的时间点(datetime) calendar: 日历对象,包含休息日集合、调休工作日集合、班次规则 返回: bool """这里calendar对象至少要包含三份数据:
- 固定休息规则:比如每周日休息,用规则表达而不是枚举,减少数据量。
- 例外休息日集合:法定节假日、临时停产日,这些是"本来是工作日但实际休息"的日子。
- 例外工作日集合:调休上班的周末,这些是"本来是休息日但实际工作"的日子。
判断逻辑的优先级必须是:先查例外工作日,再查例外休息日,最后才走固定规则。顺序反了就会出错。举个例子,某个周日被调休成工作日,如果你先判断"周日休息"就直接返回false,那调休就失效了。
def is_workday(ts, calendar): d = ts.date() if d in calendar.extra_workdays: return True if d in calendar.extra_holidays: return False return d.weekday() not in calendar.weekly_rest_days2.3 一个容易被忽略的细节:时间点还是日期
注意上面函数接收的是ts(时间点),但判断时只用了.date()。这在大多数场景没问题,但如果工厂有夜班跨零点,问题就来了。
假设夜班从晚上20:00上到次日早上8:00,那么次日凌晨2点这个时间点,它属于哪一天的工作日?按自然日算,它属于次日;按班次归属算,它属于前一天的夜班。这两种算法在统计产量、计算工时时结果完全不同。
我的处理方式是:工作日判定函数只负责"这一天有没有排班",不负责"这个时间点属于哪个班次"。班次归属交给单独的班次解析函数。职责分离之后,每个函数都简单可控。如果你把两件事揉在一起,后面维护会非常痛苦。
提示:日历数据一定要带生效时间范围。我遇到过工厂中途改班制的情况,上半月双休、下半月单休,如果日历对象没有版本或生效区间,历史数据重算时就会用新规则算旧日期,结果全错。
3. 工作日偏移函数:加三天不等于三个工作日
3.1 业务场景决定了这个函数必须存在
排程里最常见的需求是:"这个工单需要3个工作日完成,从今天开始算,交期是哪天?"或者反过来:"距离交期还有5个工作日,够不够?"
如果你直接today + timedelta(days=3),那得到的是自然日偏移,不是工作日偏移。周五加三天得到周一,但如果中间隔着周末,实际工作日可能只过了一天。这个错误在交期计算里是致命的,会让排程结果整体偏移。
3.2 正向偏移与反向偏移要分开实现
工作日偏移分两个方向:正向(往后推N个工作日)和反向(往前推N个工作日)。很多人想用一个函数带正负号搞定,我建议分开写,因为两者的边界处理逻辑不一样。
正向偏移的逻辑:
def add_workdays(start_ts, n, calendar): if n < 0: raise ValueError("正向偏移要求n>=0") current = start_ts added = 0 while added < n: current += timedelta(days=1) if is_workday(current, calendar): added += 1 return current反向偏移的逻辑类似,只是每次减一天。看起来简单,但有几个坑必须注意。
3.3 起始日算不算第一个工作日
这是业务语义问题,不是技术问题,但必须和业务方确认清楚。同样是"3个工作日",有两种理解:
- 含起始日:今天算第1个工作日,往后数2个,得到第3个工作日。
- 不含起始日:今天不算,从明天开始数3个工作日。
这两种算法结果可能差一天。我的经验是:交期计算通常用"不含起始日",因为"今天开始做,3个工作日后交付"意味着今天做了一天,还要再做3天。但考勤统计里"连续工作3天"通常含起始日。所以这个参数应该做成显式配置,不要写死。
def add_workdays(start_ts, n, calendar, include_start=False): current = start_ts added = 0 if include_start and is_workday(current, calendar): added = 1 while added < n: current += timedelta(days=1) if is_workday(current, calendar): added += 1 return current3.4 性能问题:循环偏移在批量场景下会拖垮系统
上面这种一天一天循环的写法,单个调用没问题,但排程系统里往往要算几千上万个工单的交期。如果每个工单都循环几十天,累计起来就是几十万次日期判断,性能会明显下降。
优化思路有两个。第一,预计算工作日序列。把未来一两年的工作日提前算好,存成一个有序数组,偏移时直接做数组下标运算,O(1)就能得到结果。第二,用数学近似加速。先按自然日估算一个范围,再在附近微调,减少循环次数。
预计算方案我实际用过,效果很好。具体做法是维护一个workday_list,里面是所有工作日的日期,再配一个字典记录每个日期在列表中的下标。正向偏移N天就是workday_list[idx + n],反向就是workday_list[idx - n]。前提是日历数据在预计算周期内不变,如果中途有临时调整,需要重建序列。
注意:预计算序列要留足余量。我见过只预计算了半年,结果跨年排程时下标越界。建议至少预计算两年,并在接近边界时触发重建。
4. 工作时长计算:自然日时长和工作日时长是两回事
4.1 为什么不能直接用时间戳相减
两个时间点相减得到的是自然日时长,包含休息时间。但工厂关心的是有效工作时长。比如一个工单从周五下午5点开始,到周一上午10点结束,自然日时长是65小时,但实际工作时间可能只有几小时,因为中间隔了周末和夜间非工作时段。
这个差异在OEE统计、产能核算、工时成本计算里影响巨大。如果用工单的自然日时长去算设备利用率,结果会严重偏低,因为大量时间设备根本没在运行。
4.2 工作时长计算需要班次模型
要算有效工作时长,光有工作日判定不够,还需要班次模型。班次模型要回答:某个工作日的哪些时间段是工作时段。
最简单的班次模型是固定时段,比如每天8:00-12:00、13:00-17:00。复杂一点的有多个班次,早班、中班、夜班,时段可能重叠也可能不重叠。夜班跨零点的情况尤其要小心。
我推荐把班次模型抽象成"工作日内的时段列表":
# 某工厂的工作日时段配置 shift_config = { "default": [("08:00", "12:00"), ("13:00", "17:00")], "night": [("20:00", "23:59:59"), ("00:00", "08:00")] # 跨零点需特殊处理 }跨零点的班次处理起来最麻烦。我的做法是:把跨零点的班次拆成两段,前一段归属当天,后一段归属次日,但在班次标识上保持同一个班次ID。这样计算时长时不会漏,统计班次产量时也能正确归集。
4.3 工作时长函数的实现思路
计算两个时间点之间的有效工作时长,核心是遍历中间经过的每一天,对每一天求"该天工作时段与查询区间的交集",再把所有交集时长累加。
def work_duration(start_ts, end_ts, calendar): if start_ts >= end_ts: return timedelta(0) total = timedelta(0) current_date = start_ts.date() end_date = end_ts.date() while current_date <= end_date: if is_workday(current_date, calendar): for seg_start, seg_end in get_shift_segments(current_date, calendar): # 求交集并累加 overlap = intersect(start_ts, end_ts, seg_start, seg_end) total += overlap current_date += timedelta(days=1) return total这段代码看起来直白,但intersect函数的边界处理是重灾区。区间求交要考虑开闭区间、相等情况、完全包含、完全不相交等多种情形。我建议单独写单元测试覆盖这些边界,不要靠肉眼检查。
4.4 一个真实踩坑:夏令时和时区
如果工厂跨时区,或者系统用了本地时间,夏令时切换那天会出现23小时或25小时的情况。虽然国内不涉及夏令时,但如果你的系统要对接海外厂区,这个问题必须提前考虑。
我的建议是:内部计算统一用UTC时间戳,只在展示层转本地时间。日历数据里的时段配置也用UTC存储,避免夏令时导致的时段错位。这个原则在跨时区项目里帮我省了大量排查时间。
5. 时间窗口求交:排程冲突检测的核心
5.1 为什么需要窗口求交
排程系统里经常要判断两个时间窗口是否冲突。比如设备A在某个时段被工单1占用,现在要排工单2,需要判断工单2的时间窗口和设备A的占用窗口有没有重叠。如果有重叠,就不能排,或者要调整。
这个需求本质上是区间求交。但工厂场景下的区间不是简单的连续区间,而是"工作日内的多个时段片段"。所以求交之前,要先把时间窗口展开成时段片段列表。
5.2 时间窗口展开函数
展开函数的输入是一个时间范围,输出是它覆盖的所有工作时段片段:
def expand_to_segments(start_ts, end_ts, calendar): segments = [] current_date = start_ts.date() while current_date <= end_ts.date(): if is_workday(current_date, calendar): for seg_start, seg_end in get_shift_segments(current_date, calendar): s = max(seg_start, start_ts) e = min(seg_end, end_ts) if s < e: segments.append((s, e)) current_date += timedelta(days=1) return segments展开之后,两个窗口的冲突检测就变成两个片段列表的两两求交。如果任意一对片段有交集,就说明冲突。
5.3 求交的复杂度与优化
两个窗口各展开成M和N个片段,两两求交是O(M*N)。对于单个判断,这个复杂度可以接受。但如果要在一个大排程里做全局冲突检测,比如几百个工单互相检查,复杂度就上去了。
优化思路是先按时间排序,再用双指针扫描。两个片段列表都按开始时间排序后,可以用类似归并的方式线性扫描,复杂度降到O(M+N)。这个优化在工单量大的时候效果明显,我实测过从几秒降到几十毫秒。
def has_conflict(segs_a, segs_b): segs_a.sort() segs_b.sort() i = j = 0 while i < len(segs_a) and j < len(segs_b): a_s, a_e = segs_a[i] b_s, b_e = segs_b[j] if a_s < b_e and b_s < a_e: return True if a_e <= b_e: i += 1 else: j += 1 return False5.4 边界语义:相邻算不算冲突
这是业务上必须确认的问题。设备A占用到10:00,工单2从10:00开始,算不算冲突?
从物理角度,10:00整这个瞬间,设备刚好切换,理论上不冲突。但从工程角度,通常要留缓冲时间,比如换模、清洁、预热。所以实际项目里,我一般会引入一个缓冲时长参数,求交时把区间端点各扩展缓冲时长,再判断是否重叠。
def has_conflict_with_buffer(segs_a, segs_b, buffer): for a_s, a_e in segs_a: for b_s, b_e in segs_b: if a_s < b_e + buffer and b_s < a_e + buffer: return True return False缓冲时长设多少,取决于工艺要求。换模快的设备可能几分钟,慢的可能半小时。这个参数不要写死,做成配置项。
6. 日历数据的存储与版本管理
6.1 日历数据为什么需要版本
前面反复提到日历版本,这里展开说。工厂日历不是一成不变的,法定节假日每年调整,临时停产随时可能发生,班制也可能中途变更。如果日历数据只有一份"当前状态",那么历史工单重算时就会用新日历算旧日期,结果必然出错。
我踩过最惨的一次坑是:某工厂临时调整了国庆前后的调休安排,系统直接覆盖了旧日历数据。结果上个月已经结算的工时全部重算,和财务数据对不上,排查了两天才定位到是日历被覆盖。
正确做法是日历数据带生效时间范围,每次变更生成新版本,旧版本保留。查询某个日期的日历规则时,根据日期找到对应版本。
class CalendarVersion: def __init__(self, effective_from, effective_to, rules): self.effective_from = effective_from self.effective_to = effective_to self.rules = rules def get_calendar_for_date(d, versions): for v in versions: if v.effective_from <= d <= v.effective_to: return v raise ValueError("没有匹配的日历版本")6.2 存储结构的选择
日历数据存哪里,也有讲究。小规模场景用关系型数据库一张表就够,字段包括日期、类型(工作日/休息日/调休工作日)、班次配置ID。大规模场景或者需要频繁查询的场景,可以加载到内存缓存,甚至用位图压缩存储。
位图方案我特别推荐:一年365天,用365个bit表示每天是否工作,一个long数组就能存下。查询某天是否工作日就是位运算,速度极快。缺点是班次配置没法用位图表达,需要额外存储。所以位图适合只判断工作日的场景,完整日历还是用结构化存储。
6.3 缓存失效策略
日历数据加载到内存后,什么时候刷新?如果每次查询都读数据库,性能扛不住;如果一直不刷新,临时调整又生效不了。
我的做法是:日历数据变更时主动推送失效通知,缓存收到通知后重新加载。如果系统没有推送机制,就设一个较短的过期时间,比如5分钟,兼顾性能和时效。但要注意,重算历史数据时不能用缓存,必须直接读对应版本的日历,否则又会踩版本错乱的坑。
7. 几个函数组合起来的实战场景
7.1 工单交期计算
需求:工单需要5个工作日完成,从当前时间开始,每天工作8小时,求预计完成时间。
这个需求要组合工作日偏移和工作时长计算。先算需要几个自然日才能凑够5个工作日,再在最后一天加上剩余工时。实现时要注意:如果当天已经工作了一部分,剩余工时要从当天剩余工作时段里扣。
def estimate_finish(start_ts, work_hours, calendar): remaining = timedelta(hours=work_hours) current = start_ts while remaining > timedelta(0): if is_workday(current, calendar): for seg_start, seg_end in get_shift_segments(current.date(), calendar): if seg_end <= current: continue seg_start = max(seg_start, current) available = seg_end - seg_start if available >= remaining: return seg_start + remaining remaining -= available current = seg_end current = next_day_start(current, calendar) return current这段代码的next_day_start要处理跨天、跨周末、跨节假日,是另一个容易出bug的地方。我的经验是把它单独抽出来测试,输入各种边界日期验证输出。
7.2 设备利用率统计
需求:统计某设备在过去一周的利用率,即实际运行时长除以可用工作时长。
可用工作时长就是工作日的工作时段总时长,用工作时长计算函数对整周求值即可。实际运行时长来自设备日志,需要和日历求交,剔除休息时段的记录。这里要注意日志时间戳的精度和时区,我遇到过日志用本地时间、日历用UTC,导致统计结果偏差几小时的情况。
7.3 排程冲突批量检测
需求:给一批工单分配设备,检测是否有时间冲突。
先把每个工单的时间窗口展开成片段列表,再按设备分组,组内两两检测冲突。工单量大时用前面说的双指针优化。如果检测到冲突,还要给出冲突的具体时间段,方便排程员调整。所以冲突检测函数最好返回冲突区间列表,而不只是布尔值。
8. 写这类函数的几条经验
第一,日历对象要设计成不可变的。一旦创建就不允许修改,需要变更就创建新版本。这样多线程环境下不用担心数据被改,也方便做版本追溯。
第二,所有时间计算函数都要有单元测试,且测试用例要覆盖边界。跨月、跨年、跨节假日、调休日、夜班跨零点、区间端点相等、完全包含、完全不相交,这些都要覆盖。我维护的日历函数测试用例有上百个,每次改动跑一遍,能挡住大部分回归问题。
第三,不要把业务规则硬编码在函数里。哪些天休息、班次怎么排、缓冲多久,全部做成配置。不同工厂规则不同,硬编码意味着每接一个厂就要改代码,维护成本极高。
第四,性能问题要提前考虑。单次调用快不代表批量调用快,排程和统计场景都是批量操作。预计算、缓存、双指针这些优化手段,在项目初期就预留好接口,后期加不用大改。
第五,时间语义要和业务方对齐。含不含起始日、相邻算不算冲突、跨零点归属哪天,这些不是技术能决定的,必须和业务确认并写进文档。我见过因为"含不含起始日"理解不一致,导致交期整体差一天,排程和实际生产对不上的事故。
最后分享一个排查技巧:日历相关bug往往在特定日期才暴露,比如跨年、闰年、调休。我习惯在测试环境把系统时间调到这些特殊日期跑一遍,比等到生产环境出问题再排查省事得多。另外,日志里打印时间时一定要带上时区和日历版本号,否则事后排查根本对不上。