Python 自动化办公最常见的入口,不是学完语法再找场景,而是把每周重复的表格合并、清洗、拆发、生成文档和发邮件串成一条可运行流水线。原文给出的判断标准很实用:规则是否固定、频次是否够高、出错代价是否够大。三条都满足就值得写脚本;自动化真正的价值除了省时间,还在于消除人工操作带来的随机错误。
【工具选型与能力边界】
办公自动化的常见需求可以拆成读取、处理、产出、调度四层。pandas 适合表格读取、清洗、合并和统计;openpyxl 适合读写 .xlsx 并控制单元格样式、列宽和公式;python-docx 适合基于模板生成 Word;pdfplumber 适合提取 PDF 中的文字和表格;pypdf 适合 PDF 拆分、合并和元信息处理;pathlib 与 shutil 负责文件整理;smtplib 与 email 负责邮件;schedule 适合单机常驻脚本的周期执行。
需要避免两个误区。第一,不要用 openpyxl 读取几万行大表做数据分析,它的设计目标是精细控制单元格,读大文件慢且吃内存;更合理的分工是 pandas 做数据处理,openpyxl 只负责最终美化。第二,pandas 不能替代 openpyxl 做复杂格式,报表要颜色、边框、列宽和冻结表头时,仍需 openpyxl 收尾。
能力边界也要清楚:扫描版 PDF 不接 OCR 无法稳定提取文字;没有开放接口的桌面客户端不适合用界面自动化强行操作;需要大量主观判断的任务应先保留人工环节。
【环境准备与目录约定】
原文建议 Python 3.9 及以上,具体以项目依赖兼容性为准。开发工具可用 PyCharm 社区版或 VS Code 加 Python 插件。依赖不要全局安装,在项目目录建虚拟环境更稳:
- python -m venv .venv
- source .venv/bin/activate
- python -m pip install --upgrade pip
- pip install pandas openpyxl python-docx pdfplumber pypdf schedule
复制代码
国内网络安装慢时可指定镜像源:
- pip install pandas openpyxl python-docx pdfplumber pypdf schedule -i https://pypi.tuna.tsinghua.edu.cn/simple
复制代码
建议目录按原始文件、输出、归档、模板、日志、脚本、配置分离。两个关键约定要长期坚持:原始文件只读,脚本只往 output 写,处理成功后再移动到 archive;所有路径集中放在 config.py,避免换机器时逐个脚本改路径。依赖锁定用 pip freeze > requirements.txt,部署时用 pip install -r requirements.txt 还原。
涉及公司数据时,脚本处理的文件必须是工作中合法有权访问的;个人信息、薪酬、合同等敏感数据本地处理完要清理临时文件;连接公司邮箱或业务系统前先获得授权,使用专门账号。任何会修改或删除原始文件的操作,都先在测试目录预演。
【Excel 合并:先把数据正确合到一起】
最核心的第一步不是做漂亮报表,而是确保多表能正确合并。原文示例把 data/raw 下多个门店销售表合并,统一保留列,清洗空行和脏数据,按部门汇总并输出两个 sheet。关键点包括:用 dtype={'门店': str} 防止 001 变成 1;过滤 Excel 打开时生成的 ~$ 临时文件;列名 strip 去空格;缺必需列时直接退出而不是带错误继续跑。
- from pathlib import Path
- import pandas as pd
- BASE_DIR = Path(__file__).resolve().parent.parent
- RAW_DIR = BASE_DIR / 'data' / 'raw'
- OUTPUT_DIR = BASE_DIR / 'data' / 'output'
- OUTPUT_FILE = OUTPUT_DIR / '销售汇总.xlsx'
- KEEP_COLUMNS = ['门店', '部门', '日期', '商品', '数量', '单价', '金额']
- def read_one(file_path: Path) -> pd.DataFrame:
- df = pd.read_excel(file_path, dtype={'门店': str})
- df = df.dropna(how='all').dropna(axis=1, how='all')
- df.columns = [str(c).strip() for c in df.columns]
- df['来源文件'] = file_path.name
- return df
- def main() -> None:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- files = sorted(f for f in RAW_DIR.glob('*.xlsx') if not f.name.startswith('~$'))
- if not files:
- raise SystemExit(f'在 {RAW_DIR} 下没有找到任何 xlsx 文件,请检查路径')
- frames = []
- for f in files:
- try:
- frames.append(read_one(f))
- except Exception as exc:
- print(f'[跳过] {f.name} 读取失败:{exc}')
- if not frames:
- raise SystemExit('所有文件都读取失败,请检查表结构')
- merged = pd.concat(frames, ignore_index=True)
- missing = [c for c in KEEP_COLUMNS if c not in merged.columns]
- if missing:
- raise SystemExit(f'以下必需列缺失,请先统一表头:{missing}')
- merged = merged[KEEP_COLUMNS].copy()
- merged['门店'] = merged['门店'].astype(str).str.strip()
- merged['部门'] = merged['部门'].astype(str).str.strip()
- merged['日期'] = pd.to_datetime(merged['日期'], errors='coerce')
- for col in ['数量', '单价', '金额']:
- merged[col] = pd.to_numeric(merged[col], errors='coerce')
- before = len(merged)
- merged = merged.dropna(subset=['日期', '金额'], how='all')
- print(f'清洗前 {before} 行,清洗后 {len(merged)} 行')
- mask = merged['金额'].isna() & merged['数量'].notna() & merged['单价'].notna()
- merged.loc[mask, '金额'] = merged.loc[mask, '数量'] * merged.loc[mask, '单价']
- summary = (
- merged.groupby('部门', as_index=False)
- .agg(订单数=('金额', 'size'), 销售金额=('金额', 'sum'))
- .sort_values('销售金额', ascending=False)
- )
- summary['销售金额'] = summary['销售金额'].round(2)
- with pd.ExcelWriter(OUTPUT_FILE, engine='openpyxl') as writer:
- merged.to_excel(writer, sheet_name='明细', index=False)
- summary.to_excel(writer, sheet_name='部门汇总', index=False)
- print(f'完成,输出文件:{OUTPUT_FILE}')
- if __name__ == '__main__':
- main()
复制代码
运行命令是 python scripts/merge_excel.py。如果报必需列缺失,先打开报错文件看表头是否被合并单元格或标题行挤到第二行;这种情况可给 read_excel 加 skiprows=1 之类参数先跳过标题行。
【报表格式化与按部门拆分】
数据正确后再处理列宽、表头颜色、数字格式和冻结表头。freeze_panes='A2' 让滚动时表头固定,列宽按内容估算,金额列加千分位。格式化脚本读取销售汇总,对明细和部门汇总分别处理:
- from pathlib import Path
- from openpyxl import load_workbook
- from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
- from openpyxl.utils import get_column_letter
- BASE_DIR = Path(__file__).resolve().parent.parent
- REPORT = BASE_DIR / 'data' / 'output' / '销售汇总.xlsx'
- HEADER_FILL = PatternFill('solid', fgColor='1F4E78')
- HEADER_FONT = Font(color='FFFFFF', bold=True, size=11)
- THIN = Side(style='thin', color='BFBFBF')
- BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
- def style_sheet(ws, money_cols=()) -> None:
- for cell in ws[1]:
- cell.fill = HEADER_FILL
- cell.font = HEADER_FONT
- cell.alignment = Alignment(horizontal='center', vertical='center')
- cell.border = BORDER
- for idx, column in enumerate(ws.columns, start=1):
- max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
- ws.column_dimensions[get_column_letter(idx)].width = min(max_len * 2 + 4, 40)
- headers = {c.value: c.column for c in ws[1]}
- for name in money_cols:
- if name in headers:
- col = headers[name]
- for row in range(2, ws.max_row + 1):
- ws.cell(row=row, column=col).number_format = '#,##0.00'
- ws.freeze_panes = 'A2'
- def main() -> None:
- wb = load_workbook(REPORT)
- style_sheet(wb['明细'], money_cols=('金额', '单价'))
- style_sheet(wb['汇总'] if '汇总' in wb.sheetnames else wb['部门汇总'], money_cols=('销售金额',))
- wb.save(REPORT)
- print(f'格式已应用到:{REPORT}')
- if __name__ == '__main__':
- main()
复制代码
按部门拆分时用 groupby 循环生成单独文件,空部门跳过。真实数据里部门名可能包含斜杠,文件名中要替换掉,否则会被文件系统当成路径分隔符导致报错。
- from pathlib import Path
- import pandas as pd
- BASE_DIR = Path(__file__).resolve().parent.parent
- SOURCE = BASE_DIR / 'data' / 'output' / '销售汇总.xlsx'
- SPLIT_DIR = BASE_DIR / 'data' / 'output' / '按部门拆分'
- def main() -> None:
- SPLIT_DIR.mkdir(parents=True, exist_ok=True)
- detail = pd.read_excel(SOURCE, sheet_name='明细')
- for dept, group in detail.groupby('部门'):
- if pd.isna(dept) or str(dept).strip() == '':
- continue
- safe_name = str(dept).replace('/', '_').strip()
- target = SPLIT_DIR / f'{safe_name}_销售明细.xlsx'
- group.to_excel(target, index=False)
- print(f'已生成:{target.name}({len(group)} 行)')
- if __name__ == '__main__':
- main()
复制代码
【Word 模板批量生成】
Word 批量生成不建议从零拼排版,更省事的是先做模板,把变化内容写成 {{姓名}}、{{部门}}、{{金额}} 这类占位符,再用 python-docx 替换。替换时要注意保留原有格式:先把段落内多个 run 的文本合并,完成替换后写回第一个 run,其余 run 清空,避免格式错乱。原文给出的段落替换函数如下:
- def replace_in_paragraph(paragraph, mapping: dict) -> None:
- full_text = ''.join(run.text for run in paragraph.runs)
- replaced = full_text
- for key, value in mapping.items():
- replaced = replaced.replace(f'{{{{{key}}}}}', str(value))
- if replaced == full_text:
- return
- paragraph.runs[0].text = replaced
- for run in paragraph.runs[1:]:
- run.text = ''
复制代码
主流程读取人员名单,逐行构造 mapping,打开模板文档,遍历 doc.paragraphs 调用替换函数;表格内的单元格段落也用同一函数处理。这样格式由 Word 模板保证,脚本只负责填内容。
【文件整理、邮件与定时任务】
原文将文件重命名归档、带附件邮件和 schedule 定时执行放在同一条流水线中。实现上可用 pathlib 和 shutil 做重命名与分类归档,用 smtplib 与 email 构造带附件邮件,用 schedule 做单机常驻脚本的周期执行。这里要强调工程习惯:任何批量改名或删除操作先预演再执行;邮件发送使用授权账号并控制收件范围;定时任务要写日志,失败时能定位到具体文件和步骤。
【排查思路与工程建议】
常见问题包括:~$ 临时文件导致读取失败;门店编号被识别成数字;列名前后空格导致合并后列对不上;表头被合并单元格挤到第二行;部门名含斜杠导致文件名报错;空行和备注行混入数据。排查时先看报错文件的实际表头,再看脚本的列名约定和路径配置。
工程化上建议配置与代码分离,日志要写清输入文件、行数变化、输出路径和异常原因;脚本要尽量幂等和可重跑,输出目录可覆盖,原始文件只读并归档;依赖锁定版本,权限最小化,数据边界清晰。做到这些,脚本才从一次性玩具变成可长期维护的办公工具。 |