做考勤统计这种事真的会让人抓狂。尤其到了每月月底面对几百号人密密麻麻的打卡记录光是用Excel公式拉工时、盯迟到早退、核对加班就能耗掉好几天。我一开始也觉得用Python写脚本是不是有点“杀鸡用牛刀”但真正写出来跑通之后才发现这东西的价值不在“能算”而在“算得稳、算得快、还能反复用”。这篇文章就把我做的这套月报考勤工时计算脚本的整体思路、代码实现和踩过的坑全部摊开讲。先把这个脚本能做什么说清楚输入一张标准格式的考勤打卡表自动清洗脏数据按员工、按日期算出每天实际工时再汇总出整个月的工时统计顺带标记迟到、早退、漏打卡这些异常情况最后输出成一张可直接发出去的Excel报表。适合谁看两类人一是想搞懂办公自动化怎么做、手里又有Python基础的人二是被月底考勤报表折磨到想换工作的HR和行政——你哪怕不写代码看完也能明白“这个东西到底比我手动拉公式强在哪”。1. 为什么考勤工时这种“小活”也值得用Python1.1 表格公式和Python脚本的本质区别如果你只是偶尔统计几十个人的考勤用Excel公式确实没问题。但考勤统计这个场景有一个很要命的特征表格格式永远不统一。不同部门导出的打卡记录可能是不同软件生成的钉钉导出一版、企业微信导出一版、门禁系统又导出一版列名不一样、时间格式不一样、甚至同一个人一天打卡几次都未必有规律。Excel公式是“一次性”的思路这列这样拉公式那列这样下拉算完这个月下个月重新来。中间一旦出现空单元格、加班到半夜跨了天、漏打卡补签这些情况公式就乱给你看。Python脚本则是“生产线”的思路写一次函数把数据清洗、工时计算、异常标记的逻辑全部固化下来以后每个月只要换一下文件路径回车一跑结果就出来了。1.2 考勤数据本质上是什么往深了说考勤数据的核心就是两列时间戳一个上班打卡时间一个下班打卡时间。工时计算就是“下班时间减去上班时间”这件事真正麻烦的不是减法本身而是时间格式不统一、数据有缺失、跨天计算这些“周边问题”。把这层逻辑想透了你就明白用Python做这件事的技术含量在哪儿需要把各种格式的时间字符串统一成 datetime 对象需要处理“下班时间在数值上小于上班时间”这种跨天情况需要区分工作日、周末、法定节假日需要识别重复打卡、补卡、漏卡等异常说白了考勤工时脚本的核心考点只有一个怎么把乱七八糟的时间数据干净地变成一个“可用”的数字。1.3 工具选型为什么是pandas openpyxl datetime技术栈里最核心的是三个库。pandas负责读取表格、做行列操作、分组汇总这类数据处理工作它就是最顺手的工具openpyxl是pandas读取.xlsx文件的后端引擎专门负责Excel格式的底层解析dateutil或标准库的datetime负责把时间字符串变成对象。三个组合起来整个流程可以做到“从Excel读进来到写成Excel出去”全覆盖中间不依赖任何额外的人工干预。额外说一句如果你用的考勤系统导出的是.csv格式那连openpyxl都可以不用pandas直接搞定。但为了兼容大多数人的使用场景我用.xlsx作为标准输入格式。2. 核心计算逻辑先定规则再写代码2.1 每天的工时到底怎么算最简版本的公式是工时 下班时间 - 上班时间听起来简单落地时至少有三个细节要考虑。第一上下班时间必须包含“日期”信息否则跨天打卡的时候“23:00下班”和“09:00上班”相减会得到负数第二打卡记录未必只有两段很多人一上午有进出记录比如早上8:30刷一次、中午11:50出去吃饭又刷一次下午再刷第三系统导出的“上下班时间”列很多时候不是标准的时间类型而是一长串带有星期几、甚至AM/PM的文本。我后来定的规则是优先使用系统导出的“上班时间”和“下班时间”两列不追求把一天多次打卡全部解析出来。因为很多考勤系统本身已经帮你做了“最早一次算上班、最晚一次算下班”的预处理。如果你的公司导出的是原始流水那处理方式会稍微不同我在后面第3部分会专门补充“多段打卡怎么处理”的思路。2.2 时间格式的统一策略拿到任何一个考勤表第一步永远不是算工时而是先看格式。我写脚本的第一行固定是print(df.dtypes)先看时间列到底是字符串、datetime还是被读成了数值。这条看起来很基础但很多人就是栽在这Excel里看起来是“2025-06-01 08:30”实际背后存的是小数pandas一读出来全变成时间戳或数字。对字符串类型的时间稳妥的做法是用pandas.to_datetime()强制转换。这一步会把“2025/6/1 8:30”“2025-06-01 08:30:00”“6月1日 08:30”这类常见变体统一成同一个格式。如果你发现转换之后出现 NaN说明那行数据是异常要么是空值、要么是文本需要标记出来人工核对不要顺手删掉。2.3 迟到、早退、加班怎么标记才合理工时算出来之后下一步就是判断异常。这里需要先定义规则不同公司的考勤口径差异很大比如迟到是“晚于上班时间就算”还是“有10分钟宽限期”加班是“超过标准工时就算”还是“必须超过多少小时才算”。我常用的参数设计是标准上班时间“09:00”迟到线设为“09:30”宽限期半小时标准下班时间“18:00”早退线设为“17:30”提前半小时开始标黄标准日工时8小时超出的部分按“加班”记录所有这些参数我都是写成变量放在脚本顶部方便每个月根据公司政策调整。不要把这些阈值写死在计算函数里面会给自己埋雷。3. 完整代码实现从Excel到Excel的完整闭环3.1 第一步准备输入文件确认列名我先说一下我假设的输入表长什么样因为代码是围绕这个结构写的员工姓名工号日期上班打卡下班打卡张伟0012025-06-012025-06-01 08:522025-06-01 18:03李娜0022025-06-012025-06-01 09:122025-06-01 18:45你把文件放到脚本同目录下然后把文件名改成考勤记录.xlsx就行。3.2 读取与清洗数据import pandas as pd from datetime import timedelta # 读取Excelopenpyxl引擎是处理xlsx格式的标配 df pd.read_excel(考勤记录.xlsx, engineopenpyxl) # 第一步看列名和数据类型避免后面踩雷 print(列名, df.columns.tolist()) print(数据类型\n, df.dtypes) print(前5行\n, df.head())这里有个小细节engineopenpyxl不加有时候也能跑但加了会更稳因为 pandas 默认的引擎对某些 xlsx 文件解析容易出兼容性问题。运行完上面的代码你会看到每一列到底是什么类型这一步千万别跳过。如果发现日期列读取后是字符串或者带时分秒的长文本就执行时间转换# 统一把日期列转成标准日期格式疯掉的数据会变成NaN df[日期] pd.to_datetime(df[日期]).dt.date # 上班和下班打卡列统一转成datetime时间格式errorscoerce表示转不过去的变成NaN df[上班打卡] pd.to_datetime(df[上班打卡], errorscoerce) df[下班打卡] pd.to_datetime(df[下班打卡], errorscoerce)转换完成后df[上班打卡]里存的就是Python的Timestamp对象可以直接做加减运算。这一步是整个脚本的基石——后面所有计算都依赖这一步的格式统一。3.3 计算每日工时核心函数接下来写一个函数接收一行数据返回工时精确到小时保留1位小数def calcul_duration(row): start row[上班打卡] end row[下班打卡] # 打卡缺失或无效时不硬算返回空值并标记 if pd.isna(start) or pd.isna(end): return None # 跨天处理下班时间在数值上小于上班时间说明加班跨过了午夜 if end start: end end timedelta(days1) # 返回小时数保留两位小数 return round((end - start).total_seconds() / 3600, 2)注意这里最关键的跨天判断。如果你下午17:00上班、次日凌晨01:00下班那么end - start会得到一个负数直接算会报错或者得出负工时。加上end start判断后给end加一天结果就正确了。对一行一行的计算我建议直接用applydf[当日工时] df.apply(calcul_duration, axis1) # 顺便算出是否迟到、是否早退 df[是否迟到] df[上班打卡].apply(lambda x: 是 if not pd.isna(x) and x.time() pd.to_datetime(09:30).time() else 否) df[是否早退] df[下班打卡].apply(lambda x: 是 if not pd.isna(x) and x.time() pd.to_datetime(17:30).time() else 否)apply是按行遍历对几千条数据完全够用。如果你公司有几万人、几十万行记录那可以改用np.where向量化加速但普通场景用不上咱们别提前优化。3.4 多段打卡的扩展思路有些考勤系统导出的不是“上下班两列”而是同一天有多行打卡流水。比如时间2025-06-01 08:302025-06-01 12:002025-06-01 13:302025-06-01 18:20这种情况我一般先按“日期分组取最早时间为上班最晚时间为下班”来处理# 假设df有“打卡时间”列 df[日期] pd.to_datetime(df[打卡时间]).dt.date grouped df.groupby([员工姓名, 工号, 日期])[打卡时间].agg([min, max]).reset_index() grouped.columns [员工姓名, 工号, 日期, 上班打卡, 下班打卡]如果你的场景是“上午下午各算一次时长”再细分就行。核心思想一样先分组再聚合。3.5 月度汇总与分组输出每日工时算完后接下来要按员工汇总月度工时# 提取月份便于分组 df[月份] pd.to_datetime(df[日期]).dt.to_period(M) # 按月统计 monthly df.groupby([员工姓名, 工号, 月份]).agg( 出勤天数(当日工时, count), 总工时(当日工时, sum), 平均工时(当日工时, mean), 迟到次数(是否迟到, lambda x: (x 是).sum()), 早退次数(是否早退, lambda x: (x 是).sum()) ).reset_index() # 保留两位小数 monthly[总工时] monthly[总工时].round(2)这一段里的“出勤天数”我用的是count()它不会把空值算进去正好对应“有打卡记录才算一天”。如果你公司是“无打卡记录但请了假也算出勤”那就不能用这个逻辑了得另接请假系统。3.6 输出报表最后写回Excel输出两个Sheet一个存每日明细一个存月度汇总output_path 考勤月度汇总.xlsx with pd.ExcelWriter(output_path, engineopenpyxl) as writer: df.to_excel(writer, sheet_name每日明细, indexFalse) monthly.to_excel(writer, sheet_name月度汇总, indexFalse) print(处理完成输出文件, output_path)注意这里用了ExcelWriter配合with上下文管理器确保文件写完自动关闭保存不会出现文件被占用写不进去的问题。3.7 完整脚本骨架把上面的片段拼起来加一个入口函数就是一份可以每天/每月都跑的最小可用脚本import pandas as pd from datetime import timedelta INPUT_FILE 考勤记录.xlsx OUTPUT_FILE 考勤月度汇总.xlsx LATE_TIME 09:30 EARLY_TIME 17:30 def calcul_duration(row): start row[上班打卡] end row[下班打卡] if pd.isna(start) or pd.isna(end): return None if end start: end end timedelta(days1) return round((end - start).total_seconds() / 3600, 2) def mark_late_or_early(df): late_t pd.to_datetime(LATE_TIME).time() early_t pd.to_datetime(EARLY_TIME).time() df[是否迟到] df[上班打卡].apply(lambda x: 是 if not pd.isna(x) and x.time() late_t else 否) df[是否早退] df[下班打卡].apply(lambda x: 是 if not pd.isna(x) and x.time() early_t else 否) return df def main(): df pd.read_excel(INPUT_FILE, engineopenpyxl) df[日期] pd.to_datetime(df[日期]).dt.date df[上班打卡] pd.to_datetime(df[上班打卡], errorscoerce) df[下班打卡] pd.to_datetime(df[下班打卡], errorscoerce) df[当日工时] df.apply(calcul_duration, axis1) df mark_late_or_early(df) df[月份] pd.to_datetime(df[日期]).dt.to_period(M) monthly df.groupby([员工姓名, 工号, 月份]).agg( 出勤天数(当日工时, count), 总工时(当日工时, sum), 平均工时(当日工时, mean), 迟到次数(是否迟到, lambda x: (x 是).sum()), 早退次数(是否早退, lambda x: (x 是).sum()) ).reset_index() with pd.ExcelWriter(OUTPUT_FILE, engineopenpyxl) as writer: df.to_excel(writer, sheet_name每日明细, indexFalse) monthly.to_excel(writer, sheet_name月度汇总, indexFalse) print(已完成输出, OUTPUT_FILE) if __name__ __main__: main()这个小脚本就是一套完整的月报统计流水线。你只需要把考勤系统的导出文件命名为考勤记录.xlsx然后运行这个.py文件就能拿到汇总表。4. 常见问题与排查技巧真实战中才会碰到的事4.1 “读取Excel时报错Excel file format cannot be determined”说明什么这句话的意思是pandas没认出这是个xlsx。常见原因有两个一是文件后缀叫.xlsx但实际是别的格式比如从网页表格复制粘贴后另存为Excel、但其实内容是HTML结构二是文件本身损坏或者被加密。解决思路先用Excel打开文件另存为“Excel工作簿(.xlsx)”格式再试把engineopenpyxl改成engineopenpyxl仍报错的话考虑文件本身问题用十六进制编辑器或用zip查看.xlsx的头部是“PK”开头才是真正的xlsx——但这步太技术了大多数人遇到之后直接另存为就能解决。4.2 “上班打卡”这列读出来是小数这是考勤系统最常见的老坑。Excel内部存储时间其实是小数1代表1900-01-010.5代表中午12点。如果你看到0.36875这种值说明pandas把它读成了数值。处理方案是在读取时指定 dtypedf pd.read_excel(INPUT_FILE, engineopenpyxl, dtype{上班打卡: str, 下班打卡: str})强制转成字符串后再用pd.to_datetime去统一解析。小数的内容在转成字符串后可能变成“0.36875”这时就联动了 Excel 帮你存的那一串日期其实原始表格里单元格格式是“日期”但pandas读文件时没有自动识别。最稳妥的办法还是先在Excel里全选时间列设置单元格格式为“文本”再另存为一次。这种脏数据问题能预先在源头清理的就不要在代码里硬刚。4.3 跨天打卡工时到底怎么算我最开始写脚本时没注意跨天结果连续几个人的夜班工时全是负数那叫一个尴尬。解决方法是end start就加一天但还有一个隐藏问题如果考勤表给的是 “日期” “上班时间” “下班时间” 三列而不是完整的“时间戳”两列那跨天判断可能失效。因为“上班时间”和“下班时间”可能没有日期信息只有08:30和01:00这种时候光比较大小没用你需要依赖“日期”字段判断如果下班时间小于上班时间说明下班日期应该是“日期1天”。所以我在计算工时前会先把“日期”和“时间”合并成完整时间戳df[上班打卡] pd.to_datetime(df[日期].astype(str) df[上班时间]) df[下班打卡] pd.to_datetime(df[日期].astype(str) df[下班时间])这样跨天的情况才能被下面这行正确处理if end start: end end timedelta(days1)4.4 漏打卡怎么办删除还是置空一个员工早上忘打卡上班时间为空但下班时间正常。这时候如果用 NaN 计算他的当天工时就会是空值。我处理的原则是上班和下班其中一列缺失就不算工时但保留记录在“备注”列标记“漏打卡”绝对不擅自把缺失时间填成平均上下班时间——这种“好心补数据”会产生假工时以后真出纠纷说不清楚单独输出一个“异常打卡列表”交给HR或主管人工复核这份异常清单很有用我通常会把漏打卡员工单独导出一个 Sheetabnormal df[df[当日工时].isna()] abnormal.to_excel(漏打卡待复核.xlsx, indexFalse)4.5 周末节假日要怎么考虑这个场景默认脚本是按“所有日期都计算”处理的。如果你公司只有周一至周五算正常出勤周六周日算加班就需要在脚本里增加“判断日期是星期几”的步骤df[星期] pd.to_datetime(df[日期]).dt.dayofweek # 周一至周五对应0-4周六5周日6 df[是否工作日] df[星期].apply(lambda x: 是 if x 5 else 否)如果涉及法定节假日、调休就要用第三方库chinese_calendar来处理。它的用法很简单先pip install chinese-calendar然后import chinese_calendar但注意不同年份、不同版本的假期数据有更新要确认版本是最新的。这个库我没有放进主脚本因为很多公司考勤系统的“周末/节假日判断”在导出时已经做了不一定要在脚本里重复造轮子。4.6 精度问题总工时要不要四舍五入计算出的工时经常出现8.3333333这种数字因为时间秒数除以3600除不尽。如果直接四舍五入到2位那么一天一天累计下来误差会变大。我建议每日工时保留4位小数月度汇总时再四舍五入到2位。这样月度汇总的误差会小得多。这是我踩过的坑之前直接在每日工时上round(2)月底总和跟考勤系统的统计差出十几分钟最后全组加班找原因结果就是逐日舍入误差累积导致的。正确做法df[当日工时] df[当日工时].round(4) monthly[总工时] monthly[总工时].round(2)4.7 不要过度设计什么时候该用Python、什么时候用Excel就够了必须说句公道话如果你们公司就二三十人考勤表又规规矩矩直接Excel公式效率可能更高。Python的优势是“规模化 标准化”不是“把简单的事变复杂”。我做这个脚本的初衷是因为每月要处理跨越4个系统的合并考勤报表数据量上千行Excel公式写着写着就卡死而且出错之后定位困难。Python的最大价值是过程可回溯结果可复现——同样的输入一定得到同样的输出这比Excel手动下拉可靠得多。另一个反直觉的体验脚本上线之后其实最耗时间的不是写代码而是定规则。迟到多久算迟到、加班怎么算、漏打卡谁来确认……这些业务口径定不清楚再好的脚本也是白搭。所以动手写代码之前先去找负责人确认清楚计算规则再用代码把这些规则落下来否则你每个月底都会被“规则变了”这几个字反复折腾。写在最后脚本上线之后一定要做的两件事第一跑完脚本别急着发报表先拿一个月的数据做“回测”——把脚本算出来的汇总结果跟考勤系统原始统计值对上误差超过一分钟都要查。我第一次跑脚本时对出来竟然差出1小时后来发现是一个员工连续两周的打卡时间格式跟别人不一致被to_datetime解析成了NaN。这类问题不看数据根本发现不了。第二输出文件里每行都要保证“来源可追溯”。Excel报表里最好带上原始的打卡时间不要只给最终的工时数字否则员工来问“我这个月为什么18号没工时”你拿不出原始凭证就会变成“你说什么就是什么”的被动局面。说实话这个脚本写完之后我每个月月底的考勤统计从两三天压缩到差不多十分钟省下来的时间主要是用来处理那些真正需要人判断的事情——漏打卡核实、跨天记录确认、和考勤专员的口径对齐。工具只是帮我解决“机械化”的那一部分但光这一部分就足够让月末那几天过得舒服很多。如果你也被月考勤折磨过强烈建议照着这篇文章的思路跑通一个最小版本然后再根据自己公司的实际情况慢慢加功能。你会发现一开始花两三个小时写脚本之后每个月都在赚这两个小时。