几百行打卡明细,10 秒揪出所有考勤异常

2026-10-06 · 喵窝资源铺

月底算工资前总有一道工序:把打卡明细过一遍,找出缺卡的、迟到的、工时不够的。几十人一个月就是上千行,肉眼扫一遍既慢又漏。这篇给一个 只依赖 openpyxl 的脚本:自动识别 4 类异常,输出「异常汇总 + 异常明细」两个 sheet,HR 拿到直接按人找。文中代码实跑验证,真实输出贴在下面。

识别哪 4 类异常

异常类型判定条件
全天缺卡上下班都没记录(含纯空格单元格)
单边缺卡只有一头有记录(顺带把另一头的时间写进异常描述,方便核对)
迟到上班时间晚于 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)} 人")

实跑时踩到的 3 个真坑

  1. 「空单元格」和「空格字符串」是两种东西。Excel 里手滑敲了一个空格的单元格,v is None 是 False,不做 str(v).strip() 判断就会当成有值,全天缺卡的人被漏掉。本脚本统一先 strip 再判空。
  2. 打卡机导出的时间格式不统一:有的是真时间类型,有的是 "09:15" 字符串,有的是 Excel 序列号 46000.5。解析函数要按「datetime → time → 数字 → 逐个格式试字符串」的顺序兜底,任何一层直接 strptime 硬转都会崩。
  3. 「下班早于上班」不能只报负数。直接算工时会得出 -0.5h 这种怪结果混进工时不足里;单独拎出来报「疑漏打夜班或填反」,HR 一眼知道去查什么。同理 cout < cin 的行要先拦截再算工时,否则 8h 阈值判断也失真。

另外:姓名为空的脏行(打卡正常)不会被误杀成异常,而是归到 ? 名下——异常判定只看时间,姓名缺失是另一个数据质量问题,混在一起反而干扰排查。

相关文章

🎈 没时间自己跑脚本?喵窝资源铺提供代跑服务:把文件发给我们,AI 执行 + 人工核验后把结果文件发回。查看全部工具与服务 →(虚拟物品,交付后不退款,下单前请确认需求)

← 返回博客合集