实验室弹性打卡制度,上班钉钉考勤,代码自动化统计时长,详细流程(已实测好用)
摘要:为解决实验室严格考勤问题,试行了弹性打卡制度,取得了不错的效果。本文档详细说明了制度细则、钉钉操作流程,并提供了原创的Python自动统计代码,可直接生成异常打卡值和总工时。
前言
现将制度详情及配套的自动统计方法公开,以便其他团队借鉴。弹性打卡制度刚试行不久,可能还有很多优化空间。欢迎大家有意见可以交流讨论。可以联系本人邮箱获得更快的反馈:211191511@qq.com。文章有很多自己生成的跳转链接,不知道怎么删掉,不用管。
一、实验室弹性作息制度
1. 概述
参与范围:实验室所有在读学生可自愿选择弹性打卡或沿用原先的打卡方式。考勤周期:以周为单位统计,按月处理。每周按 6 个工作日计算。时长要求:日均打卡至少 9 小时,即每周总时长需满足 54 小时。打卡时长不足者,津贴扣减 100元/周。打卡规则:
每日需有偶数次打卡(即 2, 4, 6… 次),不能为奇数次。常规情况(签到+签退)即为2次。两次打卡前后间隔需超过20分钟(24:00前后时段除外)。若签到后超过24:00离开,需在24:00前进行一次签退,待超过24:00后,再进行一次签到,离开时最终签退。 调整机制:同学可自主提出申请,经实验室老师评估上周科研推进情况后,可酌情调整打卡时限。
2. 打卡教程
(1)选择签到工具
在钉钉群内选择签到,不要选择打卡。
(2)签到
钉钉群内进行签到。签到类型需选择签到。
(3)签退
钉钉群内进行签退。签到类型需选择签退。
3. 补充条款
学校项目:参加新生心理测评、体检等学校要求的项目,不计入科研时长,需自行找补时间(该日打卡时长可灵活调整,但周总时长必须满足54小时)。短时外出:前往其他教学楼递交个人材料(如综测表等),15分钟左右可返回的情况下,可以不签退。接打电话:请在闻天楼内进行,避免随机抽查时不在场。特殊情况处理:
出差/病假:按每天 9 小时计算。事假:视具体情况而定。周四晚运动:按 3 小时计算。实验室会议、运动会、新生上课等:根据实际情况补打卡。 行为规范:严禁在实验室打游戏、听歌、看小说。违者:
一月内发现1次,本月按学校最低要求发放津贴;一月内发现2次,本月取消津贴;一月内发现3次,予以清退。 补卡:允许每周补卡1次。作息建议:建议每日睡眠时间为 7±1 小时。统计轮值:采用轮值制,每周由一名同学负责统计。有特殊情况可联系统计人说明。第 i 周打卡情况在第 i+1 周内统计完毕即可。后续已修改为按月统计总时长达标即可。
4. 随机抽查
频次:不定时、不定次数。方式:在微信群(为避免打扰,建议将钉钉群设置为免打扰)内随机抽取1名在场同学。该同学需在5分钟内,于钉钉群发送各屋现场照片,以核实在场情况。造假处理:
签到后不在实验室科研,过后返回或以忘记签退为由辩解,均视为造假,违背诚信原则。第1次:按学校要求发放津贴并警告;第2次:予以清退。 设备号:后台会记录设备号,每人手机设备号唯一(从入学至毕业不变)。如更换新手机,务必提前说明。备注:随机抽查如在原工作时间内,沿用旧打卡方式的同学也需出现在照片中(外出科研任务除外),否则按迟到/早退处理。
二、自动化统计流程
1.钉钉群管理员从钉钉导出“签到报表.xlsx”,只保留“已签到”的分表,只保留所需的四列即可。
2.统计微信群里所有补卡和请假的情况放到excel中,并在导出报表打卡明细中进行补卡。
<弹性打卡群>
<补卡备注>
3.将“签到报表.xlsx”重命名为input.xlsx,python代码与input文件在同一文件夹下,直接运行输出output,里面只需要看总时间和异常值两个分表即可。
4.修正异常值,对output中异常值的分表中有问题的信息发到微信群,通过反馈进行补卡,修复全部异常值再重新运行代码,导出新的output文件。
5.通过output文件中个人统计的分表中获取每个人打卡时长,再对请假和补时间的加进去,写一个“总时长统计.xlsx”(按姓名降序方便复制时间)
<每周明细> <汇总四周数据>
三、python 代码如下:
# -*- coding: utf-8 -*-
import pandas as pd
import re
from datetime import timedelta
# ========= 配置 =========
input_path = r"input.xlsx" # ← 改成你的输入文件(xlsx/csv/tsv,列:姓名 日期 时间 签到类型)
output_xlsx = "output.xlsx" # ← 想导出Excel就填路径,例如 r"weekly_times.xlsx";留空则不导出
# =======================
# ---------- 规范化 ----------
def normalize_time(t: str) -> str:
"""把 'H:MM'/'HH:MM'/'HH:MM:SS'(含全角冒号/隐藏空格)统一为 'HH:MM:SS'。"""
t = str(t).replace(':', ':').replace('\u00A0', ' ')
t = re.sub(r'\s+', ' ', t).strip()
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', t)
if not m:
raise ValueError(f"非法时间: {t!r}")
hh, mm, ss = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
return f"{hh:02d}:{mm:02d}:{ss:02d}"
def normalize_date(d: str) -> str:
"""把日期统一为 YYYY-MM-DD,容忍 '2025年10月20日'/'2025/10/20' 等格式。"""
d = str(d).replace('\u00A0', ' ')
d = d.replace('年', '-').replace('月', '-').replace('日', '')
d = re.sub(r'\s+', ' ', d).strip()
try:
pd.to_datetime(d, format="%Y-%m-%d")
return d
except Exception:
dt = pd.to_datetime(d, errors="raise")
return dt.strftime("%Y-%m-%d")
def hhmm_from_seconds(sec: int) -> str:
mins = int(round(sec / 60))
return f"{mins // 60:02d}:{mins % 60:02d}"
# ---------- 读取 ----------
def read_input(path: str) -> pd.DataFrame:
if path.lower().endswith(".csv"):
df = pd.read_csv(path, dtype=str)
elif path.lower().endswith((".tsv", ".txt")):
df = pd.read_csv(path, dtype=str, sep="\t")
else:
df = pd.read_excel(path, dtype=str)
need = {"姓名", "日期", "时间", "签到类型"}
if not need.issubset(set(df.columns)):
raise ValueError(f"缺少必要列:{need},实际列:{list(df.columns)}")
df = df[list(need)].copy().astype(str)
df["姓名"] = df["姓名"].str.replace('\u00A0', ' ', regex=False).str.strip()
df["签到类型"] = df["签到类型"].str.strip()
# 逐行规范
df["日期"] = df["日期"].apply(normalize_date)
df["时间"] = df["时间"].apply(normalize_time)
# 合成时间戳
dt_str = (df["日期"].str.replace('\u00A0', ' ', regex=False).str.strip()
+ " " +
df["时间"].str.replace('\u00A0', ' ', regex=False).str.strip())
dt_str = dt_str.str.replace(r"\s+", " ", regex=True).str.strip()
ts = pd.to_datetime(dt_str, format="%Y-%m-%d %H:%M:%S", errors="coerce")
bad = df[ts.isna()][["姓名", "日期", "时间", "签到类型"]]
if not bad.empty:
print("【警告】以下行无法解析为时间戳(已忽略):")
print(bad.to_string(index=False))
df = df[~ts.isna()].copy()
df["ts"] = ts[~ts.isna()]
return df
# ---------- 配对(全量) ----------
def pair_all_shifts(clean_df: pd.DataFrame):
"""在全量数据上,按人配对上/下班;连续上班=>自动闭合上一段。返回(班次df, 异常df)。"""
df = clean_df.sort_values(["姓名", "ts", "签到类型"]).copy()
dup_mask = df.duplicated(subset=["姓名", "ts", "签到类型"], keep="first")
dups = df[dup_mask]
base = df[~dup_mask]
anomalies = []
shift_rows = []
for name, g in base.groupby("姓名", sort=True):
g = g.sort_values("ts")
open_start = None
for _, r in g.iterrows():
ts, typ = r["ts"], r["签到类型"]
if typ == "上班":
if open_start is None:
open_start = ts
else:
# 自动闭合上一段
anomalies.append({"姓名": name, "问题": "连续上班(已自动闭合上一段)", "时间": ts})
shift_rows.append({
"姓名": name,
"开始时间": open_start,
"结束时间": ts,
"时长(秒)": int((ts - open_start).total_seconds())
})
open_start = ts
elif typ == "下班":
if open_start is None:
anomalies.append({"姓名": name, "问题": "无上班的下班(忽略)", "时间": ts})
else:
if ts >= open_start:
shift_rows.append({
"姓名": name,
"开始时间": open_start,
"结束时间": ts,
"时长(秒)": int((ts - open_start).total_seconds())
})
else:
anomalies.append({"姓名": name, "问题": "下班早于上班(忽略该对)", "时间": ts})
open_start = None
else:
anomalies.append({"姓名": name, "问题": f"未知签到类型: {typ}", "时间": ts})
if open_start is not None:
anomalies.append({"姓名": name, "问题": "存在未下班记录(未计入)", "时间": open_start})
for _, r in dups.iterrows():
anomalies.append({"姓名": r["姓名"], "问题": "重复记录(已去重)", "时间": r["ts"]})
shifts = pd.DataFrame(shift_rows)
anom_df = pd.DataFrame(anomalies).sort_values(["姓名", "时间"]) if anomalies else \
pd.DataFrame(columns=["姓名", "问题", "时间"])
return shifts, anom_df
# ---------- 周裁剪 ----------
def clip_shifts_to_week(shifts: pd.DataFrame, week_start: pd.Timestamp, week_end: pd.Timestamp) -> pd.DataFrame:
"""把班次裁剪到 [week_start, week_end) 区间,仅保留有交集的部分。"""
if shifts.empty:
return shifts.copy()
a = shifts.copy()
a["开始剪"] = a["开始时间"].where(a["开始时间"] >= week_start, week_start)
a["结束剪"] = a["结束时间"].where(a["结束时间"] <= week_end, week_end)
a = a[a["结束剪"] > a["开始剪"]].copy()
a["时长(秒)"] = (a["结束剪"] - a["开始剪"]).dt.total_seconds().astype(int)
a["日期"] = a["开始剪"].dt.date.astype(str)
return a[["姓名", "开始剪", "结束剪", "日期", "时长(秒)"]].rename(
columns={"开始剪": "开始时间", "结束剪": "结束时间"}
)
# ---------- 主流程 ----------
def main():
df = read_input(input_path)
if df.empty:
print("没有可统计的数据。")
return
# 全量配对
shifts_all, anom = pair_all_shifts(df)
# 自动确定“本周”区间(以最早日期所在周为周一~周日)
first_day = df["ts"].dt.floor("D").min().date()
week_start = pd.Timestamp(first_day) - timedelta(days=pd.Timestamp(first_day).weekday())
week_end = week_start + timedelta(days=7) # 半开区间上界
# 裁剪到本周
shifts = clip_shifts_to_week(shifts_all, week_start, week_end)
# 汇总
if shifts.empty:
print(f"本周({week_start.date()} ~ {(week_end - timedelta(days=1)).date()})无可统计班次。")
return
totals = shifts.groupby("姓名", as_index=False)["时长(秒)"].sum()
totals["时长(小时)"] = totals["时长(秒)"] / 3600.0
totals["总计(HH:MM)"] = totals["时长(秒)"].apply(hhmm_from_seconds)
totals = totals.sort_values("时长(秒)", ascending=False)
# 控制台输出
print("=== 本周区间 ===")
print(f"{week_start.date()} ~ {(week_end - timedelta(days=1)).date()}")
print("\n=== 本周个人总工时 ===")
for _, r in totals.iterrows():
print(f"{r['姓名']}: {r['总计(HH:MM)']} ({r['时长(小时)']:.2f} 小时)")
if not anom.empty:
# 仅提示条数;需要可改为打印前若干条
print(f"\n(提示)发现 {len(anom)} 条异常记录;如需导出查看请设置 output_xlsx。")
# 可选:导出 Excel
if output_xlsx:
with pd.ExcelWriter(output_xlsx) as w:
raw_export = df.sort_values(["姓名", "ts"]).rename(columns={"ts": "时间戳"})
raw_export.to_excel(w, sheet_name="原始明细", index=False)
shifts_all.sort_values(["姓名", "开始时间"]).to_excel(w, sheet_name="全量班次", index=False)
shifts.sort_values(["姓名", "开始时间"]).to_excel(w, sheet_name="本周班次", index=False)
totals.to_excel(w, sheet_name="个人汇总", index=False)
if not anom.empty:
anom.to_excel(w, sheet_name="数据异常", index=False)
print(f"\n已导出:{output_xlsx}")
if __name__ == "__main__":
main()