背景
企业微信的考勤管理后台可以导出打卡日报,但导出的是原始数据——两个 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 的文档对这些边界情况的说明不太充分,基本靠踩坑总结。
项目已开源,欢迎使用和贡献:
- GitHub:github.com/huhuxianer/…
- Gitee(国内镜像):gitee.com/huhuxianer/…
如果你也有类似的 Excel 自动化需求,希望这些经验对你有帮助。