欢迎光临
我们一直在努力

LIMS 数据导出成 Excel,报表每月还得手工重排一遍

口径表驱动的月度质量报表

每月月初要出一份质量报表,数据都在系统里,系统也有报表模块。可那套模板是验证过的,加一列、换个单位、调一下小数位,都要走变更、等排期,慢的时候一个月。业务等不起。于是最常见的做法是:把明细导出来,在 Excel 里筛、分组、拉公式、重排格式,一份报表半天就没了。

为了解决这个问题,Python 提供了 openpyxl 和 csv 这两个库:csv 逐行读导出的明细文件,openpyxl 按模板把结果写回 Excel。

本文按四步走:先把报表口径写进一张 Excel 表,再读明细、汇总、出报表。口径变了改表,代码一行不动。配套示例带故意造出来的边界数据,可以直接复跑。

为什么是「导出来自己在 Excel 里出报表」

系统的报表模板动一次要走变更和验证,等的是排期;而报表口径月月有人提——今天加一列,明天换个单位,后天客户说小数点要留三位。

导出的明细是给系统用的:中心是代码、单位是原始单位、小数位没定;报表是给人看的:中心要全称、单位要统一、小数位按客户要求。这两套口径本来就不是一回事。

第一步:把口径写进一张表

口径表三个工作表,各管一件事。

工作表一行是改它会怎样
中心字典 中心代码 → 中心名称 报表里多一个中心、中心改名都靠它
项目口径 一个检测项目:目标单位、换算系数、小数位 单位换成别的、小数点改位数靠它
报表定义 一份报表:输出文件名 + 中心列表 想让某个中心单独出一份报表,改这一格

路径和列名统一放在文件顶部,一律按名字取,不按第几列。

出处:run.py 顶部的路径与字段常量区

DETAIL_CSV = RAW_DIR / "lims_export_结果明细.csv"
SPEC_XLSX = RAW_DIR / "报表口径.xlsx"
REPORT_TPL = TPL_DIR / "月度质量报表模板.xlsx"

# —— 明细文件的列名:一律按名字取,不按第几列 ——
F_SAMPLE = "样本编号"
F_CENTER = "中心代码"
F_ITEM = "检测项目"
F_RESULT = "结果"
F_UNIT = "单位"
F_JUDGE = "判定"
F_STATUS = "状态"

STATUS_OK = "已出报告" # 只有这个状态进统计
JUDGE_PASS, JUDGE_FAIL = "合格", "不合格"
REPORT_COLS = ["检测项目", "单位", "检测项数", "合格", "不合格", "未判定", "合格率(%)", "结果均值"]
SHEET_EXCLUDED = "未纳入清单"

EXCLUDE_REASON = {"已作废": "已作废,不进统计", "复检中": "复检中,结果未定"}

# 结果列里带单位时(例:98.6 g/L)拆成数值 + 单位;只给边界的写法(例:<0.01)走另一条
RE_NUM_UNIT = re.compile(r"^([-+]?\\d+(?:\\.\\d+)?)\\s+([A-Za-z%/][A-Za-z0-9/%.]*)$")
RE_LIMIT = re.compile(r"^([<>]=?)\\s*([-+]?\\d+(?:\\.\\d+)?)$")

上面用到的 re 在 run.py 顶部就 import 了,不在这里现引。

中心名字差一个看不见的空格就是两个名字。凡是要比较的字符串,先过一遍归一化。

出处:run.py 的 norm()(紧随顶部的常量区)

def norm(text):
"""去掉所有空白(含全角空格),比较中心名 / 列名 / 项目名之前一律先过它。"""
return "".join(str(text if text is not None else "").split())

读上面那三张表,用的是同一个函数,只是把「判空列」当参数传进去。

出处:run.py 的 read_rows()(文件前部)

def read_rows(path, sheet, key_col):
"""读一张表,返回 [{列名: 值}]。

key_col 是这张表的判空列:这一列为空的行当表尾说明行跳过。
每张表都有自己合适的判空列,写死某一列会把别的表整张读空。
"""
from openpyxl import load_workbook

