一批样品几十上百个,编号得按规则编:前缀分检测类型,中间一段年月,后面一段流水号。手工编就是下拉填充加复制粘贴:序号列被 Excel 当成数字,0010 变成 10;隔几天接着编,谁也想不起上一批编到几号,撞号了当时看不出来,等报告发出去才发现,一个重号后面全得回头改。
为了解决这个问题,Python 提供了 openpyxl 与 csv 标准库这套组合:读历史编号台账,按「前缀 + 年月」分段取出已用最大流水号,本批样品顺着往下编,编号按文本格式写回 Excel。
本文按四步走:配置 → 读台账 → 编编号 → 输出,只解决「本批样品批量编号、接上历史台账」。
先想清楚:编号为什么容易重
编号是三段拼的:前缀、年月、流水号。只有流水号递增,而它递增的范围不是整张台账,是「同一个前缀 + 同一个年月」这一小段——全表取一个最大号再 +1,跨组立刻撞。
两条规矩先立下:
- 流水号一律当数字比,不当字符串比。按字符串取最大号会取到
9,下一号编成0010,跟台账里已有的撞上。 - 编不了的不猜。名称空、类型没配规则、日期认不出,一律挂起写明原因等人确认。
四步代码与示例run.py同源,函数名一搜对得上。
第一步:配置路径与字段
会变的都提到文件顶部;路径一律从Path(__file__).resolve().parent起头,不写绝对路径。
出处:run.py顶部的路径与字段常量区
BASE = Path(__file__).resolve().parent RAW_DIR = BASE / "01_raw_data" OUT_DIR = BASE / "02_output" SRC_DIR = BASE / "source" TPL_DIR = SRC_DIR / "templates" SAMPLES_FILE = RAW_DIR / "本批样品清单.csv" LEDGER_FILE = RAW_DIR / "编号台账_已用编号.csv" RULE_FILE = TPL_DIR / "编号规则表.csv" LOG_FILE = SRC_DIR / "run_log.txt" OUT_NAME = "样品编号清单.xlsx"出处:同一常量区的编号形状正则与字段别名表
ID_RE = re.compile(r"^([A-Za-z]+)-(\d{6})-(\d+)$") ALIASES = { ID_COL: ("编号", "样品编号", "样品号"), NAME_COL: ("样品名称", "样品名"), TYPE_COL: ("检测类型", "检测类别", "检测项目"), SRC_COL: ("样品来源", "来源", "委托方"), DATE_COL: ("送样日期", "收样日期", "送检日期", "采样日期"), MADE_COL: ("编号生成日", "生成日期"), }两块用到的re、csv、io在顶部 import;ID_COL、NAME_COL这些常量在别名表上面一行一个。别名表把「标准字段 → 可能出现的列名」挂起来。
第二步:读台账与本批样品
台账是导出文件,表头前面往往压着几行导出信息。
出处:run.py的read_text()与find_header()(文件中部)
def read_text(path): """读文本并返回实际用的编码:系统导出的 CSV 常在 UTF-8 和 GBK 之间摇摆。""" for enc in ("utf-8-sig", "gbk"): try: return path.read_text(encoding=enc), enc except UnicodeDecodeError: continue raise UnicodeDecodeError("csv", b"", 0, 1, f"{path.name}: 编码既不是 utf-8 也不是 gbk") def find_header(rows): """找表头行:第一个「至少 2 个单元格命中已知字段名」的行。 不能只看「非空单元格 >= 2」——导出信息那行正好也是两格(台账名 + 导出时间), 会被当成表头,整张表从第一行就开始读错。按字段名认表头才认得住。 """ known = {n for names in ALIASES.values() for n in names} for i, r in enumerate(rows): if sum(1 for c in r if str(c or "").strip() in known) >= 2: return i return -1这个坑是实测撞出来的:导出信息那行也是两格,最初被当成了表头。
出处:run.py的parse_id()(文件中部)
def parse_id(s): """拆编号为 (前缀, 日期段, 流水号);认不出的返回 None,交给待确认,不猜。""" m = ID_RE.match(str(s or "").strip()) if not m: return None return m.group(1).upper(), m.group(2), int(m.group(3))注意最后那个int()——「流水号当数字」这条规矩就落在它身上,后面比大小不会再退化成字符串比较。
出处:run.py的load_rules()(文件中部)
def load_rules(path): """读编号规则表:检测类型 → (前缀, 流水位数)。 编号规则是管理口径,写在表里不写死在代码里——新开一种检测类型、 或者流水位数要从 3 位改 4 位,加一行表就行,不用动脚本。 """ text, _ = read_text(path) rows = [r for r in csv.reader(io.StringIO(text)) if any(str(c).strip() for c in r)] rules = {} for r in rows[1:]: t = str(r[0]).strip() prefix = str(r[1]).strip().upper() if len(r) > 1 else "" width = to_int(r[2]) if len(r) > 2 else None if t and prefix and width: rules[t] = (prefix, width) if not rules: raise ValueError(f"{path.name}: 规则表是空的,没法编编号") return rules规则表本身就是一张普通 CSV:
| 检测类型 | 编号前缀 | 流水位数 |
|---|---|---|
| 水质检测 | WS | 4 |
| 土壤检测 | TR | 4 |
| 食品接触材料 | FCM | 3 |
出处:run.py的load_ledger()里逐行归堆那一段(文件中后部)
parts = parse_id(sid) if parts is None: continue prefix, month, seq = parts key = (prefix, month) if seq > seq_max.get(key, 0): seq_max[key] = seq这几行在逐行循环里,前面还有几句把整行收进台账明细、同一个编号第二次出现时记一笔重号。seq_max读完就是每一段的续编起点。
第三步:编编号,先判能不能编
出处:run.py的make_ids()(文件中后部,开头几段判空)
if not s[NAME_COL]: pending.append((s, "样品名称为空,编号没法挂靠")) continue if s[TYPE_COL] not in rules: pending.append((s, f"检测类型「{s[TYPE_COL]}」不在编号规则表里")) continue if s[DATE_COL] is None: pending.append((s, "送样日期为空或认不出,取不到编号日期段")) continue名称空、类型没配规则、日期认不出,这三类挂起,每条都带原始行号进「待确认」表。静默跳过最坑:台账里少一批样品,当天不一定发现。
出处:run.py的make_ids()(同一函数,拼编号那一段)
prefix, width = rules[s[TYPE_COL]] month = s[DATE_COL].strftime("%Y%m") key = (prefix, month) seq = seq_max.get(key, 0) + 1 seq_max[key] = seq new_rows.append({ ID_COL: f"{prefix}-{month}-{seq:0{width}d}", NAME_COL: s[NAME_COL], TYPE_COL: s[TYPE_COL], SRC_COL: s[SRC_COL], DATE_COL: s[DATE_COL].isoformat(), MADE_COL: today.isoformat(), "溢出": seq > 10 ** width - 1, })make_ids()在自己的seq_max上编一个抬一格,同一个文件里连着几条样品序号自然连着走。关键动作和理由对着看:
| 动作 | 为什么这么写 |
|---|---|
| 按「前缀 + 年月」分组取最大号 | 编号是分段的,全表取一个最大号 +1 一定跨组撞 |
流水号int()之后再比大小 | 按字符串比"9" > "0010",取 max 取到9,下一号编成0010撞上台账 |
| 日期段取样品自己的送样日期 | 补录上月的样品要回上月那一段接着编 |
用f"{seq:0{width}d}"补零,不截断 | 位数不够时编号变长(1000);截成000就是两个样品一个号 |
编完立刻抬seq_max[key] | 本批自己的号也得先排队,否则同一批里就会重 |
示例里食品接触材料流水位数是 3、历史已编到 998,本批三件会编出999、1000、1001:编号变长但唯一,终端会打提示。位数该调,但不能为了凑格式截断。
第四步:输出,四张表各管一件事
出处:run.py的write_outputs()(文件后部,新编号清单那一段)
ws = wb.active ws.title = "新编号清单" ws.append(cols) for r in new_rows: ws.append([r.get(c, "") for c in cols]) if r["溢出"]: for j in range(1, len(cols) + 1): ws.cell(row=ws.max_row, column=j).fill = FILL_OVERFLOW for j in range(1, len(cols) + 1): ws.cell(row=1, column=j).font = Font(bold=True) for i in range(2, ws.max_row + 1): ws.cell(row=i, column=1).number_format = "@" # 编号按文本存,前导零才不掉四张表:新编号清单(本批的号,贴标签、导给现场都用它,位数溢出整行标红)、编号台账(历史 + 本批全量)、待确认(编不了的样品写明第几行、什么问题,历史重号也列这里)、编号汇总(按「前缀 + 年月」计数)。
编号列那行number_format = "@"不能省:设成文本格式,0010才不会在客户双击那一刻变成10。
出处:run.py的verify()(文件末尾,回读那两条)
wb = load_workbook(out) first_cell = wb["新编号清单"].cell(row=2, column=1) oks.append(("回读:编号存成文本且前导零在位", first_cell.number_format == "@" and first_cell.value == new_ids[0]))run.py还有一层工程外壳:缺依赖自动清华源装、往source/run_log.txt追加日志、跑完把刚存的 Excel 重新打开核一遍,共 11 项断言。内存里对不算对——第一次跑就有一条红的:漏设文本格式,补上才过。
数据是故意造的边界:被 Excel 吃过前导零的TR-202610-9、一对重号、一组位数快溢出的、一条补录上月的、四行缺字段。
小结
编样品编号,难的不是那个循环,是三件事:
- 分段续编:最大流水号按「前缀 + 年月」分组取,全表取一个是错的;
- 数字归数字、文本归文本:比较时当数字(否则
9盖过0010),存的时候当文本(否则前导零留不住); - 编不了的不猜:挂起写清原因等人补,编不出来比错编一个的危害小得多。
这三条做对,编号清单才敢当凭据用。
完整源码
本文配套的可运行示例(含造数据脚本、编号规则表、11 项回读断言)已在公开仓库,克隆下来直接复跑:
huang_jianhua0101/examples - Gitee.com
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类:
- 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错
- 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表
- 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对
- 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。