月底交考勤月度汇总表:一个脚本按人算齐出勤/迟到/缺卡/总工时

2026-10-06 · 喵窝资源铺

上一篇写了把异常行揪出来,但很多公司月底要交的是另一种表:考勤月度汇总——一人一行,列上出勤天数、迟到几次、缺卡几次、总工时,老板签字用。这种表最烦的不是算,是口径:缺卡的算不算出勤?时间填反的行算不算迟到?这篇给一个 openpyxl 脚本把这些口径问题一次讲清,代码实跑验证,真实输出贴在下面。

汇总表长什么样

列口径(代码里一行可改)
出勤天数有打卡记录的天数;单边缺卡也算出勤(人来了/走了),全天缺勤不算
迟到次数上班晚于 9:30 的天数(只数有完整上下班的行)
缺卡次数单边缺卡次数
全天缺勤上下班都没记录(含空格、空字符串)
时间填反下班早于上班的行,单独一列,不混进迟到也不算工时
总工时只累计下班 > 上班的行

实跑结果(不是编的)

构造 11 行数据,故意埋了空格单元格、字符串时间 "09:00"、时间填反、姓名为空的脏行、整行空行:

?: 出勤1天 迟到0 缺卡0 全天缺勤0 填反0 工时9.00h
张三: 出勤4天 迟到1 缺卡2 全天缺勤0 填反0 工时17.33h
李四: 出勤2天 迟到0 缺卡1 全天缺勤1 填反0 工时15.92h
王五: 出勤0天 迟到0 缺卡1 全天缺勤1 填反1 工时0.00h

核对几个关键点:李四 9:10 上班没有被算迟到(阈值 9:30,9:10 在内);王五 18:00→8:30 的填反行进了「时间填反」列而不是迟到列,工时 0 没被污染;连跑三次输出文件 md5 完全一致。

用法

python kq_month.py 考勤明细.xlsx 考勤月度汇总.xlsx

输入表要求 4 列:姓名、日期、上班打卡、下班打卡,按表头名找列,列顺序随意。

完整脚本

# -*- coding: utf-8 -*-
"""考勤月度汇总:按人统计出勤天数、迟到次数、缺卡、总工时,输出一人一行的月度表。"""
from openpyxl import load_workbook, Workbook
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):
    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

stats = {}   # 姓名 -> dict
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                                 # 整行空:跳过
    name = str(row[col["姓名"]]).strip() if row[col["姓名"]] else "?"
    cin = parse_time(row[col["上班打卡"]])
    cout = parse_time(row[col["下班打卡"]])
    st = stats.setdefault(name, {"days": 0, "late": 0, "miss": 0, "hours": 0.0, "full_miss": 0})
    if cin is None and cout is None:
        st["full_miss"] += 1; st["miss"] += 1
        continue
    if cin is None or cout is None:
        st["miss"] += 1; st["days"] += 1         # 单边缺卡也算出勤
        continue
    if cout < cin:                     # 先拦截填反行,别让它污染迟到/出勤统计
        st["reversed"] = st.get("reversed", 0) + 1
        continue
    st["days"] += 1
    if cin.time() > dt.time(9, 30):
        st["late"] += 1
    st["hours"] += (cout - cin).total_seconds() / 3600

out = Workbook()
now = dt.datetime(2026, 1, 1)
out.properties.created = now
out.properties.modified = now
s1 = out.active; s1.title = "月度汇总"
s1.append(["姓名", "出勤天数", "迟到次数", "缺卡次数", "全天缺勤", "时间填反", "总工时(h)"])
for name in sorted(stats):
    st = stats[name]
    s1.append([name, st["days"], st["late"], st["miss"], st["full_miss"], st.get("reversed", 0), round(st["hours"], 2)])
out.save(dst)
for name in sorted(stats):
    st = stats[name]
    print(f"{name}: 出勤{st['days']}天 迟到{st['late']} 缺卡{st['miss']} 全天缺勤{st['full_miss']} 填反{st.get('reversed',0)} 工时{st['hours']:.2f}h")

实跑时踩到的 3 个真坑

  1. 时间填反的行会污染迟到统计。第一版把 18:00→8:30 的行算成「出勤 1 天、迟到 1 次」(18:00 > 9:30),数字交上去完全误导人。填现行必须先拦截(cout < cin 在迟到判断之前 continue),单独进一列。
  2. openpyxl 每次保存都会写入当前时间到文件元数据,同样的数据连跑两次文件 md5 就不一样,看起来「不幂等」。把 Workbook().properties.created / modified 设成固定时间后,三次连跑 md5 才真正一致。这个坑不影响业务只影响校验,但会让你怀疑代码有问题白查半天。
  3. 出勤口径要先定:缺卡算不算出勤。本脚本口径是单边缺卡算出勤(人到了/走了)、全天缺勤不算——和多数公司行政口径一致,但如果你们公司「缺卡当天一律算缺勤」,把 st["days"] += 1 从单边缺卡分支里挪掉即可。口径错了表就废了,所以单独说。

相关文章

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

← 返回博客合集