wb = load_workbook(path, data_only=True)
ws = wb[sheet]
rows = list(ws.iter_rows(values_only=True))
head = [norm(c) for c in rows[0]]
if key_col not in head:
raise ValueError(f"「{sheet}」里没有判空列「{key_col}」,表头是 {head}")
idx = head.index(key_col)
out = []
for row in rows[1:]:
if len(row) <= idx or norm(row[idx]) == "":
continue
out.append(dict(zip(head, row)))
wb.close()
return out

中心字典看「中心代码」判空,项目口径看「检测项目」判空。写死某一列,换张表就可能整张读成空,一行错都不报。表尾那行说明文字,就是靠判空列留空被跳过的。

第二步:读明细,先给结果分形态

结果列不止一种写法:纯数值、只给边界的 <0.01、文字结论「未检出」,还有整格空的。它们都要计数、都要参与判定,但只有纯数值能进均值——<0.01 按 0.01 算会把均值拉低,「未检出」按 0 算更离谱。

出处:run.py 的 parse_result()(文件中部)

def parse_result(raw):
"""结果解析成 (形态, 数值, 结果列里带的单位)。

形态:num 纯数值 / limit 只给边界(<0.01) / text 文字结论(未检出) / empty 空。
只有 num 进均值——<0.01 按 0.01 计会把均值拉低,未检出按 0 计更离谱。
"""
text = (raw or "").strip()
unit = ""
if not text:
return "empty", None, unit
hit = RE_NUM_UNIT.match(text)
if hit:
text, unit = hit.group(1), hit.group(2) # 有的导出版本把单位写在结果里
hit = RE_LIMIT.match(text)
if hit:
return "limit", float(hit.group(2)), unit
try:
return "num", float(text), unit
except ValueError:
return "text", None, unit

动作为什么这么写
状态先过滤,只留「已出报告」 作废、复检中的记录混进统计,合格率就再也说不清
<0.01 单独记成「只给边界」 按 0.01 计入均值,整列均值会被拉低
一律按列名取值、判空列当参数传 导出文件列序会变、每张表的判空列不一样,写死就整张读空

接着逐行读明细,把文件里那张表变成一条条记录。

出处:run.py 的 read_detail()(紧随 parse_result())

def read_detail():
"""读结果明细,逐行把结果解析成形态 + 数值。表尾说明行靠第一列判空跳过。"""
out = []
with DETAIL_CSV.open("r", encoding="utf-8-sig", newline="") as fh:
for row in csv.DictReader(fh):
if norm(row.get(F_SAMPLE)) == "":
continue
kind, value, unit_in_result = parse_result(row.get(F_RESULT))
out.append({
F_SAMPLE: norm(row.get(F_SAMPLE)),
F_CENTER: norm(row.get(F_CENTER)),
F_ITEM: norm(row.get(F_ITEM)),
F_UNIT: norm(row.get(F_UNIT)) or unit_in_result,
F_JUDGE: norm(row.get(F_JUDGE)),
F_STATUS: norm(row.get(F_STATUS)),
"形态": kind,
"数值": value,
})
return out

有一条记录把单位写进了结果列(98.6 g/L),单位列反而是空的。所以单位取「单位列优先,空则用结果里拆出来的」,不然这条会被当成没单位的数值混过去。

第三步:按口径汇总

一个中心、一个项目出报表上的一行。项数按记录数点,合格与不合格按判定栏数,剩下的一律进「未判定」。

出处:run.py 的 aggregate()(文件中部)

def aggregate(center, detail, item_spec, item_order):
"""一个中心一段明细:按项目归堆,出「一个项目一行」的统计。

只吃状态为「已出报告」的记录;结果先按口径换算到目标单位再求均值。
"""
rows = []
for item in item_order:
recs = [d for d in detail
if d[F_CENTER] == center and d[F_ITEM] == item and d[F_STATUS] == STATUS_OK]
if not recs:
continue
spec = item_spec.get(item, {})
nums = [d["数值"] * spec.get("换算系数", 1.0) for d in recs if d["形态"] == "num"]
passed = sum(1 for d in recs if d[F_JUDGE] == JUDGE_PASS)
failed = sum(1 for d in recs if d[F_JUDGE] == JUDGE_FAIL)
denom = passed + failed
rows.append({
"检测项目": item,
"单位": spec.get("目标单位", ""),
"检测项数": len(recs),
"合格": passed,
"不合格": failed,
"未判定": len(recs) – passed – failed,
"合格率(%)": round(passed / denom * 100, 1) if denom else None,
"结果均值": round(sum(nums) / len(nums), spec.get("小数位", 2)) if nums else None,
})
return rows

