2026-10-06 · 喵窝资源铺
场景:月底算工资,HR 拿着打卡机导出的流水(几百行「姓名,日期,时间」)和排班表,一行行人工对——谁迟到了、谁缺了下班卡、谁压根没来。这篇给一个纯标准库的 Python 脚本(kaoqin_check.py):输入排班表(姓名,日期,班次)+ 打卡流水两个 CSV,交叉核对后输出异常清单 CSV(迟到/早退/缺卡/旷工,宽限 5 分钟可调)。文中代码实跑验证,真实输出贴在下面。
造了 7 条排班、11 条打卡,覆盖正常/迟到/早退/缺卡/旷工/跨天夜班六种情况:
姓名,日期,班次,应上班,应下班,实际上班,实际下卡,结论,备注 张伟,2026-10-01,早班,08:00,16:00,07:55,16:02,正常, 张伟,2026-10-02,早班,08:00,16:00,08:18,16:00,迟到18分钟, 李娜,2026-10-01,中班,12:00,20:00,11:52,20:05,正常, 李娜,2026-10-02,晚班,16:00,24:00,15:58,00:10(次日),正常, 王强,2026-10-01,早班,08:00,16:00,07:58,,缺下班卡, 王强,2026-10-02,早班,08:00,16:00,,,,旷工,无任何打卡记录 刘洋,2026-10-01,中班,12:00,20:00,12:00,19:30,早退30分钟,
连跑两次输出 md5 一致(幂等)。运行日志:核对 7 条排班,输出 5 条异常。
晚班 16:00-24:00,李娜次日 00:10 打下班卡,第一版把她判成「缺下班卡」——明明卡就在那里。
排查发现:把 24:00 统一转成 1440 分钟后,e_m <= s_m(下班≤上班即跨天)这个判断永远不成立了,1440 > 960。于是找下班卡时只在当天找,次日的 00:10 根本不在搜索范围里。正解是显式补上:
cross_day = e_m <= s_m or e == '24:00'
教训:「把特殊值归一化」之后,原来依赖特殊值的判断会静默失效。归一化和判断逻辑必须一起改,而且要用一条含跨天夜班的用例当验收测试——不含这个 case 的话这 bug 永远发现不了,直到某个月底工资算错。
「00:10 下班卡算不算 16:00-24:00 班次的下班卡」——如果把搜索条件写成「时间在班次时段附近」,跨天后的 00:10 换算成 1450 分钟,必须先把「次日的卡 +24h 偏移」再比较。
正解:找下班卡时遍历当天+(跨天时)次日两天的打卡,次日的卡统一加 1440 分钟偏移后与 e_m 比较,窗口放宽到 e_m - 120 以后的第一张卡。同理上班卡窗口是 [s_m-120, s_m+180]——有人提前两小时到岗先打个卡很正常,窗口太窄会把正常卡漏掉造成误报「缺上班卡」。
打卡机 08:00:03 打卡算不算迟到?各公司规矩不同。脚本把 GRACE = 5(分钟)做成常量:迟到/早退判定都要超过宽限值才记异常,否则每天几十条 1-2 分钟的「迟到」会把异常表刷爆,HR 反而不看了。宁可少报几条真实边缘 case,也要保证报出来的每条都值得处理——和上篇聊天记录提取的「宁漏不误」是同一条设计哲学。
#!/usr/bin/env python3
"""考勤交叉核对:排班表(姓名,日期,班次) × 打卡记录(姓名,日期,时间) → 异常清单CSV"""
import csv, datetime
SHIFTS = {'早班': ('08:00','16:00'), '中班': ('12:00','20:00'), '晚班': ('16:00','24:00')}
GRACE = 5
def to_min(t):
h, m = map(int, t.split(':'))
return h*60 + m
def check(rows, punches):
by_punch = {}
for n, d, t in punches:
by_punch.setdefault((n, d), []).append(to_min(t))
out = []
for name, date, shift in rows:
s, e = SHIFTS[shift]
s_m, e_m = to_min(s), 24*60 if e == '24:00' else to_min(e)
cross_day = e_m <= s_m or e == '24:00'
next_day = (datetime.date.fromisoformat(date) + datetime.timedelta(days=1)).isoformat()
days = [date, next_day] if cross_day else [date]
tin = tout = None
for p in sorted(by_punch.get((name, date), [])):
if s_m - 120 <= p <= s_m + 180 and tin is None:
tin = p
for dd in days:
off = 0 if dd == date else 24*60
for p in sorted(by_punch.get((name, dd), [])):
if p + off >= e_m - 120 and tout is None:
tout = (dd, p)
if tin is None and tout is None:
out.append([name, date, shift, s, e, '', '', '旷工', '无任何打卡记录'])
continue
status = []
if tin is None:
status.append('缺上班卡')
elif tin - s_m > GRACE:
status.append(f'迟到{tin-s_m}分钟')
if tout is None:
status.append('缺下班卡')
elif (tout[1] + (0 if tout[0] == date else 24*60)) < e_m - GRACE:
status.append(f'早退{e_m-(tout[1]+(0 if tout[0]==date else 24*60))}分钟')
tin_s = f'{tin//60:02d}:{tin%60:02d}' if tin is not None else ''
tout_s = (f'{tout[1]//60:02d}:{tout[1]%60:02d}' + ('(次日)' if tout[0] != date else '')) if tout else ''
out.append([name, date, shift, s, e, tin_s, tout_s, ';'.join(status) or '正常', ''])
return out
if __name__ == '__main__':
rows = list(csv.reader(open('kaoqin_shifts.csv', encoding='utf-8')))[1:]
punches = list(csv.reader(open('kaoqin_punches.csv', encoding='utf-8')))[1:]
result = check(rows, punches)
with open('kaoqin_check_out.csv', 'w', newline='', encoding='utf-8') as f:
w = csv.writer(f)
w.writerow(['姓名','日期','班次','应上班','应下班','实际上班','实际下卡','结论','备注'])
w.writerows(result)
print(f'核对 {len(rows)} 条排班,输出 {sum(1 for r in result if r[7] != "正常")} 条异常')
喵窝资源铺提供代跑脚本服务:把排班表和打卡流水两个 CSV 发过来,跑完把异常清单(CSV)发回你,¥5 起,虚拟物品不退款(跑不通不收费)。班次时间不同可说明,脚本按你的班次定制。脚本本身免费自取。
📖 相邻文章:聊天记录批量提取待办+承诺日期 · 考勤时间列清洗成工时 · 两张表人名模糊匹配合并 · 博客目录
异常清单仅供参考,追责/扣薪请以原始打卡数据为准(输出保留实际上下班时间便于核对)。