各部门的 Excel 对不上号?一个脚本合并多表:别名列名、乱日期、重复行全搞定

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))

实测输出(3 个故意做乱的表)

测试数据: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 一致。

按自己的业务改哪里

懒得自己跑?

把你的几个 Excel 发给喵窝,我们按你的表头跑好合并脚本,交付「合并总表 + 重复行复核表 + 汇总」三件套,9.9 元起,说明 AI 完成人工核验。虚拟物品,交付后不退款。更多实跑脚本见 喵窝资源铺。