夜雨聆风学习资料网

ARTICLE · 1220865

实验室 Excel 模板版本一乱,报表口径就统一不了

实验室 Excel 模板版本一乱,报表口径就统一不了

共享盘里那个检验记录模板,已经变成七八个文件:- 副本、(最终版)、_2024改、(旧),还有一份连版本号都没有。谁手上哪一版说不清;两个人按不同版本填同一批样品,汇总时列对不上;审计一问“现行版是哪一份”就答不上来。

为了解决这个问题,Python 提供了 openpyxl 这套读写 xlsx 的库,配上“不算文件名、算结构指纹”的打法:只看有几个可见工作表、表头有哪些列名,骨架一样的就是同一版;同版内按版本号挑出在用的那份,再列出它跟别的版本的差别。名字、时间、版本号都可能骗人,骨架不会。

本文按四步走:配判据 → 算指纹 → 归堆挑现行 → 输出清单与模板。讲到把这堆盘点清楚为止,模板下发与审批不在本文范围。

一、先看清乱在哪

三个现象都见过:

  • 名字最像终版的,往往不是最新。(最终版)
    加了两列,版本号只写 V2.0,_2025新倒是 V2.2。
  • 修改时间也不可信。
     有人把- 副本打开看一眼、随手保存,它就成了全目录最新。
  • 有的文件压根没有版本号。
     标题行被删,表头直接顶到第一行。

所以判据不能是文件名,也不能只是时间,只能看结构。

二、判据:算结构,不算名字

判据
为什么这么定
可见工作表个数
隐藏的「填写说明」谁都会改,算进去就一文件一组
表头列名集合(归一后排序)
列序常被拖动,按顺序比会把同版拆散
表头行靠列名命中定位
前面压着标题和说明行,写死第一行只读到空
版本号(表头上方用正则抓)
唯一能表达"谁新",但得跟结构一起看
修改时间
只在版本号并列时做次序

三、分四步写

第一步:配置路径与字段

先定三件事:哪些文件进盘点、表头靠哪几个列名认、版本号长什么样。路径全按脚本目录推,换机器不用改。NAME_KEY 看着多余,其实最省事——共享盘里还躺着台账、进度表,混进来版本文数会翻倍。

出处:run.py 顶部常量区(路径与字段,import re 在文件最顶部)

# 只有文件名带「模板」的才进盘点:同一个共享盘里还躺着台账、进度表,混进来会把版本数算飞NAME_KEY = "模板"# 表头不一定在第一行,靠这几个列名找它在第几行HEADER_KEYS = ("样品编号", "检测项目", "检测结果", "单位")# 表头最多往前看几行(标题行 + 填写说明行,现实里最多压两三行)HEADER_SCAN = 10# 版本号写在标题行里,形如「版本号:V2.1」,用正则抓VER_RE = re.compile(r"V\s*(\d+(?:\.\d+)?)", re.I)# 文件名里带这些词,容易让人误以为是最新版HINT_WORDS = ("最终", "最新", "新")# 只认这两种,.xls 是旧格式,openpyxl 打不开SKIP_SUFFIX = (".xlsx", ".xlsm")

第二步:读数据,算指纹

一个文件取三样:表头在第几行、版本号、结构指纹。表头不能写死第一行——_2024改 的表头在第 3 行;也不能按“非空最多的一行”猜,模板带示例数据,数据行比表头热闹。

出处:run.py 的 norm() 与 find_header()(文件中部,紧接日志函数之后)

def norm(value):    """列名归一:全角转半角、去掉所有空白、转小写,专门用来比列名同不同。"""    text = unicodedata.normalize("NFKC", str(value if value is not None else ""))    return re.sub(r"\s+", "", text).lower()def find_header(rows):    """表头在第几行?返回 0 基行号;命中列名最多的那一行才算表头。"""    keys = {norm(k) for k in HEADER_KEYS}    best, best_hits = -1, 0    for i, row in enumerate(rows[:HEADER_SCAN]):        hits = len(keys & {norm(c) for c in row})        if hits > best_hits:            best, best_hits = i, hits    return best if best_hits >= 2 else -1

norm() 里的 NFKC 是关键:检测结果(mg/L) 与 检测结果(mg/L) 是人手敲出的两种写法。版本号按整数元组比,字符串比会把 V2.10 排到 V2.9 前面。

出处:run.py 的 read_version() 与 ver_key()(紧跟 find_header())

