根据打卡时间,计算每日工时,中间存在午餐午休、晚餐时间,只有当工时区间覆盖了午休,才扣午休;覆盖多少,扣多少。
出勤工时
= 取整后下班时间 − 取整后上班时间
− 实际覆盖的午休时间
− 实际覆盖的晚餐时间
py
import os
import traceback
import logging
import numpy as np
import pandas as pd
import tkinter as tk
from tkinter import filedialog, messagebox
# =========================
# 配置区
# =========================
DEVICE_KEYWORD = "产业城总部_"
OUT_SUMMARY_NAME = "考勤月报_横向工时.xlsx"
OUT_DETAIL_NAME = "考勤工时_计算明细.xlsx"
LOG_FILE = "attendance.log"
# =========================
# 时间 / 区间工具函数
# =========================
def parse_datetime(date_str, time_str):
"""日期 + 时间 -> Timestamp"""
return pd.to_datetime(f"{date_str} {time_str}", errors="coerce")
def round_to_half_hour(ts):
"""15 分钟阈值,取整到最近半小时"""
if pd.isna(ts):
return ts
minutes = ts.hour * 60 + ts.minute
rounded = ((minutes + 15) // 30) * 30
return ts.normalize() + pd.Timedelta(minutes=int(rounded))
# 只有当工时区间覆盖了午休,才扣午休;覆盖多少,扣多少
def overlap_minutes(a_start, a_end, b_start, b_end):
"""计算两个时间区间的重叠分钟数"""
start = max(a_start, b_start)
end = min(a_end, b_end)
if end <= start:
return 0
return int((end - start).total_seconds() // 60)
# =========================
# 单日明细计算
# =========================
def compute_daily_detail(group):
"""
计算某员工某一天的考勤明细
"""
date_str = group["日期"].iloc[0]
day = pd.to_datetime(date_str).normalize()
times = [parse_datetime(date_str, t) for t in group["打卡时间"]]
times = [t for t in times if not pd.isna(t)]
if not times:
return {
"出勤工时": np.nan,
"上班时间(用于计算)": None,
"下班时间(用于计算)": None,
"原始最早打卡": None,
"原始最晚打卡": None,
"打卡次数": 0,
}
raw_start = min(times)
raw_end = max(times)
start_limit = day + pd.Timedelta(hours=8, minutes=30)
work_start = max(raw_start, start_limit)
work_end = max(raw_end, work_start)
work_start_calc = round_to_half_hour(work_start)
work_end_calc = round_to_half_hour(work_end)
gross_minutes = int((work_end_calc - work_start_calc).total_seconds() // 60)
lunch = overlap_minutes(
work_start_calc, work_end_calc,
day + pd.Timedelta(hours=12),
day + pd.Timedelta(hours=13, minutes=30)
)
dinner = overlap_minutes(
work_start_calc, work_end_calc,
day + pd.Timedelta(hours=17, minutes=30),
day + pd.Timedelta(hours=18, minutes=30)
)
net_minutes = max(0, gross_minutes - lunch - dinner)
return {
"出勤工时": net_minutes / 60,
"上班时间(用于计算)": work_start_calc.strftime("%H:%M"),
"下班时间(用于计算)": work_end_calc.strftime("%H:%M"),
"原始最早打卡": raw_start.strftime("%H:%M:%S"),
"原始最晚打卡": raw_end.strftime("%H:%M:%S"),
"打卡次数": len(times),
}
# =========================
# 核心处理逻辑(输出到指定目录)
# =========================
def process_attendance(input_file, out_dir):
"""
生成:
1) 横向月报
2) 纵向计算明细
"""
logging.info(f"读取源文件: {input_file}")
df = pd.read_excel(input_file, dtype=str)
df = df[df["设备名称"].astype(str).str.contains(DEVICE_KEYWORD, na=False)]
if df.empty:
raise ValueError(f"未找到设备名称包含"{DEVICE_KEYWORD}"的记录")
summary_rows = []
detail_rows = []
for (emp_id, name, date_str), g in df.groupby(["工号", "姓名", "日期"]):
detail = compute_daily_detail(g)
date_dt = pd.to_datetime(date_str)
detail_rows.append({
"工号": emp_id,
"姓名": name,
"日期": date_dt,
**detail
})
summary_rows.append({
"工号": emp_id,
"姓名": name,
"日期": date_dt,
"日期列": date_dt.strftime("%m-%d"),
"出勤工时": detail["出勤工时"],
})
# ===== 明细表 =====
detail_df = pd.DataFrame(detail_rows)
detail_df = detail_df.sort_values(["工号", "日期"])
detail_df["日期"] = detail_df["日期"].dt.strftime("%Y-%m-%d")
detail_path = os.path.join(out_dir, OUT_DETAIL_NAME)
detail_df.to_excel(detail_path, index=False)
# ===== 月报 =====
summary_df = pd.DataFrame(summary_rows)
date_columns = (
summary_df.sort_values("日期")["日期列"]
.drop_duplicates()
.tolist()
)
summary_table = summary_df.pivot_table(
index=["工号", "姓名"],
columns="日期列",
values="出勤工时",
aggfunc="first"
).reset_index()
summary_table = summary_table.reindex(
columns=["工号", "姓名"] + date_columns
)
summary_path = os.path.join(out_dir, OUT_SUMMARY_NAME)
summary_table.to_excel(summary_path, index=False)
logging.info("生成完成")
return summary_path, detail_path
# =========================
# GUI 主入口
# =========================
def main():
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
encoding="utf-8"
)
try:
root = tk.Tk()
root.title("考勤工时计算")
root.geometry("360x160")
tk.Label(root, text="请选择打卡源数据 Excel").pack(pady=8)
def run():
try:
# ① 选源文件
input_file = filedialog.askopenfilename(
parent=root,
title="请选择打卡源数据 Excel",
filetypes=[("Excel 文件", "*.xlsx *.xls")]
)
if not input_file:
return
# ② 选输出目录
out_dir = filedialog.askdirectory(
parent=root,
title="请选择结果保存目录"
)
if not out_dir:
return
summary_path, detail_path = process_attendance(input_file, out_dir)
messagebox.showinfo(
"完成",
"已生成文件:\n\n"
f"{summary_path}\n"
f"{detail_path}"
)
except Exception:
logging.exception("处理失败")
messagebox.showerror(
"错误",
f"程序运行失败,请查看 {LOG_FILE}"
)
tk.Button(root, text="开始计算", command=run).pack(pady=12)
root.mainloop()
except Exception:
with open("fatal_error.log", "w", encoding="utf-8") as f:
f.write(traceback.format_exc())
if __name__ == "__main__":
main()