ARTICLE · 1220865
实验室 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, 0for i, row in enumerate(rows[:HEADER_SCAN]):hits = len(keys & {norm(c) for c in row})if hits > best_hits:best, best_hits = i, hitsreturn 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_workbookwb = 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():continuename = path.nameif name.startswith("~$"):skipped.append((name, "Excel 打开时留下的锁文件,不是模板"))continueif path.suffix.lower() == ".xls":skipped.append((name, "旧格式 .xls,先另存为 .xlsx 再盘点"))continueif path.suffix.lower() not in SKIP_SUFFIX:skipped.append((name, "不是 Excel 文件"))continueif NAME_KEY not in path.stem:skipped.append((name, "文件名不含「模板」,不在这次盘点范围"))continuetry: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"] - 1d_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].widthd_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+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。















































