def read_version(rows, head):    """在表头上方几行里找「V2.1」这种版本号,找不到就返回 None。"""    for row in rows[:head + 1]:        for cell in row:            if isinstance(cell, str):                hit = VER_RE.search(cell)                if hit:                    return f"V{hit.group(1)}"    return Nonedef ver_key(ver):    """把 V2.2 变成 (2, 2) —— 直接按字符串比会把 V2.10 排到 V2.9 前面。"""    return tuple(int(x) for x in ver[1:].split(".")) if ver else ()

指纹 = 可见工作表个数 + 排序后的列名。隐藏页不算:_2025新 挂着隐藏的「填写说明」,谁补一句都算换版,永远归不完。

出处:run.py 的 probe()(文件中部,读一个文件取全套信息)

def probe(path):    """读一个文件,取出版本号、表头行、列名、可见工作表数,算结构指纹。"""    from openpyxl import load_workbook    wb = load_workbook(path, read_only=True, data_only=True)    visible = [ws.title for ws in wb.worksheets if ws.sheet_state == "visible"]    hidden = [ws.title for ws in wb.worksheets if ws.sheet_state != "visible"]    sheet = wb[visible[0]]    rows = [list(r) for r in sheet.iter_rows(max_row=HEADER_SCAN + 3, values_only=True)]    wb.close()    head = find_header(rows)    if head < 0:        raise ValueError(f"前 {HEADER_SCAN} 行里找不到表头,列名可能被改过")    cols = [c for c in rows[head] if norm(c)]    return {        "path": path,        "name": path.name,        "sub": path.parent.relative_to(RAW_DIR).as_posix(),        "ver": read_version(rows, head),        "header_row": head + 1,        "cols": cols,        "visible": visible,        "hidden": hidden,        "mtime": datetime.fromtimestamp(path.stat().st_mtime),        # 指纹 = 可见页签个数 + 列名集合(排序后取,列序不算差异)        "sig": f"{len(visible)}|{'/'.join(sorted(norm(c) for c in cols))}",    }

第三步:归堆、挑现行版、比差异

挑现行版:先比版本号,并列再比修改时间,最后才看文件名。副本时间再新,版本号低就排后面。

出处:run.py 的 pick_current() 与 diff_cols()(文件中部,probe() 之后)

def pick_current(files):    """组内挑现行版:先比版本号,版本号一样再比修改时间,最后按文件名。"""    return sorted(files, key=lambda f: (ver_key(f["ver"]), f["mtime"], f["name"]),                  reverse=True)def diff_cols(base_cols, other_cols):    """两组列名的差:多出来的、缺掉的,回原样列名方便人看。"""    base = {norm(c): c for c in base_cols}    other = {norm(c): c for c in other_cols}    extra = [other[k] for k in other if k not in base]    missing = [base[k] for k in base if k not in other]    return extra, missing

归堆按指纹,组号按文件数从多到少。只有一份文件的组不能自称现行版,标“待并版”——是新分支还是残留得人定。

出处:run.py 的 group_files()(紧跟 diff_cols())

def group_files(taken):    """按指纹归堆,堆按文件数从多到少编号 G1、G2……"""    buckets = {}    for item in taken:        buckets.setdefault(item["sig"], []).append(item)    groups = []    for sig, files in buckets.items():        ordered = pick_current(files)        groups.append({"sig": sig, "files": ordered, "current": ordered[0]})    groups.sort(key=lambda g: (len(g["files"]), ver_key(g["current"]["ver"])), reverse=True)    for idx, group in enumerate(groups, 1):        group["no"] = f"G{idx}"    return groups
动作
为什么这么写
指纹用排序后的列名
列序被打乱的_2024改也该进主版
只算可见工作表
隐藏说明页不算换版
先比版本号,再比时间
副本保存一次就变“最新”

第四步:输出清单与受控模板

扫盘先要解决“不让坏文件带崩整轮”:零字节、被 Excel 锁住、.xls 旧格式,全都记账跳过。

出处:run.py 的 scan()(文件中部,probe() 与 pick_current() 之间)

def scan():    """走一遍模板目录:该跳过的跳过,该报错的报错,别让一个坏文件带崩整轮。"""    taken, skipped = [], []    for path in sorted(RAW_DIR.rglob("*")):        if not path.is_file():            continue        name = path.name        if name.startswith("~$"):            skipped.append((name, "Excel 打开时留下的锁文件,不是模板"))            continue        if path.suffix.lower() == ".xls":            skipped.append((name, "旧格式 .xls,先另存为 .xlsx 再盘点"))            continue        if path.suffix.lower() not in SKIP_SUFFIX:            skipped.append((name, "不是 Excel 文件"))            continue        if NAME_KEY not in path.stem:            skipped.append((name, "文件名不含「模板」,不在这次盘点范围"))            continue        try:            taken.append(probe(path))        except Exception as exc:                      # 坏文件只记账,不中断            skipped.append((name, f"打不开:{exc}"))    return taken, skipped

差异表分两段:组内互比,各组现行再跟全局基准比。

出处:run.py 的 write_list() 里差异明细那一段(函数后半段,第三张表)

    # ③ 差异明细:组内其他文件 vs 组内现行;各组现行 vs 全局基准    ws = wb.create_sheet("差异明细")    base = groups[0]["current"]    rows = []    for g in groups:        cur = g["current"]        for f in g["files"][1:]:            extra, missing = diff_cols(cur["cols"], f["cols"])            sheet_gap = len(f["visible"]) - len(cur["visible"])            rows.append([g["no"], f["name"], cur["name"], "、".join(extra) or "—",                         "、".join(missing) or "—",                         f"{sheet_gap:+d}" if sheet_gap else "0"])        if g["no"] != "G1":            extra, missing = diff_cols(base["cols"], cur["cols"])            sheet_gap = len(cur["visible"]) - len(base["visible"])            rows.append([g["no"], cur["name"], f"{base['name']}(G1)",                         "、".join(extra) or "—", "、".join(missing) or "—",                         f"{sheet_gap:+d}" if sheet_gap else "0"])

最后照现行版另存干净模板:只留标题行、表头、一行空位,样式列宽照抄。

出处:run.py 的 write_clean()(文件后半段,紧跟 write_list())

    for name in baseline["visible"]:        s_ws = src[name]        head = find_header([list(r) for r in s_ws.iter_rows(            max_row=HEADER_SCAN, values_only=True)])        if head < 0:                                  # 汇总页没有样品编号,按主表的行位取            head = baseline["header_row"] - 1        d_ws = wb.create_sheet(name)        d_ws.cell(row=1, column=1,                  value=f"{name}(受控版 {baseline['ver']},生效 "                        f"{datetime.now():%Y-%m-%d})").font = Font(bold=True, size=12)        for idx in range(1, s_ws.max_column + 1):            s_cell = s_ws.cell(row=head + 1, column=idx)            d_cell = d_ws.cell(row=2, column=idx, value=s_cell.value)            if s_cell.has_style:                d_cell.font = copy(s_cell.font)                d_cell.fill = copy(s_cell.fill)                d_cell.border = copy(s_cell.border)                d_cell.alignment = copy(s_cell.alignment)            d_ws.column_dimensions[d_cell.column_letter].width = \                s_ws.column_dimensions[s_cell.column_letter].width        d_ws.freeze_panes = "A3"

四、跑出来是什么样

11 个文件跑一遍:纳入 7、跳过 4;归 3 组——主版式 5 份、(最终版) 单独(多出两列)、(旧) 单独(少三列);现行版是_2025新(V2.2),既不是时间最新的副本,也不是名字最像终版的那份;1 份没版本号判“待确认”;回读断言 15 项全过。

(最终版) 最典型:名字最像定稿,版本号却比全盘最高低一档,多出的两列没人知道该不该留,只能摆到桌上让业务和 QA 定。

五、统一之后,留住两条规矩

盘点只解决“现在这一堆”。两条规矩:①版本号写进表格,不靠文件名——标题行固定一格写版本号:V2.3;②换版先跑盘点,把差异表贴进变更记录,正好是变更控制要的证据。受控模板放只读目录,改前提变更。

总结

模板版本乱,乱在判据:名字、时间、版本号都可能骗人,结构不会。按“可见工作表 + 列名集合”归堆,按版本号挑现行版,把差异列成表,再照现行版另存干净模板——四步做完,这堆 Excel 就有了明确现行版和可查的差异记录。仓库示例换成自己的目录就能跑。

完整源码

本文配套示例已开源,含造数据脚本与回读断言,克隆下来直接复跑:

https://gitee.com/huang-jianhua-gitee/examples/tree/main/20261021

关于我

在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。

本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。

现在主要做这几类: - 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错 - 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表 - 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对 - 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上

手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。

相关学习资料