用 FastAPI + Pandas + OpenPyXL 搭建考勤报表自动生成系统

6 阅读5分钟

背景

企业微信的考勤管理后台可以导出打卡日报,但导出的是原始数据——两个 Sheet,几百行记录。HR 每个月要把这些数据手动整理成公司内部的考勤统计表,费时费力。

这个需求的特点是:输入格式固定、输出格式固定、转换规则明确。很适合用代码来自动化。本文分享我用 FastAPI + Pandas + OpenPyXL 实现这套系统的技术思路。

架构设计

整个系统前后端分离,架构比较简单:

用户浏览器 (Vue 3 + Element Plus)
       ↓ 上传 xlsx / 请求导出
FastAPI 后端 (Python)
       ├── parser.py      → Pandas 解析 Excel
       ├── attendance.py   → OpenPyXL 生成考勤报表
       └── overtime.py     → OpenPyXL 生成加班报表

为什么选这几个库:

  • FastAPI:接口定义简洁,自带 Swagger 文档,文件上传/下载开箱即用
  • Pandas:处理表格数据的瑞士军刀,筛选、分组、聚合一行搞定
  • OpenPyXL:能读写 xlsx 的样式(字体、颜色、合并单元格、公式),生成的报表不是裸数据而是带格式的

核心实现

1. 解析企微导出文件

企微导出的 xlsx 有个坑:表头不是简单的一行,而是两行——第一行是分组名("基础信息"、"考勤概况"等),第二行才是字段名("部门"、"职务"等)。而且有些列只有分组名没有字段名。

处理方式是合并两行表头:

def _merge_headers(grp_row, fld_row):
    """合并分组名行和字段名行,生成最终列名"""
    headers = []
    current_group = ""
    for g, f in zip(grp_row, fld_row):
        g_str = "" if pd.isna(g) else str(g).strip()
        f_str = "" if pd.isna(f) else str(f).strip()
        if g_str:
            current_group = g_str
        # 优先取字段名,没有就用分组名
        name = f_str if f_str else current_group
        # 处理重名列(比如多个"打卡时间")
        headers.append(_deduplicate(headers, name))
    return headers

数据从第 5 行开始读,用 dtype=str 统一按字符串读取,避免 Pandas 自动类型推断把日期搞乱。

2. 考勤状态映射

企微导出的状态是中文文字("正常"、"迟到"、"正常(补卡)"等),需要映射成公司内部使用的符号(√、※、○ 等)。

映射规则放在 config.json 里,不硬编码:

{
  "attendance_symbols": {
    "正常": "√",
    "正常(补卡)": "√",
    "迟到": "※",
    "早退": "◇",
    "旷工": "×",
    "缺卡": "◎",
    "事假": "○",
    "病假": "☆",
    "出差": "△",
    "年假": "÷",
    "调休": "#"
  }
}

匹配逻辑是先精确匹配,匹配不上再做包含匹配。这样 "正常(外出打卡)" 可以命中 "正常" 的规则,不用穷举所有变体:

def get_attendance_symbol(status: str) -> str:
    symbols = config["attendance_symbols"]
    if status in symbols:
        return symbols[status]        # 精确匹配
    for key, symbol in symbols.items():
        if key in status:
            return symbol             # 包含匹配
    return status

3. 基于模版生成报表

这是最复杂的部分。生成的报表不能是纯数据表格,得带格式——合并单元格、周末蓝色底色、COUNTIF 汇总公式等。

方案是模版驱动:预先做好一个 Excel 模版(含表头、样式、示例数据),代码基于模版进行填充。

核心流程:

def generate_attendance_report(overview_df, template_path, output_path, year, month):
    # 1. 复制模版文件
    shutil.copy2(template_path, output_path)
    wb = load_workbook(output_path)
    ws = wb.worksheets[0]

    # 2. 动态调整行数(插入或删除)
    if n_persons > TEMPLATE_PERSONS:
        ws.insert_rows(note_row, extra_rows)
    elif n_persons < TEMPLATE_PERSONS:
        ws.delete_rows(start, extra_rows)

    # 3. 逐人写入数据(每人两行:上午+下午)
    for name in names_ordered:
        _write_person_pair(ws, am_row, pm_row, name, day_symbols)
        # 合并姓名列的两行
        ws.merge_cells(start_row=am_row, end_row=pm_row, ...)

这里有几个坑值得注意:

坑 1:MergedCell 不能写入。OpenPyXL 中合并单元格的从属格是 MergedCell 类型,直接写会报错。所有写入操作都要先判断:

def _safe_write(ws, row, col, value):
    cell = ws.cell(row, col)
    if not isinstance(cell, MergedCell):
        cell.value = value

坑 2:插入行不复制样式ws.insert_rows() 插入的是空白行,没有边框和背景色。需要手动从参考行复制样式:

def _copy_row_style(ws, src_row, dst_row):
    for col in range(1, ws.max_column + 1):
        src = ws.cell(src_row, col)
        dst = ws.cell(dst_row, col)
        if isinstance(dst, MergedCell):
            continue
        dst.font = copy.copy(src.font)
        dst.border = copy.copy(src.border)
        dst.fill = copy.copy(src.fill)

坑 3:取消合并要先收集再操作。不能在遍历 ws.merged_cells.ranges 的同时修改它,否则会报运行时错误。要先收集到列表再逐个取消:

to_remove = [str(m) for m in ws.merged_cells.ranges             if m.min_row <= row_end and m.max_row >= row_start]
for m_str in to_remove:
    ws.unmerge_cells(m_str)

4. 加班时段分档统计

加班报表需要根据实际下班打卡时间判断属于哪个时段:

def _time_to_label(t: time) -> Optional[str]:
    if t >= time(20, 0) and t < time(22, 0):
        return "20:00-22:00"
    if t >= time(22, 0):
        return "22:00之后"
    return None  # 20:00 之前不算加班

时段阈值也放在配置文件里,方便不同公司调整。

5. 前端部分

前端用 Vue 3 + Element Plus,比较常规。有一个值得一提的细节:导出接口返回的是二进制文件流,前端需要从 Content-Disposition header 里提取文件名:

const disposition = response.headers['content-disposition']
const filename = decodeURIComponent(
  disposition.match(/filename*=UTF-8''(.+)/)[1]
)

这样文件名可以由后端动态生成(比如 研发部_2026年2月考勤数据统计.xlsx),前端不用硬编码。

部署

提供了 Docker Compose 一键部署:

services:
  backend:
    build: ./backend
    expose: ["8000"]
  frontend:
    build: ./frontend
    ports: ["8082:80"]
    depends_on: [backend]

前端构建后由 Nginx 托管静态文件,同时反向代理 /api 到后端。

总结

这个项目麻烦的地方不在业务逻辑,而在 Excel 操作的细节——合并单元格、样式复制、公式注入、动态行数调整,每个都有坑。OpenPyXL 的文档对这些边界情况的说明不太充分,基本靠踩坑总结。

项目已开源,欢迎使用和贡献:

如果你也有类似的 Excel 自动化需求,希望这些经验对你有帮助。