2026-10-05 · 喵窝资源铺
月底汇总最烦的不是算数,是对齐:华东的表列名是「日期/部门/金额」,华南的叫「记账日期/科室/金额(元)」,还有一张英文的 date/dept/amt,而且表头不在第一行——前面塞了两行说明。日期一会儿是字符串 2026/9/1,一会儿是真日期,一会儿是 Excel 序列号 46262。复制粘贴对一遍起码半天,还容易把重复报销合并进去算重。这篇给一个 只依赖 openpyxl 的合并脚本,文中代码实跑验证,真实输出见下。
| 乱象 | 脚本的处理 |
|---|---|
| 列名五花八门 | 别名表映射:金额(元)/amt/花费 → 统一为「金额」,别名表自己改 |
| 表头不在第一行 | 前 10 行扫描,命中标准列名最多的那行当表头(命中<2 个列名整表跳过) |
| 日期三种写法 | 字符串 6 种格式 + datetime 类型 + Excel 序列号,统一成日期 |
| 跨表重复行 | 按全字段指纹去重,被剔除的行单独存「重复行_待复核」sheet |
坑1:正则里全角括号。想把「金额(元)」清洗成「金额」,我第一版写成r"[((]元|元$)"——右括号被当成分组符,直接re.error: unbalanced parenthesis。正确写法r"[((]元$|元$"。坑2:Excel 序列号别手算起点。46262 应该是哪天?我写测试数据时按直觉注释了 9/3,跑出来是 2026-08-28,跟openpyxl.utils.datetime.from_excel官方换算完全一致——1900 日期体系的偏移起点是 1899-12-30,不是 1900-01-01(Lotus 兼容的世纪老 bug)。手算起点差一天,整列日期全歪。
存为 merge_excel.py,运行 python merge_excel.py 输入目录 输出.xlsx(递归读子目录):
# -*- coding: utf-8 -*-
import sys, os, re, glob
from datetime import datetime, date, timedelta
from openpyxl import Workbook, load_workbook
from openpyxl.utils import get_column_letter
ALIASES = {
"日期": ["日期", "date", "day", "报销日期", "记账日期"],
"部门": ["部门", "department", "dept", "科室", "组"],
"项目": ["项目", "项目名", "project", "事项", "用途"],
"金额": ["金额", "金额(元)", "金额(元)", "amt", "amount", "花费", "支出"],
"经办人": ["经办人", "经手人", "person", "申请人", "负责人"],
}
DATE_COL, AMOUNT_COL = "日期", "金额"
def normalize_header(h):
if h is None: return ""
s = str(h).strip().lower().replace("(", "(").replace(")", ")")
s = re.sub(r"\s+", "", s)
return re.sub(r"[((]元$|元$", "", s)
def build_lookup():
lookup = {}
for std, alts in ALIASES.items():
for a in alts:
lookup[normalize_header(a)] = std
lookup[normalize_header(std)] = std
return lookup
def parse_date(v):
if isinstance(v, datetime): return v.date()
if isinstance(v, date): return v
if v is None: return None
s = str(v).strip()
if not s: return None
for f in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y年%m月%d日",
"%m/%d/%Y", "%d/%m/%Y", "%Y%m%d"):
try: return datetime.strptime(s, f).date()
except ValueError: pass
if re.fullmatch(r"\d{5}(\.\d+)?", s): # Excel 序列号
try: return date(1899, 12, 30) + timedelta(days=int(float(s)))
except OverflowError: return None
return None
def find_header_row(rows, lookup, scan=10):
best_i, best_hits = 0, 0
for i, row in enumerate(rows[:scan]):
hits = sum(1 for c in row if normalize_header(c) in lookup)
if hits > best_hits: best_i, best_hits = i, hits
return best_i, best_hits
def merge_dir(indir, outfile):
lookup = build_lookup()
std_cols = list(ALIASES.keys())
merged, dupes, files_used, seen = [], [], [], set()
files = [f for f in sorted(glob.glob(os.path.join(indir, "**", "*.xls*"), recursive=True))
if not os.path.basename(f).startswith("~$")]
if not files:
print(f"[FAIL] {indir} 下没找到任何 xlsx/xls"); return 1
for f in files:
try:
wb = load_workbook(f, read_only=True, data_only=True)
except Exception as e:
print(f"[WARN] 打不开 {os.path.basename(f)}: {e},跳过"); continue
ws = wb.active
rows = [list(r) for r in ws.iter_rows(values_only=True)]
wb.close()
if not rows: continue
hrow, hits = find_header_row(rows, lookup)
if hits < 2:
print(f"[WARN] {os.path.basename(f)} 没认出表头(只命中{hits}个列名),跳过"); continue
colmap = {}
for j, c in enumerate(rows[hrow]):
std = lookup.get(normalize_header(c))
if std: colmap[j] = std
n_new = 0
for r in rows[hrow + 1:]:
rec = {std: (r[j] if j < len(r) else None) for j, std in colmap.items()}
if all(v in (None, "") for v in rec.values()): continue
if DATE_COL in rec: rec[DATE_COL] = parse_date(rec[DATE_COL])
if AMOUNT_COL in rec and rec[AMOUNT_COL] is not None:
try: rec[AMOUNT_COL] = round(float(str(rec[AMOUNT_COL]).replace(",", "")), 2)
except ValueError: pass
key = tuple(str(rec.get(c)) for c in std_cols)
if key in seen: dupes.append(rec)
else:
seen.add(key); merged.append(rec); n_new += 1
files_used.append((os.path.basename(f), n_new))
out = Workbook(); ws = out.active; ws.title = "合并总表"
ws.append(std_cols)
for rec in merged: ws.append([rec.get(c) for c in std_cols])
for j in range(1, len(std_cols) + 1):
ws.column_dimensions[get_column_letter(j)].width = 14
ws2 = out.create_sheet("重复行_待复核"); ws2.append(std_cols)
for rec in dupes: ws2.append([rec.get(c) for c in std_cols])
ws3 = out.create_sheet("汇总"); ws3.append(["来源文件", "新增行数"])
for name, n in files_used: ws3.append([name, n])
total = sum(r[AMOUNT_COL] for r in merged if isinstance(r.get(AMOUNT_COL), (int, float)))
ws3.append(["合计金额", total]); ws3.append(["重复剔除行数", len(dupes)])
out.save(outfile)
print(f"[OK] 合并 {len(files_used)} 个文件 -> {outfile}")
print(f" 有效 {len(merged)} 行,剔除重复 {len(dupes)} 行,合计金额 {total}")
return 0
if __name__ == "__main__":
indir = sys.argv[1] if len(sys.argv) > 1 else "."
outfile = sys.argv[2] if len(sys.argv) > 2 else "merged_总表.xlsx"
sys.exit(merge_dir(indir, outfile))
测试数据:9月_华东.xlsx(标准表头,字符串日期)、9月_华南.xlsx(「记账日期/科室/金额(元)」别名表头,真日期类型,含 1 行跨表重复)、Sep_report.xlsx(英文表头 + 前面 2 行说明 + Excel 序列号日期)。真实运行结果:
[OK] 合并 3 个文件 -> merged_out.xlsx
有效 6 行,剔除重复 1 行,合计金额 8869.3
- 9月_华东.xlsx: +3
- 9月_华南.xlsx: +1
- Sep_report.xlsx: +2
输出文件三个 sheet:合并总表(列名全部对齐,6 行日期/金额全部正确)、重复行_待复核(那条「云服务器 1200」被精准抓出来,而不是被默默算重或默默删掉)、汇总(各文件贡献行数 + 合计金额 + 重复数)。序列号 46262 → 2026-08-28,与 openpyxl 官方 from_excel 一致。
ALIASES 字典:左边是你想要的标准列名,右边是各部门可能叫法的全集parse_date 的 fmts 列表里追加把你的几个 Excel 发给喵窝,我们按你的表头跑好合并脚本,交付「合并总表 + 重复行复核表 + 汇总」三件套,9.9 元起,说明 AI 完成人工核验。虚拟物品,交付后不退款。更多实跑脚本见 喵窝资源铺。