这里的 denom 是最要紧的一行:分母是「合格 + 不合格」,不是项数。示例里上海中心有一项只有一条记录、判定栏是 —,它合格率就是空的,不是 0。单位换算也只在这里做一次——数据里是 mg/L,报表要 mg/dL,系数写在口径表里。

第四步:填进模板,一个中心一页

模板一个格子都不改。脚本按第一列的标签反查表头行和合计行,中间那一段就是明细区。

出处:run.py 的 head_row() 与 fill_sheet()(文件中部)

def head_row(ws, label):
"""按第一列的标签反查行号——模板挪行、加行都不用改代码。"""
for r in range(1, ws.max_row + 1):
if norm(ws.cell(r, 1).value) == label:
return r
raise ValueError(f"模板里找不到标签「{label}」")

def fill_sheet(ws, center_name, rows):
"""把统计结果填进模板:目标行不够就清值留边框,一格不动样式。"""
head = head_row(ws, "检测项目")
total = head_row(ws, "合计")
for r in range(1, head):
if norm(ws.cell(r, 1).value) == "中心:":
ws.cell(r, 2).value = center_name
for i, row_no in enumerate(range(head + 1, total)):
for j, col in enumerate(REPORT_COLS, 1):
value = rows[i][col] if i < len(rows) else None
ws.cell(row_no, j).value = value if value != "" else None
ws.cell(total, 1).value = "合计"
for j, col in zip(range(3, 7), ["检测项数", "合格", "不合格", "未判定"]):
ws.cell(total, j).value = sum(r[col] for r in rows)
denom = ws.cell(total, 4).value + ws.cell(total, 5).value
ws.cell(total, 7).value = round(ws.cell(total, 4).value / denom * 100, 1) if denom else None

明细区预留 12 行,填不满的行只清值、边框一格不动。别删行——边框跟着没,打印是白格;也别留着上一版的值,不然会被当成这一版的数据发出去。

最后按报表定义循环出报表:几个中心就复制几页模板,没进统计的记录单独一张「未纳入清单」,写清每一条为什么没算。

出处:run.py 的 build_reports()(文件后部;函数开头还有一句 from openpyxl import load_workbook,这里略去不贴)

made = []
for rep in read_report_def():
codes = [c.strip() for c in str(rep["中心列表"]).split("|") if c.strip()]
wb = load_workbook(REPORT_TPL)
template = wb["报表模板"]
excluded = [d for d in detail if d[F_STATUS] != STATUS_OK and d[F_CENTER] in codes]
first = True
for code in codes:
if code not in centers:
raise ValueError(f"中心代码「{code}」在中心字典里找不到")
name = centers[code]
rows = aggregate(code, detail, item_spec, item_order)
ws = template if first else wb.copy_worksheet(template)
first = False
fill_sheet(ws, name, rows)
ws.title = name
log_line(f" {norm(rep['报表名'])} / {name}:{len(rows)} 个项目")
add_excluded_sheet(wb, excluded)
out_file = OUT_DIR / str(rep["输出文件名"])
wb.save(out_file)
wb.close()
made.append((out_file, len(codes), len(excluded)))
return made

run.py 还有一层工程外壳:缺依赖自动装、把每次运行记进日志、生成完自动回读校验并打印逐项结果。那段与本文主线无关,就不逐行贴了。

总结

  • 口径写在表里,不写在代码里。换客户、换单位、改小数位,改表就行。
  • 状态先过滤,再谈统计。作废、复检中的记录混进来,合格率就再也说不清。
  • 结果分形态。只有纯数值进均值,<0.01 和「未检出」只参与计数与判定。
  • 合格率的分母是判过定的项数,未判定单独列一栏。
  • 明细区填不满就清值留边框,别删行;没进统计的记录单独成页写清原因。

完整源码

本文配套的可运行示例已开源,含故意造出来的边界数据,克隆下来直接复跑:

huang_jianhua0101/examples – Gitee.com

关于我

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

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

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

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

赞(0)
未经允许不得转载:171主机测评 » LIMS 数据导出成 Excel,报表每月还得手工重排一遍
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址