attendance_STS / app.py
Angelguava's picture
Update app.py
9a91886 verified
Raw
History Blame Contribute Delete
17.4 kB
import os
import json
import io
import pandas as pd
import streamlit as st
import gspread
from google.oauth2.service_account import Credentials
import plotly.express as px
# =========================
# 基本設定
# =========================
LATE_TIME = "08:00"
EARLY_LEAVE_TIME = "17:00"
MAX_WORK_HOURS = 12
MIN_WORK_HOURS = 1
NORMAL_WORK_HOURS = 8
GOOGLE_SHEET_URL = "https://docs.google.com/spreadsheets/d/1RGnMJW5Ja0e3rDJrChvrxlExlcqkB1WvIhFU-tRNWmo/edit?usp=sharing"
GOOGLE_WORKSHEET_NAME = "表單回應 1"
# =========================
# Google Sheets 讀取
# =========================
def get_gspread_client():
secret_text = os.environ.get("GOOGLE_CREDS_JSON")
if not secret_text:
raise ValueError("找不到 GOOGLE_CREDS_JSON,請到 Hugging Face Space Settings > Secrets 設定。")
creds_info = json.loads(secret_text)
scopes = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive",
]
credentials = Credentials.from_service_account_info(creds_info, scopes=scopes)
return gspread.authorize(credentials)
def load_google_sheet_by_url(sheet_url, worksheet_name=None):
gc = get_gspread_client()
spreadsheet = gc.open_by_url(sheet_url)
worksheet = spreadsheet.worksheet(worksheet_name) if worksheet_name else spreadsheet.sheet1
values = worksheet.get_all_values()
if not values:
raise ValueError("Google Sheet 是空的,沒有任何資料。")
header = values[0]
rows = values[1:] if len(values) > 1 else []
return pd.DataFrame(rows, columns=header)
# =========================
# 資料清洗
# =========================
def clean_attendance_data(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df.columns = [str(col).strip() for col in df.columns]
column_map = {
"員工編號": "emp_id",
"員工姓名": "emp_name",
"部門": "department",
"日期": "date",
"上班時間": "check_in",
"下班時間": "check_out",
"備註": "remark",
}
df = df.rename(columns={k: v for k, v in column_map.items() if k in df.columns})
required_cols = ["emp_id", "emp_name", "date", "check_in", "check_out"]
for col in required_cols:
if col not in df.columns:
raise ValueError(f"缺少必要欄位:{col}")
if "department" not in df.columns:
df["department"] = ""
if "remark" not in df.columns:
df["remark"] = ""
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df["check_in"] = df["check_in"].replace("", pd.NA)
df["check_out"] = df["check_out"].replace("", pd.NA)
df["check_in"] = pd.to_datetime(
df["date"].dt.strftime("%Y-%m-%d") + " " + df["check_in"].astype(str),
errors="coerce"
)
df["check_out"] = pd.to_datetime(
df["date"].dt.strftime("%Y-%m-%d") + " " + df["check_out"].astype(str),
errors="coerce"
)
return df
# =========================
# 備註自動判斷
# =========================
def build_remark(row, normal_work_hours: int) -> str:
remarks = []
if row["漏打卡"]:
if pd.isna(row["check_in"]) and pd.isna(row["check_out"]):
remarks.append("上班、下班皆未打卡")
elif pd.isna(row["check_in"]):
remarks.append("上班未打卡")
elif pd.isna(row["check_out"]):
remarks.append("下班未打卡")
if row["遲到"]:
remarks.append("遲到")
if row["早退"]:
remarks.append("早退")
if row["工時過長"]:
remarks.append("工時過長")
if row["工時過短"]:
remarks.append("工時過短")
if row["工時不足"]:
remarks.append(f"工時不足{normal_work_hours}小時")
return "、".join(remarks) if remarks else "正常"
# =========================
# 稽核邏輯
# =========================
def audit_attendance(
df: pd.DataFrame,
late_time: str,
early_leave_time: str,
max_work_hours: float,
min_work_hours: float,
normal_work_hours: float,
) -> pd.DataFrame:
df = df.copy()
df["漏打卡"] = df["check_in"].isna() | df["check_out"].isna()
df["標準上班時間"] = pd.to_datetime(
df["date"].dt.strftime("%Y-%m-%d") + f" {late_time}",
errors="coerce"
)
df["標準下班時間"] = pd.to_datetime(
df["date"].dt.strftime("%Y-%m-%d") + f" {early_leave_time}",
errors="coerce"
)
df["遲到"] = (df["check_in"] > df["標準上班時間"]).fillna(False)
df["早退"] = (df["check_out"] < df["標準下班時間"]).fillna(False)
df["工時"] = (df["check_out"] - df["check_in"]).dt.total_seconds() / 3600
df["工時過長"] = (df["工時"] > max_work_hours).fillna(False)
df["工時過短"] = (df["工時"] < min_work_hours).fillna(False)
df["工時不足"] = ((df["工時"] >= min_work_hours) & (df["工時"] < normal_work_hours)).fillna(False)
df["是否異常"] = (
df["漏打卡"]
| df["遲到"]
| df["早退"]
| df["工時過長"]
| df["工時過短"]
| df["工時不足"]
)
# 保留原始備註供查閱
df["原始備註"] = df["remark"].astype(str).fillna("").str.strip()
# 系統自動備註
df["系統備註"] = df.apply(lambda row: build_remark(row, int(normal_work_hours)), axis=1)
# 正式版:最終備註完全採用系統自動判斷
df["最終備註"] = df["系統備註"]
return df
# =========================
# 統計
# =========================
def build_employee_summary(df: pd.DataFrame) -> pd.DataFrame:
summary = df.groupby(["emp_id", "emp_name", "department"], dropna=False).agg(
遲到次數=("遲到", "sum"),
早退次數=("早退", "sum"),
漏打卡次數=("漏打卡", "sum"),
工時過長次數=("工時過長", "sum"),
工時過短次數=("工時過短", "sum"),
工時不足次數=("工時不足", "sum"),
總異常次數=("是否異常", "sum"),
).reset_index()
return summary
def build_department_summary(df: pd.DataFrame) -> pd.DataFrame:
summary = df.groupby("department", dropna=False).agg(
遲到次數=("遲到", "sum"),
早退次數=("早退", "sum"),
漏打卡次數=("漏打卡", "sum"),
工時過長次數=("工時過長", "sum"),
工時過短次數=("工時過短", "sum"),
工時不足次數=("工時不足", "sum"),
總異常次數=("是否異常", "sum"),
).reset_index().rename(columns={"department": "部門"})
return summary
# =========================
# 匯出 Excel
# =========================
def to_excel_bytes(sheets: dict) -> bytes:
buf = io.BytesIO()
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
for sheet_name, df_sheet in sheets.items():
df_sheet.to_excel(writer, sheet_name=sheet_name, index=False)
return buf.getvalue()
# =========================
# 顯示用 index 從 1 開始
# =========================
def reset_display_index(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df.index = range(1, len(df) + 1)
return df
# =========================
# Streamlit UI
# =========================
st.set_page_config(page_title="打卡稽核系統", layout="wide", page_icon="🕐")
with st.sidebar:
st.header("⚙️ 稽核參數設定")
late_time_input = st.time_input(
"遲到判定時間",
value=pd.Timestamp(f"2000-01-01 {LATE_TIME}").time()
)
early_time_input = st.time_input(
"早退判定時間",
value=pd.Timestamp(f"2000-01-01 {EARLY_LEAVE_TIME}").time()
)
max_hours_input = st.number_input(
"工時過長上限(小時)",
min_value=1.0,
max_value=24.0,
value=float(MAX_WORK_HOURS)
)
min_hours_input = st.number_input(
"工時過短下限(小時)",
min_value=0.0,
max_value=12.0,
value=float(MIN_WORK_HOURS)
)
normal_hours_input = st.number_input(
"標準工時(小時,低於此值視為工時不足)",
min_value=1.0,
max_value=24.0,
value=float(NORMAL_WORK_HOURS)
)
st.divider()
st.caption("Google Sheet 設定")
sheet_url = st.text_input(
"試算表連結",
value=GOOGLE_SHEET_URL
)
worksheet_name = st.text_input(
"工作表名稱",
value=GOOGLE_WORKSHEET_NAME
)
late_time_str = late_time_input.strftime("%H:%M")
early_leave_time_str = early_time_input.strftime("%H:%M")
st.title("🕐 Google 打卡稽核系統")
st.caption("連接 Google Sheets,自動偵測遲到 / 早退 / 漏打卡 / 異常工時 / 工時不足 / 自動備註")
run_btn = st.button("📥 讀取並開始分析", type="primary", use_container_width=True)
if run_btn:
with st.spinner("讀取 Google Sheet 中..."):
try:
df_raw = load_google_sheet_by_url(sheet_url, worksheet_name)
except Exception as e:
st.error(f"❌ 無法讀取 Google Sheet:{e}")
st.stop()
with st.spinner("資料清洗與稽核中..."):
try:
df_clean = clean_attendance_data(df_raw)
df_audit = audit_attendance(
df_clean,
late_time=late_time_str,
early_leave_time=early_leave_time_str,
max_work_hours=max_hours_input,
min_work_hours=min_hours_input,
normal_work_hours=normal_hours_input,
)
emp_summary = build_employee_summary(df_audit)
dept_summary = build_department_summary(df_audit)
abnormal_df = df_audit[df_audit["是否異常"]].copy()
except Exception as e:
st.error(f"❌ 資料處理失敗:{e}")
st.stop()
total = len(df_audit)
abn_cnt = int(df_audit["是否異常"].sum())
late_cnt = int(df_audit["遲到"].sum())
early_cnt = int(df_audit["早退"].sum())
miss_cnt = int(df_audit["漏打卡"].sum())
short_cnt = int(df_audit["工時不足"].sum())
c1, c2, c3, c4, c5, c6 = st.columns(6)
c1.metric("📋 總筆數", total)
c2.metric(
"⚠️ 總異常數",
abn_cnt,
delta=f"{abn_cnt / total * 100:.1f}%" if total > 0 else "0%",
delta_color="inverse"
)
c3.metric("🕗 遲到", late_cnt)
c4.metric("🏃 早退", early_cnt)
c5.metric("❓ 漏打卡", miss_cnt)
c6.metric("⏱️ 工時不足", short_cnt)
st.divider()
tab1, tab2, tab3, tab4, tab5 = st.tabs([
"📊 圖表總覽",
"⚠️ 異常明細",
"👤 員工統計",
"🏢 部門統計",
"🗃️ 原始資料"
])
with tab1:
col_a, col_b = st.columns(2)
with col_a:
st.subheader("各部門異常次數")
if not dept_summary.empty:
fig = px.bar(
dept_summary,
x="部門",
y="總異常次數",
color="總異常次數",
color_continuous_scale="Reds",
text="總異常次數"
)
fig.update_traces(textposition="outside")
fig.update_layout(showlegend=False, height=350)
st.plotly_chart(fig, use_container_width=True)
with col_b:
st.subheader("異常類型分佈")
anomaly_types = {
"遲到": late_cnt,
"早退": early_cnt,
"漏打卡": miss_cnt,
"工時過長": int(df_audit["工時過長"].sum()),
"工時過短": int(df_audit["工時過短"].sum()),
"工時不足": short_cnt,
}
fig2 = px.pie(
names=list(anomaly_types.keys()),
values=list(anomaly_types.values()),
hole=0.4
)
fig2.update_layout(height=350)
st.plotly_chart(fig2, use_container_width=True)
st.subheader("員工工時分佈")
work_hours_data = df_audit.dropna(subset=["工時"])
if not work_hours_data.empty:
fig3 = px.histogram(
work_hours_data,
x="工時",
nbins=20,
labels={"工時": "工時(小時)", "count": "筆數"}
)
fig3.add_vline(
x=min_hours_input,
line_dash="dash",
line_color="orange",
annotation_text="最短工時"
)
fig3.add_vline(
x=normal_hours_input,
line_dash="dash",
line_color="blue",
annotation_text=f"標準工時({normal_hours_input:g}h)"
)
fig3.add_vline(
x=max_hours_input,
line_dash="dash",
line_color="red",
annotation_text="最長工時"
)
fig3.update_layout(height=300)
st.plotly_chart(fig3, use_container_width=True)
with tab2:
st.subheader(f"⚠️ 異常紀錄(共 {len(abnormal_df)} 筆)")
col_f1, col_f2 = st.columns(2)
with col_f1:
anomaly_filter = st.multiselect(
"篩選異常類型",
["遲到", "早退", "漏打卡", "工時過長", "工時過短", "工時不足"],
default=[]
)
with col_f2:
dept_options = ["全部"] + sorted(df_audit["department"].dropna().astype(str).unique().tolist())
dept_filter = st.selectbox("篩選部門", dept_options)
filtered = abnormal_df.copy()
if anomaly_filter:
mask = pd.Series(False, index=filtered.index)
for col in anomaly_filter:
mask = mask | filtered[col]
filtered = filtered[mask]
if dept_filter != "全部":
filtered = filtered[filtered["department"].astype(str) == dept_filter]
display_cols = [
"emp_id",
"emp_name",
"department",
"date",
"check_in",
"check_out",
"工時",
"遲到",
"早退",
"漏打卡",
"工時過長",
"工時過短",
"工時不足",
"系統備註",
"最終備註",
]
display_cols = [c for c in display_cols if c in filtered.columns]
flag_cols = [c for c in ["遲到", "早退", "漏打卡", "工時過長", "工時過短", "工時不足"] if c in filtered.columns]
st.dataframe(
reset_display_index(filtered[display_cols]).style.applymap(
lambda v: "background-color: #ffe0e0;" if v is True else "",
subset=flag_cols
),
use_container_width=True,
height=400
)
with tab3:
st.subheader("👤 員工異常統計")
sort_col = st.selectbox(
"排序依據",
["總異常次數", "遲到次數", "早退次數", "漏打卡次數", "工時不足次數"]
)
emp_sorted = emp_summary.sort_values(sort_col, ascending=False)
st.dataframe(reset_display_index(emp_sorted), use_container_width=True, height=400)
if not emp_sorted.empty:
top10 = emp_sorted.head(10)
fig4 = px.bar(
top10,
x="emp_name",
y=sort_col,
color=sort_col,
color_continuous_scale="Oranges",
title=f"Top 10 員工 — {sort_col}",
text=sort_col
)
fig4.update_traces(textposition="outside")
fig4.update_layout(showlegend=False, height=350)
st.plotly_chart(fig4, use_container_width=True)
with tab4:
st.subheader("🏢 部門異常統計")
st.dataframe(reset_display_index(dept_summary), use_container_width=True)
if not dept_summary.empty:
melt = dept_summary.melt(id_vars="部門", var_name="類型", value_name="次數")
fig5 = px.bar(
melt[melt["類型"] != "總異常次數"],
x="部門",
y="次數",
color="類型",
barmode="group",
title="部門異常類型明細"
)
fig5.update_layout(height=350)
st.plotly_chart(fig5, use_container_width=True)
with tab5:
st.subheader("🗃️ 原始資料")
st.dataframe(reset_display_index(df_raw), use_container_width=True, height=400)
st.divider()
st.subheader("📤 匯出報表")
excel_bytes = to_excel_bytes({
"異常明細": abnormal_df,
"員工統計": emp_summary,
"部門統計": dept_summary,
"完整稽核": df_audit,
})
st.download_button(
label="⬇️ 下載 Excel 報表(含四個工作表)",
data=excel_bytes,
file_name="打卡稽核報表.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
use_container_width=True,
type="primary"
)
st.success("✅ 分析完成!")