ARTICLE · 1230215
药企QC台账Excel模板,月度汇总总对不上只能手工重算

QC 的检验台账,很少一个人从头记到尾。有人把日期敲成「10月9日」,有人把批号列设成常规格式,0010234 存进去就成了 10234,状态栏里合格、符合规定、OK 都见过。平时都能用,一到月底出汇总就露馅:光对齐写法就得半天,批次数还跟 QA 对不上,只能退回手工核。
为了解决这个问题,Python 提供了 openpyxl 这套读写 xlsx 的库,配上「先把台账字段钉死、再让程序按钉死的口径算」的打法:判定都挂在受控检项清单上,日期、批号、状态、检验员先归一再算;归月看报告日期,复检只看末次,在检不进分母。口径写进代码,下个月不用重新对一遍。
本文按四步走:配字段 → 读台账 → 算口径 → 出汇总。讲到「一份人工台账出一份当月汇总」为止,图表、写回原台账、系统对接不在范围。
一、先定字段:台账是给汇总用的,不是给人看的
台账常被当流水账记,可它是月度汇总唯一的数据源:少一列就得回头翻记录。先把列钉死:
三条硬规矩:日期列设日期格式;批号列设文本,0010234 掉一个零就跟真叫 10234 的批号分不开;状态列加下拉,人填不出「合格✔」。另外别合并单元格——合并格只有一个格有值,看着「那批都算」,程序读到的是「只有第一行有批号」。
二、读台账:表头不一定在第一行,列的位置也不可信
真实台账上面常压着「部门」「月份」这类行,表头在第 3 行甚至第 5 行。所以先读检项清单建字典,再定位表头、按列名取列——列顺序谁都能调。
出处:run.py 的 read_spec()(顶部常量区之后)
def read_spec(path: Path) -> dict:"""检项清单:检项名 → 编码 + 标准 TAT。后面所有口径都挂在这张表上。"""import openpyxlws = openpyxl.load_workbook(path, data_only=True).worksheets[0]spec = {}for row in ws.iter_rows(min_row=2, values_only=True):if not row or not row[0]:continuename = str(row[1]).strip()spec[name] = {"code": str(row[0]).strip(), "name": name, "tat": int(row[2])}return spec
键用检项名称不用编码,因为人填台账写的永远是名字;编码和标准 TAT 留给超期判定。
出处:run.py 的 find_header() 与 read_ledger()(紧跟 read_spec())
def find_header(ws, depth: int = 6):"""表头不一定在第一行:往上最多翻 6 行,找同时含「批号」「检项」的那行。"""for r in range(1, depth + 1):vals = [str(c.value).strip() if c.value is not None else "" for c in ws[r]]if "批号" in vals and "检项" in vals:return r, valsraise SystemExit("前 6 行里没找到含「批号」「检项」的表头行")def read_ledger(path: Path):"""按表头名称取列,不认列的位置;批号、品名都空的行当备注/空行跳过。"""import openpyxlws = openpyxl.load_workbook(path, data_only=True)["台账"]hrow, heads = find_header(ws)col = {name: i for i, name in enumerate(heads) if name}rows, skipped = [], []for r in range(hrow + 1, ws.max_row + 1):vals = [c.value for c in ws[r]]raw = {k: (vals[i] if i < len(vals) else None) for k, i in col.items()}if not raw.get("检验单号") and not raw.get("批号"):skipped.append(r)continuerows.append(raw)return rows, skipped
skipped 别急着丢:「跳过了几行」本身是校验点——22 行却读到 25 行,说明表头定位偏了。非数据行按「单号和批号都空」判:备注行往往在品名列写一句话。
三、把脏数据归一:批号、日期、状态、检验员
四种脏法,四个小函数:
2-2026A-0926 | 2-2026A-0926 | |
2026/10/32026.10.8、10月9日、20261010 | ||
符合规定OK、合格✔ | ||
张伟(代)张 伟 |
出处:run.py 的 norm_batch() 与 parse_date()(文件中部,read_ledger() 之后)
def norm_batch(v):"""批号一律当文本:全角横线换半角、空格清掉;数字型的标出来交给上层判。"""if isinstance(v, (int, float)):return str(int(v)), Truereturn str(v or "").translate(BATCH_CLEAN).strip(), Falsedef parse_date(v, year: int):"""四种写法统一成 date:datetime / 2026/10/3 / 2026.10.8 / 10月9日 / 20261010。"""if v is None or v == "":return Noneif isinstance(v, dt.datetime):return v.date()if isinstance(v, dt.date):return vif isinstance(v, (int, float)):s = str(int(v))if len(s) == 8:return dt.date(int(s[:4]), int(s[4:6]), int(s[6:]))return Nones = str(v).strip().replace("年", "-").replace("月", "-").replace("日", "")s = re.sub(r"[/.\s]", "-", s)parts = [p for p in s.split("-") if p]try:if len(parts) == 3:return dt.date(int(parts[0]), int(parts[1]), int(parts[2]))if len(parts) == 2: # 「10月9日」没年份,用台账年份补return dt.date(year, int(parts[0]), int(parts[1]))except ValueError:return Nonereturn None
BATCH_CLEAN 是顶部的翻译表。parse_date() 的年份参数别用「今年」——补录去年单子时会把 9 月算成明年 9 月。
出处:run.py 的 norm_status() 与 norm_person()(文件中部)
def norm_status(v) -> str:"""状态归一成 合格 / 不合格 / 在检 / 待确认;认不出来的一律待确认,不猜。"""s = str(v or "").strip().rstrip("✔✓√").strip().upper()if not s:return ""if s in OK_WORDS:return "合格"if s in NG_WORDS:return "不合格"if s in DOING_WORDS:return "在检"return "待确认"def norm_person(v) -> str:"""「张伟(代)」「张 伟」「李娜(夜班)」→ 张伟 / 张伟 / 李娜。"""s = re.sub(r"[((].*?[))]", "", str(v or ""))return re.sub(r"\s+", "", s)
三个状态集合也在顶部。认不出来的返回「待确认」,不硬塞进合格——「部分合格」算不算放行,得由写记录的人答。
串起来逐行清洗,缺判据的进待确认清单:
出处:run.py 的 build_records()(norm_person() 之后,这里只贴判定那一段)
reason = ""if digit_like and len(batch) < 7:reason = "批号疑似丢前导零"elif item not in spec:reason = "检项不在检项清单"elif status == "待确认":reason = "状态写法无法判定"elif report and not status:reason = "有报告日期但没填状态"if reason:pending.append({"单号": raw.get("检验单号"), "批号": batch,"检项": item, "原因": reason})continue
10234 到底是 0010234 还是 010234,程序猜不出来,只能挂起让人看一眼——比默默补个零安全。
四、算口径:复检取末次、归月看报告日期、TAT 大于标准才算超
三件事最容易各人一个算法,写进代码就不用每月吵一次:
> 不用 >= |
出处:run.py 的 judge()(文件下半部,build_records() 之后,从中段贴起)
counts, latest = {}, {}for rec in records:key = (rec["批号"], rec["检项"])counts[key] = counts.get(key, 0) + 1old = latest.get(key)if old is None or (rec["报告"] or dt.date.min) >= (old["报告"] or dt.date.min):latest[key] = recout = []for rec in records:key = (rec["批号"], rec["检项"])last = latest[key]item = dict(rec)# 同单号同报告日期都相等 → 这条就是末次;复检的前几次挂在下面备查item["末次"] = last["单号"] == rec["单号"] and last["报告"] == rec["报告"]item["复检"] = "是" if counts[key] > 1 else "否"rec = item
dt.date.min 是垫底技巧:报告日期是 None 时直接比大小会抛错。这段之后算归月、TAT 和超期,是直白的 if/else,没贴。
五、出汇总:以检项清单为左表
最后一步容易出两个错:只按台账里出现过的检项汇总(本月一条都没有的检项直接消失);把在检的单子算进合格率分母。
出处:run.py 的 month_rows() 与 summarize()(judge() 之后)
def month_rows(out, month: str):"""当月完成(按报告日期归月)+ 当月开工还没出报告的(按开始日期归月);只认末次。"""return [r for r in out if r["末次"] and (r["归月"] == month or(r["报告"] is None and r["开始"].strftime("%Y-%m") == month))]def summarize(cur, spec: dict):"""以检项清单为左表:本月零批次的检项也要出一行,否则看报表的人以为漏了。"""rows = []for item, info in spec.items():sub = [r for r in cur if r["检项"] == item]done = [r for r in sub if r["判定"] in ("合格", "不合格")]tats = [r["TAT"] for r in done if r["TAT"] is not None]rows.append({"检项编码": info["code"],"检项": item,"检验次数": len(done),"涉及批次": len({r["批号"] for r in done}),"合格": sum(1 for r in done if r["判定"] == "合格"),"不合格": sum(1 for r in done if r["判定"] == "不合格"),"在检": sum(1 for r in sub if r["判定"] == "在检"),"超期": sum(1 for r in done if r["超期"] == "是"),"平均TAT": round(sum(tats) / len(tats), 1) if tats else 0,"标准TAT": info["tat"],})return rows
汇总同时给三个数:检验次数、涉及批次(批号去重)、在检。同一批号测了含量和有关物质,就是两个检验次数、一个批次。
写盘只提一处:规范台账里批号列重设文本格式,日期列设 yyyy-mm-dd。
出处:run.py 的 write_ledger()(summarize() 之后)
for row in ws.iter_rows(min_row=2, min_col=3, max_col=3):row[0].number_format = "@"
run.py 另有一段工程外壳不逐行贴:跑前补依赖(清华源)、往 source/run_log.txt 追加运行主机与时间、跑完回读输出做 19 项断言打 PASS/FAIL。
总结
台账的问题不在记得糙,在于字段没为汇总留接口:日期是文本、批号是数字、状态自由发挥,月底只能人肉对齐。把口径钉进代码——归月看报告日期、复检只认末次、在检不进分母、超期用大于号——换个人算也是同一套。 示例里故意造了日期五种写法、批号存成数字、全角横线、复检两条、跨月、零批次检项,跑一遍能看到 4 条挂起、19 项断言全绿。
完整源码
本文配套的可运行示例已开源,把自己的台账换进 01_raw_data/,一条命令就能复跑:
https://gitee.com/huang-jianhua-gitee/examples/tree/main/20261022
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类: - 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错 - 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表 - 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对 - 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。















































































