一批样品做完仪器分析结果明细从仪器软件导出成 CSV要套进报表模板出报表。模板里含量、判定、合格率三列都是公式值一贴进去三件事全来了公式格被贴成死数字明细 7 行、模板只预置 4 行多出来的行没公式合格率还按老范围数 4 行报表发出去自己都不知道数小了。为了解决这个问题Python 提供了openpyxl openpyxl.formula.translate.Translatoropenpyxl 打开模板时把公式原样留着Translator 把公式里的行号按目标行平移。要解决的不是「把值填进去」而是让模板原有的公式在新行上照样算对。本文按四步走钉住路径与开关、读数据把算不出来的先挂起、值列照写公式列跟着行数走、保存后回读公式还在不在。一、公式丢在哪三个坑都不是「公式写错了」打开方式决定公式生死。load_workbook(path)读到的是公式字符串加data_onlyTrue读到的是缓存值用它改完再保存公式整列消失只剩死数字。公式不会跟着行数走。Excel 把区域转成「表格」后有「计算列」插行会自动补公式脚本没这待遇ws.insert_rows()只搬格子公式里的G7还是G7。汇总范围也不会自己变。COUNTIF(J7:J10,合格)在明细变成 7 行后仍只数 4 行。反过来明细只有 2 行、剩下两行还带着公式空格被当 0 乘判定算成「合格」合格率被凭空刷高。二、四步走步骤一路径、字段、开关钉在一处路径全用Path(__file__).resolve().parent相对定位。BATCH是本批检测日期抬头和文件名都从它来——不要用date.today()否则同一个件昨天跑和今天跑报表名不一样。出处run.py顶部的路径与字段常量区BASE Path(__file__).resolve().parent RAW_DIR BASE / 01_raw_data OUT_DIR BASE / 02_output SRC_DIR BASE / source TPL_FILE SRC_DIR / templates / 检测结果报表模板.xlsx LOG_FILE SRC_DIR / run_log.txt RESULT_FILE RAW_DIR / 仪器导出结果.csv UNIT_FILE RAW_DIR / 受检单位清单.csv HANG_FILE OUT_DIR / 挂起清单.csv BATCH 20260930 # 本批检测日期抬头与文件名都用它写死常量、不取当天 SHEET_NAME 检测结果报表 UNIT_KEY 受检单位 COL_SAMPLE 样品编号 COL_ITEM 检测项目 COL_AMOUNT 含量(mg/kg) COL_JUDGE 判定模板预置了几行明细脚本是读模板得到的步骤三讲不是写死的。步骤二读仪器导出的数据算不出来的先挂起有一处必须当参数传这张表靠哪一列判空。结果明细的表尾有一条说明行样品编号空着用它判空正好跳过受检单位清单的判空列是「受检单位」。把某张表的判空列写死、套到另一张表上那张表会被整张读空一行错都不报。出处run.py的to_float()与read_table()中部def to_float(text): 洗数字洗不干净返回 None。写「0.01」这类检出限表示法的读数不许当 0.01 用。 t str(text or ).strip().replace(,, ) if not t: return None try: return float(t) except ValueError: return None def read_table(path, key_col): 读 CSVkey_col 是这张表「靠它判空」的那一列——每张表不一样必须当参数传。 with open(path, newline, encodingutf-8-sig) as fh: return [r for r in csv.DictReader(fh) if str(r.get(key_col) or ).strip()]数字一定要洗。仪器读数里有两种「不是数字」空的和0.01这种只给检出限的写法。后者不能当 0.01、也不能当 0——怎么处理是业务口径脚本猜不得只能挂起。出处run.py的read_source()中部# 判定顺序业务顺序读数 → 称样量 → 定容 → 稀释倍数 → 限值 reason if read is None: reason 仪器读数缺失或不是数字检出限表示法不能直接参与计算 elif weigh is None or weigh 0: reason 称样量缺失或不是正数 elif vol is None: reason 定容体积缺失 elif dil is None: reason 稀释倍数缺失不默认按 1 算 elif limit is None: reason 限值缺失判定列算不出来 if reason: hangs.append({COL_SAMPLE: sample, UNIT_KEY: unit, COL_ITEM: item_name, 仪器读数(mg/L): (r.get(仪器读数(mg/L)) or ).strip(), 挂起原因: reason}) continue挂起顺序就是业务顺序读数 → 称样量 → 定容体积 → 稀释倍数 → 限值。顺序反了挂起理由会指向不对的补料动作——拿着「稀释倍数缺失」去问检测组检测组回一句这行读数本来就是空的。另外一处要归一化受检单位里的全角空格用.join(s.split())去干净否则同一家会被拆成两家、报表多出一份。步骤三值列照写公式列让行号跟着走第一件模板里的公式靠「内容」认位置不靠坐标表头行用「样品编号」反查明细区用「含量列写着公式的连续行」认——模板挪位置、多几行都不怕。出处run.py的find_layout()与label_value()中部def find_layout(ws): 按栏目名反查表头行与列号模板挪了位置也不怕写死坐标必漂。 head_row None for row in ws.iter_rows(min_row1, max_rowws.max_row): if any(c.value COL_SAMPLE for c in row): head_row row[0].row break if head_row is None: raise SystemExit(f模板里找不到表头「{COL_SAMPLE}」请检查模板) cols {str(c.value).strip(): c.column for c in ws[head_row] if c.value} return head_row, cols def label_value(ws, label): 按标签反查它右边那一格抬头格返回 (行, 列)。 for row in ws.iter_rows(min_row1, max_rowws.max_row): for c in row: if str(c.value or ).strip() label: return c.row, c.column 1 raise SystemExit(f模板里找不到标签「{label}」)出处run.py的detail_rows()与find_summary()中部def detail_rows(ws, head_row, amount_col): 表头下面、含量列写着公式的连续行就是模板预置明细行靠公式认不数行数。 rows, r [], head_row 1 while r ws.max_row: v ws.cell(rowr, columnamount_col).value if isinstance(v, str) and v.startswith(): rows.append(r) elif rows: break r 1 return rows def find_summary(ws, judge_col): 汇总行靠「判定列里那个带 COUNTIF 的公式」认不靠行号猜。 for r in range(1, ws.max_row 1): v ws.cell(rowr, columnjudge_col).value if isinstance(v, str) and COUNTIF in v: return r raise SystemExit(模板里找不到汇总行判定列应有 COUNTIF 公式)汇总行同理靠判定列里那个带COUNTIF的公式认不数行号。第二件行数对不上就调。数据 7 行、预置 4 行就插 3 行数据 2 行、预置 4 行就把多的 2 行清空——只清值样式留着否则边框一掉打印是白格。清空不是为了好看空行带着公式空格被当 0 乘判定会算出「合格」。出处run.py的fit_detail()中部def fit_detail(ws, preset, count, amount_col, judge_col): 把明细区调成 count 行不够就插行拷样式多了就清空值样式留着边框不掉。 from openpyxl.formula.translate import Translator from openpyxl.utils import get_column_letter last preset[-1] if count len(preset): extra count - len(preset) ws.insert_rows(last 1, extra) # 插行openpyxl 只搬格子不动公式里的引用 for r in range(last 1, last 1 extra): for col in range(1, judge_col 1): ws.cell(rowr, columncol)._style copy(ws.cell(rowlast, columncol)._style) rows list(range(preset[0], preset[0] count)) src preset[0] for r in rows: # 公式按行号平移相对引用才会指到本行 for col in (amount_col, judge_col): letter get_column_letter(col) formula ws.cell(rowsrc, columncol).value ws.cell(rowr, columncol, valueTranslator(formula, originf{letter}{src}).translate_formula(f{letter}{r})) for r in preset[count:]: # 多出来的行清值留样式空行绝不会被算进合格率 for col in range(1, judge_col 1): ws.cell(rowr, columncol).value None return rows公式平移用Translator别拿正则替换行号——它认得相对引用和绝对引用正则不认汇总里带$的范围会被一起改掉。前提是模板明细行的公式写成相对引用G7写成$G$7平移过去还指着第 7 行。第三件汇总的统计范围要重写它不跟着行数变得自己把J7:J10换成J7:J13。出处run.py的retarget_summary()中部def retarget_summary(ws, rows, preset, judge_col): 汇总公式的统计范围不会自己跟着行数变得按实际明细行重写。 from openpyxl.utils import get_column_letter letter get_column_letter(judge_col) old_ref f{letter}{preset[0]}:{letter}{preset[-1]} new_ref f{letter}{rows[0]}:{letter}{rows[-1]} cell ws.cell(rowfind_summary(ws, judge_col), columnjudge_col) if old_ref in str(cell.value): cell.value str(cell.value).replace(old_ref, new_ref) return new_ref拼起来就是一步完整动作出处run.py的fill_report()中部def fill_report(ws, unit, items, report_no): 把一家单位的明细填进模板副本值列照写公式列让它自己延展。 head_row, cols find_layout(ws) amount_col, judge_col cols[COL_AMOUNT], cols[COL_JUDGE] preset detail_rows(ws, head_row, amount_col) rows fit_detail(ws, preset, len(items), amount_col, judge_col) for label, value in ((报告编号, report_no), (UNIT_KEY, unit), (检测日期, batch_date())): r, c label_value(ws, label) cell ws.cell(rowr, columnc, valuevalue) if label 检测日期: cell.number_format yyyy-mm-dd # 不设格式Excel 里会显示成 46265 for i, item in enumerate(items, start1): r rows[i - 1] ws.cell(rowr, column1, valuei) ws.cell(rowr, columncols[COL_SAMPLE], valueitem[COL_SAMPLE]).number_format ws.cell(rowr, columncols[COL_ITEM], valueitem[COL_ITEM]) for name in VALUE_COLS: ws.cell(rowr, columncols[name], valueitem[name]) retarget_summary(ws, rows, preset, judge_col) return rows动作为什么这么写用 Translator 平移明细行公式插行不会自动补公式正则替换会误伤绝对引用多余预置行清值留样式空行带公式会被算成「合格」清样式边框也掉汇总范围按实际行数重写COUNTIF范围不跟着行数变算错却不报错步骤四保存后回读读的是公式不是值一家单位一份报表模板本身一个格子都不动。出处run.py的build_one()中部def build_one(unit, items, plan): 一家单位一份报表从模板重新 load 一次模板本身一个格子都不动。 from openpyxl import load_workbook wb load_workbook(TPL_FILE) # 不加 data_only加了公式会被读成缓存值保存后整列公式消失 ws wb[SHEET_NAME] rows fill_report(ws, unit, items, plan[报告编号]) out OUT_DIR / f检测结果报表_{plan[简称]}_{BATCH}.xlsx wb.save(out) wb.close() return out, rowsload_workbook(TPL_FILE)不能加data_onlyTrue加了它公式被读成缓存值保存下去就没了。一行代码毁掉整个模板。回读校验也一样要核「公式还在不在」读的就是公式字符串。出处run.py的verify()文件底部letter get_column_letter(judge_col) rows detail_rows(ws, head_row, amount_col) sum_row find_summary(ws, judge_col) want(f{short} 明细行数, len(rows), exp_rows) want(f{short} 表头行, head_row, 6) want(f{short} 含量列全是公式、没被写成值, all(str(ws.cell(rowr, columnamount_col).value).startswith() for r in rows), True) want(f{short} 判定公式都指向本行, all(fH{r} in str(ws.cell(rowr, columnjudge_col).value) for r in rows), True) want(f{short} 汇总行紧跟在明细下面, sum_row, max(rows) exp_blank 1) want(f{short} 汇总范围随行数重写, f{letter}{rows[0]}:{letter}{rows[-1]} in str(ws.cell(rowsum_row, columnjudge_col).value), True)openpyxl不算公式只把公式字符串写进文件报表在 Excel、WPS、LibreOffice 里打开时才重算。能核的是「公式还在不在、行号对不对、范围改没改」数值对不对得打开文件看。总结模板里的公式最怕的不是写错是在看不见的地方被换成值。守住三件事打开模板不加data_only该跟着行数走时用Translator平移公式、多出来的行清值留样式汇总范围按实际明细行重写。最后回读一遍读公式字符串——值会骗人公式不会。完整源码本文配套的可运行示例已开源模板、边界数据与回读校验都在里面克隆下来直接复跑huang_jianhua0101/examples - Gitee.com关于我在实验室一线待了 13 年9 年制药 4 年第三方检测做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导招标到 3Q 验证到验收全流程也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验其中一段经 Citrix 远程接入。现在专做实验室里那些重复劳动报表自动生成、仪器数据对接、合规文档批量处理。本科物理化学、硕士计算机化学既听得懂 QA 说的变更控制也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词不用你解释。现在主要做这几类- 检验报告与台账批量生成模板不动数据自动填格式一步不错- 仪器数据对接色谱、光谱、酶标仪导出的原始文件解析、清洗、入库、转成报表- 合规文档自动化SOP、验证方案、批记录这类重复文档的批量生成与核对- 数据完整性核查按 ALCOA 逐条核对原始数据与记录是否对得上手里有这类活儿卡着或者只是想问问能不能自动化都欢迎评论区聊先把问题说清楚再谈怎么做。