实验室弹性打卡制度,上班钉钉考勤,代码自动化统计时长,详细流程(已实测好用)

部署运行你感兴趣的模型镜像

摘要:为解决实验室严格考勤问题,试行了弹性打卡制度,取得了不错的效果。本文档详细说明了制度细则、钉钉操作流程,并提供了原创的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. 补充条款

  1. 学校项目:参加新生心理测评、体检等学校要求的项目,不计入科研时长,需自行找补时间(该日打卡时长可灵活调整,但周总时长必须满足54小时)。
  2. 短时外出:前往其他教学楼递交个人材料(如综测表等),15分钟左右可返回的情况下,可以不签退。
  3. 接打电话:请在闻天楼内进行,避免随机抽查时不在场。
  4. 特殊情况处理
    • 出差/病假:按每天 9 小时计算。
    • 事假:视具体情况而定。
    • 周四晚运动:按 3 小时计算。
    • 实验室会议、运动会、新生上课等:根据实际情况补打卡。
  5. 行为规范:严禁在实验室打游戏、听歌、看小说。违者:
    • 一月内发现1次,本月按学校最低要求发放津贴;
    • 一月内发现2次,本月取消津贴;
    • 一月内发现3次,予以清退。
  6. 补卡:允许每周补卡1次
  7. 作息建议:建议每日睡眠时间为 7±1 小时。
  8. 统计轮值:采用轮值制,每周由一名同学负责统计。有特殊情况可联系统计人说明。第 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()

您可能感兴趣的与本文相关的镜像

Python3.10

Python3.10

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值