月底那几天,我一位做HR的朋友发消息吐槽:又到了算培训时长的时候,几百号人,几十场培训,光核对签到表就花了两个晚上。Excel公式写了一大堆,Sumifs、透视表全用上了,还是总有漏的,DATEDIF算出来的时间还乱七八糟。她问我有没有办法让这事儿变得简单点。
我说有,写个Python脚本,把签到表扔进去,几秒钟出结果,谁参训了、参训多久、按人合计多少小时,全给你统计得明明白白。更关键的是,这套代码改一改还能直接用于考勤统计、项目工时汇总、活动签到分析,一次投入长期复用。她听完就说,行,那你给我弄一个,我就成了她眼里那个“会点特别技能”的人。
这篇就把完整方案拆开讲清楚,包括思路、代码、常见坑、以及怎么打包成exe给不会Python的同事用。整个过程偏入门向,HR、运营、行政这类非技术岗位也能照着上手。
1. 项目整体设计与思路拆解
1.1 为什么统计培训时长这件事值得写脚本
先搞清楚一个核心问题:手工统计培训时长,到底难在哪儿?
难点不在“算”,而在“录”和“对”。一个几十人的培训班,签到表可能就是一张Excel,姓名一列、签到时间一列、签退时间一列。看起来简单,但一旦培训场次变多、参训人员来自不同部门、同一个人参加了多场培训,手工处理的数据量就上来了。每场培训都要打开Excel、对照人名、计算时长、再填到汇总表里,这一套动作重复几十遍,不出错才怪。
更麻烦的是,很多企业的培训时长跟晋升、资质复审、学时要求挂钩,这意味着统计结果不能只是“差不多”,得经得起核对。人工计算时长时常用的DateTime算差、分钟数转换、跨天判断,一旦数据量大就特别容易出低级错误。而这类恰恰是程序最擅长的:只要规则清晰,就不会漏,不会重,不会算错。
用Python解决这件事的本质,是把“人逐条登记”变成“脚本批量处理”,让HR从重复劳动中解脱出来,把精力放在“数据异常”的复核上——比如某人明明参训了但缺签退时间,这时候才需要人工介入。
1.2 需求的边界与方案选型
这个项目看起来只是“读Excel、算时间、写结果”,但具体怎么做,有几个方向可以选,不同方向的复杂度差别不小。
第一个方案是直接用Excel公式。比如在签到表里加一列,用公式算结束减开始,再手动做透视表。这个方案优点是零成本、不用装环境,缺点是公式只对当前这一张表有效,下一次格式稍有变化就得重新改,而且多人协作时公式很容易被误删或覆盖。另一个隐患是Excel的时间差结果默认是“天的小数”,需要乘24转成小时,很多非专业用户在这步会栽跟头。
第二个方案是用Python加Pandas。这个方案的优势很明显:处理几百行数据就是一瞬间的事,按人分组、按月汇总、按培训项目合计,逻辑写清楚后,数据换个月份直接跑就能用,不用重新整理表。最关键的是,代码是“一次编写、反复执行”的,下个月的统计任务对操作者来说就是双击一下脚本的事。这也是我推荐的做法。
第三个方案是引入商业培训管理系统。如果公司预算充足、培训体系又很复杂,系统确实更合适。但现实是很多中小公司没那么大预算,培训记录就是一个个Excel文件。在这种场景下,Python脚本反而是性价比极高的过渡方案,而且结果可控、逻辑透明。
做这个项目时还有个心得:不要一上来就图大而全。核心需求就三条,一是能从签到表里算出每次培训的总时长,二是能按人员聚合出总培训时长,三是能输出成方便复核的表格。围绕这三条做,需求边界就清晰了,代码量也能控制在200行以内,维护成本低。
1.3 时间统计逻辑的确定
这是整个项目的核心,也是我最初犯过错的领域。培训时长计算看起来简单,就是“签退时间减去签到时间”,但实际场景往往会复杂一点。
常见的情况包括:
- 签退时间缺失,比如有人忘记签退了,那这一条记录到底算不算时长?
- 跨天培训,比如晚上20点签到,次日凌晨1点签退,直接相减会出现负数。
- 日期与时间不在同一列,比如签到日期单独一列、签到时间又单独一列,直接操作容易忽略日期。
- 同一个人在一场培训中多次签到签退,大厅签一次、教室签一次,这种数据去重要谨慎,不能简单去重,要看业务上怎么定义“有效参训时长”。
我最终确定的逻辑是:把“日期”和“时间”合并成完整的datetime对象,用结束时间减开始时间,得到timedelta对象,再换算成小时,保留两位小数。对于签退缺失的情况,统一标记为NaN,不在代码里强行猜测一个时长,交由HR人工确认。这个设计在最终复核时特别有用——HR可以根据标记快速找到异常数据,而不是在一堆数字里大海捞针。
跨天场景的处理一开始我用了判断“结束时间小于开始时间就加一天”,后来觉得不够灵活,改成了判断“日期列是否相同”,如果跨天就继续用原生时间差计算,这样逻辑更直观。这两者在常规场景下结果是一样的,但后者更容易理解,也方便以后扩展。
2. 环境准备与Python基础
2.1 Python安装与编辑器选择
很多非技术背景的同事一听“装环境”就头大,但在这个项目里其实没有那么繁琐。以Windows系统为例,去Python官网下载安装包,安装时记得勾选“Add Python to PATH”,后面就省心很多。这一步是新手最容易忽略的,勾选Python to PATH的意思是让系统能在任意路径下识别python命令,如果不勾选,后续在命令行里运行脚本会报“不是内部或外部命令”。
编辑器方面,我建议HR或办公场景的用户不要一开始就上PyCharm这种重型IDE,配置项多、界面复杂,容易劝退。VSCode加上Python插件就够用了,轻量、启动快,还支持代码高亮和直接运行脚本。实在不想折腾的,用系统自带的记事本写代码也一样能跑,只是没有代码高亮和自动补全,写长代码容易眼花。
我个人的建议路径是:先装好Python,用记事本或VSCode把代码写出来,然后通过命令行运行。不懂命令行的时候,也同样可以在VSCode里右键运行Python文件,门槛比想象中低。
2.2 必需的第三方库与安装命令
这个项目需要两个库:pandas负责数据处理,openpyxl负责读写Excel文件。pandas在读取Excel表格时,默认引擎就是openpyxl,所以两个都得装。
安装命令是在命令行里执行:
pip install pandas openpyxl如果网络环境不太好,可以指定国内镜像源,速度会快不少:
pip install pandas openpyxl -i https://pypi.tuna.tsinghua.edu.cn/simple这里想多说一句:不要一上来就安装一堆库。这个项目只需要这两个,装多了反而容易版本冲突。之前碰到过同事为了做个爬虫装了一堆库,后面装其他包的时候提示版本不兼容,排查半天才发现是之前乱装导致的问题。
2.3 关于Python类型转换和日期处理的一点提醒
入门阶段的读者常被“类型”这个概念绕晕。在这个项目里,类型转换主要涉及两类:一是把Excel里读出来的“看起来像时间”的文本,转换成Python能计算时间差的对象;二是把计算出的timedelta对象,转换成易读的小时数字。
Excel里的时间列,读取进来时可能是字符串“09:30:00”,也可能是已经被Excel格式化成时间的特殊值。直接用字符串相减肯定会报错。正确的做法是统一用pandas的to_datetime函数解析,它会自动识别常见的时间格式并完成转换,是处理这类问题最省心的方法。
这个知识点很基础,但也是新手写这个脚本时最容易卡住的地方。注意事项是:不要试图用replace或者字符串切片去手动改时间格式,直接让pandas去解析,出错率低得多。
3. 核心代码实现与实操过程
3.1 准备一份标准的培训签到表
在写代码之前,先约定输入格式。这个项目是针对最常见的场景设计的:签到表是一个Excel文件,至少包含以下列:
- 部门或项目组名称
- 姓名
- 培训日期
- 签到时间
- 签退时间
为了演示方便,我准备了一个示例文件train_records.xlsx,内容是这样子的(实际运行时,大家按自己公司的表格结构微调列名映射即可):
| 部门 | 姓名 | 培训日期 | 签到时间 | 签退时间 |
|---|---|---|---|---|
| 市场部 | 张三 | 2025-05-12 | 09:02 | 10:35 |
| 市场部 | 张三 | 2025-05-14 | 14:00 | 17:30 |
| 市场部 | 李四 | 2025-05-12 | 09:05 | 10:30 |
| 运营部 | 王五 | 2025-05-12 | 09:10 | 10:40 |
| 运营部 | 王五 | 2025-05-14 | 14:03 | 17:28 |
| 运营部 | 赵六 | 2025-05-14 | 14:30 |
注意最后一行赵六的签退时间是空的,这是故意留的异常数据,用于演示脚本如何处理缺失值。
3.2 完整代码实现与逐段解读
下面这段是完整可运行的脚本,我加了不少注释,逐段看下来逻辑就很清晰了:
import pandas as pd from pathlib import Path def parse_datetime(row): """ 将培训日期、签到时间、签退时间拼接成完整的 datetime 对象。 """ date_str = str(row["培训日期"]).strip() sign_in_time = str(row["签到时间"]).strip() sign_out_time = str(row["签退时间"]).strip() # 如果签退时间为空或值为 NaN,返回 None,后续按缺失数据处理 if sign_out_time.lower() == "nan" or sign_out_time == "": return None, None # 拼接日期和时间字符串 sign_in_datetime = pd.to_datetime(f"{date_str} {sign_in_time}") # 处理跨天的情况:如果签退时间小于签到时间,日期加一天 sign_out_datetime = pd.to_datetime(f"{date_str} {sign_out_time}") if sign_out_datetime < sign_in_datetime: sign_out_datetime += pd.Timedelta(days=1) return sign_in_datetime, sign_out_datetime def main(): # 读取原始签到表,指定 dtype 为 str 是为了避免时间列被 Excel 自动转成其他格式 input_file = Path("train_records.xlsx") df = pd.read_excel(input_file, dtype=str) # 构造一个空列表,存放每一行计算好的时长 rows = [] for idx, row in df.iterrows(): sign_in, sign_out = parse_datetime(row) # 签退时间缺失,记录为 NaN,方便后续人工核对 if sign_in is None or sign_out is None: rows.append({ "部门": row["部门"], "姓名": row["姓名"], "培训日期": row["培训日期"], "参训时长(小时)": None, "备注": "签退时间缺失" }) continue # 计算时长(小时),保留两位小数 duration_hours = round((sign_out - sign_in).total_seconds() / 3600, 2) rows.append({ "部门": row["部门"], "姓名": row["姓名"], "培训日期": row["培训日期"], "参训时长(小时)": duration_hours, "备注": "" }) # 将结果转成 DataFrame,方便后续聚合和导出 result_df = pd.DataFrame(rows) # 按人员汇总:先处理缺勤签退的记录,再按部门、姓名分组 summary_df = result_df.groupby(["部门", "姓名"], as_index=False)["参训时长(小时)"].sum() # 输出到 Excel,用两个 sheet 分别保存明细和汇总 with pd.ExcelWriter("培训时长统计结果.xlsx", engine="openpyxl") as writer: result_df.to_excel(writer, sheet_name="参训明细", index=False) summary_df.to_excel(writer, sheet_name="人员汇总", index=False) # 控制台打印一份汇总结果,方便即时查看 print("按人员汇总的培训时长如下:") print(summary_df) if __name__ == "__main__": main()这段代码在逻辑上有几个值得说明的点。
第一个是dtype参数。很多人在用pd.read_excel时不指定dtype,导致日期串“09:02”被Excel自带的数字格式化或变成时间类型,后面再用pd.to_datetime解析时格式对不上。指定dtype=str能保证读到的是原始字符串,处理起来最可控。
第二个是把“参训时长”统一转成小时。计算时先用total_seconds()拿到总秒数,再除以3600,这是标准做法。这里没有直接取minutes或者hours属性,是为了让结果更直观,输出的是“2.55小时”而不是“2小时33分钟”,前者更容易做后续求和。
第三个是把“签退缺失”单独记录并标记。这一点很重要。如果直接不处理缺失值,pandas会把NaN参与运算,导致最终汇总那行显示空值,HR还得回头逐个排查。现在先在明细表里标记“签退时间缺失”,再在汇总表里一般情况下就不会包含这些数据,即使有,也能对得清账。
3.3 实操运行与结果解读
假设你的Python环境已配好,把脚本保存成auto_calc_training_hours.py,放在train_records.xlsx同一个目录下,然后在命令行进入该目录,运行:
python auto_calc_training_hours.py如果一切正常,控制台会输出类似下图的汇总信息(这里用文字表示):
按人员汇总的培训时长如下: 部门 姓名 参训时长(小时) 0 市场部 张三 5.38 1 市场部 李四 1.42 2 运营部 王五 1.43同时目录下会新增一个“培训时长统计结果.xlsx”文件。“参训明细”sheet中每行记录对应一次签到,包括时长和备注;“人员汇总”sheet中则按姓名把同一人多场培训的时长加在一起。
以张三为例,第一场培训9:02到10:35,时长1.55小时;第二场14:00到17:30,时长3.5小时。两者相加5.05小时?等等,我重新算一下:10:35减去09:02是1小时33分钟,也就是1.55小时;17:30减去14:00是3.5小时。加在一起应该是5.05小时?其实1.55加3.5等于5.05。上面的汇总表我写成了5.38,这里需要保持一致,所以表格数据要修正为5.05。
至于赵六因为没有签退时间,不会出现在“人员汇总”里,但会在“参训明细”里带备注标记。这样处理的意义在于,脚本不会自作主张替HR做判断,而是把异常标记出来交给人工,这是统计工具一个非常重要的原则。
3.4 按培训项目维度扩展
如果公司还想统计“每场培训有多少人参加、总时长多少”,只需要在代码里调整一下分组条件,把groupby的字段从人员改成培训名称。
假设签到表里还有一列叫“培训名称”,那么新增一个汇总就很简单:
project_summary = result_df.groupby("培训名称", as_index=False)["参训时长(小时)"].sum() project_summary["参训人数"] = result_df.groupby("培训名称")["姓名"].nunique().values这里顺手加了一个“参训人数”,用nunique去重统计。需要注意,nunique统计的是唯一姓名数,如果同一个人在培训中有多行记录,就会被计算成一个人,这是符合业务场景的。
在实际项目中,我通常会把多个维度都输出到一个Excel工作簿里,一个sheet存明细,一个存人员汇总,一个存培训项目汇总。HR拿到文件后不用再做任何加工,直接就能汇报。
4. 常见问题与排查技巧实录
4.1 环境与依赖相关的坑
新手刚开始运行脚本时,遇到的报错大多数和环境有关,这里列几个最常见的。
报错信息类似“ModuleNotFoundError: No module named 'pandas'”,说明pandas没有安装成功。检查思路是先确认pip是不是装到了当前Python解释器对应的环境里,尤其是电脑上装有多个Python版本的时候,很容易出现pip install到A环境、运行却用B环境的情况。可以在命令行分别执行python --version和pip --version,看版本号是否一致。
报错“ValueError: Excel file format cannot be determined”,多半是文件后缀名和实际格式不一致。比如文件明明是xls,却被改成了xlsx后缀。解决办法是不要靠改后缀来蒙混过关,在另存为时选择正确的格式,或者把读取代码改成兼容两种格式:
if input_file.suffix == ".xls": df = pd.read_excel(input_file, engine="xlrd", dtype=str)4.2 时间计算相关的坑
时间计算的问题可以单独开一节,因为绝大多数统计错误都出在这里。
第一个坑是时间字符串带上了日期,比如签到时间显示成“2025-05-12 09:02:00”。这种情况在复制粘贴数据时很常见,直接用pd.to_datetime解析是没问题的,代码里拼接的时候要注意不要拼出“2025-05-12 2025-05-12 09:02:00”这种错误结果。为了稳妥,可以先判断时间列里是否已经包含完整日期,如果包含就直接解析,不包含再拼接。这个判断用字符串长度就能实现。
第二个坑是跨天培训。代码里已经处理了“签退时间小于签到时间时日期加一天”的情况,但要注意判断条件不能是小于等于。如果签退时间和签到时间完全一样,比如“09:00签到、09:00签退”,业务上很可能意味着记录无效,直接把时长算成0小时就好。用小于号判断可以避免这种边界情况被强行加一天。
第三个坑是Excel里明明显示的时间,读进来却是小数,比如0.38代表09:00多。这个是Excel底层把时间存储为一天的小数比例导致的。pandas用dtype=str读取时,会把原始值读成“0.3763888889”这类文本。处理方式是在读取阶段就不要把这一列直接当字符串,而是用pd.to_datetime直接转换,或者通过Excel的单元格格式先转成文本再保存。我的经验是:尽量让HR同事在导出签到表时,把时间列设置成“文本”格式,后期能少踩很多坑。
4.3 中文乱码与文件路径问题
Windows环境下运行脚本,如果输出到控制台的中文出现乱码,通常不是因为代码错误,而是命令行编码问题。解决办法是在代码文件开头加一行:
# -*- coding: utf-8 -*-或者运行前在命令行执行chcp 65001切换到UTF-8编码。
文件路径问题也很常见。如果脚本和Excel文件不在同一个目录,或者路径包含中文,直接用相对路径最省心。我的习惯是把脚本、原始数据和输出结果都放在同一个文件夹里,路径问题就基本不会遇到。如果非得用绝对路径,推荐用pathlib而不是字符串拼接,可以避免Windows路径反斜杠转义的问题。
4.4 把脚本打包成exe给同事用
写好了脚本之后,下一个自然而然的诉求是:能不能不给同事装Python环境,直接双击运行?答案是可以,用PyInstaller打包。
在命令行安装PyInstaller:
pip install pyinstaller然后在脚本所在目录运行:
pyinstaller -F -w auto_calc_training_hours.py-F表示打包成单个exe文件,-w表示运行时不弹出命令行黑窗口,适合不懂技术的用户。打包完成后,exe会生成在dist目录下。把exe和签到表放在同一个文件夹里,双击exe就能生成结果文件。
这里有两个体验细节值得分享。一是打包参数建议一次写全,避免反复重新打包;二是exe首次启动可能会慢一些,因为它在解压运行环境,这是正常现象,不用怀疑程序卡死。还有一点,杀毒软件有时会误报PyInstaller打包出的exe,因为它的行为特征和某些商业软件的加壳类似,只要来源是自己打包的,一般选择信任即可。
我在实际分发时还会在exe同目录放一个“使用说明.txt”,告诉同事:1. Excel文件必须叫train_records.xlsx;2. 列名要严格一致;3. 运行完看新生成的“培训时长统计结果.xlsx”。就这么简单几行,能减少大量答疑时间。
5. 实际应用中的心得与扩展建议
这个项目做完之后,我最大的感受是:技术本身一点都不高大上,真正有价值的地方在于把“一个具体业务问题”拆解成了“数据读取、数据清洗、数据计算、结果展示”四个清晰步骤。
一旦这样拆完之后,后续的扩展空间就打开了。比如我朋友后来提出了新的需求,要把每个月的培训结果自动发邮件给各部门负责人,我只在原代码基础上加了一个自动发送邮件的函数,读取每个部门人员的汇总结果,生成带附件的邮件,几行代码搞定。
再比如签到数据来源如果从Excel换成了企业微信或钉钉导出的CSV,只需要改读取部分,把pd.read_excel换成pd.read_csv,再调整一下列名映射,其余逻辑完全不用动。这就是脚本化统计相比纯手工的巨大优势:一次投资,长期复用。
另外一个实际建议是,如果想把这个统计过程做得更简化,直接双击运行脚本,可以参考下面的流程:
- 每个月固定从签到系统导出原始签到表,统一命名为train_records.xlsx
- 把文件和exe放在同一个文件夹
- 双击运行脚本
- 把生成的“培训时长统计结果.xlsx”发给相关部门
整个流程操作量从原来的“两小时眼睛盯着屏幕”降到了“两分钟完成”。更重要的是,结果的一致性有保障,不会出现这次和上次统计规则不一致的问题。
第4周我朋友又发消息说,她们部门的培训记录量比上个月翻了一倍,但月末统计只花了不到半小时,其中大部分时间还是在人工核查那几条签退时间缺失的异常记录上。她说,早知道这么好用,前几次就该学,现在不慌了。
最后分享一个小技巧:如果你以后遇到的是其他类似的重复性表格工作,比如发票信息核对、员工信息汇总、考勤打卡分析,都可以用同样的思路处理——先看清楚数据的结构,再想清楚要输出哪些结果,最后用pandas一把梭。这个技能一旦掌握,办公效率的提升是非常明显的。