引言
实验室里那份日报周报,卡住人的从来不是"出表",是"对数"。同一个 10 月 12 日,在收到的几份导出文件里能写成2026/10/12、2026-10-12 09:20:31、2026年10月12日,甚至26.10.13;完成日期空着的那几条到底算不算当天的完成;上周 6 件这周 6 件,可这周实际只走了四天。这些不对齐不是粗心,是几套系统的口径本来就不一样——每次出周报都得先花大半天人工对一遍,对完才敢发。
为了解决这个问题,我们的思路是「按字段名读 CSV + 显式日期归一 + 连续区间切片」这一套组合:把四种日期写法落成同一天,把没记录的那天显式留在表里,把"完成""在办""超 TAT"的定义写死在代码里,跑一遍出两份表,再用断言把结果验一遍。
本文按四步走:先配置路径与字段,再读数据,然后归堆,最后出表并回读校验。配套示例只吃三份 CSV,不接任何系统。
先把这四条口径钉死
代码写得再对,口径不定也没用。动手前先拍定这四条:
- 空档日补 0 还是留空?留空。补 0 等于断言"那天什么都没发生",留空才看得出是没记录、不是没干活。
- 完成日期为空怎么算?算在办,不算当天完成。丢掉它在办一条不剩;当 0 天又会让完成数看着没变。
- 超 TAT 怎么判?完成日减最早接收日,跟承诺天数比。判不了的(接收台账里没这个样品、承诺天数没填)不许当 0 天算进平均——那会让"平均周转"好看得离谱,真出问题反而查不出来。
- 周界怎么划?走 ISO 周(周一到周日),不用"最近 7 天"——那个窗口跨两个自然周,跟"上周"比的时候起点还每天往后挪。
第一步:配置路径与字段
所有路径从脚本自身的位置推导,换目录不用改代码;依赖缺失时自动走国内源安装。
出处:run.py顶部的路径与字段常量区(Path来自文件顶部from pathlib import Path)
BASE = Path(__file__).resolve().parent RAW_DIR = BASE / "01_raw_data" OUT_DIR = BASE / "02_output" SRC_DIR = BASE / "source" LOG_FILE = SRC_DIR / "run_log.txt" RECV_FILE = RAW_DIR / "样品接收台账.csv" DONE_FILE = RAW_DIR / "检测完成台账.csv" ABN_FILE = RAW_DIR / "异常台账.csv" DAILY_NAME = "实验室日报.xlsx" WEEKLY_NAME = "实验室周报.xlsx"列名一律写常量、按表头取,不按第几列。台账模板一加一列,按序号读就整体错位。
出处:run.py顶部同一段的字段常量
# 列名按导出文件里的表头取,不按第几列——模板一加列,按序号读就整体错位 RECV_DATE, RECV_ORDER = "接收日期", "委托单号" RECV_SAMPLE, RECV_QTY = "样品编号", "样品数" DONE_DATE, DONE_SAMPLE = "完成日期", "样品编号" DONE_ITEM, DONE_CONC = "检测项", "结论" TAT_COL = "TAT承诺天数" ABN_DATE, ABN_SAMPLE, ABN_TYPE, ABN_STATE = "发生日期", "样品编号", "异常类型", "处理状态" EMPTY = "" # 数字列没填时写空,不写 0 WEEKDAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]EMPTY是个约定:数字列没填就写空字符串,不写 0。这条定了,汇总表就不会为了好看而撒谎。
第二步:读数据
出处:run.py的read_ledger()与to_date()
def read_ledger(path): """读一张台账。用 utf-8-sig——导出文件的 BOM 不剥掉,第一个列名会带上隐藏字符。""" with open(path, encoding="utf-8-sig", newline="") as f: return list(csv.DictReader(f)) def to_date(v): """把台账里的日期写法归一到 date,认不出来返回 None。 同一个 10 月 12 日,三张导出文件里能写出四种字符串:`2026/10/12`、 `2026-10-12 09:20:31`、`2026年10月12日`、`26.10.13`。不归一直按字符串 分组,同一天会被拆成四天,日报看着就是断档的。 """ s = str(v or "").strip() if not s: return None s = s.split(" ")[0].split("T")[0] # 带时间戳的先切掉时间 s = s.replace("年", "-").replace("月", "-").replace("日", "") s = s.replace("/", "-").replace(".", "-") for fmt in ("%Y-%m-%d", "%y-%m-%d"): try: return datetime.strptime(s, fmt).date() except ValueError: continue return None关键不是"解析",是认不出来时返回None而不是抛错:几十行没日期,直接炸掉整份日报不值得,留None交给待核清单。样品编号这类前导零字段全程按字符串走。
数量列同理,单独一个转换函数:
出处:run.py的to_int()
def to_int(v): """数量列:空、空格、"—" 都不是 0,是没填。区分不开,均值一算就被拉低。""" s = str(v or "").strip() if not s: return None try: return int(float(s)) except ValueError: return None第三步:归堆
把每张台账变成后面能直接查的结构。接收台账归成"样品 → 一条记录":
出处:run.py的load_recv()
def load_recv(): """接收台账 → {样品编号: {day, order, qty}},另带两份待核清单。 同一样品登记多次只留最早那次:补录、复检会把同一样品再写一遍, 按行数算接收量会虚高,取最后一次还会把 TAT 算长(把补录那天当成收货日)。 """ out, dups, dropped = {}, [], [] for r in read_ledger(RECV_FILE): sample = str(r.get(RECV_SAMPLE) or "").strip() day = to_date(r.get(RECV_DATE)) if day is None: dropped.append(("接收台账", sample, str(r.get(RECV_DATE) or "").strip())) continue if sample in out: dups.append(("接收台账", sample, f"{r.get(RECV_ORDER, '')} 与先登记的那条重复,只算一次")) out[sample]["day"] = min(out[sample]["day"], day) continue out[sample] = {"day": day, "order": str(r.get(RECV_ORDER) or "").strip(), "qty": to_int(r.get(RECV_QTY))} return out, dups, dropped最后那个min(...)决定同一样品多次登记时保留最早那次接收日。取最后一次会把补录日当收货日,TAT 直接算长,报表上全是假超期。
完成台账要把三种情况分开:
出处:run.py的load_done()
def load_done(recv): """完成台账 → 已完成的项 + 在办项 + 两份待核清单。 完成日期为空不是「当天没干活」,是「还没完成」:丢掉它在办一条不剩, 当成 0 又会让完成数看着没变。所以空值和认不出的值分两堆,别混。 """ rows, doing, dups, dropped, seen = [], [], [], [], set() for r in read_ledger(DONE_FILE): sample = str(r.get(DONE_SAMPLE) or "").strip() item = str(r.get(DONE_ITEM) or "").strip() raw = str(r.get(DONE_DATE) or "").strip() promise = to_int(r.get(TAT_COL)) day = to_date(raw) if day is None: if raw: dropped.append(("完成台账", sample, raw)) else: doing.append({"sample": sample, "item": item}) continue if (sample, item, day) in seen: # 同一天同一样品同一检项写两遍:只算一次 dups.append(("完成台账", sample, f"{item} 在 {day} 重复登记,只算一次")) continue seen.add((sample, item, day)) tat = tat_days(recv, sample, day, promise) rows.append({"day": day, "sample": sample, "item": item, "concl": str(r.get(DONE_CONC) or "").strip(), "tat": tat, "promise": promise, "over": tat is not None and promise is not None and tat > promise}) return rows, doing, dups, dropped在办数按"截至某一天"算,所以要先把每个样品的最后完成日捞出来:
出处:run.py的last_done()与backlog()
def last_done(done): """样品 → 最后一次完成日(同一样品多个检项,取最晚那天)。""" out = {} for r in done: out[r["sample"]] = max(out.get(r["sample"], r["day"]), r["day"]) return out def backlog(recv, last, day): """截至 day 当天的在办样品数:已接收,但完成日晚于 day 或还没完成的。""" return sum(1 for s, info in recv.items() if info["day"] <= day and (s not in last or last[s] > day))关键那步:把三种"脏"分开
这一步决定报表能不能直接用。三种脏数据混在一起,报表就一定对不上,对照表如下:
| 脏数据 | 处理 | 为什么这么写 |
|---|---|---|
| 日期空 / 认不出(如「待定」) | 进待核清单,不进任何一天 | 静默丢掉,事后永远查不到那天少了一条 |
| 完成日期为空 | 进在办堆,不算当天完成 | 丢掉它在办一条不剩;当 0 又虚增完成数 |
| 同样品同检项同一天重复 | 只算一次,记一条重复 | 按行数算,完成数直接虚高 |
| 同样品同检项跨天重复 | 都算(是复检) | 复检是真结果,去重等于销毁证据 |
| 同一样品接收登记多次 | 保留最早接收日 | 取最后一次会把补录日当收货日,TAT 算长 |
| 数量列为空 | 不计入合计,当天备注 | 当 0 会把均值拉低 |
第四步:出表并回读校验
出表的骨架是把日期范围铺成连续列表,再逐天填指标:
出处:run.py的day_list()与daily_rows()
def day_list(d1, d2): """连续的日期列表:中间没记录的那天也要留一行。 跳过空档日,日报看着天天满勤;补 0 又等于断言「那天什么都没发生」。 留空才对——空着才看得出是没记录,不是没干活。 """ return [d1 + timedelta(days=i) for i in range((d2 - d1).days + 1)] def daily_rows(days, recv, done, abn, last): """按天出一行汇总。空档日也出一行,指标留空、不补 0。""" rows = [] for day in days: r_day = [i for i in recv.values() if i["day"] == day] d_day = [r for r in done if r["day"] == day] a_day = [r for r in abn if r["day"] == day] qty = [i["qty"] for i in r_day if i["qty"] is not None] note = [] if not (r_day or d_day or a_day): note.append("三张台账当天都没有记录") if any(i["qty"] is None for i in r_day): note.append("有样品数未填") rows.append({ "day": day, "recv_n": len(r_day) or None, "qty": sum(qty) if qty else None, "done_n": len(d_day) or None, "over_n": sum(1 for r in d_day if r["over"]) or None, "abn_n": len(a_day) or None, "backlog": backlog(recv, last, day), "note": ";".join(note), }) return rows周报最容易翻车的是"本周比上周"。周中出周报,本周才走了四天,拿四天去对上一整周,年年都是"断崖式下滑"——那是口径的问题,不是业绩的问题:
出处:run.py的week_key()
def week_key(day): """ISO 周界:周一是一周第一天。返回 (年, 周号)。 别用「最近 7 天」代替——那个窗口跨两个自然周,跟「上周」比的时候 起点还每天往后挪,比出来的差值没有意义。 """ return day.isocalendar()[:2]出处:run.py的same_period()
def same_period(days): """本周区间与上周同期区间。 周中出周报,本周才走了 4 天;拿 4 天去对上一整周 7 天,年年都是 「断崖式下滑」——那是口径的问题,不是业绩的问题。所以上周切到同一星期几。 """ this_start = next(d for d in days if week_key(d) == week_key(days[-1])) this_end = days[-1] last_start = this_start - timedelta(days=7) return this_start, this_end, last_start, last_start + (this_end - this_start)出处:run.py的week_range()
def week_range(key): """ISO 周的完整区间:周一到周日。区间写整周,别写「这一周里出现过的日子」—— 后者看着像这周只有 5 天,读者会以为漏了周末。""" start = date.fromisocalendar(key[0], key[1], 1) return start, start + timedelta(days=6)最后把这些"该有人核一遍的"汇成一张清单,日报周报才敢发出去:
出处:run.py的gap_rows()
def gap_rows(daily, dups, dropped, doing): """该有人核一遍的,全汇成一张清单:空档日、认不出的日期、重复登记、在办项。""" out = [] for row in daily: if not row["note"]: continue kind = "空档日" if "都没有记录" in row["note"] else "待核" out.append([kind, row["day"], row["note"], EMPTY]) for src, key, raw in dropped: out.append(["日期认不出", None, f"{src} 里写的「{raw}」,没归进任何一天", key]) for src, key, note in dups: out.append(["重复登记", None, f"{src}:{key} {note}", key]) for r in doing: out.append(["在办", None, f"{r['sample']} 的 {r['item']} 还没完成", r["sample"]]) return out生成之后不查完不算完。跑完把两份成品重新打开,逐条验:
出处:run.py的verify()(日报那几条断言)
oks.append(("日报行数 = 连续天数(含空档日)", len(rows) == len(daily) == 11)) oks.append(("周末空档日留行、指标留空不补 0", rows["2026-10-10"][2] in (None, EMPTY) and rows["2026-10-10"][8] == "三张台账当天都没有记录")) oks.append(("带时间戳的日期归到同一天(10-12 接收 2 个样品)", rows["2026-10-12"][2] == 2)) oks.append(("重复登记的样品只算一次(10-12 共 24 件)", rows["2026-10-12"][3] == 24)) oks.append(("完成日期为空算在办、不算当天完成(10-15 在办 3 个)", rows["2026-10-15"][7] == 3)) oks.append(("超 TAT 共 4 条(按接收日+承诺天数判)", sum(r["over_n"] or 0 for r in daily) == 4))内存里对不算对。12 项断言全过才打印 PASS,任一条失败立刻能看到是哪一步错了。
小结
真正难的从来不是 Python。三张台账能变成日报周报,靠的是四条口径先被拍死:空档日留空、完成日期为空算在办、超 TAT 用最早接收日、周界走 ISO 周。代码只是把口径固化下来——你拍板,它每次跑都一样。
反过来,这四条没人拍板,脚本再漂亮也只是把错误固化得更快。配套示例的数据我故意留了十一种坑(四种日期写法、认不出的日期、完成日期为空、数量为空、两种重复、周末空档、跨周等),跑一遍 12 项断言全过才算对。你手里那份对不上的表,多半也是这四条里有一条没定。
完整源码
三张台账 → 日报一份 + 周报一份的完整可跑示例(含造数据脚本、12 项回读断言):
huang_jianhua0101/examples - Gitee.com
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类:
- 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错
- 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表
- 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对
- 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。