夜雨聆风学习资料网

ARTICLE · 1230215

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

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

QC 的检验台账,很少一个人从头记到尾。有人把日期敲成「10月9日」,有人把批号列设成常规格式,0010234 存进去就成了 10234,状态栏里合格、符合规定、OK 都见过。平时都能用,一到月底出汇总就露馅:光对齐写法就得半天,批次数还跟 QA 对不上,只能退回手工核。

为了解决这个问题,Python 提供了 openpyxl 这套读写 xlsx 的库,配上「先把台账字段钉死、再让程序按钉死的口径算」的打法:判定都挂在受控检项清单上,日期、批号、状态、检验员先归一再算;归月看报告日期,复检只看末次,在检不进分母。口径写进代码,下个月不用重新对一遍。

本文按四步走:配字段 → 读台账 → 算口径 → 出汇总。讲到「一份人工台账出一份当月汇总」为止,图表、写回原台账、系统对接不在范围。

一、先定字段:台账是给汇总用的,不是给人看的

台账常被当流水账记,可它是月度汇总唯一的数据源:少一列就得回头翻记录。先把列钉死:

列
为什么必须有
检验单号
每行身份证,复检补录靠它
批号
批次数按它去重,必须存文本
检项
汇总横轴,写法与清单逐字一致
检验员
按人统计超期,写法要归一
开始日期
TAT 起点,真日期
报告日期
归月依据;空着 = 还没出报告
状态
只有合格 / 不合格 / 在检

三条硬规矩:日期列设日期格式;批号列设文本,0010234 掉一个零就跟真叫 10234 的批号分不开;状态列加下拉,人填不出「合格✔」。另外别合并单元格——合并格只有一个格有值,看着「那批都算」,程序读到的是「只有第一行有批号」。

二、读台账:表头不一定在第一行,列的位置也不可信

真实台账上面常压着「部门」「月份」这类行,表头在第 3 行甚至第 5 行。所以先读检项清单建字典,再定位表头、按列名取列——列顺序谁都能调。

出处:run.py 的 read_spec()(顶部常量区之后)

def read_spec(path: Path) -> dict:    """检项清单:检项名 → 编码 + 标准 TAT。后面所有口径都挂在这张表上。"""    import openpyxl    ws = 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]:            continue        name = 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, vals    raise SystemExit("前 6 行里没找到含「批号」「检项」的表头行")def read_ledger(path: Path):    """按表头名称取列,不认列的位置;批号、品名都空的行当备注/空行跳过。"""    import openpyxl    ws = 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)            continue        rows.append(raw)    return rows, skipped

skipped 别急着丢:「跳过了几行」本身是校验点——22 行却读到 25 行,说明表头定位偏了。非数据行按「单号和批号都空」判:备注行往往在品名列写一句话。

三、把脏数据归一:批号、日期、状态、检验员

四种脏法,四个小函数:

原写法
归一成
为什么
2-2026
、A-0926
2-2026
、A-0926
全角横线与空格肉眼看不见,去重会算成两批
2026/10/3
、2026.10.8、10月9日、20261010
统一的 date
月份切错,整月汇总全歪
符合规定
、OK、合格✔
合格
同义不同写,不归一就各算一类
张伟(代)
、张 伟
张伟
按人统计变成三个人

出处:run.py 的 norm_batch() 与 parse_date()(文件中部,read_ledger() 之后)

def norm_batch(v):    """批号一律当文本:全角横线换半角、空格清掉;数字型的标出来交给上层判。"""    if isinstance(v, (int, float)):        return str(int(v)), True    return 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 None    if isinstance(v, dt.datetime):        return v.date()    if isinstance(v, dt.date):        return v    if 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 None    s = 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 None    return 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 大于标准才算超

三件事最容易各人一个算法,写进代码就不用每月吵一次:

动作
为什么这么写
同批号同检项多条 → 只看报告日期最大那条
复检以最后一次为准;其余标「末次=否」留档
归月看报告日期,不看开始日期
9 月开工 10 月出报告,产出算 10 月
TAT = 报告日期 − 开始日期,自然日
工作日要维护节假日表,先算自然日留余量
超期用 > 不用 >=
标准 12 天、实际正好 12 天不算超

出处:run.py 的 judge()(文件下半部,build_records() 之后,从中段贴起)

    counts, latest = {}, {}    for rec in records:        key = (rec["批号"], rec["检项"])        counts[key] = counts.get(key, 0) + 1        old = latest.get(key)        if old is None or (rec["报告"] or dt.date.min) >= (old["报告"] or dt.date.min):            latest[key] = rec    out = []    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+ 逐条核对原始数据与记录是否对得上

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

相关学习资料