报告一批批出齐,分发这一步才开始:按客户要的文件名改一遍、一份报告发给两个收件人、纸质件按合同寄几份、发完再回头把发放登记表补齐。手工做法是复制、改名、粘进客户文件夹,再对着台账填表——名字改错一个,客户收到的就是别人的报告;登记表漏一行,追溯时拿不出证据。
为了解决这个问题,Python 提供了 pathlib 和 shutil,配上 openpyxl 写登记表:三份表加一个原件目录读进来,按每家客户的模板改名、按客户拆包,顺手生成发放登记表与客服分发清单。
本文按四步走:配置路径与状态常量、读四份东西、按客户模板改名并挑出不能发的、写登记表与分发清单。仓库里两个 py:run.py跑主线,source/build_fixtures.py造数据;自动装依赖、写日志、回读校验是外壳,不逐行贴。
一、卡在哪:名字是客户定的,记录是制度定的
第三方检测是「一单客户独立走一遍」:洽询、送样、收样通知、检测、报告、关账。报告出来以后这一段,制度上要登记发放编号、数量、领取单位、领取人、发放日期;客户那边又对文件名各有要求。两头都得满足,手工做就容易出错。
| 卡点 | 手工做法 | 本文做法 |
|---|---|---|
| 每家客户命名要求不一样 | 一家记一套,改的时候翻记录 | 一行命名模板 + 占位符,改模板不改代码 |
| 客户名尾巴粘着全角空格 | 被当成两家,收件人查不到 | 客户名先压成比较键 |
| 报告待审核、已作废 | 夹在中间一起发出去 | 状态先判,挂起的不占分发件 |
| 一份报告两个收件人、纸质要三份 | 登记表只记一行 | 按「报告 × 收件人」展开,份数分方式给 |
二、四步走
第 1 步:配置路径与状态常量。路径从脚本自己的位置长出来,拷到别的电脑不用改。三个常量是刻意的:发放日期写死,不许用date.today()——隔天重跑同一批报告,日期就变了,跟客服手上的邮件时间对不上;不能分发的状态单独列表,作废件流出去是合规事故;非法字符统一替换,产品名里的斜杠不换连文件都建不出来。
出处:run.py顶部的路径与状态常量区(from pathlib import Path这类顶层 import 都在文件顶部)。
ROOT = Path(__file__).resolve().parent RAW_DIR = ROOT / "01_raw_data" OUT_DIR = ROOT / "02_output" TPL_DIR = ROOT / "source" / "templates" LOG_FILE = ROOT / "source" / "run_log.txt" ORIG_DIR = RAW_DIR / "报告原件" REPORT_CSV = RAW_DIR / "报告台账.csv" NAMING_CSV = RAW_DIR / "客户命名规则.csv" RECIPIENT_CSV = RAW_DIR / "客户收件人.csv" TPL_XLSX = TPL_DIR / "报告发放登记表.xlsx" LEDGER_XLSX = OUT_DIR / "发放登记台账.xlsx" MANIFEST_CSV = OUT_DIR / "客户分发清单.csv" PACK_DIR = OUT_DIR / "报告分发" # 发放日期:登记表上「发放日期」这一栏填哪天,得是定的。 # **不许用 date.today()**——隔天重跑,同一批报告的发放日期就变了,跟客服手上的邮件时间对不上。 SEND_DAY = date(2026, 9, 25) # 不对外分发的报告状态。作废报告的电子件流出去是合规事故,宁可挂起也不改名。 BLOCK_STATUS = {"待审核": "报告未出具,不能分发", "已作废": "报告已作废,不对外分发"} # 文件名里不许出现的字符。产品名、委托单号里都可能带斜杠,不换掉连文件都建不出来。 ILLEGAL_RE = re.compile(r'[\\/:*?"<>|\r\n\t]+') LEFTOVER_RE = re.compile(r"\{[^}]*\}") # 拼完还剩花括号 = 模板里有不认识的字段 DATE_FMTS = ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d") DATE_FMT = "yyyy-mm-dd" # 写进 Excel 的日期格要顺手设格式,否则显示成序列号第 2 步:读四份东西(含归堆)。台账一行一份报告,命名规则按客户名收成字典,收件人收成「客户 → 收件人列表」——一家客户可能有两个收件人。读 CSV 有两个小动作必须做:第一列为空的行整行跳过(表尾合计行、说明行第一列都是空的,说明文字写进第一列就多出一份报告);原件编号按字符串处理、不做int(),老系统留下的编号是0000122,转成整数就变122,跟台账永远对不上。
出处:run.py的read_csv_rows()(读数据一节最前面)、read_reports()/read_naming()/read_recipients()(紧跟其后)与scan_originals()(读数据一节末尾)。
def read_csv_rows(path): """读 CSV。第一列为空的行一律跳过——表尾的合计行、说明行第一列都是空的, 说明文字要是写进第一列,就会被当成一份报告混进分发清单。""" with open(path, "r", encoding="utf-8-sig", newline="") as f: rows = list(csv.reader(f)) header = [clean(x) for x in rows[0]] out = [] for raw in rows[1:]: if not raw or not clean(raw[0]): continue out.append({header[i]: clean(raw[i]) if i < len(raw) else "" for i in range(len(header))}) return outdef scan_originals(): """扫报告原件目录,用文件名(去扩展名)当报告编号。 文件名一律按字符串处理,**不做 int 转换**——老系统留下的编号是 `0000122`, 转成整数就变成 `122`,跟台账永远对不上,人还看不出是哪儿错了。 """ found = {} for path in sorted(ORIG_DIR.glob("*.pdf")): found[path.stem] = path return found第 3 步:判定能不能发,再拼文件名。判定顺序就是业务顺序,一步都错不得:
| 动作 | 为什么这么写 |
|---|---|
| 先判状态,再判别的 | 作废件不管名字对不对都不能发;顺序反了挂起理由会张冠李戴 |
| 拼完再查一遍残留的花括号 | 客户模板里字段名写错一个字,带花括号的文件名就发给客户了 |
| 撞名两份都挂起 | 只挂后一份,前一份照样发出去,两家客户的报告就混了 |
出处:check_report()(分发判定一节开头)、make_filename()(紧跟其后)、plan()末段。
def check_report(rep, naming, recipients, originals): """判定顺序就是业务顺序:报告没出 → 不说名字的事;客户没登记收件人 → 不判原件; 原件不在 → 不拼名字。 顺序反了挂起理由就会张冠李戴:一份还没审核的报告被标成「原件缺失」, 客服拿着错理由去补资料,补完还是发不出去。 """ if rep["状态"] in BLOCK_STATUS: return "挂起", rep["状态"], BLOCK_STATUS[rep["状态"]] rule = naming.get(cust_key(rep["客户名称"])) if rule is None: return "挂起", "客户命名规则缺失", "台账里的客户在命名规则表里查不到,不知道按谁的规矩改名" if not recipients.get(cust_key(rep["客户名称"])): return "挂起", "未登记收件人", "客户收件人表里没有这一家,报告不知道发给谁" if clean(rep["报告编号"]) not in originals: return "挂起", "原件缺失", "报告原件目录里找不到这份报告,改不了名" if rep["报告日期"] is None: return "挂起", "报告日期认不出", "日期列写法不在支持列表里,不能用它拼文件名" return "正常", "", ""def make_filename(rep, rule): """按客户给的模板拼文件名:先换占位符,再换非法字符,最后统一加 .pdf。 拼完必须回头查一遍还有没有没换掉的花括号——客户把字段名写错一个字 (`{产品名}`),不检查就会把一个带花括号的文件名发给客户。 """ name = rule["模板"] for key in ("报告编号", "委托单号", "产品名称"): name = name.replace("{%s}" % key, clean(rep[key])) name = name.replace("{客户}", clean(rule["写客户"]) or clean(rep["客户名称"])) name = name.replace("{报告日期}", rep["报告日期"].strftime(rule["日期写法"])) if LEFTOVER_RE.search(name): raise ValueError(f"模板里有不认识的占位符:{LEFTOVER_RE.findall(name)}") return ILLEGAL_RE.sub("-", name).strip() + ".pdf"dup = {k for k, group in keys.items() if len(group) > 1} if dup: kept = [] for item in items: if item["键"] in dup: held.append({"报告编号": item["rep"]["报告编号"], "客户名称": item["rep"]["客户名称"], "问题": "命名撞名", "说明": f"同一家客户下另有报告算出同名文件 {item['文件名']}," "两份都挂起,先跟客户确认改名规则"}) else: kept.append(item) items = kept return items, held第 4 步:出分发件、写登记表。分发件按客户建目录、把原件复制过去;登记表按「报告 × 收件人」一行,份数按发放方式给——纸质按合同份数,电子只记一份,多记会让台账数量虚高。最后给客服一份分发清单,照着发就行。登记表往模板里填,表头行按栏目名反查,模板上加标题行、改列顺序都不影响。
出处:pack()(输出一节最前面)、ledger_rows()(紧跟其后)、write_manifest()(输出一节末尾)。
def pack(items, originals): """按客户建目录、把原件**复制**成分发件。 复制不是移动:原件是归档凭证,动它等于动了可追溯性;分发件只是副本, 目录每次重跑先清空,免得上一版留下的文件被当成这一版发出去。 """ if PACK_DIR.exists(): shutil.rmtree(PACK_DIR) rows = [] for item in items: cust_dir = PACK_DIR / ILLEGAL_RE.sub("-", clean(item["rep"]["客户名称"])) cust_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(originals[clean(item["rep"]["报告编号"])], cust_dir / item["文件名"]) rows.append(item) return rowsdef ledger_rows(items): """发放登记表按「报告 × 收件人」一行——制度里的登记表记的是**每一次发放动作**, 一份报告发给两个人就是两次发放。 份数按发放方式给:纸质按委托约定的份数(台账里的报告份数),电子只记 1 份。 电子件多记份数会让台账上的数量虚高,对不上客户实际拿到的纸。 """ rows = [] for item in items: rep = item["rep"] for rec in item["收件人"]: paper = rec["发放方式"] == "纸质" rows.append([rep["报告编号"], rep["产品名称"], rep["报告份数"] if paper else "1", rep["客户名称"], rec["收件人"], rec["电话"], rec["发放方式"], SEND_DAY, item["文件名"], "", f"纸质件按 {rep['报告份数']} 份寄出" if paper else "电子件加密发送"]) return rowsdef write_manifest(items): """给客服一份分发清单:哪个收件人、要发哪几份、邮箱是哪个,照着发就行。""" groups = {} for item in items: for rec in item["收件人"]: key = (item["rep"]["客户名称"], rec["收件人"], rec["发放方式"], rec["邮箱"]) groups.setdefault(key, []).append(item["文件名"]) with open(MANIFEST_CSV, "w", encoding="utf-8-sig", newline="") as f: w = csv.writer(f) w.writerow(["客户名称", "收件人", "电子邮箱", "发放方式", "报告数", "分发文件", "建议邮件主题"]) for (cust, who, way, mail), files in groups.items(): w.writerow([cust, who, mail, way, len(files), ";".join(sorted(files)), f"【检测报告】{cust} 报告已出具({len(files)} 份)"]) return groups三、踩过的坑(都在这份数据上复现过)
| 坑 | 现象 | 怎么改 |
|---|---|---|
| 判定顺序排错 | 没登记收件人的客户全被挂「未登记收件人」,撞名分支根本没跑到,等于没测 | 按业务顺序排:状态 → 命名规则 → 收件人 → 原件 → 日期 |
| 编号转成整数 | 0000122变122,跟台账对不上 | 编号全程当字符串 |
date写进 Excel、读回是datetime | 回读校验假失败 | 比之前先.date() |
| 表尾说明文字放进第一列 | 台账上多出一份不存在的报告 | 说明行第一列留空 |
| 分发件用移动而不是复制 | 原件目录被搬空,归档凭证没了 | 复制;重跑先清空分发目录 |
总结
这段活的难点不是改名,是两边都不许错:客户要的文件名得对得上,制度要的发放记录一行不能少。所以脚本分三件事——按客户模板改名、把不能发的挑出来挂起、把登记表和分发清单一起生成。判定顺序按业务排,挂起理由才指向该补的东西。配套示例可复跑:台账 11 行 → 分发件 4 份 → 登记 6 行 → 挂起 7 份,回读校验 26 项全过。
完整源码
本文配套的可运行示例已开源,把01_raw_data/里的表换成自己机构的,克隆下来直接复跑:
huang_jianhua0101/examples - Gitee.com
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类:
- 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错
- 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表
- 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对
- 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。