ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python考勤生成实战:从打卡流水到Excel报表的自动化处理

Python考勤生成实战:从打卡流水到Excel报表的自动化处理 每个月月底对着考勤机导出的几千行打卡流水手动在Excel里筛迟到、早退、缺卡一弄就是一下午——这是不少HR和行政的日常。我也经历过这个阶段后来干脆用Python写了一套考勤生成脚本把这件事从手工活变成自动化流程。这篇文章就是当时项目落地的完整记录从原始数据的清洗、考勤规则的配置到报表的自动生成都展开讲清楚。不管你是刚接触Python的小白还是已经在写办公自动化脚本的开发者看完都能照着搭一套自己的考勤生成工具。1. 先想清楚再动手考勤处理的完整技术路线1.1 考勤处理的真实痛点在哪里先说说我最初遇到的问题。公司用的是常见的指纹打卡机月底从设备里导出一张Excel表大概长这样工号、姓名、日期时间、考勤机编号甚至还有打卡失败的记录。这张表看起来规整实际上是纯流水账——一个人一个月可能有一百多条刷卡记录一天进出五六次都很正常。人事要做的事情是把这些流水变成每个人每天的出勤结论来没来、几点到的、几点走的、迟到多久、早退没有、加没加班。手工处理的核心难点有三个。第一是量大几百人乘以一个月二十多天的工作日流水几千上万条excel筛起来眼睛疼第二是规则多迟到宽限、午休时间、加班起始线、不同班次不同上下班时间一个人力资源表格里塞的规则比业务代码还复杂第三是容易出错手滑拖错一行、公式下拉漏了几行月底汇总数对不上最后还得回头重查。1.2 为什么选Python而不是继续用Excel公式有人可能会说Excel透视表加几个IF公式也能做。没错小规模、规则固定的场景Excel确实够用。但一旦规则变成平日9点上班18点下班、周五提前到17点半、每季度末有半天调休嵌套函数写得比论文还长后面接手的人看着就头大。Python的好处在于逻辑和数据分离。数据处理用pandas时间计算用datetime报表输出用openpyxl每个环节都是独立的代码块规则放在配置文件里想改就改。而且脚本是一次投入、长期复用每个月跑一遍就行不用每次手工调整公式范围。另外Python的生态在这个场景里几乎是量身定做的。pandas处理表格分组聚合非常顺手几万行数据毫秒级完成openpyxl能直接生成带样式、带颜色、带透视汇总的Excel文件配合schedule或者计划任务甚至能实现每到月底自动跑脚本、自动发邮件。这些能力用Excel公式实现成本很高但在Python里就是几行代码的事。1.3 代码架构怎么划分才不拧巴我一开始犯过一个错把读取、清洗、判定、输出全写在一个大函数里结果就是改一个判定规则动不动影响其他逻辑。后来重构成了三层结构数据层负责读取考勤机导出的Excel或CSV统一字段名和数据类型输出标准化的DataFrame规则层定义班次、上下班时间、宽限分钟数、加班阈值等参数并提供判定函数输出层接收判定结果用openpyxl生成带格式的Excel报表和汇总数据。这样做的好处是哪怕考勤机换了一个型号、导出的字段名变了只需要改数据层公司考勤制度调整只需要改规则层报表样式美化只动输出层。三个模块之间通过明确的函数接口衔接排错也很舒服。2. 数据清洗把打卡流水变成标准化记录2.1 打卡数据从哪里来怎么读进来不同考勤设备导出的格式差别很大。最常见的两种一种是从设备自带的软件导出的Excel通常有固定的列结构另一种是CSV文件可能是UTF-8编码可能是GBK编码。我自己遇到的是设备软件导出的Excel第一行是字段名列有工号、姓名、考勤日期、签到时间、签退时间但问题是每人每天只有一行实际打卡过程被软件压缩过了想还原细节还得回到打卡流水表。更通用的做法是直接读取原始流水。这种表的核心字段其实就几个人员编号、人员姓名、打卡时间、打卡标志有的设备会标IN/OUT有的不标。读取时用pandas的read_excel或者read_csv就行但有两个地方要注意。第一CSV文件的编码不要猜直接用encodingutf-8读报错就换encodinggbk。我踩过的最蠢的坑就是文件名和字段名里的中文全部乱码排查了半天才发现是编码问题。第二有些考勤软件导出的Excel前面会有几行标题或者合并单元格真实表头可能在第二行甚至第三行。这时候read_excel的header参数就要跟着调比如header1表示第二行是列名。2.2 时间解析和去重的三个坑原始流水读进来之后第一步就是统一时间格式。Excel里存的时间通常显示成2025/4/1 8:31:45这种字符串但有的设备会导出成Excel日期序列号就是那种43320.6的数值。pandas读进来之后用pd.to_datetime解析需要分情况处理import pandas as pd # 字符串格式 df[打卡时间] pd.to_datetime(df[打卡时间], errorscoerce) # 如果读出来是数值型Excel日期序列号要加origin和unit强制转换成日期 df[打卡时间] pd.to_datetime(df[打卡时间], origin1899-12-30, unitD)第二个坑是时间精度。考勤机导出的时间有的带秒有的不带秒还有的老设备会重复记录同一分钟内的多次刷卡。同一个员工在17:02:58和17:02:31刷了两次卡这种记录对最终判定没有影响应该合并。我用的策略是按人员和时间分桶在一定的窗口内取第一次或者最后一次。# 先按人员和日期排序 df df.sort_values([人员编号, 打卡时间]) # 去除完全重复的记录 df df.drop_duplicates(subset[人员编号, 打卡时间]) # 一分钟内的重复打卡只保留最早的一条防止干扰上班时间的判定 df[分钟桶] df[打卡时间].dt.floor(min) df df.drop_duplicates(subset[人员编号, 分钟桶], keepfirst)第三个坑是打卡标志的处理。有些考勤机会在流水里标注上班和下班但很多人中午出去吃饭、下午回来也会刷卡设备的判断并不可靠。所以我在清洗阶段直接忽略设备的IN/OUT标志把它作为一个原始时间点保留具体的上下班匹配交给后面的规则引擎处理。2.3 跨天班次的处理思路考勤生成里最麻烦的是夜班场景。如果班次是晚上23:00到次日7:00那么凌晨0点到7点的打卡记录不能简单归到当天。我当时的做法是先给工作日增加一个班次日的概念。具体实现如果打卡时间是凌晨且存在前一天晚上的班次就把这条记录的业务日期往前推一天。比如4月1日23:50的打卡和4月2日1:30的打卡从业务上讲都是4月1日夜班的一部分。df[业务日期] df[打卡时间].dt.date # 对跨天班次凌晨打卡归入前一个业务日 night_start_hour 18 # 假设前一天的18点之后的打卡都算当天夜班 mask (df[打卡时间].dt.hour night_start_hour) df.loc[mask, 业务日期] df.loc[mask, 打卡时间].dt.date - pd.Timedelta(days1)如果场景里还牵扯到跨天请假、跨天加班这个逻辑会再复杂一层需要配合排班表一起处理。这里不展开但记住一条原则考勤系统里的天不一定等于自然日而是班次日。3. 规则引擎这才是考勤生成的核心3.1 班次规则用配置表管理别写死在代码里考勤规则每家都不一样最忌讳的就是把上下班时间硬编码在判断函数里。我用的方式是搞一个班次配置表用字典或者JSON存放改制度的时候只动配置不碰代码SHIFT_CONFIG { standard: { name: 标准班, start_time: 09:00, end_time: 18:00, late_grace_minutes: 5, # 迟到宽限5分钟 early_grace_minutes: 0, # 早退宽限0分钟 work_seconds: 8 * 3600, # 标准工作时长秒 overtime_start: 18:30, # 超过这个时间算加班 overtime_threshold: 30, # 加班满30分钟起算 } }配置放在顶部的好处是业务部门说下个月迟到的宽限从5分钟改成3分钟你只需要改一个数字再把脚本跑一遍。而且这种配置表的形式还能扩展出多班次同一个表里放早班、中班、晚班员工通过排班表和班次ID关联规则引擎在计算时按班次取值。3.2 上班下班打卡点的匹配算法拿到一个人在某一天的所有打卡时间后核心问题就变成哪条是上班打卡哪条是下班打卡。最笨的办法是取当天第一次作为上班卡、最后一次作为下班卡。但这个方案遇到弹性作息就会出问题。比如员工早上7点就来打卡吃完早饭8点才开始工作或者出门忘了打卡回来补刷这些都是真实存在的场景。我用的匹配逻辑是分窗口取最值根据班次的上下班时间切出时间窗口在窗口内做筛选。def match_punch_times(shifts, punch_times): shifts: 当天生效的班次对象 punch_times: 当天该员工的所有打卡时间列表datetime格式 start_win_lower pd.Timestamp.combine(shifts.date, datetime.time(6, 0)) start_win_upper pd.Timestamp.combine(shifts.date, datetime.time(12, 0)) end_win_lower pd.Timestamp.combine(shifts.date, datetime.time(12, 0)) end_win_upper pd.Timestamp.combine(shifts.date, datetime.time(23, 59)) # 上班打卡在上午窗口内取最接近上班时间的一条 start_candidates [t for t in punch_times if start_win_lower t start_win_upper] if not start_candidates: # 宽容处理如果上午没有记录把视线放宽到全天第一次 start_candidates [min(punch_times)] if punch_times else [] # 下班打卡在下午窗口内取最晚的一条 end_candidates [t for t in punch_times if end_win_lower t end_win_upper] if not end_candidates: end_candidates [max(punch_times)] if punch_times else [] start_time min(start_candidates) if start_candidates else None end_time max(end_candidates) if end_candidates else None return start_time, end_time这个逻辑的好处是对一天刷很多次卡的场景天然免疫。上午09:12刷了、中午11:50出去吃饭刷了、下午13:20回来刷了、傍晚18:03走的时候刷了系统会从上午窗口选09:12作为上班卡从下午窗口选18:03作为下班卡中午的两次自动忽略。如果公司没有严格的上午/下午窗口还有一种变体把打卡时间按距离理论上下班时间最近的原则匹配用差分排序实现效果也差不多。但窗口法更直观排查结果时人也容易理解。3.3 迟到、早退、缺卡、加班的判断逻辑匹配到上下班打卡时间之后后面的判断就很机械了。把配置里的时间和实际打卡时间都转成纯粹的时分秒数值然后相减比较from datetime import datetime, time def calc_attendance(shift, start_time, end_time): result { status: 正常, late_minutes: 0, early_minutes: 0, overtime_minutes: 0, work_seconds: 0, } if start_time is None or end_time is None: result[status] 缺卡 return result # 转成当天的时间对象方便计算 start start_time.time() end end_time.time() # 上班时间/下班时间 shift_start datetime.strptime(shift[start_time], %H:%M).time() shift_end datetime.strptime(shift[end_time], %H:%M).time() # 迟到判定 start_seconds start.hour * 3600 start.minute * 60 start.second shift_start_seconds shift_start.hour * 3600 shift_start.minute * 60 if start_seconds shift_start_seconds shift.get(late_grace_minutes, 0) * 60: result[late_minutes] (start_seconds - shift_start_seconds) // 60 result[status] 迟到 # 早退判定 end_seconds end.hour * 3600 end.minute * 60 end.second shift_end_seconds shift_end.hour * 3600 shift_end.minute * 60 if end_seconds shift_end_seconds - shift.get(early_grace_minutes, 0) * 60: result[early_minutes] (shift_end_seconds - end_seconds) // 60 result[status] 早退 if result[status] 正常 else 迟到早退 # 工作时长扣除午休时间简化处理时直接减固定时长 lunch_minutes 60 result[work_seconds] max(0, end_seconds - start_seconds - lunch_minutes * 60) # 加班判定 ot_start datetime.strptime(shift[overtime_start], %H:%M).time() ot_seconds ot_start.hour * 3600 ot_start.minute * 60 if end_seconds ot_seconds: overtime (end_seconds - ot_seconds) // 60 if overtime shift.get(overtime_threshold, 30): result[overtime_minutes] overtime return result这个示例是简化版但结构完整。需要注意的细节午休扣除不能一刀切有的岗位不存在固定午休有的岗位午休两小时所以午休分钟数也应该在班次配置里声明而不是写死在代码中。缺卡的判定还有一个边界情况上下班都缺那是因为请假应该和请假数据交叉比对后再区分。我当时的方案是先把请假员工名单加载成集合缺卡且当天在请假名单里的记录标记为请假不在名单里的标记为缺卡这样月底汇总才不会被业务部门质疑。3.4 报表生成与格式美化判定结果出来后最终交付物通常包括两张表一张是明细表一个人一天一行一张是汇总表一个人一个月一行。明细表我用openpyxl直接生成因为要对单元格做颜色标记、调列宽、加边框这部分pandas自带to_excel能力不够。核心思路先把DataFrame写进临时Excel再用openpyxl打开调整格式。from openpyxl import Workbook from openpyxl.styles import PatternFill, Font, Alignment from openpyxl.utils.dataframe import dataframe_to_rows wb Workbook() ws wb.active ws.title 考勤明细 # 写表头 headers [工号, 姓名, 日期, 班次, 上班打卡, 下班打卡, 状态, 迟到分钟, 早退分钟, 工作时长, 加班分钟] ws.append(headers) # 数据填充 for row in result_df.itertuples(indexFalse): ws.append(list(row)) # 样式调整 header_fill PatternFill(start_color4472C4, end_color4472C4, fill_typesolid) for cell in ws[1]: cell.fill header_fill cell.font Font(colorFFFFFF, boldTrue) cell.alignment Alignment(horizontalcenter, verticalcenter) # 状态标签着色 red_fill PatternFill(start_colorFFC7CE, end_colorFFC7CE, fill_typesolid) for row in ws.iter_rows(min_row2, min_col7): for cell in row: if cell.value 迟到 or cell.value 早退 or cell.value 缺卡: cell.fill red_fill # 冻结首行设置列宽 ws.freeze_panes A2 for col, width in zip(ABCDEFGHIJK, [10, 12, 12, 10, 20, 20, 12, 12, 12, 14, 12]): ws.column_dimensions[col].width width wb.save(考勤生成结果.xlsx)汇总表可以直接用pandas的groupby来做统计每个人一个月的迟到次数、早退次数、缺卡次数、累计加班分钟summary result_df.groupby([工号, 姓名]).agg( 迟到次数(状态, lambda x: (x 迟到).sum()), 早退次数(状态, lambda x: (x 早退).sum()), 缺卡次数(状态, lambda x: (x 缺卡).sum()), 累计加班分钟(加班分钟, sum), ).reset_index()这一步基本就是考勤生成的收尾工作了。业务部门只要打开Excel看红色标记、看汇总数就能直接拿去给领导签字。4. 实战中踩过的坑问题排查记录4.1 Excel日期序列号把时间搞炸了这是这个项目里我花时间最久的一次排查。考勤软件从系统里导出的Excel打卡时间那一列在Excel里显示2025/4/1 8:31但pandas读出来是一个浮点数比如45347.35这种。网上查了一圈才知道Excel存储日期不是存字符串而是存自1900年1月0日以来的天数45347.35就代表第45347天的第0.35天。问题在于pandas的read_excel默认会把这个数值识别成浮点列直接pd.to_datetime会报错或者全部变成NaT。正确的转换方式是加origin和unit参数df[打卡时间] pd.to_datetime(df[打卡时间], origin1899-12-30, unitD)为什么origin是1899-12-30而不是1900-01-01因为Excel有一个著名的1900年闰年bug它把1900年2月29日这种不存在的日期也算了一天所以从1900年1月1日对应的序列号1反推origin要往前挪一天。这个小知识在数据清洗阶段能救命。4.2 缺卡、补卡、异常状态的兜底策略刚开始跑脚本的时候缺卡率特别高。排查后发现问题出在匹配窗口上有的人上午11点才上班排班制结果上午窗口的截止时间12点在部分场景下不适用还有的外勤人员一天可能只有一条打卡记录早上刷了一次就走了一直到傍晚才回来这时候最晚一条作为下班卡没问题但如果只有早上一条记录下班匹配就失败了系统会直接标缺卡。我的兜底策略分三层第一层标准窗口匹配正常情况走这套第二层如果上班没有记录退化为取当天最早一条如果下班没有记录退化为取当天最晚一条第三层如果取出来的结果是上下班打卡时间间隔太短比如间隔小于4小时明显不符合常理标记为异常而不是直接算迟到早退需要人工复核。第三层很重要。因为业务数据不是实验数据宁可多标记一个异常让人去看也不要去猜一个结果。漏掉一个异常比多标记一个疑点严重得多——多标了还能人工确认漏了就直接发错工资了。4.3 数据量大时的性能优化几百人一个月的打卡流水大概1到3万条这个量级pandas处理是毫无压力的。但要注意别用iterrows去逐行算逻辑那个性能真的差。我第一次实现时把每一天、每一个人的匹配逻辑放在双重循环里跑三万人条数据跑了快一分钟优化之后毫秒级完成。核心优化手段其实就是把循环改成向量化操作和字典分组。# 坏的写法双重循环逐行处理 for uid in user_list: for day in day_list: calc(...) # 好的写法先按人员和日期分组只对非空组执行逻辑 grouped df.groupby([人员编号, 业务日期]) for (uid, day), group in grouped: # group就是这个人在这一天的所有打卡时间 start_time, end_time match_punch_times(shift_map[uid], group[打卡时间].tolist())groupby天然把数据按员工日期做了分片每个组里的人数很少匹配逻辑只跑有效分组空组自动跳过性能就直接上来了。如果数据量到了几十万条还可以用multiprocessing做并行但考勤场景基本用不到。4.4 异常记录速查表最后整理一张我在排查过程中总结的问题对照表希望能帮后来人少走弯路现象可能原因解决方案读出来的时间全是NaTExcel日期序列号未指定originpd.to_datetime加origin1899-12-30, unitD中文乱码CSV文件编码不是UTF-8改为encodinggbk尝试读取缺卡记录特别多上下班匹配窗口设置不合理放宽窗口加兜底逻辑标异常同一个人同时出现迟到和早退上下班打卡点匹配反了检查是否取到了午休时段的打卡记录有记录但汇总表人数不对工号列有空格或者数字格式不统一清洗时统一astype(str)并strip加班时间计算不准未扣除午休时长把午休分钟数放进班次配置统一计算打卡时间对不上人员多设备数据未按人员合并去重先union所有数据再做groupby这里面的每一条我在项目落地过程中都实实在在遇到过。最想提醒的还是那句考勤生成这种脚本真正的难点从来不是Python语法而是把业务规则翻译成代码的过程以及对各种脏数据的处理。最后说几点我的个人体会。第一做这类自动化脚本一定要先把原始数据文件做好备份不要在原始文件上直接改所有清洗结果另存一份不然调了几次脚本后原始数据被污染了后面想重新排查就麻烦了。第二第一次做好的时候先拿一个部门的数据跑一遍跟人事手工算的旧结果对一遍数确认完全一致后再全量跑不要一上来就信脚本的输出。第三代码里多留一些调试用的中间结果比如把每个员工每天的上下班打卡点单独导出一张表出了问题能一眼看出是哪一步逻辑错了。这几点虽然听起来啰嗦但在实际项目里确实能省下大把时间和解释成本。
返回列表