
引言实验室里那份日报周报卡住人的从来不是出表是对数。同一个 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 PathBASE 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, encodingutf-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(daysi) 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(days7) 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(days6)最后把这些该有人核一遍的汇成一张清单日报周报才敢发出去出处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 逐条核对原始数据与记录是否对得上手里有这类活儿卡着或者只是想问问能不能自动化都欢迎评论区聊先把问题说清楚再谈怎么做。