2026-10-06 · 喵窝资源铺
月底算工资前总有一道工序:把打卡明细过一遍,找出缺卡的、迟到的、工时不够的。几十人一个月就是上千行,肉眼扫一遍既慢又漏。这篇给一个 只依赖 openpyxl 的脚本:自动识别 4 类异常,输出「异常汇总 + 异常明细」两个 sheet,HR 拿到直接按人找。文中代码实跑验证,真实输出贴在下面。
| 异常类型 | 判定条件 |
|---|---|
| 全天缺卡 | 上下班都没记录(含纯空格单元格) |
| 单边缺卡 | 只有一头有记录(顺带把另一头的时间写进异常描述,方便核对) |
| 迟到 | 上班时间晚于 9:30(阈值在代码里一行改) |
| 工时不足 / 时间填反 | 下班−上班 < 8h 报工时不足;下班早于上班单独报「疑漏打夜班或填反」 |
构造了一份 11 行数据的考勤明细,故意埋了字符串时间、纯空格、姓名为空的脏行:
扫描数据行 11 行,异常 7 条,涉及 4 人 张三: 2 条 李四: 2 条 王五: 2 条 赵六: 1 条
异常明细 sheet 输出:
张三 2026-10-02 上班缺卡(下班 18:00) 张三 2026-10-03 迟到 10:12(晚于9:30) 李四 2026-10-02 工时不足 7.00h(09:00→16:00) 李四 2026-10-03 下班早于上班(09:00→08:30),疑漏打夜班或填反 王五 2026-10-01 全天缺卡(上下班都没记录) 王五 2026-10-02 下班缺卡(上班 08:50) 赵六 2026-10-01 全天缺卡(上下班都没记录)
关键验证点:"09:15" 这种字符串时间被正确解析成正常行(没误报);纯空格 " " 判全天缺卡;连跑两次输出文件 md5 完全一致(幂等,可放进定时任务)。
存为 kq_anomaly.py,运行:
python kq_anomaly.py 考勤明细.xlsx 考勤异常汇总.xlsx
输入表要求有 4 列:姓名、日期、上班打卡、下班打卡(列顺序随意,脚本按表头名找列,不写死列号)。
# -*- coding: utf-8 -*-
"""考勤异常行自动汇总:读打卡明细,输出每人异常次数+异常明细 sheet。"""
from openpyxl import load_workbook, Workbook
from collections import defaultdict
import sys, datetime as dt
src, dst = sys.argv[1], sys.argv[2]
wb = load_workbook(src)
ws = wb.active
header = [str(c.value).strip() if c.value else "" for c in ws[1]]
col = {name: i for i, name in enumerate(header)} # 按表头找列,不写死列号
def parse_time(v):
"""尽量把单元格值转成 datetime;转不了返回 None。"""
if v is None or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, dt.datetime):
return v
if isinstance(v, dt.time):
return dt.datetime.combine(dt.date.today(), v)
if isinstance(v, (int, float)): # Excel 时间序列号
return dt.datetime(1899, 12, 30) + dt.timedelta(days=float(v))
s = str(v).strip()
for fmt in ("%H:%M:%S", "%H:%M", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M"):
try:
return dt.datetime.strptime(s, fmt)
except ValueError:
pass
return None
anomalies = defaultdict(list) # 姓名 -> [异常描述]
row_count = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if all(v is None or str(v).strip() == "" for v in row):
continue # 整行空:跳过,不算缺卡
row_count += 1
name = str(row[col["姓名"]]).strip() if row[col["姓名"]] else "?"
day = row[col["日期"]]
cin = parse_time(row[col["上班打卡"]])
cout = parse_time(row[col["下班打卡"]])
if cin is None and cout is None:
anomalies[name].append(f"{day} 全天缺卡(上下班都没记录)")
continue
if cin is None:
anomalies[name].append(f"{day} 上班缺卡(下班 {cout:%H:%M})")
continue
if cout is None:
anomalies[name].append(f"{day} 下班缺卡(上班 {cin:%H:%M})")
continue
if cout < cin:
anomalies[name].append(f"{day} 下班早于上班({cin:%H:%M}→{cout:%H:%M}),疑漏打夜班或填反")
continue
hours = (cout - cin).total_seconds() / 3600
if hours < 8:
anomalies[name].append(f"{day} 工时不足 {hours:.2f}h({cin:%H:%M}→{cout:%H:%M})")
if cin.time() > dt.time(9, 30):
anomalies[name].append(f"{day} 迟到 {cin:%H:%M}(晚于9:30)")
out = Workbook()
s1 = out.active; s1.title = "异常汇总"
s1.append(["姓名", "异常次数", "异常类型速览"])
for name in sorted(anomalies, key=lambda n: -len(anomalies[n])):
types = {}
for a in anomalies[name]:
t = a.split(" ", 1)[1].split("(")[0] if " " in a else a
types[t] = types.get(t, 0) + 1
s1.append([name, len(anomalies[name]), ";".join(f"{k}×{v}" for k, v in types.items())])
s2 = out.create_sheet("异常明细")
s2.append(["姓名", "异常描述"])
for name, lst in anomalies.items():
for a in lst:
s2.append([name, a])
if not anomalies:
s2.append(["(无)", "全部正常"])
out.save(dst)
print(f"扫描数据行 {row_count} 行,异常 {sum(len(v) for v in anomalies.values())} 条,涉及 {len(anomalies)} 人")
v is None 是 False,不做 str(v).strip() 判断就会当成有值,全天缺卡的人被漏掉。本脚本统一先 strip 再判空。"09:15" 字符串,有的是 Excel 序列号 46000.5。解析函数要按「datetime → time → 数字 → 逐个格式试字符串」的顺序兜底,任何一层直接 strptime 硬转都会崩。-0.5h 这种怪结果混进工时不足里;单独拎出来报「疑漏打夜班或填反」,HR 一眼知道去查什么。同理 cout < cin 的行要先拦截再算工时,否则 8h 阈值判断也失真。另外:姓名为空的脏行(打卡正常)不会被误杀成异常,而是归到 ? 名下——异常判定只看时间,姓名缺失是另一个数据质量问题,混在一起反而干扰排查。
🎈 没时间自己跑脚本?喵窝资源铺提供代跑服务:把文件发给我们,AI 执行 + 人工核验后把结果文件发回。查看全部工具与服务 →(虚拟物品,交付后不退款,下单前请确认需求)