news 2026/10/17 10:42:32

实验室日报周报自动生成:日期口径不一,汇总表永远对不上

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
实验室日报周报自动生成:日期口径不一,汇总表永远对不上

引言

实验室里那份日报周报,卡住人的从来不是"出表",是"对数"。同一个 10 月 12 日,在收到的几份导出文件里能写成2026/10/12、2026-10-12 09:20:31、2026年10月12日,甚至26.10.13;完成日期空着的那几条到底算不算当天的完成;上周 6 件这周 6 件,可这周实际只走了四天。这些不对齐不是粗心,是几套系统的口径本来就不一样——每次出周报都得先花大半天人工对一遍,对完才敢发。

为了解决这个问题,我们的思路是「按字段名读 CSV + 显式日期归一 + 连续区间切片」这一套组合:把四种日期写法落成同一天,把没记录的那天显式留在表里,把"完成""在办""超 TAT"的定义写死在代码里,跑一遍出两份表,再用断言把结果验一遍。

本文按四步走:先配置路径与字段,再读数据,然后归堆,最后出表并回读校验。配套示例只吃三份 CSV,不接任何系统。

先把这四条口径钉死

代码写得再对,口径不定也没用。动手前先拍定这四条:

  1. 空档日补 0 还是留空?留空。补 0 等于断言"那天什么都没发生",留空才看得出是没记录、不是没干活。
  2. 完成日期为空怎么算?算在办,不算当天完成。丢掉它在办一条不剩;当 0 天又会让完成数看着没变。
  3. 超 TAT 怎么判?完成日减最早接收日,跟承诺天数比。判不了的(接收台账里没这个样品、承诺天数没填)不许当 0 天算进平均——那会让"平均周转"好看得离谱,真出问题反而查不出来。
  4. 周界怎么划?走 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+ 逐条核对原始数据与记录是否对得上

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 10:41:21

JavaWeb课程设计实战:手写Servlet+JSP打造个人主页与家乡档案系统

1. 从课程设计到个人名片&#xff1a;这个项目到底在做什么很多同学拿到“JavaWeb课程设计”这个题目时&#xff0c;第一反应是去网上找一套现成的后台管理系统模板&#xff0c;改改标题就交差。但“展示和管理自己和家乡的信息”这个题目&#xff0c;其实比想象中更有发挥空间…

作者头像 李华
网站建设 2026/10/17 10:40:25

桌面自动化工具 OpenClaw ,飞书微信联动任务下发实战

&#x1f4d6; 前言 本文面向 Windows 系统用户&#xff0c;系统梳理 OpenClaw 的标准化部署流程。全程无需输入任何命令行&#xff0c;所有操作均依托可视化向导完成&#xff0c;即便是零基础用户也能独立走完整套部署。文中同时汇总了高频报错的对应解决方案&#xff0c;力求…

作者头像 李华
网站建设 2026/10/17 10:39:42

Unity无限跑酷源码:生成回收、难度曲线与避坑指南

简介&#xff1a;这是一份面向Unity开发者的无限跑酷游戏完整项目源码&#xff0c;适合想要学习游戏逻辑、角色控制、程序化生成与移动端优化的初中级开发者。资源基于Unity引擎制作&#xff0c;包含完整工程文件与场景设置&#xff0c;核心代码覆盖障碍生成、无限地图循环、角…

作者头像 李华
网站建设 2026/10/17 10:37:24

OpenCV视频采集延迟根源剖析与低延迟优化实战

1. 从一个让人抓狂的延迟说起如果你用 OpenCV 做过视频处理&#xff0c;大概率遇到过这种场景&#xff1a;摄像头画面明明就在眼前&#xff0c;但程序里读出来的帧总是慢半拍&#xff0c;而且这个延迟不是忽大忽小&#xff0c;是稳定地慢那么几百毫秒。你挥手&#xff0c;画面里…

作者头像 李华
网站建设 2026/10/17 10:36:44

基于深度学习的疲劳驾驶检测系统:从关键点到时序判定

深夜跑完一趟长途&#xff0c;把车停进服务区的时候&#xff0c;我的眼睛已经酸到快睁不开。那一刻我意识到&#xff0c;市面上很多疲劳驾驶检测产品&#xff0c;和我自己研究时的体验差距有多大。基于深度学习的疲劳驾驶检测系统&#xff0c;不是简单拿个摄像头对着人脸、判断…

作者头像 李华
网站建设 2026/10/17 10:33:17

多算法声纹识别工程实现:从GMM-UBM到x-vector全流程解析

简介&#xff1a;这是一套基于声纹识别&#xff08;说话人识别&#xff09;的多种算法实现源码包&#xff0c;面向语音信号处理、生物特征识别方向的研究生、工程师及竞赛爱好者&#xff0c;可帮助快速理解不同声纹算法的原理与落地方式。资源共包含20个文件&#xff0c;以15个…

作者头像 李华