Files
aars/backend/main.py
T

3736 lines
146 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import secret
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Optional, List
import json
from database import get_db, create_access_token, verify_password, init_db, Base, engine
from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord
from sqlalchemy import func as sqlfunc
from schemas import *
from auth import get_current_user, get_current_admin, get_current_user_from_query
from pydantic import BaseModel, Field
# ============ Pydantic Schemas (Holidays + Leave) ============
class HolidayCreate(BaseModel):
date: date
name: str
name_en: Optional[str] = None
region: Optional[str] = "HK"
is_mandatory: Optional[bool] = True
notes: Optional[str] = None
class HolidayUpdate(BaseModel):
date: Optional[date] = None
name: Optional[str] = None
name_en: Optional[str] = None
region: Optional[str] = None
is_mandatory: Optional[bool] = None
notes: Optional[str] = None
class HolidayOut(BaseModel):
id: int
date: date
name: str
name_en: Optional[str] = None
region: Optional[str] = None
is_mandatory: Optional[bool] = None
source: Optional[str] = None
notes: Optional[str] = None
last_edited_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class LeaveCreate(BaseModel):
employee_name: str
employee_id: Optional[str] = None
date: date
leave_type: str
hours: float
reason: Optional[str] = None
approved_by: Optional[str] = None
notes: Optional[str] = None
class LeaveUpdate(BaseModel):
employee_name: Optional[str] = None
employee_id: Optional[str] = None
date: Optional[date] = None
leave_type: Optional[str] = None
hours: Optional[float] = None
reason: Optional[str] = None
approved_by: Optional[str] = None
notes: Optional[str] = None
class LeaveOut(BaseModel):
id: int
employee_name: str
employee_id: Optional[str] = None
date: date
leave_type: str
hours: float
reason: Optional[str] = None
approved_by: Optional[str] = None
source: Optional[str] = None
notes: Optional[str] = None
last_edited_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
def enrich_attendance_with_leave(records, db):
"""Apply holiday + leave override to attendance records (in-place)."""
from collections import defaultdict as _dd
if not records:
return records
dates = [r.date for r in records if r.date]
if not dates:
return records
min_date, max_date = min(dates), max(dates)
holidays = db.query(Holiday).filter(
Holiday.date >= min_date, Holiday.date <= max_date
).all()
holidays_by_date = {h.date: h for h in holidays}
emp_names = list({r.employee_name for r in records if r.employee_name})
leaves = []
if emp_names:
leaves = db.query(LeaveRecord).filter(
LeaveRecord.date >= min_date, LeaveRecord.date <= max_date,
LeaveRecord.employee_name.in_(emp_names),
).all()
leave_by_emp_date = _dd(lambda: _dd(list))
for lv in leaves:
leave_by_emp_date[lv.employee_name][lv.date].append(lv)
for r in records:
if not r.date:
continue
if r.date in holidays_by_date:
h = holidays_by_date[r.date]
# Check roster: if shift's public_holiday == 'Yes', employee works on holidays
# Don't override with holiday status in that case
shift_holiday = '休息' # default: assume rest on holiday
if r.shift_code:
shift_info = get_shift_schedule_full(r.shift_code, r.weekday or '')
if shift_info and len(shift_info) >= 3:
shift_holiday = shift_info[2]
if shift_holiday == 'Yes':
# Employee works on public holidays - keep attendance status, append holiday note
base = r.status_text or "正常"
r.status_text = f"{base} (🎉{h.name})"
continue
# Employee rests on public holidays - mark as holiday unless already has clock-in
has_clock_in = (
(r.check_in is not None) or
(r.actual_in and r.actual_in not in ("", "-"))
)
if not has_clock_in:
r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue
# Has clock-in but roster says rest on holiday - still show holiday
r.status_code = "holiday"
r.status_text = f"\U0001F3D6\uFE0F{h.name}"
continue
emp_leaves = leave_by_emp_date.get(r.employee_name, {}).get(r.date, [])
if not emp_leaves:
continue
# Aggregate all leaves by type (sum hours when multiple records of same type)
leave_by_type = {}
for lv in emp_leaves:
t = (lv.leave_type or "").upper()
h_val = float(lv.hours or 0)
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
# Build display text from aggregated values (no duplicates)
leave_parts = []
for t, h in sorted(leave_by_type.items()):
if h > 0:
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
leave_text = " + ".join(leave_parts)
original_code = (r.status_code or "").lower()
if original_code == "missing":
types = sorted(leave_by_type.keys())
if len(types) == 1:
r.status_code = types[0].lower()
else:
r.status_code = "mixed_leave"
r.status_text = leave_text
elif original_code in ("al", "sl", "cl"):
# Pure leave status: replace with aggregated leave text
if len(leave_by_type) > 1:
r.status_code = "mixed_leave"
r.status_text = leave_text
else:
r.status_text = leave_text
elif original_code not in ("missing", "", "holiday", "al", "sl", "cl"):
# Non-leave attendance status: strip any previously-enriched leave suffix and rebuild
base_text = (r.status_text or "正常").split(" + ")[0] # keep only attendance part
base_text = base_text.split(" (")[0] # strip holiday annotation
if leave_text:
r.status_text = f"{base_text} + {leave_text}"
else:
r.status_text = base_text
else:
# original_code is empty or holiday or already handled above
base = r.status_text or "正常"
r.status_text = f"{base} + {leave_text}"
return records
import logging
from logging_config import setup_logging, set_request_id, set_user_id
from middleware import (
RequestIDMiddleware,
AccessLogMiddleware,
unhandled_exception_handler,
)
setup_logging()
logger = logging.getLogger(__name__)
app = FastAPI(title="AARS API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Structured logging middleware
app.add_middleware(AccessLogMiddleware)
app.add_middleware(RequestIDMiddleware)
# Catch-all exception handler (after middleware)
app.add_exception_handler(Exception, unhandled_exception_handler)
# Data directory for storing uploaded Excel files
DATA_DIR = "/app/data"
os.makedirs(DATA_DIR, exist_ok=True)
@app.on_event("startup")
async def startup():
init_db()
logger.info("AARS backend started")
# ============ Auth ============
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.email).first()
if not user or not verify_password(form_data.password, user.password_hash):
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}})
raise HTTPException(status_code=401, detail="Invalid email or password")
set_user_id(user.id)
access_token = create_access_token({"sub": str(user.id)})
logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}})
return TokenResponse(access_token=access_token)
@app.get("/api/auth/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
# ============ Excel File Storage ============
def get_excel_path(section: str) -> str:
"""Get path to the stored Excel file for a section"""
return os.path.join(DATA_DIR, f"{section}.xlsx")
def read_excel_file(section: str):
"""Read Excel file and return headers and rows"""
import openpyxl
path = get_excel_path(section)
if not os.path.exists(path):
return None, []
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb.active
headers = [cell.value for cell in ws[1]]
rows = list(ws.iter_rows(min_row=2, values_only=True))
return headers, rows
# ============ Upload Excel (Replace entire file) ============
@app.delete("/api/upload/{section}")
async def delete_uploaded_file(
section: str,
current_user: User = Depends(get_current_user)
):
"""Delete the stored Excel file for a section"""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
path = get_excel_path(section)
if os.path.exists(path):
os.remove(path)
return {"message": f"{section} file deleted"}
@app.post("/api/upload/{section}")
async def upload_excel(
section: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload Excel file — saves file AND imports rows into SQL table."""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Save the file (keep for legacy compatibility + export)
path = get_excel_path(section)
contents = await file.read()
with open(path, "wb") as f:
f.write(contents)
# Read rows back
headers, rows = read_excel_file(section)
if headers is None:
return {
"message": f"{section} file uploaded (no rows parsed)",
"filename": file.filename, "rows": 0, "columns": 0, "headers": [],
}
if section == "attendance":
imported = await _import_attendance_to_db(db, headers, rows, current_user.id)
logger.info("attendance import to DB",
extra={"context": {"user_id": current_user.id,
"imported": imported["imported"],
"updated": imported["updated"],
"skipped": imported["skipped"]}})
return {
"message": f"attendance uploaded + imported",
"filename": file.filename,
"rows": len(rows),
"columns": len(headers),
"headers": headers,
"db_import": imported,
}
if section == "accident":
imported = await _import_accident_to_db(db, headers, rows, current_user.id)
logger.info("accident import to DB",
extra={"context": {"user_id": current_user.id,
"imported": imported["imported"],
"updated": imported["updated"],
"errors": imported["errors"]}})
return {
"message": f"accident uploaded + imported",
"filename": file.filename,
"rows": len(rows),
"columns": len(headers),
"headers": headers,
"db_import": imported,
}
return {
"message": f"{section} file uploaded",
"filename": file.filename,
"rows": len(rows),
"columns": len(headers),
"headers": headers,
}
async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) -> dict:
"""Parse Excel rows and upsert into attendance_records table.
Skips records with is_manually_edited=True (manual edits are preserved).
Returns counts: imported (new), updated (existing overwritten), skipped.
"""
# Find column indices from header
col = {}
for i, h in enumerate(headers):
if not h:
continue
hs = str(h).strip().lower()
if hs in ("company",):
col["company"] = i
elif "staff" in hs or "員工" in str(h) or "name" in hs:
col["staff"] = i
elif "dept" in hs or "部門" in str(h):
col["department"] = i
elif hs in ("date", "日期") or "date" in hs:
col["date"] = i
elif "week" in hs or "星期" in str(h):
col["weekday"] = i
elif "check" in hs and "in" in hs or "actual" in hs and "in" in hs or "上班" in str(h) or "實際" in str(h) and "上班" in str(h):
col["check_in"] = i
elif "check" in hs and "out" in hs or "actual" in hs and "out" in hs or "下班" in str(h) or "實際" in str(h) and "下班" in str(h):
col["check_out"] = i
elif "班次" in str(h) or "shift" in hs:
col["shift"] = i
imported = updated = skipped = errors = 0
for row in rows:
try:
def cell(k, default=None):
idx = col.get(k)
if idx is None or idx >= len(row):
return default
return row[idx]
staff = cell("staff")
date_val = cell("date")
if not staff or not date_val:
errors += 1
continue
# Parse date
if hasattr(date_val, "date"):
d = date_val.date() if isinstance(date_val, datetime) else date_val
elif isinstance(date_val, date):
d = date_val
elif isinstance(date_val, str):
d = None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
try:
d = datetime.strptime(date_val, fmt).date()
break
except ValueError:
continue
if d is None:
errors += 1
continue
else:
errors += 1
continue
# Find existing
existing = db.query(AttendanceRecord).filter(
AttendanceRecord.employee_name == staff,
AttendanceRecord.date == d,
).first()
if existing and (existing.last_edited_at is not None or existing.is_manually_edited):
skipped += 1
continue
# Parse times
def parse_time(v, fallback_date):
if not v:
return None
if isinstance(v, datetime):
return v
if isinstance(v, date):
return None
if isinstance(v, str):
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(v, fmt).time()
except ValueError:
continue
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M",
"%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M"):
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
ci_raw = cell("check_in")
co_raw = cell("check_out")
ci_dt = parse_time(ci_raw, d)
co_dt = parse_time(co_raw, d)
# Combine date + time
def to_dt(t, base_date):
if t is None:
return None
if isinstance(t, datetime):
return t
if isinstance(t, time):
return datetime.combine(base_date, t)
return None
ci_full = to_dt(ci_dt, d)
co_full = to_dt(co_dt, d)
shift_code = cell("shift")
# Calculate status using existing helper
shift_start = None
shift_end = None
if shift_code:
shift_obj = db.query(Shift).filter(Shift.shift_code == shift_code).first()
if shift_obj:
weekday_en = cell("weekday") or d.strftime("%A")
start_str, end_str = shift_obj.get_schedule(weekday_en)
try:
if start_str and end_str:
shift_start = datetime.strptime(start_str, "%H:%M").time()
shift_end = datetime.strptime(end_str, "%H:%M").time()
except (ValueError, TypeError):
pass
status = calculate_attendance_status(ci_full, co_full, shift_start, shift_end)
record = existing or AttendanceRecord(
employee_name=staff, date=d,
)
record.company = cell("company")
record.department = cell("department")
record.date = d
record.weekday = cell("weekday")
record.check_in = ci_full
record.check_out = co_full
record.shift_code = shift_code
record.status_code = status["status_code"]
record.status_text = status["status_text"]
record.late_minutes = status["late_minutes"]
record.early_minutes = status["early_minutes"]
record.ot_minutes = status["ot_minutes"]
record.expected_in = status["expected_in"]
record.expected_out = status["expected_out"]
record.actual_in = status["actual_in"]
record.actual_out = status["actual_out"]
record.raw_data = {"source_row": list(row)}
if existing:
updated += 1
else:
db.add(record)
imported += 1
except Exception as e:
errors += 1
logger.error("attendance import row failed",
extra={"context": {"error": str(e)[:200], "row": list(row)[:8]}})
db.commit()
return {"imported": imported, "updated": updated, "skipped": skipped, "errors": errors}
# ============ Attendance Calculation ============
def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]:
"""Get the expected start and end time for a shift on a given weekday.
Returns (start_time, end_time, public_holiday) tuple.
public_holiday is 'Yes' if the shift requires working on public holidays, '休息' otherwise.
"""
roster_path = get_excel_path("roster")
if not os.path.exists(roster_path):
return None
import openpyxl
wb = openpyxl.load_workbook(roster_path, data_only=True)
ws = wb.active
headers = [cell.value for cell in ws[1]]
# Find column indices
shift_col = None
ph_col = None
for i, h in enumerate(headers):
if h == '班次':
shift_col = i
elif h == 'public holiday':
ph_col = i
if shift_col is None:
return None
# Weekday mapping
weekday_map = {
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六',
'Sunday': '星期日', '星期一': '星期一', '星期二': '星期二',
'星期三': '星期三', '星期四': '星期四', '星期五': '星期五',
'星期六': '星期六', '星期日': '星期日'
}
day_col_map = {
'星期一': None, '星期二': None, '星期三': None,
'星期四': None, '星期五': None, '星期六': None, '星期日': None
}
for i, h in enumerate(headers):
if h in day_col_map:
day_col_map[h] = i
target_day = weekday_map.get(weekday, weekday)
# Find the shift row
for row in ws.iter_rows(min_row=2, values_only=True):
if row[shift_col] == shift_code:
public_holiday = str(row[ph_col]).strip() if ph_col is not None else '休息'
day_col = day_col_map.get(target_day)
if day_col is not None:
schedule = row[day_col]
if schedule and schedule != '-' and schedule != '休息':
# Parse "HH:MM-HH:MM" format
try:
parts = str(schedule).split('-')
if len(parts) == 2:
start_str = parts[0].strip()
end_str = parts[1].strip()
start_time = datetime.strptime(start_str, '%H:%M').time()
end_time = datetime.strptime(end_str, '%H:%M').time()
return (start_time, end_time, public_holiday)
except:
pass
return None
def parse_datetime(time_val) -> Optional[datetime]:
"""Parse datetime from various formats"""
if not time_val:
return None
if isinstance(time_val, datetime):
return time_val
if isinstance(time_val, str):
for fmt in ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%H:%M:%S', '%H:%M']:
try:
return datetime.strptime(time_val, fmt)
except:
continue
return None
def calculate_attendance_status(check_in, check_out, shift_start, shift_end):
"""Calculate attendance status: late_minutes, early_minutes, ot_minutes, is_missing
Returns: {
"is_missing": bool,
"late_minutes": int,
"early_minutes": int,
"ot_minutes": int,
"status_code": str, # e.g. "late_early", "early_ot", "normal"
"status_text": str,
"expected_in": str,
"expected_out": str,
"actual_in": str,
"actual_out": str
}
"""
result = {
"is_missing": False,
"late_minutes": 0,
"early_minutes": 0,
"ot_minutes": 0,
"status_code": "normal",
"status_text": "正常",
"expected_in": shift_start.strftime("%H:%M") if shift_start else "-",
"expected_out": shift_end.strftime("%H:%M") if shift_end else "-",
"actual_in": "-",
"actual_out": "-"
}
# Check for missing punch
if not check_in or not check_out:
result["is_missing"] = True
result["status_code"] = "missing"
result["status_text"] = "缺勤"
if check_in:
result["actual_in"] = check_in.strftime("%H:%M") if isinstance(check_in, datetime) else str(check_in)
if check_out:
result["actual_out"] = check_out.strftime("%H:%M") if isinstance(check_out, datetime) else str(check_out)
return result
# Parse check-in/out times
check_in_dt = parse_datetime(check_in)
check_out_dt = parse_datetime(check_out)
if not check_in_dt or not check_out_dt:
result["is_missing"] = True
result["status_code"] = "missing"
result["status_text"] = "缺勤"
return result
result["actual_in"] = check_in_dt.strftime("%H:%M")
result["actual_out"] = check_out_dt.strftime("%H:%M")
# Skip if invalid times (Excel default "0" = 18:00:00 exactly)
# Only treat second-precise 18:00:00 as the default, not 18:00:03 (real swipe)
if (check_in_dt.hour == 18 and check_in_dt.minute == 0 and check_in_dt.second == 0
and check_out_dt.hour == 18 and check_out_dt.minute == 0 and check_out_dt.second == 0):
result["is_missing"] = True
result["status_code"] = "missing"
result["status_text"] = "缺勤"
return result
# Detect same check-in/out (exact same second = forgotten swipe)
if check_in_dt and check_out_dt:
if check_in_dt == check_out_dt:
result["is_missing"] = True
result["status_code"] = "abnormal"
result["status_text"] = "⚠️異常"
return result
if not shift_start or not shift_end:
# No roster data, can't calculate
return result
# Calculate late minutes
check_in_time = check_in_dt.time()
if check_in_time > shift_start:
diff = (datetime.combine(check_in_dt.date(), check_in_time) -
datetime.combine(check_in_dt.date(), shift_start)).total_seconds() / 60
result["late_minutes"] = round(diff)
# Calculate early minutes (leaving before scheduled end)
check_out_time = check_out_dt.time()
if shift_end > shift_start:
# Handle overnight shifts
if shift_end < shift_start:
# Assume next day
expected_end_dt = datetime.combine(check_out_dt.date() + timedelta(days=1), shift_end)
else:
expected_end_dt = datetime.combine(check_out_dt.date(), shift_end)
if check_out_dt < expected_end_dt:
diff = (expected_end_dt - check_out_dt).total_seconds() / 60
result["early_minutes"] = round(diff)
# Calculate OT minutes (leaving after scheduled end)
if check_out_dt > expected_end_dt:
diff = (check_out_dt - expected_end_dt).total_seconds() / 60
result["ot_minutes"] = round(diff)
# Determine status code and text
status_parts = []
if result["late_minutes"] > 0:
status_parts.append(f"遲到{result['late_minutes']}")
if result["early_minutes"] > 0:
status_parts.append(f"早退{result['early_minutes']}")
if result["ot_minutes"] > 0:
status_parts.append(f"OT{result['ot_minutes']}")
if result["late_minutes"] > 0 and result["early_minutes"] > 0:
result["status_code"] = "late_early"
elif result["late_minutes"] > 0 and result["ot_minutes"] > 0:
result["status_code"] = "late_ot"
elif result["early_minutes"] > 0 and result["ot_minutes"] > 0:
result["status_code"] = "early_ot"
elif result["late_minutes"] > 0:
result["status_code"] = "late"
elif result["early_minutes"] > 0:
result["status_code"] = "early"
elif result["ot_minutes"] > 0:
result["status_code"] = "ot"
else:
result["status_code"] = "normal"
result["status_text"] = " / ".join(status_parts) if status_parts else "正常"
return result
@app.get("/api/attendance/lateness")
async def get_lateness_stats(current_user: User = Depends(get_current_user)):
"""Calculate full attendance statistics: late, early, OT"""
headers, rows = read_excel_file("attendance")
if headers is None or not rows:
return {"stats": [], "total_late": 0, "total_early": 0, "total_ot": 0, "total_missing": 0}
staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None
for i, h in enumerate(headers):
h_lower = str(h).lower() if h else ''
if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower:
staff_col = i
elif 'date' in h_lower or '日期' in h_lower:
date_col = i
elif 'week' in h_lower or '星期' in h_lower:
week_col = i
elif 'check' in h_lower and 'in' in h_lower or '上班' in h_lower:
checkin_col = i
elif 'check' in h_lower and 'out' in h_lower or '下班' in h_lower:
checkout_col = i
elif '班次' in h_lower:
shift_col = i
if staff_col is None or checkin_col is None or shift_col is None:
return {"error": "Missing required columns", "headers": headers}
employee_stats = {}
totals = {"late": 0, "early": 0, "ot": 0, "missing": 0}
for row in rows:
staff = row[staff_col]; check_in = row[checkin_col]
check_out = row[checkout_col] if checkout_col is not None else None
shift_code = row[shift_col]; week_day = row[week_col] if week_col is not None else None
if not staff or not shift_code:
continue
shift_times = get_shift_schedule_full(shift_code, week_day or '')
shift_start = shift_times[0] if shift_times else None
shift_end = shift_times[1] if shift_times else None
status = calculate_attendance_status(check_in, check_out, shift_start, shift_end)
staff_key = str(staff)
if staff_key not in employee_stats:
employee_stats[staff_key] = {"name": staff, "shift": shift_code, "late_count": 0, "late_minutes": 0, "early_count": 0, "early_minutes": 0, "ot_count": 0, "ot_minutes": 0, "missing_count": 0}
if status["is_missing"]:
employee_stats[staff_key]["missing_count"] += 1
totals["missing"] += 1
else:
if status["late_minutes"] > 0:
employee_stats[staff_key]["late_count"] += 1
employee_stats[staff_key]["late_minutes"] += status["late_minutes"]
totals["late"] += status["late_minutes"]
if status["early_minutes"] > 0:
employee_stats[staff_key]["early_count"] += 1
employee_stats[staff_key]["early_minutes"] += status["early_minutes"]
totals["early"] += status["early_minutes"]
if status["ot_minutes"] > 0:
employee_stats[staff_key]["ot_count"] += 1
employee_stats[staff_key]["ot_minutes"] += status["ot_minutes"]
totals["ot"] += status["ot_minutes"]
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
return {
"stats": stats,
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
"total_late_minutes": totals["late"],
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
"total_early_minutes": totals["early"],
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
"total_ot_minutes": totals["ot"],
"total_missing_count": totals["missing"],
}
@app.get("/api/attendance/lateness/records")
async def get_lateness_records(
staff: Optional[str] = None,
current_user: User = Depends(get_current_user)
):
"""Get detailed attendance status records"""
headers, rows = read_excel_file("attendance")
if headers is None or not rows:
return []
staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None
for i, h in enumerate(headers):
h_lower = str(h).lower() if h else ''
if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower:
staff_col = i
elif 'date' in h_lower or '日期' in h_lower:
date_col = i
elif 'week' in h_lower or '星期' in h_lower:
week_col = i
elif 'check' in h_lower and 'in' in h_lower or '上班' in h_lower:
checkin_col = i
elif 'check' in h_lower and 'out' in h_lower or '下班' in h_lower:
checkout_col = i
elif '班次' in h_lower:
shift_col = i
records = []
for row in rows:
staff_val = row[staff_col] if staff_col is not None else None
check_in = row[checkin_col] if checkin_col is not None else None
check_out = row[checkout_col] if checkout_col is not None else None
shift_code = row[shift_col] if shift_col is not None else None
week_day = row[week_col] if week_col is not None else None
date_val = row[date_col] if date_col is not None else None
if staff_val and shift_code:
shift_times = get_shift_schedule_full(shift_code, week_day or '')
shift_start = shift_times[0] if shift_times else None
shift_end = shift_times[1] if shift_times else None
status = calculate_attendance_status(check_in, check_out, shift_start, shift_end)
record = {
"staff": staff_val, "date": str(date_val)[:10] if date_val else None,
"weekday": week_day, "shift": shift_code,
"status_code": status["status_code"], "status_text": status["status_text"],
"expected_in": status["expected_in"], "expected_out": status["expected_out"],
"actual_in": status["actual_in"], "actual_out": status["actual_out"],
"late_minutes": status["late_minutes"], "early_minutes": status["early_minutes"], "ot_minutes": status["ot_minutes"]
}
if staff is None or record["staff"] == staff:
records.append(record)
return records
@app.delete("/api/roster")
async def delete_roster(current_user: User = Depends(get_current_user)):
"""Delete the stored roster file"""
path = get_excel_path("roster")
if os.path.exists(path):
os.remove(path)
return {"message": "Roster file deleted"}
@app.get("/api/roster/info")
async def get_roster_info(current_user: User = Depends(get_current_user)):
"""Get info about stored roster Excel file"""
path = get_excel_path("roster")
if not os.path.exists(path):
return {"uploaded": False, "rows": 0, "columns": 0, "headers": []}
headers, rows = read_excel_file("roster")
return {
"uploaded": True,
"rows": len(rows) if rows else 0,
"columns": len(headers) if headers else 0,
"headers": headers if headers else []
}
@app.post("/api/roster/upload")
async def upload_roster(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user)
):
"""Upload Roster Excel file"""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
path = get_excel_path("roster")
contents = await file.read()
with open(path, "wb") as f:
f.write(contents)
headers, rows = read_excel_file("roster")
return {
"message": "Roster file uploaded successfully",
"filename": file.filename,
"rows": len(rows) if rows else 0,
"columns": len(headers) if headers else 0,
"headers": headers if headers else []
}
@app.get("/api/roster/data")
async def get_roster_data(current_user: User = Depends(get_current_user)):
"""Get full roster Excel data"""
headers, rows = read_excel_file("roster")
if headers is None:
return {"headers": [], "rows": []}
return {"headers": headers, "rows": rows}
@app.get("/api/roster/shifts")
async def get_shifts(current_user: User = Depends(get_current_user)):
"""Get all shifts from roster file"""
headers, rows = read_excel_file("roster")
if headers is None:
return []
# Find columns
shift_col = None
desc_col = None
for i, h in enumerate(headers):
if h and str(h).lower() in ['班次', 'shift', 'shift code']:
shift_col = i
elif h and str(h).lower() in ['描述', 'description', 'desc']:
desc_col = i
shifts = []
for row in rows:
shift_code = row[shift_col] if shift_col is not None else None
if shift_code:
shifts.append({
"code": shift_code,
"description": row[desc_col] if desc_col is not None else ""
})
return shifts
@app.get("/api/roster/assignments")
async def get_assignments(current_user: User = Depends(get_current_user)):
"""Get employee-shift assignments"""
# Read from attendance data
headers, rows = read_excel_file("attendance")
if headers is None:
return {}
# Find 班次 column
shift_col = None
emp_col = None
for i, h in enumerate(headers):
if h and '班次' in str(h):
shift_col = i
elif h and any(kw in str(h).lower() for kw in ['員工', 'employee', 'name', '員工名稱']):
emp_col = i
assignments = {}
if shift_col is not None:
for row in rows:
emp = row[emp_col] if emp_col is not None and emp_col < len(row) else None
shift = row[shift_col] if shift_col is not None and shift_col < len(row) else None
if emp and shift:
assignments[str(emp)] = str(shift)
return assignments
@app.post("/api/roster/assign")
async def assign_shift(
employee: str = Form(...),
shift: str = Form(...),
current_user: User = Depends(get_current_user)
):
"""Assign employee to shift"""
assignments_path = os.path.join(DATA_DIR, "shift_assignments.json")
# Load existing
if os.path.exists(assignments_path):
with open(assignments_path, "r") as f:
assignments = json.load(f)
else:
assignments = {}
assignments[employee] = shift
return RedirectResponse(url="/")
@app.get("/api/{section}/info")
async def get_section_info(section: str):
"""Get info about stored Excel file"""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
headers, rows = read_excel_file(section)
if headers is None:
return {"uploaded": False, "rows": 0, "columns": 0, "headers": []}
return {
"uploaded": True,
"rows": len(rows),
"columns": len(headers),
"headers": headers
}
# ============ List Records (from Excel) ============
# ============ Frontend Compat Aliases ============
# Inserted BEFORE wildcard routes to win FastAPI route matching.
@app.get("/api/dashboard/summary")
async def dashboard_summary(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Read from attendance_records table (SQL). Returns frontend-expected field names."""
from datetime import date as _date, datetime as _dt
q = db.query(AttendanceRecord)
if date_from:
try:
q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.all()
today = _date.today()
month_start = today.replace(day=1)
# Frontend expects total_records + count-by-status fields
by_status = {}
by_department = {}
staff_set = set()
for r in rows:
sc = r.status_code or "unknown"
by_status[sc] = by_status.get(sc, 0) + 1
if r.department:
by_department[r.department] = by_department.get(r.department, 0) + 1
if r.employee_name:
staff_set.add(r.employee_name)
return {
# Backend / dashboard canonical fields
"total": len(rows),
"today": sum(1 for r in rows if r.date == today),
"this_month": sum(1 for r in rows if r.date and r.date >= month_start),
"by_status": by_status,
"by_department": by_department,
"trend": [],
# Frontend-expected fields (alias for legacy UI)
"total_records": len(rows),
"normal_count": by_status.get("normal", 0),
"late_count": sum(v for k, v in by_status.items() if "late" in k),
"early_count": sum(v for k, v in by_status.items() if "early" in k),
"ot_count": sum(v for k, v in by_status.items() if "ot" in k),
"missing_count": by_status.get("missing", 0),
"abnormal_count": by_status.get("abnormal", 0),
"staff_count": len(staff_set),
}
@app.get("/api/attendance/stats")
async def attendance_stats(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Read aggregated stats from attendance_records (SQL).
Returns Option 1 detail per employee:
- late_count, late_minutes
- late_avg_minutes, late_max_minutes ← new
- early_count, early_minutes
- early_avg_minutes, early_max_minutes ← new
- ot_count, ot_minutes
- ot_avg_minutes, ot_max_minutes ← new
- missing_count
- last_attendance_date ← new
- consecutive_missing ← new (current streak of missing)
- attendance_rate ← new (% non-missing)
"""
from datetime import datetime as _dt, date as _date, timedelta as _td
q = db.query(AttendanceRecord)
if date_from:
try:
q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.order_by(AttendanceRecord.date.asc()).all()
# Group rows by employee (preserve chronological order)
by_emp = {}
for r in rows:
if not r.employee_name:
continue
by_emp.setdefault(r.employee_name, []).append(r)
employee_stats = []
totals = {"late": 0, "early": 0, "ot": 0, "missing": 0}
for name, emp_rows in by_emp.items():
late_count = late_minutes = 0
late_min_list = []
early_count = early_minutes = 0
early_min_list = []
ot_count = ot_minutes = 0
ot_min_list = []
missing_count = 0
last_date = None
for r in emp_rows:
sc = (r.status_code or "").lower()
if "late" in sc:
late_count += 1
lm = r.late_minutes or 0
late_minutes += lm
late_min_list.append(lm)
totals["late"] += lm
if "early" in sc:
early_count += 1
em = r.early_minutes or 0
early_minutes += em
early_min_list.append(em)
totals["early"] += em
if "ot" in sc:
ot_count += 1
om = r.ot_minutes or 0
ot_minutes += om
ot_min_list.append(om)
totals["ot"] += om
if sc == "missing":
missing_count += 1
totals["missing"] += 1
if r.date and (last_date is None or r.date > last_date):
last_date = r.date
# Consecutive missing streak (current run ending at last row)
consecutive_missing = 0
for r in reversed(emp_rows):
if (r.status_code or "").lower() == "missing":
consecutive_missing += 1
else:
break
total_records = len(emp_rows)
attendance_rate = (
round((total_records - missing_count) / total_records * 100)
if total_records > 0 else 0
)
employee_stats.append({
"name": name,
"shift": emp_rows[-1].shift_code or "-",
# Late
"late_count": late_count,
"late_minutes": late_minutes,
"late_avg_minutes": round(late_minutes / late_count) if late_count > 0 else 0,
"late_max_minutes": max(late_min_list) if late_min_list else 0,
# Early
"early_count": early_count,
"early_minutes": early_minutes,
"early_avg_minutes": round(early_minutes / early_count) if early_count > 0 else 0,
"early_max_minutes": max(early_min_list) if early_min_list else 0,
# OT
"ot_count": ot_count,
"ot_minutes": ot_minutes,
"ot_avg_minutes": round(ot_minutes / ot_count) if ot_count > 0 else 0,
"ot_max_minutes": max(ot_min_list) if ot_min_list else 0,
# Missing
"missing_count": missing_count,
"consecutive_missing": consecutive_missing,
"last_attendance_date": last_date.isoformat() if last_date else None,
# Overall
"total_records": total_records,
"attendance_rate": attendance_rate,
})
employee_stats.sort(key=lambda x: x["late_minutes"], reverse=True)
return {"stats": employee_stats, **totals}
@app.get("/api/attendance/records")
async def attendance_records(
page: int = 1,
per_page: int = 50,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "desc",
search: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
staff: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Paginated records from attendance_records (SQL)."""
from datetime import datetime as _dt
from sqlalchemy import or_, and_
q = db.query(AttendanceRecord)
if date_from:
try:
q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
if staff:
q = q.filter(AttendanceRecord.employee_name == staff)
if status:
q = q.filter(AttendanceRecord.status_code == status)
if search:
like = f"%{search}%"
q = q.filter(or_(
AttendanceRecord.employee_name.like(like),
AttendanceRecord.department.like(like),
AttendanceRecord.shift_code.like(like),
))
# sort
sortable = {
"date": AttendanceRecord.date,
"employee_name": AttendanceRecord.employee_name,
"weekday": AttendanceRecord.weekday,
"shift_code": AttendanceRecord.shift_code,
"status_code": AttendanceRecord.status_code,
}
col = sortable.get(sort_by, AttendanceRecord.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
total = q.count()
rows = q.offset((page - 1) * per_page).limit(per_page).all()
enrich_attendance_with_leave(rows, db)
def to_dict(r):
return {
"id": r.id,
"staff": r.employee_name,
"employee_name": r.employee_name,
"company": r.company,
"department": r.department,
"date": r.date.isoformat() if r.date else None,
"weekday": r.weekday,
"shift": r.shift_code,
"shift_code": r.shift_code,
"status_code": r.status_code,
"status_text": r.status_text,
"expected_in": r.expected_in,
"expected_out": r.expected_out,
"actual_in": r.actual_in,
"actual_out": r.actual_out,
"late_minutes": r.late_minutes or 0,
"early_minutes": r.early_minutes or 0,
"ot_minutes": r.ot_minutes or 0,
"is_manually_edited": bool(r.is_manually_edited),
}
return {
"records": [to_dict(r) for r in rows],
"total": total,
"page": page,
"per_page": per_page,
}
@app.get("/api/attendance/{record_id}")
async def get_attendance_record(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get single attendance record by id from DB."""
r = db.query(AttendanceRecord).filter(AttendanceRecord.id == record_id).first()
if not r:
raise HTTPException(status_code=404, detail="Record not found")
return {
"id": r.id,
"employee_name": r.employee_name,
"company": r.company,
"department": r.department,
"date": r.date.isoformat() if r.date else None,
"weekday": r.weekday,
"shift_code": r.shift_code,
"check_in": r.check_in.isoformat() if r.check_in else None,
"check_out": r.check_out.isoformat() if r.check_out else None,
"status_code": r.status_code,
"status_text": r.status_text,
"late_minutes": r.late_minutes or 0,
"early_minutes": r.early_minutes or 0,
"ot_minutes": r.ot_minutes or 0,
"expected_in": r.expected_in,
"expected_out": r.expected_out,
"actual_in": r.actual_in,
"actual_out": r.actual_out,
"is_manually_edited": bool(r.is_manually_edited),
}
@app.put("/api/attendance/{record_id}")
async def update_attendance_record(
record_id: int,
payload: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update attendance record. Marks is_manually_edited = True so re-import won't overwrite."""
from datetime import datetime as _dt
r = db.query(AttendanceRecord).filter(AttendanceRecord.id == record_id).first()
if not r:
raise HTTPException(status_code=404, detail="Record not found")
editable = {"status_code", "status_text", "late_minutes", "early_minutes",
"ot_minutes", "actual_in", "actual_out", "shift_code", "department"}
for k, v in payload.items():
if k in editable:
setattr(r, k, v)
r.is_manually_edited = True
r.last_edited_at = datetime.utcnow()
r.last_edited_by = current_user.id
db.commit()
logger.info("attendance record updated", extra={"context": {"record_id": record_id, "user_id": current_user.id}})
return {"message": "ok", "id": record_id}
# ============ Employees List (combined source) ============
@app.get("/api/employees/list")
async def list_employees(
source: Optional[str] = "all", # "all" / "attendance" / "leave"
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List distinct employee names (real staff only).
Sources: attendance_records + leave_records (NOT accident - that table stores
submitter emails, not staff names).
Filter: names containing "@" (email format) are excluded automatically.
Used by frontend dropdowns to show ALL staff, even those who haven't taken leave yet.
"""
import re
EMAIL_RE = re.compile(r"@")
names = set()
if source in ("all", "attendance"):
for n in db.query(AttendanceRecord.employee_name).distinct().all():
if n[0] and not EMAIL_RE.search(n[0]):
names.add(n[0])
if source in ("all", "leave"):
for n in db.query(LeaveRecord.employee_name).distinct().all():
if n[0] and not EMAIL_RE.search(n[0]):
names.add(n[0])
# NOTE: Accident.submitter_email contains submitter EMAIL (e.g. info@scmedical.hk),
# NOT the injured staff name. We exclude accident from employee list to avoid
# polluting staff dropdown with email addresses.
sorted_names = sorted(names, key=lambda x: x.lower())
return {"employees": sorted_names, "count": len(sorted_names), "source": source, "excluded": "accident (email column)"}
# ============ Holidays CRUD ============# ============ Holidays CRUD ============
@app.get("/api/holidays", response_model=List[HolidayOut])
async def list_holidays(
year: Optional[int] = None,
region: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all holidays, optionally filtered by year / region."""
q = db.query(Holiday)
if year:
q = q.filter(sqlfunc.extract("year", Holiday.date) == year)
if region:
q = q.filter(Holiday.region == region)
return q.order_by(Holiday.date.asc()).all()
@app.get("/api/holidays/{holiday_id}", response_model=HolidayOut)
async def get_holiday(
holiday_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get single holiday by id."""
h = db.query(Holiday).filter(Holiday.id == holiday_id).first()
if not h:
raise HTTPException(status_code=404, detail="Holiday not found")
return h
@app.post("/api/holidays", response_model=HolidayOut)
async def create_holiday(
body: HolidayCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Create a new holiday (admin only)."""
existing = db.query(Holiday).filter(Holiday.date == body.date).first()
if existing:
raise HTTPException(status_code=409, detail=f"Holiday already exists for {body.date}")
h = Holiday(
date=body.date,
name=body.name,
name_en=body.name_en,
region=body.region or "HK",
is_mandatory=body.is_mandatory if body.is_mandatory is not None else True,
source="manual",
notes=body.notes,
)
db.add(h)
db.commit()
db.refresh(h)
logger.info("holiday created", extra={"context": {"id": h.id, "date": str(h.date)}})
return h
@app.put("/api/holidays/{holiday_id}", response_model=HolidayOut)
async def update_holiday(
holiday_id: int,
body: HolidayUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Update an existing holiday (admin only)."""
h = db.query(Holiday).filter(Holiday.id == holiday_id).first()
if not h:
raise HTTPException(status_code=404, detail="Holiday not found")
update_data = body.dict(exclude_unset=True)
for k, v in update_data.items():
setattr(h, k, v)
h.source = "manual_edit"
h.last_edited_at = datetime.utcnow()
h.last_edited_by = current_user.id
db.commit()
db.refresh(h)
return h
@app.delete("/api/holidays/{holiday_id}")
async def delete_holiday(
holiday_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Delete a holiday (admin only)."""
h = db.query(Holiday).filter(Holiday.id == holiday_id).first()
if not h:
raise HTTPException(status_code=404, detail="Holiday not found")
db.delete(h)
db.commit()
return {"deleted": holiday_id}
@app.post("/api/holidays/upload")
async def upload_holidays(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Bulk upload holidays from CSV or Excel.
CSV columns: date,name,name_en,is_mandatory,notes
Excel: same columns (row 1 = header)
Logic:
- Date exists + never edited (last_edited_at IS NULL) -> UPDATE
- Date exists + manually edited (last_edited_at NOT NULL) -> SKIP
- New date -> INSERT
"""
from io import BytesIO
content = await file.read()
fname = file.filename.lower()
rows = []
if fname.endswith(".csv"):
import csv, io as _io
reader = csv.DictReader(_io.StringIO(content.decode("utf-8-sig")))
for row in reader:
rows.append({
"date": row.get("date", "").strip(),
"name": row.get("name", "").strip(),
"name_en": row.get("name_en", "").strip() or None,
"is_mandatory": (row.get("is_mandatory", "true").strip().lower() in ("true", "1", "yes", "y")),
"notes": row.get("notes", "").strip() or None,
})
elif fname.endswith((".xlsx", ".xls")):
import openpyxl
wb = openpyxl.load_workbook(BytesIO(content), data_only=True)
ws = wb.active
headers = [str(c.value or "").strip() for c in ws[1]]
col_idx = {h: i for i, h in enumerate(headers)}
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or all(v is None for v in row):
continue
def getv(h, default=None):
v = row[col_idx[h]] if h in col_idx and col_idx[h] < len(row) else None
return v if v is not None else default
rows.append({
"date": str(getv("date") or "").strip(),
"name": str(getv("name") or "").strip(),
"name_en": str(getv("name_en") or "").strip() or None,
"is_mandatory": bool(getv("is_mandatory", True)),
"notes": str(getv("notes") or "").strip() or None,
})
else:
raise HTTPException(status_code=400, detail="File must be CSV or Excel")
added = 0
updated = 0
skipped = 0
errors = []
for i, r in enumerate(rows):
try:
if not r["date"] or not r["name"]:
errors.append(f"Row {i+2}: missing date or name")
skipped += 1
continue
from datetime import datetime as _dt
if isinstance(r["date"], str):
d = _dt.strptime(r["date"], "%Y-%m-%d").date()
else:
d = r["date"]
existing = db.query(Holiday).filter(Holiday.date == d).first()
if existing:
if existing.last_edited_at is not None:
errors.append(f"Row {i+2}: {d} was edited at {existing.last_edited_at.isoformat()} - skipped")
skipped += 1
continue
# Not edited - safe to update
existing.name = r["name"]
existing.name_en = r["name_en"]
existing.is_mandatory = r["is_mandatory"]
existing.notes = r["notes"]
existing.source = "csv_upload"
updated += 1
continue
db.add(Holiday(
date=d,
name=r["name"],
name_en=r["name_en"],
region="HK",
is_mandatory=r["is_mandatory"],
source="csv_upload",
notes=r["notes"],
))
added += 1
except Exception as e:
errors.append(f"Row {i+2}: {str(e)}")
skipped += 1
db.commit()
return {
"added": added, "updated": updated, "skipped": skipped,
"errors": errors[:20],
"total_in_db": db.query(Holiday).count(),
}
@app.post("/api/holidays/refresh")
async def refresh_holidays_from_gov_hk(
years: Optional[str] = None, # CSV: "2025,2026,2027"
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Refresh holidays from internal static list (sourced from gov.hk).
Admin endpoint. Adds new dates, updates existing ones (only if source=gov_hk).
Manually edited holidays are NOT overwritten.
"""
from parsers import get_all_holidays
target_years = [int(y) for y in years.split(",")] if years else [2025, 2026, 2027]
all_holidays = [h for h in get_all_holidays() if h[0].year in target_years]
added = 0
updated = 0
skipped = 0
for d, name_zh, name_en, is_mandatory in all_holidays:
existing = db.query(Holiday).filter(Holiday.date == d).first()
if existing:
# Don't overwrite manual edits
if existing.source and existing.source.startswith("manual"):
skipped += 1
continue
if (existing.name != name_zh or existing.name_en != name_en
or existing.is_mandatory != is_mandatory):
existing.name = name_zh
existing.name_en = name_en
existing.is_mandatory = is_mandatory
existing.source = "gov_hk"
updated += 1
else:
db.add(Holiday(
date=d, name=name_zh, name_en=name_en,
region="HK", is_mandatory=is_mandatory, source="gov_hk",
))
added += 1
db.commit()
return {
"added": added, "updated": updated, "skipped": skipped,
"total_in_db": db.query(Holiday).count(),
"years": target_years,
}
# ============ Leave Records CRUD ============
@app.get("/api/leave", response_model=List[LeaveOut])
async def list_leaves(
employee_name: Optional[str] = None,
leave_type: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List leave records with optional filters."""
from datetime import datetime as _dt
q = db.query(LeaveRecord)
if employee_name:
q = q.filter(LeaveRecord.employee_name == employee_name)
if leave_type:
q = q.filter(LeaveRecord.leave_type == leave_type.upper())
if date_from:
try:
q = q.filter(LeaveRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(LeaveRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
return q.order_by(LeaveRecord.date.desc(), LeaveRecord.employee_name.asc()).all()
@app.post("/api/leave", response_model=LeaveOut)
async def create_leave(
body: LeaveCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a leave record."""
body.leave_type = body.leave_type.upper()
if body.leave_type not in ("SL", "CL", "AL"):
raise HTTPException(status_code=400, detail="leave_type must be SL / CL / AL")
if body.hours < 0 or body.hours > 24:
raise HTTPException(status_code=400, detail="hours must be 0-24")
lv = LeaveRecord(
employee_name=body.employee_name,
employee_id=body.employee_id,
date=body.date,
leave_type=body.leave_type,
hours=body.hours,
reason=body.reason,
approved_by=body.approved_by,
source="manual",
notes=body.notes,
created_by=current_user.id,
)
db.add(lv)
try:
db.commit()
except Exception as e:
db.rollback()
if "UNIQUE" in str(e).upper() or "Duplicate" in str(e):
raise HTTPException(status_code=409, detail=f"Leave record exists for {body.employee_name} {body.date} {body.leave_type}")
raise
db.refresh(lv)
return lv
@app.put("/api/leave/{leave_id}", response_model=LeaveOut)
async def update_leave(
leave_id: int,
body: LeaveUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a leave record."""
lv = db.query(LeaveRecord).filter(LeaveRecord.id == leave_id).first()
if not lv:
raise HTTPException(status_code=404, detail="Leave record not found")
update_data = body.dict(exclude_unset=True)
if "leave_type" in update_data and update_data["leave_type"]:
update_data["leave_type"] = update_data["leave_type"].upper()
if update_data["leave_type"] not in ("SL", "CL", "AL"):
raise HTTPException(status_code=400, detail="leave_type must be SL / CL / AL")
for k, v in update_data.items():
setattr(lv, k, v)
lv.last_edited_at = datetime.utcnow()
lv.last_edited_by = current_user.id
db.commit()
db.refresh(lv)
return lv
@app.delete("/api/leave/{leave_id}")
async def delete_leave(
leave_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a leave record."""
lv = db.query(LeaveRecord).filter(LeaveRecord.id == leave_id).first()
if not lv:
raise HTTPException(status_code=404, detail="Leave record not found")
db.delete(lv)
db.commit()
return {"deleted": leave_id}
@app.post("/api/leave/upload")
async def upload_leaves(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Bulk upload leave records from CSV or Excel.
CSV columns: employee_name,date,leave_type,hours,reason,approved_by
Excel: same columns (row 1 = header)
"""
from io import BytesIO
content = await file.read()
fname = file.filename.lower()
rows = []
if fname.endswith(".csv"):
import csv, io as _io
reader = csv.DictReader(_io.StringIO(content.decode("utf-8-sig")))
for row in reader:
rows.append({
"employee_name": row.get("employee_name", "").strip(),
"date": row.get("date", "").strip(),
"leave_type": row.get("leave_type", "").strip().upper(),
"hours": float(row.get("hours", 0) or 0),
"reason": row.get("reason", "").strip() or None,
"approved_by": row.get("approved_by", "").strip() or None,
})
elif fname.endswith((".xlsx", ".xls")):
import openpyxl
wb = openpyxl.load_workbook(BytesIO(content), data_only=True)
ws = wb.active
headers = [str(c.value or "").strip() for c in ws[1]]
col_idx = {h: i for i, h in enumerate(headers)}
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or all(v is None for v in row):
continue
def getv(h):
v = row[col_idx[h]] if h in col_idx and col_idx[h] < len(row) else None
return v
rows.append({
"employee_name": str(getv("employee_name") or "").strip(),
"date": str(getv("date") or "").strip(),
"leave_type": str(getv("leave_type") or "").strip().upper(),
"hours": float(getv("hours") or 0),
"reason": str(getv("reason") or "").strip() or None,
"approved_by": str(getv("approved_by") or "").strip() or None,
})
else:
raise HTTPException(status_code=400, detail="File must be CSV or Excel")
added = 0
updated = 0
skipped = 0
errors = []
for i, r in enumerate(rows):
try:
if not r["employee_name"] or not r["date"] or not r["leave_type"]:
errors.append(f"Row {i+2}: missing required field")
skipped += 1
continue
if r["leave_type"] not in ("SL", "CL", "AL"):
errors.append(f"Row {i+2}: invalid leave_type")
skipped += 1
continue
from datetime import datetime as _dt
if isinstance(r["date"], str):
d = _dt.strptime(r["date"], "%Y-%m-%d").date()
else:
d = r["date"]
existing = db.query(LeaveRecord).filter(
LeaveRecord.employee_name == r["employee_name"],
LeaveRecord.date == d,
LeaveRecord.leave_type == r["leave_type"],
).first()
if existing:
if existing.last_edited_at is not None:
# Was manually edited - protect from overwrite
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
skipped += 1
continue
# Not edited - safe to update (or multiple identical records exist - merge by adding hours)
existing.hours = r["hours"] # overwrite with latest uploaded value
existing.reason = r["reason"]
existing.approved_by = r["approved_by"]
existing.source = "csv_upload"
updated += 1
continue
db.add(LeaveRecord(
employee_name=r["employee_name"],
date=d,
leave_type=r["leave_type"],
hours=r["hours"],
reason=r["reason"],
approved_by=r["approved_by"],
source="csv_upload",
created_by=current_user.id,
))
added += 1
except Exception as e:
errors.append(f"Row {i+2}: {str(e)}")
skipped += 1
db.commit()
return {
"added": added, "updated": updated, "skipped": skipped,
"errors": errors[:20], # First 20 errors only
"total_in_db": db.query(LeaveRecord).count(),
}
# =============================================================================
# Backup / Restore + Version
# =============================================================================
import shutil, hashlib
from datetime import datetime as _dt2
BACKUP_DIR = Path("/app/data/backups")
DB_PATH = "/app/data/aars.db"
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
def _backup_path(name):
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
@app.get("/api/admin/backup/list")
async def list_backups(
current_user: User = Depends(get_current_user),
):
"""List all backup files."""
files = []
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
files.append({
"filename": p.name,
"size": p.stat().st_size,
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
})
return files
@app.post("/api/admin/backup/create")
async def create_backup(
note: str = "",
current_user: User = Depends(get_current_user),
):
"""Create a manual backup."""
src = Path(DB_PATH)
if not src.exists():
raise HTTPException(status_code=500, detail="Database file not found")
dst = _backup_path("manual")
shutil.copy2(src, dst)
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
@app.get("/api/admin/backup/download/{filename}")
async def download_backup(
filename: str,
current_user: User = Depends(get_current_user),
):
"""Download a backup file."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
from fastapi.responses import FileResponse
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
@app.post("/api/admin/backup/restore")
async def restore_backup(
filename: str = Body(...),
current_user: User = Depends(get_current_user),
):
"""Restore from backup. Creates an auto-backup of current state first."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
# Auto-backup current state
src = Path(DB_PATH)
if src.exists():
auto_dst = _backup_path("auto_pre_restore")
shutil.copy2(src, auto_dst)
# Restore
shutil.copy2(fp, src)
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
@app.post("/api/admin/reset/{section}")
async def reset_section(
section: str,
current_user: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Reset (truncate) a specific data section. Creates auto-backup first."""
valid = {"attendance": AttendanceRecord, "holidays": Holiday, "leaves": LeaveRecord}
if section not in valid:
raise HTTPException(status_code=400, detail=f"Unknown section: {section}")
# Auto-backup before reset
src = Path(DB_PATH)
if src.exists():
auto_dst = _backup_path(f"auto_pre_{section}_reset")
shutil.copy2(src, auto_dst)
# Truncate table
model = valid[section]
db.execute(model.__table__.delete())
db.commit()
return {"reset": section, "auto_backup": auto_dst.name}
@app.get("/api/version")
async def get_version(
current_user: User = Depends(get_current_user),
):
"""Get app version info."""
import subprocess
try:
result = subprocess.run(
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
cwd="/app",
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
parts = result.stdout.strip().split("|")
commit = parts[0]
date = parts[1] if len(parts) > 1 else ""
else:
commit = "unknown"
date = ""
except Exception:
commit = "unknown"
date = ""
return {
"version": "1.2.0",
"commit": commit,
"date": date,
}
@app.get("/api/{section}")
async def list_records(
section: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=1000),
current_user: User = Depends(get_current_user)
):
"""List records from stored Excel file"""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
headers, rows = read_excel_file(section)
if headers is None:
return {"total": 0, "page": page, "page_size": page_size, "data": []}
# Pagination
start = (page - 1) * page_size
end = start + page_size
page_rows = rows[start:end]
# Build response with row number as id
data = []
for idx, row in enumerate(page_rows, start=start + 1):
record = {"_row": idx} # Row number (1-based, excluding header)
for i, header in enumerate(headers):
if header:
record[str(header)] = row[i] if i < len(row) else None
data.append(record)
return {
"total": len(rows),
"page": page,
"page_size": page_size,
"data": data
}
# ============ Dashboard (from Excel) ============
@app.get("/api/dashboard/{section}")
async def dashboard(section: str, current_user: User = Depends(get_current_user)):
"""Get dashboard stats from stored Excel file"""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
headers, rows = read_excel_file(section)
if headers is None or not rows:
return {"total": 0, "this_month": 0, "by_status": {}, "by_department": {}, "trend": []}
today = date.today()
month_start = date(today.year, today.month, 1)
# Find date column (look for common date column names)
date_col_idx = None
for i, h in enumerate(headers):
if h and any(kw in str(h).lower() for kw in ['date', '日期', '時間']):
date_col_idx = i
break
# Find department column
dept_col_idx = None
for i, h in enumerate(headers):
if h and any(kw in str(h).lower() for kw in ['dept', '部門', '部門名稱']):
dept_col_idx = i
break
total = len(rows)
this_month = 0
by_department = {}
trend = []
for row in rows:
# Count this month
if date_col_idx is not None and date_col_idx < len(row):
val = row[date_col_idx]
if val:
row_date = None
if isinstance(val, datetime):
row_date = val.date()
elif isinstance(val, date):
row_date = val
elif isinstance(val, str):
for fmt in ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y']:
try:
row_date = datetime.strptime(val, fmt).date()
break
except:
pass
if row_date and row_date >= month_start:
this_month += 1
# Count by department
if dept_col_idx is not None and dept_col_idx < len(row):
dept = str(row[dept_col_idx] or 'Unknown')
by_department[dept] = by_department.get(dept, 0) + 1
return {
"total": total,
"today": 0,
"this_month": this_month,
"by_status": {},
"by_department": by_department,
"trend": []
}
async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> dict:
"""Parse Excel rows and upsert into accidents table.
Excel (Google Form) → SQL column map (15 columns):
Col 1 時間戳記 -> submitted_at
Col 2 電子郵件地址 -> employee_name
Col 3 部門名稱 -> department
Col 4 當日事件摘要 -> description
Col 5 緊急程度 -> severity
Col 6 事件發生時間 -> time
Col 7 涉及部門 -> location
Col 8 涉及的患者或相關人員 -> patient_or_staff
Col 9 事件描述 -> long_description
Col 10 如有事件相關照片可提供-> incident_photos
Col 11 處理情況/已採取的行動-> action_taken
Col 12 需要管理層介入的事項 -> needs_escalation
Col 13 第 12 欄 -> (skipped)
Col 14 分數 -> incident_score
Col 15 事件發生日期 -> date
"""
from datetime import datetime, date, time as _time
def _parse_dt(v):
if v is None:
return None
if isinstance(v, datetime):
return v
if isinstance(v, str):
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
def _parse_date(v):
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
if isinstance(v, str):
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%m/%d/%Y"):
try:
return datetime.strptime(v, fmt).date()
except ValueError:
continue
return None
def _parse_time(v):
if v is None:
return None
if isinstance(v, _time):
return v.strftime("%H:%M:%S")
if isinstance(v, datetime):
return v.strftime("%H:%M:%S")
if isinstance(v, str):
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(v, fmt).strftime("%H:%M:%S")
except ValueError:
continue
return str(v) if v else None
def _cell(row, idx):
if idx is None or idx >= len(row):
return None
return row[idx]
imported = updated = skipped = errors = 0
for row_idx, row in enumerate(rows, start=2): # row_idx is the Excel 1-based row number
try:
submitted_at = _parse_dt(_cell(row, 0))
submitter_email = _cell(row, 1) or ""
department = _cell(row, 2)
description = _cell(row, 3) or ""
severity = str(_cell(row, 4) or "")
time_str = _parse_time(_cell(row, 5))
location = _cell(row, 6) or ""
patient_or_staff = _cell(row, 7)
long_description = _cell(row, 8)
incident_photos = _cell(row, 9)
action_taken = _cell(row, 10)
needs_escalation = _cell(row, 11)
incident_score = _cell(row, 13)
date_val = _parse_date(_cell(row, 14))
if not date_val and submitted_at:
# Fallback: derive date from submitted timestamp (col 1)
date_val = submitted_at.date()
# Skip rows missing critical identifiers
if not date_val or not submitter_email:
errors += 1
continue
if incident_score is not None and incident_score != "":
try:
incident_score = int(incident_score)
except (TypeError, ValueError):
incident_score = None
existing = db.query(Accident).filter(Accident.excel_row == row_idx).first()
if existing:
if existing.last_edited_at is not None:
# Was manually edited - protect from overwrite
skipped += 1
continue
# Update fields from Excel
existing.submitted_at = submitted_at
existing.submitter_email = submitter_email
existing.department = department
existing.description = description
existing.severity = severity
existing.time = time_str
existing.location = location
existing.patient_or_staff = patient_or_staff
existing.long_description = long_description
existing.incident_photos = incident_photos
existing.action_taken = action_taken
existing.needs_escalation = str(needs_escalation) if needs_escalation is not None else None
existing.incident_score = incident_score
existing.date = date_val
updated += 1
else:
rec = Accident(
submitted_at=submitted_at,
submitter_email=submitter_email,
department=department,
description=description,
severity=severity,
time=time_str,
location=location,
patient_or_staff=patient_or_staff,
long_description=long_description,
incident_photos=incident_photos,
action_taken=action_taken,
needs_escalation=str(needs_escalation) if needs_escalation is not None else None,
incident_score=incident_score,
date=date_val,
excel_row=row_idx,
created_by=user_id,
)
db.add(rec)
imported += 1
except Exception as e:
errors += 1
logger.warning("accident row import error",
extra={"context": {"row": row_idx, "error": str(e)}})
db.commit()
return {"imported": imported, "updated": updated, "skipped": skipped, "errors": errors}
def _get_excel_headers_accident() -> dict:
"""Read /app/data/accident.xlsx row 1 and return SQL col → Excel header map.
Cached after first call.
"""
if hasattr(_get_excel_headers_accident, "_cache"):
return _get_excel_headers_accident._cache
try:
import openpyxl
path = os.path.join(DATA_DIR, "accident.xlsx")
if not os.path.exists(path):
_get_excel_headers_accident._cache = {}
return {}
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb.active
headers = [ws.cell(row=1, column=c).value for c in range(1, ws.max_column + 1)]
# Hardcoded column index → SQL field mapping (matches _import_accident_to_db)
idx_to_sql = {
0: "submitted_at",
1: "submitter_email", # was employee_name, renamed 2026-07-20
2: "department",
3: "description",
4: "severity",
5: "time",
6: "location",
7: "patient_or_staff",
8: "long_description",
9: "incident_photos",
10: "action_taken",
11: "needs_escalation",
13: "incident_score",
14: "date",
}
result = {}
for i, h in enumerate(headers):
if i in idx_to_sql and h is not None:
result[idx_to_sql[i]] = str(h)
_get_excel_headers_accident._cache = result
return result
except Exception as e:
logger.warning("read accident excel headers failed", extra={"context": {"error": str(e)}})
_get_excel_headers_accident._cache = {}
return {}
def _get_excel_column_order_accident() -> list:
"""Return SQL columns in Excel order (Col 1..15 mapped to SQL fields).
Plus extra DB-only columns (responsible_person, medical_report) at the end
so they can be VLOOKUP'd from 個案查詢 sheet even though they are not in
the original Excel.
"""
return [
"id", # virtual
"submitted_at",
"submitter_email", # was employee_name, renamed 2026-07-20
"department",
"description",
"severity",
"time",
"incident_datetime", # combined "YYYY-MM-DD HH:MM:SS" (computed)
"location",
"patient_or_staff",
"long_description",
"incident_photos",
"action_taken",
"needs_escalation",
"incident_score",
"date",
# DB-only (not in Excel)
"injured_person", # was responsible_person, renamed 2026-07-19
"medical_report",
]
@app.get("/api/accident/dashboard/summary")
async def accident_dashboard_summary(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accident dashboard summary stats."""
from datetime import date as _date, datetime as _dt
q = db.query(Accident).filter(Accident.deleted_at.is_(None))
if date_from:
try:
q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.all()
today = _date.today()
month_start = today.replace(day=1)
total = len(rows)
by_severity = {}
by_department = {}
by_location = {}
by_injured = {}
this_month = 0
earliest_date = None
latest_date = None
for r in rows:
sc = r.severity or "unknown"
by_severity[sc] = by_severity.get(sc, 0) + 1
if r.department:
by_department[r.department] = by_department.get(r.department, 0) + 1
if r.location:
by_location[r.location] = by_location.get(r.location, 0) + 1
if r.injured_person:
by_injured[r.injured_person] = by_injured.get(r.injured_person, 0) + 1
if r.date and r.date >= month_start:
this_month += 1
if r.date:
if earliest_date is None or r.date < earliest_date:
earliest_date = r.date
if latest_date is None or r.date > latest_date:
latest_date = r.date
# Avg per day (over date span)
days_span = 1
if earliest_date and latest_date:
days_span = max((latest_date - earliest_date).days + 1, 1)
avg_per_day = round(total / days_span, 2) if total > 0 else 0
return {
"total_records": total,
"this_month": this_month,
"today": sum(1 for r in rows if r.date == today),
"by_severity": by_severity,
"by_department": by_department,
"by_location": by_location,
"by_injured": by_injured,
"severity_count": { # frontend-expected
"1": by_severity.get("1", 0),
"2": by_severity.get("2", 0),
"3": by_severity.get("3", 0),
"4": by_severity.get("4", 0),
"5": by_severity.get("5", 0),
},
"unique_locations": len(by_location),
"unique_departments": len(by_department),
"unique_responsible": len(by_injured),
"avg_per_day": avg_per_day,
"earliest_date": earliest_date.isoformat() if earliest_date else None,
"latest_date": latest_date.isoformat() if latest_date else None,
}
@app.get("/api/accident/dashboard/stats")
async def accident_dashboard_stats(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accident per-entity leaderboards (location, employee, department, responsible)."""
from datetime import datetime as _dt
q = db.query(Accident).filter(Accident.deleted_at.is_(None))
if date_from:
try:
q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.all()
# Bucket by entity
by_location = {}
by_employee = {}
by_department = {}
by_injured = {}
for r in rows:
# Severity-weighted score (severity 5 = most critical)
try:
sev_weight = int(r.severity or 1)
except (ValueError, TypeError):
sev_weight = 1
sev_weight = max(sev_weight, 1)
loc = r.location or "未分類"
by_location.setdefault(loc, {"name": loc, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_location[loc]["count"] += 1
by_location[loc]["severity_sum"] += sev_weight
emp = r.submitter_email or "未分類"
by_employee.setdefault(emp, {"name": emp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_employee[emp]["count"] += 1
by_employee[emp]["severity_sum"] += sev_weight
dept = r.department or "未分類"
by_department.setdefault(dept, {"name": dept, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_department[dept]["count"] += 1
by_department[dept]["severity_sum"] += sev_weight
rp = r.injured_person or "未分類"
by_injured.setdefault(rp, {"name": rp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_injured[rp]["count"] += 1
by_injured[rp]["severity_sum"] += sev_weight
def finalize(d):
out = []
for v in d.values():
v["avg_severity"] = round(v["severity_sum"] / v["count"], 2) if v["count"] > 0 else 0
out.append(v)
return out
return {
"by_location": sorted(finalize(by_location), key=lambda x: x["count"], reverse=True),
"by_employee": sorted(finalize(by_employee), key=lambda x: x["count"], reverse=True),
"by_department": sorted(finalize(by_department), key=lambda x: x["count"], reverse=True),
"by_injured": sorted(finalize(by_injured), key=lambda x: x["count"], reverse=True),
}
@app.get("/api/accident/records")
async def accident_records(
page: int = 1,
per_page: int = 50,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "desc",
search: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
severity: Optional[str] = None,
location: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Paginated accident records from SQL."""
from datetime import datetime as _dt
from sqlalchemy import or_
q = db.query(Accident).filter(Accident.deleted_at.is_(None))
if date_from:
try:
q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
if severity:
q = q.filter(Accident.severity == severity)
if location:
q = q.filter(Accident.location == location)
if search:
like = f"%{search}%"
q = q.filter(or_(
Accident.submitter_email.like(like),
Accident.location.like(like),
Accident.description.like(like),
Accident.department.like(like),
))
sortable = {
"id": Accident.id,
"submitted_at": Accident.submitted_at,
"date": Accident.date,
"severity": Accident.severity,
"time": Accident.time,
"location": Accident.location,
"employee_name": Accident.submitter_email,
"department": Accident.department,
"description": Accident.description,
"action_taken": Accident.action_taken,
"patient_or_staff": Accident.patient_or_staff,
"incident_score": Accident.incident_score,
"responsible_person": Accident.injured_person,
}
col = sortable.get(sort_by, Accident.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
total = q.count()
rows = q.offset((page - 1) * per_page).limit(per_page).all()
def to_dict(r):
# Compose incident_datetime from date + time (display as "YYYY-MM-DD HH:MM:SS")
incident_dt = None
if r.date:
date_part = r.date.isoformat()
time_part = r.time or "00:00:00"
# Ensure time has HH:MM:SS format (may be HH:MM from Excel import)
if len(time_part) == 5: # "HH:MM"
time_part += ":00"
incident_dt = f"{date_part} {time_part}"
return {
"id": r.id,
# Submitted time formatted as "YYYY-MM-DD HH:MM:SS" (display-friendly)
"submitted_at": r.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if r.submitted_at else None,
"date": r.date.isoformat() if r.date else None,
"time": r.time,
"incident_datetime": incident_dt, # YYYY-MM-DD HH:MM:SS combined
"location": r.location,
# Backend alias: frontend column_order uses submitter_email as key
# but legacy UI expected employee_name
"submitter_email": r.submitter_email,
"employee_name": r.submitter_email, # alias for backward compat
"injured_person_id": r.injured_person_id,
"employee_id": r.injured_person_id, # alias
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.injured_person,
"patient_or_staff": r.patient_or_staff,
"long_description": r.long_description,
"incident_photos": r.incident_photos,
"needs_escalation": r.needs_escalation,
"incident_score": r.incident_score,
"excel_row": r.excel_row,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
excel_headers = _get_excel_headers_accident()
column_order = _get_excel_column_order_accident()
return {
"records": [to_dict(r) for r in rows],
"total": total,
"page": page,
"per_page": per_page,
"excel_headers": excel_headers,
"column_order": column_order,
}
@app.put("/api/accident/{record_id}")
async def update_accident_record(
record_id: int,
payload: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update accident record. Sets last_edited_at so re-import won't overwrite.
Marks is_locked for explicit protection (UI optional).
"""
r = db.query(Accident).filter(Accident.id == record_id).first()
if not r:
raise HTTPException(status_code=404, detail="Record not found")
# Allowed fields to edit (whitelist for safety)
editable = {
"submitter_email", # was employee_name, renamed 2026-07-20
"injured_person_id", # was employee_id, renamed 2026-07-20
"injured_person", # was responsible_person, renamed 2026-07-19
"department", "description", "severity",
"time", "location", "patient_or_staff", "long_description",
"incident_photos", "action_taken", "needs_escalation",
"incident_score", "medical_report",
}
for k, v in payload.items():
if k in editable:
setattr(r, k, v)
r.last_edited_at = datetime.utcnow()
r.last_edited_by = current_user.id
db.commit()
logger.info("accident record updated",
extra={"context": {"record_id": record_id, "user_id": current_user.id}})
return {"message": "ok", "id": record_id}
@app.get("/api/accident/{record_id}")
async def get_accident_record(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single accident record by ID."""
r = db.query(Accident).filter(Accident.id == record_id, Accident.deleted_at.is_(None)).first()
if not r:
raise HTTPException(status_code=404, detail="Record not found")
excel_headers = _get_excel_headers_accident()
column_order = _get_excel_column_order_accident()
return {
"id": r.id,
"submitted_at": r.submitted_at.isoformat() if r.submitted_at else None,
"date": r.date.isoformat() if r.date else None,
"time": r.time,
"location": r.location,
"employee_name": r.submitter_email,
"employee_id": r.injured_person_id,
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.injured_person,
"patient_or_staff": r.patient_or_staff,
"long_description": r.long_description,
"incident_photos": r.incident_photos,
"needs_escalation": r.needs_escalation,
"incident_score": r.incident_score,
"excel_row": r.excel_row,
"excel_headers": excel_headers,
"column_order": column_order,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
@app.get("/api/accident/export/excel")
async def accident_export_excel(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Generate multi-sheet accident xlsx (Phase 2: formula-driven, 3 sheets).
Sheets:
- 總覽: KPIs, severity/dept distribution, case index
- 個案查詢: input case_no in C2; VLOOKUP shows detail (live, no macro needed)
+ multi-case list
+ 2-case side-by-side compare (input cells)
- 明細: full table, all Excel columns, conditional formatting on severity
"""
import xlsxwriter
import io
from datetime import datetime as _dt
from collections import Counter
q = db.query(Accident).filter(Accident.deleted_at.is_(None))
if date_from:
try:
q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.order_by(Accident.date.desc()).all()
excel_headers = _get_excel_headers_accident()
column_order = _get_excel_column_order_accident()
# Map col index (1-based, for VLOOKUP) → SQL field key
# 明細 sheet will write records starting at row 2 (header row 1).
# Column order in 明細: id, submitted_at, employee_name, department, description,
# severity, time, location, patient_or_staff, long_description, incident_photos,
# action_taken, needs_escalation, incident_score, date
col_index_lookup = {col: i + 1 for i, col in enumerate(column_order)}
# ============ Workbook + formats ============
buf = io.BytesIO()
# force_full_calc_on_load + calc_on_load ensures Excel/Numbers recompute all
# formulas when opening the file (overrides cached <v>0</v> from xlsxwriter).
wb = xlsxwriter.Workbook(
buf,
{"in_memory": True,
"calc_on_load": True,
"calc_mode": "auto"})
# Belt-and-suspenders: set workbook calc properties via set_calc_mode
wb.set_calc_mode("auto", calc_id=1)
title_fmt = wb.add_format({"bold": True, "font_size": 16, "font_color": "#1E3A8A"})
subtitle_fmt = wb.add_format({"italic": True, "font_size": 10, "font_color": "#6B7280"})
section_fmt = wb.add_format({"bold": True, "font_size": 12, "font_color": "#1E3A8A",
"bottom": 2, "bottom_color": "#93C5FD"})
kpi_label_fmt = wb.add_format({"bold": True, "font_size": 9, "font_color": "#6B7280",
"align": "left"})
kpi_value_fmt = wb.add_format({"bold": True, "font_size": 22, "font_color": "#1E3A8A",
"align": "left"})
header_fmt = wb.add_format({"font_color": "#FFFFFF", "bold": True, "align": "center",
"bg_color": "#1E40AF", "border": 1, "border_color": "#1E3A8A"})
cell_fmt = wb.add_format({"align": "left", "valign": "top"})
cell_wrap_fmt = wb.add_format({"text_wrap": True, "align": "left", "valign": "top"})
detail_label_fmt = wb.add_format({"bold": True, "bg_color": "#F1F5F9",
"border": 1, "border_color": "#CBD5E1",
"valign": "top", "text_wrap": True, "align": "left",
"indent": 1})
detail_value_fmt = wb.add_format({"border": 1, "border_color": "#CBD5E1",
"valign": "top", "text_wrap": True, "align": "left",
"indent": 1})
detail_label_alt_fmt = wb.add_format({"bold": True, "bg_color": "#E2E8F0",
"border": 1, "border_color": "#CBD5E1",
"valign": "top", "text_wrap": True, "align": "left",
"indent": 1})
severity_fmts = {}
for code, font_c, bg_c in [
("1", "#065F46", "#D1FAE5"), ("2", "#1E40AF", "#DBEAFE"),
("3", "#92400E", "#FEF3C7"), ("4", "#9A3412", "#FED7AA"),
("5", "#7F1D1D", "#FEE2E2"),
]:
severity_fmts[code] = wb.add_format({"font_color": font_c, "bg_color": bg_c,
"bold": True, "align": "center", "border": 1,
"border_color": "#E5E7EB"})
severity_fmts["default"] = wb.add_format({"align": "center", "border": 1,
"border_color": "#E5E7EB"})
# ============ Sheet 1: 總覽 ============
overview = wb.add_worksheet("總覽")
overview.set_tab_color("#1E40AF")
overview.hide_gridlines(2)
overview.set_column("A:A", 2)
overview.set_column("B:B", 30)
overview.set_column("C:C", 18)
overview.set_column("D:D", 30)
overview.set_column("E:E", 18)
overview.set_column("F:F", 30)
overview.set_column("G:G", 18)
overview.write("B2", "意外記錄 總覽 Dashboard", title_fmt)
overview.write("B3", f"生成時間: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}", subtitle_fmt)
if date_from or date_to:
range_str = f"{date_from or '起始'}{date_to or '現在'}"
else:
range_str = "全部記錄"
overview.write("B4", f"篩選範圍: {range_str}", subtitle_fmt)
overview.merge_range("B6:C6", "總記錄數", kpi_label_fmt)
overview.merge_range("B7:C7", len(rows), kpi_value_fmt)
overview.merge_range("D6:E6", "嚴重程度 5 (最高)", kpi_label_fmt)
sev5 = sum(1 for r in rows if str(r.severity or "") == "5")
overview.merge_range("D7:E7", sev5, kpi_value_fmt)
overview.merge_range("F6:G6", "最早事件日期", kpi_label_fmt)
earliest = min((r.date for r in rows if r.date), default=None)
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
overview.write("B10", "嚴重程度分佈", section_fmt)
overview.write("B11", "Severity", header_fmt)
overview.write("C11", "數量", header_fmt)
overview.write("D11", "百分比", header_fmt)
severity_codes = ["1", "2", "3", "4", "5"]
for i, code in enumerate(severity_codes):
overview.write(11 + i, 1, code, severity_fmts[code])
count = sum(1 for r in rows if str(r.severity or "") == code)
overview.write(11 + i, 2, count, cell_fmt)
pct = (count / len(rows) * 100) if rows else 0
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt)
overview.write(11 + len(severity_codes), 1, "(空)", cell_fmt)
unk = sum(1 for r in rows if not str(r.severity or ""))
overview.write(11 + len(severity_codes), 2, unk, cell_fmt)
pct_unk = (unk / len(rows) * 100) if rows else 0
overview.write(11 + len(severity_codes), 3, f"{pct_unk:.1f}%", cell_fmt)
overview.write("F10", "部門事故數 (TOP 10)", section_fmt)
overview.write("F11", "部門", header_fmt)
overview.write("G11", "數量", header_fmt)
dept_counts = Counter((r.department or "(未分類)") for r in rows)
top_depts = dept_counts.most_common(10)
for i, (dept, count) in enumerate(top_depts):
overview.write(11 + i, 5, dept, cell_fmt)
overview.write(11 + i, 6, count, cell_fmt)
nav_start_row = 80
overview.write(f"B{nav_start_row}", "個案索引 (可 click → 跳去「個案查詢」對應 row)", section_fmt)
overview.write(f"B{nav_start_row + 1}", "#", header_fmt)
overview.write(f"C{nav_start_row + 1}", "日期", header_fmt)
overview.write(f"D{nav_start_row + 1}", "時間", header_fmt)
overview.write(f"E{nav_start_row + 1}", "部門", header_fmt)
overview.write(f"F{nav_start_row + 1}", "嚴重程度", header_fmt)
overview.write(f"G{nav_start_row + 1}", "→ 查個案", header_fmt)
link_fmt = wb.add_format({"font_color": "#2563EB", "underline": 1})
for idx, r in enumerate(rows):
row_n = nav_start_row + 2 + idx
overview.write(row_n, 1, r.id, cell_fmt)
overview.write(row_n, 2, r.date.isoformat() if r.date else "", cell_fmt)
overview.write(row_n, 3, r.time or "", cell_fmt)
overview.write(row_n, 4, r.department or "", cell_fmt)
overview.write(row_n, 5, str(r.severity or ""), severity_fmts.get(str(r.severity or ""), severity_fmts["default"]))
# Use HYPERLINK formula. When clicked, jumps to sheet 個案查詢 cell A1.
# User types case_no in C5 of 個案查詢 sheet.
# xlsxwriter requires single quotes around sheet names containing non-ASCII.
# Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell).
# HYPERLINK formula would cache value 0 — write_url sets display text directly.
overview.write_url(row_n, 6,
f"internal:個案查詢!A1",
link_fmt,
string=f"#{r.id} → 查詢",
tip=f"跳到「個案查詢」輸入個案 ID {r.id}")
overview.set_row(row_n, 18)
# ============ Charts block — 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============
# 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line
# Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index.
# --- Chart 1: Severity Pie (I6) ---
sev_pie = wb.add_chart({"type": "pie"})
sev_pie.add_series({
"name": "嚴重程度分佈",
# B12:B17 = severity codes + (空)
"categories": ["總覽", 11, 1, 16, 1],
"values": ["總覽", 11, 2, 16, 2],
"data_labels": {"percentage": True, "category": True, "position": "outside_end"},
})
sev_pie.set_title({"name": "嚴重程度分佈 (Pie)"})
sev_pie.set_style(10)
sev_pie.set_size({"width": 480, "height": 320})
sev_pie.set_legend({"position": "right"})
overview.insert_chart("I6", sev_pie, {"x_offset": 5, "y_offset": 5})
# --- Chart 2: Department Bar TOP 10 (I22) ---
# Dept table starts at F12, up to 10 entries + header row.
# F12:F21 = department names; G12:G21 = counts
dept_bar = wb.add_chart({"type": "bar"})
dept_bar.add_series({
"name": "部門事故數",
"categories": ["總覽", 11, 5, 20, 5],
"values": ["總覽", 11, 6, 20, 6],
"fill": {"color": "#3B82F6"},
"border": {"color": "#1E40AF"},
"data_labels": {"value": True},
})
dept_bar.set_title({"name": "部門事故數 (TOP 10)"})
dept_bar.set_x_axis({"name": "事故數"})
dept_bar.set_y_axis({"name": "部門", "reverse": True})
dept_bar.set_legend({"none": True})
dept_bar.set_size({"width": 480, "height": 320})
overview.insert_chart("I22", dept_bar, {"x_offset": 5, "y_offset": 5})
# --- Chart 3: Severity Column (I42) ---
sev_col_chart = wb.add_chart({"type": "column"})
sev_col_chart.add_series({
"name": "嚴重程度 數量",
"categories": ["總覽", 11, 1, 15, 1], # B12:B16 = severity 1-5 only (skip (空))
"values": ["總覽", 11, 2, 15, 2],
"fill": {"color": "#EF4444"},
"border": {"color": "#7F1D1D"},
"data_labels": {"value": True},
})
sev_col_chart.set_title({"name": "嚴重程度 數量 (Column)"})
sev_col_chart.set_x_axis({"name": "嚴重程度"})
sev_col_chart.set_y_axis({"name": "事故數"})
sev_col_chart.set_legend({"none": True})
sev_col_chart.set_size({"width": 480, "height": 320})
overview.insert_chart("I42", sev_col_chart, {"x_offset": 5, "y_offset": 5})
# --- Chart 4: Monthly Trend Line (I62) ---
# Build monthly data table inline below the case index (col B, starting at row 60).
# Header at row 60; data rows 61..72 for last 12 months.
trend_start_row = 60 # 0-indexed → Excel row 61
overview.write(trend_start_row, 1, "月份", header_fmt)
overview.write(trend_start_row, 2, "事故數", header_fmt)
# Compute last 12 months from data
from collections import OrderedDict
from datetime import date as _date
today = _dt.now().date()
months = []
for offset in range(11, -1, -1):
y = today.year
m = today.month - offset
while m <= 0:
m += 12
y -= 1
months.append(f"{y}-{m:02d}")
monthly_counts = OrderedDict((m, 0) for m in months)
for r in rows:
if r.date:
key = f"{r.date.year}-{r.date.month:02d}"
if key in monthly_counts:
monthly_counts[key] += 1
for i, (m, cnt) in enumerate(monthly_counts.items()):
overview.write(trend_start_row + 1 + i, 1, m, cell_fmt)
overview.write(trend_start_row + 1 + i, 2, cnt, cell_fmt)
n_months = len(months)
trend_end_row = trend_start_row + n_months # exclusive
line_chart = wb.add_chart({"type": "line"})
line_chart.add_series({
"name": "每月事故數",
"categories": ["總覽", trend_start_row + 1, 1, trend_end_row, 1],
"values": ["總覽", trend_start_row + 1, 2, trend_end_row, 2],
"line": {"color": "#10B981", "width": 2.25},
"marker": {"type": "circle", "size": 6,
"fill": {"color": "#10B981"},
"border": {"color": "#065F46"}},
"data_labels": {"value": True},
})
line_chart.set_title({"name": "每月事故趨勢 (Last 12 months)"})
line_chart.set_x_axis({"name": "月份"})
line_chart.set_y_axis({"name": "事故數"})
line_chart.set_legend({"none": True})
line_chart.set_size({"width": 480, "height": 320})
overview.insert_chart("I62", line_chart, {"x_offset": 5, "y_offset": 5})
# ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============
# Build it now so that 個案查詢 formulas can resolve its range properly.
# However, xlsxwriter's defined_name allows forward reference; formula text is
# what matters at runtime — even if the sheet is added later, the formula string
# '明細!A:O' will be valid when Excel opens the file.
# We define 明細 here so that the formula range 明細!A2:O{max_row} resolves.
# But xlsxwriter requires sheets to be added in the order they're defined.
# Solution: build 個案查詢 first (it sets the VLOOKUP formula referencing 明細!A:O),
# then create 明細 LAST.
# Per xlsxwriter semantics, sheet order is the add_worksheet() call order.
# So: Sheet order on tab will be: 總覽 (already added), 個案查詢 (next), 明細 (last).
# The 明細 range in the formula will be accepted even if the sheet is created later.
# Actually in xlsxwriter, all sheets are written to the workbook buffer;
# the order in the file is the order of add_worksheet() calls.
# The formula reference to '明細' works as long as the sheet exists by the time
# the workbook closes. We'll add 個案查詢 before 明細.
# Define the detail fields and their source column indices in 明細
detail_fields = [
("id", "#"),
("submitted_at", "時間戳記"),
("date", "事件發生日期"),
("time", "事件發生時間"),
("department", "部門名稱"),
("employee_name", "電子郵件地址"),
("severity", "緊急程度"),
("location", "涉及部門"),
("patient_or_staff", "涉及的患者或相關人員"),
("long_description", "事件描述"),
("incident_photos", "事件相關照片"),
("description", "當日事件摘要"),
("action_taken", "處理情況 / 已採取的行動"),
("needs_escalation", "需要管理層介入"),
("incident_score", "分數"),
("injured_person", "受傷員工"),
("medical_report", "醫療報告"),
]
# ============ Sheet 2: 個案查詢 ============
detail = wb.add_worksheet("個案查詢")
detail.set_tab_color("#F59E0B")
detail.hide_gridlines(2)
detail.set_column("A:A", 2)
detail.set_column("B:B", 24) # field labels
detail.set_column("C:C", 50) # values
detail.write("B2", "個案查詢 Case Lookup", title_fmt)
detail.write("B3", "輸入個案 ID 即時顯示詳細資料 (Excel VLOOKUP 自動)", subtitle_fmt)
# Input row
input_label_fmt = wb.add_format({"bold": True, "bg_color": "#FEF3C7",
"border": 2, "border_color": "#F59E0B",
"align": "left", "indent": 1})
input_fmt = wb.add_format({"bold": True, "font_size": 14, "font_color": "#1E3A8A",
"bg_color": "#FFFBEB", "border": 2, "border_color": "#F59E0B",
"align": "center"})
# Text-type variant for cells that need string input (like case_no id)
text_input_fmt = wb.add_format({"bold": True, "font_size": 14, "font_color": "#1E3A8A",
"bg_color": "#FFFBEB", "border": 2, "border_color": "#F59E0B",
"align": "center", "num_format": "@"})
# B5 = label only (NOT merged with C5). Otherwise user-typed text goes to
# B5 (top-left of merge) instead of C5 where the formula references.
detail.write("B5", "輸入個案 ID", input_label_fmt)
# C5 = case_no input cell (NOT merged)
detail.write_string("C5", "", text_input_fmt)
# Hint
detail.merge_range("B6:C6",
"提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。",
subtitle_fmt)
# Detail rows: each pulls from 明細!<col><row> via VLOOKUP keyed on id (col A)
detail_row_start = 8
for i, (col_key, label) in enumerate(detail_fields):
row_n = detail_row_start + i
lf = detail_label_fmt if i % 2 == 0 else detail_label_alt_fmt
detail.write(row_n, 1, label, lf)
col_idx = col_index_lookup.get(col_key, 1)
# Use INDEX/MATCH for type-safe text lookup. C5 is text (id column is text).
formula = f'=IFERROR(INDEX(明細!$A:$O,MATCH($C$5,明細!$A:$A,0),{col_idx}),"(找不到個案)")'
detail.write_formula(row_n, 2, formula, detail_value_fmt)
detail.set_row(row_n, 22)
# Multi-case list (rows under detail block)
list_start_row = detail_row_start + len(detail_fields) + 2
detail.write(f"B{list_start_row}", "多個個案列表 (輸入 ID, 顯示詳情)", section_fmt)
list_header_row = list_start_row + 1
detail.write(list_header_row, 1, "個案 ID", header_fmt)
detail.write(list_header_row, 2, "日期", header_fmt)
detail.write(list_header_row, 3, "時間", header_fmt)
detail.write(list_header_row, 4, "部門", header_fmt)
detail.write(list_header_row, 5, "嚴重程度", header_fmt)
detail.write(list_header_row, 6, "員工/涉及人", header_fmt)
detail.write(list_header_row, 7, "事件摘要", header_fmt)
detail.set_column("D:H", 18)
n_list_rows = 10
for j in range(n_list_rows):
row_n = list_header_row + 1 + j
detail.write_string(row_n, 1, "", text_input_fmt) # text input cell for case_no
col_date = col_index_lookup.get("date", 14)
col_time = col_index_lookup.get("time", 7)
col_dept = col_index_lookup.get("department", 4)
col_sev = col_index_lookup.get("severity", 6)
col_emp = col_index_lookup.get("employee_name", 3)
col_desc = col_index_lookup.get("description", 5)
# Use INDIRECT or simply use cell reference for ID — but ID varies per row.
# Easier: each row's ID is in B<row_n>; build formula with that.
# VLOOKUP needs lookup_value, table, col_index, FALSE.
# table 明細!$A:$O.
# For severity col we want to apply conditional formatting so we use formula without format.
# xlsxwriter 0-indexed; Excel cell row = row_n + 1
# detail.write_string(row_n, 1, "") writes to Excel B<row_n+1>
cell_id_ref = f"$B${row_n + 1}"
# INDEX/MATCH for text id lookup (consistent with detail block)
detail.write_formula(row_n, 2,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_date}),"")',
cell_fmt)
detail.write_formula(row_n, 3,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_time}),"")',
cell_fmt)
detail.write_formula(row_n, 4,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_dept}),"")',
cell_fmt)
detail.write_formula(row_n, 5,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_sev}),"")',
severity_fmts["default"])
detail.write_formula(row_n, 6,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_emp}),"")',
cell_fmt)
detail.write_formula(row_n, 7,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_desc}),"")',
cell_wrap_fmt)
detail.set_row(row_n, 28)
# Conditional formatting on the severity column in the list (rows list_header_row+1 ..)
list_sev_first = list_header_row + 1
list_sev_last = list_header_row + n_list_rows
for code, fmt in severity_fmts.items():
if code == "default":
continue
# Match cells in column F (=index 5) that equal "1", "2", etc.
detail.conditional_format(list_sev_first, 5, list_sev_last, 5, {
"type": "cell",
"criteria": "equal to",
"value": f'"{code}"',
"format": severity_fmts[code],
})
# ============ Side-by-side compare (2 cases) ============
compare_start_row = list_header_row + n_list_rows + 3
detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt)
detail.write(f"B{compare_start_row + 1}",
"輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。",
subtitle_fmt)
cmp_label_row = compare_start_row + 2
detail.write(cmp_label_row, 1, "個案 A ID", input_label_fmt)
detail.write_string(cmp_label_row, 2, "", text_input_fmt)
detail.write(cmp_label_row, 4, "個案 B ID", input_label_fmt)
detail.write_string(cmp_label_row, 5, "", text_input_fmt)
detail.set_column("F:F", 50)
cmp_header_row = cmp_label_row + 2
detail.write(cmp_header_row, 1, "欄位", header_fmt)
detail.write(cmp_header_row, 2, "個案 A 值", header_fmt)
detail.write(cmp_header_row, 3, "", header_fmt)
detail.write(cmp_header_row, 4, "", header_fmt)
detail.write(cmp_header_row, 5, "個案 B 值", header_fmt)
detail.set_column("D:D", 5)
detail.set_column("E:E", 5)
cmp_data_start = cmp_header_row + 1
for i, (col_key, label) in enumerate(detail_fields):
row_n = cmp_data_start + i
lf = detail_label_fmt if i % 2 == 0 else detail_label_alt_fmt
detail.write(row_n, 1, label, lf)
col_idx = col_index_lookup.get(col_key, 1)
# Case A: lookup at C<cmp_label_row>. Use INDEX/MATCH for text id.
id_a = f"$C${cmp_label_row + 1}"
id_b = f"$F${cmp_label_row + 1}"
detail.write_formula(row_n, 2,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({id_a},明細!$A:$A,0),{col_idx}),"")',
detail_value_fmt)
detail.write_formula(row_n, 5,
f'=IFERROR(INDEX(明細!$A:$O,MATCH({id_b},明細!$A:$A,0),{col_idx}),"")',
detail_value_fmt)
detail.set_row(row_n, 22)
# Hint at top
detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}",
"💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。",
subtitle_fmt)
# ============ Sheet 3: 明細 ============
ws = wb.add_worksheet("明細")
ws.set_tab_color("#10B981")
ws.set_column("A:A", 8)
widths_by_col = {
"id": 8, "submitted_at": 18, "employee_name": 24, "department": 14,
"description": 38, "severity": 10, "time": 10, "location": 18,
"patient_or_staff": 28, "long_description": 60, "incident_photos": 30,
"action_taken": 38, "needs_escalation": 10, "incident_score": 8, "date": 12,
}
sev_col = None
for col_idx, col in enumerate(column_order):
header_text = (excel_headers.get(col) or col).strip()
ws.write(0, col_idx, header_text, header_fmt)
ws.set_column(col_idx, col_idx, widths_by_col.get(col, 16))
ws.set_row(0, 24)
if col == "severity":
sev_col = col_idx
ws.freeze_panes(1, 0)
for r_idx, r in enumerate(rows, start=1):
# Compose incident_datetime from date + time
incident_dt = ""
if r.date:
date_part = r.date.isoformat()
time_part = r.time or "00:00:00"
if len(time_part) == 5:
time_part += ":00"
incident_dt = f"{date_part} {time_part}"
rec = {
"id": r.id,
"submitted_at": r.submitted_at.isoformat() if r.submitted_at else "",
"date": r.date.isoformat() if r.date else "",
"time": r.time or "",
"incident_datetime": incident_dt,
"location": r.location or "",
"submitter_email": r.submitter_email or "",
"injured_person_id": r.injured_person_id or "",
"department": r.department or "",
"description": r.description or "",
"severity": str(r.severity or ""),
"medical_report": r.medical_report or "",
"action_taken": r.action_taken or "",
"injured_person": r.injured_person or "",
"patient_or_staff": r.patient_or_staff or "",
"long_description": r.long_description or "",
"incident_photos": r.incident_photos or "",
"needs_escalation": r.needs_escalation or "",
"incident_score": r.incident_score if r.incident_score is not None else "",
"excel_row": r.excel_row if r.excel_row is not None else "",
}
ws.set_row(r_idx, 30)
for c_idx, col in enumerate(column_order):
v = rec.get(col, "")
if col == "severity":
# Apply plain format; conditional formatting will color it later
ws.write(r_idx, c_idx, v, severity_fmts["default"])
elif col in ("description", "long_description", "action_taken"):
ws.write(r_idx, c_idx, v, cell_wrap_fmt)
elif col == "id":
# IMPORTANT: Write id as TEXT so VLOOKUP matches text input from cell C5
ws.write_string(r_idx, c_idx, str(v), cell_fmt)
else:
ws.write(r_idx, c_idx, v, cell_fmt)
# Apply conditional formatting on severity column (after writing data)
if sev_col is not None and rows:
first_row = 1
last_row = len(rows)
for code, fmt in severity_fmts.items():
if code == "default":
continue
ws.conditional_format(first_row, sev_col, last_row, sev_col, {
"type": "cell",
"criteria": "equal to",
"value": f'"{code}"',
"format": fmt,
})
# Also add a filter (Excel auto-filter) on the header row for easy column filtering.
ws.autofilter(0, 0, len(rows), len(column_order) - 1)
wb.close()
buf.seek(0)
date_suffix = ""
if date_from and date_to:
date_suffix = f"_{date_from}_{date_to}"
elif date_from:
date_suffix = f"_from_{date_from}"
elif date_to:
date_suffix = f"_to_{date_to}"
filename = f"accident{date_suffix}.xlsx"
logger.info("accident export to xlsx v3",
extra={"context": {"user_id": current_user.id, "rows": len(rows),
"date_from": date_from, "date_to": date_to}})
from fastapi.responses import StreamingResponse
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# ============ Record Detail (from Excel) ============
@app.get("/api/{section}/{row_id}")
async def get_record(
section: str,
row_id: int,
current_user: User = Depends(get_current_user)
):
"""Get a specific record by row number"""
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
headers, rows = read_excel_file(section)
if headers is None:
raise HTTPException(status_code=404, detail="No data file found")
idx = row_id - 1 # Convert to 0-based index
if idx < 0 or idx >= len(rows):
raise HTTPException(status_code=404, detail="Record not found")
row = rows[idx]
record = {"_row": row_id}
for i, header in enumerate(headers):
if header:
record[str(header)] = row[i] if i < len(row) else None
return record
# ============ Export ============
@app.get("/api/export/{section}/excel")
async def export_excel(
request: Request,
section: str,
token: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Export Excel file.
For attendance: builds xlsx on-the-fly from attendance_records SQL table with
full computed columns (expected_in, expected_out, actual_in, actual_out,
status_code, status_text, late/early/ot minutes).
For accident: returns the stored Excel file (legacy).
"""
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from database import SECRET_KEY, ALGORITHM
from datetime import datetime as _dt
credentials_exception = HTTPException(
status_code=401,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
current_user = None
if token:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
current_user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
except (JWTError, ValueError, TypeError):
pass
if not current_user:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
bearer_token = auth_header[7:]
try:
payload = jwt.decode(bearer_token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
current_user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
except (JWTError, ValueError, TypeError):
pass
if not current_user:
raise credentials_exception
if section not in ["attendance", "accident"]:
raise HTTPException(status_code=400, detail="Invalid section")
# Attendance: build xlsx from SQL table
if section == "attendance":
import xlsxwriter
import io
q = db.query(AttendanceRecord)
if date_from:
try:
q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date())
except ValueError:
pass
if date_to:
try:
q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date())
except ValueError:
pass
rows = q.order_by(AttendanceRecord.date.desc(), AttendanceRecord.employee_name.asc()).all()
enrich_attendance_with_leave(rows, db)
buf = io.BytesIO()
wb = xlsxwriter.Workbook(
buf,
{"in_memory": True,
"calc_on_load": True,
"calc_mode": "auto"})
wb.set_calc_mode("auto", calc_id=1)
# ============ Sheet 1: 總覽 (Dashboard) ============
overview = wb.add_worksheet("總覽")
overview.set_tab_color("#1E40AF")
overview.hide_gridlines(2)
overview.set_column("A:A", 2)
overview.set_column("B:B", 28)
overview.set_column("C:C", 18)
overview.set_column("D:D", 30)
overview.set_column("E:E", 18)
overview.set_column("F:F", 26)
overview.set_column("G:G", 18)
title_fmt = wb.add_format({"bold": True, "font_size": 16, "font_color": "#1E3A8A"})
subtitle_fmt = wb.add_format({"italic": True, "font_size": 10, "font_color": "#6B7280"})
section_fmt = wb.add_format({"bold": True, "font_size": 12, "font_color": "#1E3A8A",
"bottom": 2, "bottom_color": "#93C5FD"})
kpi_label_fmt = wb.add_format({"bold": True, "font_size": 9, "font_color": "#6B7280",
"align": "left"})
kpi_value_fmt = wb.add_format({"bold": True, "font_size": 22, "font_color": "#1E3A8A",
"align": "left"})
header_fmt_ov = wb.add_format({"font_color": "#FFFFFF", "bold": True, "align": "center",
"bg_color": "#1E40AF", "border": 1, "border_color": "#1E3A8A"})
cell_fmt_ov = wb.add_format({"align": "left", "valign": "top"})
link_fmt_ov = wb.add_format({"font_color": "#2563EB", "underline": 1})
overview.write("B2", "出勤記錄 總覽 Dashboard", title_fmt)
overview.write("B3", f"生成時間: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}", subtitle_fmt)
if date_from or date_to:
range_str = f"{date_from or '起始'}{date_to or '現在'}"
else:
range_str = "全部記錄"
overview.write("B4", f"篩選範圍: {range_str}", subtitle_fmt)
# Counters
n_normal = sum(1 for r in rows if (r.status_code or "").lower() == "normal")
n_late = sum(1 for r in rows if "late" in (r.status_code or "").lower() and "early" not in (r.status_code or "").lower())
n_early = sum(1 for r in rows if "early" in (r.status_code or "").lower() and "late" not in (r.status_code or "").lower())
n_ot = sum(1 for r in rows if "ot" in (r.status_code or "").lower())
n_abnormal = sum(1 for r in rows if (r.status_code or "").lower() == "abnormal")
total_late_min = sum(r.late_minutes or 0 for r in rows)
total_ot_min = sum(r.ot_minutes or 0 for r in rows)
overview.merge_range("B6:C6", "總記錄數", kpi_label_fmt)
overview.merge_range("B7:C7", len(rows), kpi_value_fmt)
overview.merge_range("D6:E6", "正常出勤", kpi_label_fmt)
overview.merge_range("D7:E7", n_normal, kpi_value_fmt)
overview.merge_range("F6:G6", "最早事件日期", kpi_label_fmt)
earliest = min((r.date for r in rows if r.date), default=None)
overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt)
# Status distribution table (B10:D)
overview.write("B10", "出勤狀態分佈", section_fmt)
overview.write("B11", "Status", header_fmt_ov)
overview.write("C11", "數量", header_fmt_ov)
overview.write("D11", "百分比", header_fmt_ov)
status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing",
"holiday", "al", "sl", "cl", "mixed_leave"]
status_labels = {
"normal": "正常", "late": "遲到", "late_early": "遲到+早走",
"late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班",
"ot": "加班", "abnormal": "異常", "missing": "缺勤",
"holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL",
"mixed_leave": "混合假",
}
for i, code in enumerate(status_codes_order):
count = sum(1 for r in rows if (r.status_code or "").lower() == code)
overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov)
overview.write(11 + i, 2, count, cell_fmt_ov)
pct = (count / len(rows) * 100) if rows else 0
overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov)
unk = sum(1 for r in rows if not (r.status_code or "").strip())
overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov)
overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov)
pct_unk = (unk / len(rows) * 100) if rows else 0
overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov)
# Department TOP 10 table (F10:G)
overview.write("F10", "部門記錄數 (TOP 10)", section_fmt)
overview.write("F11", "部門", header_fmt_ov)
overview.write("G11", "數量", header_fmt_ov)
from collections import Counter
dept_counts = Counter((r.department or "(未分類)") for r in rows)
top_depts = dept_counts.most_common(10)
for i, (dept, count) in enumerate(top_depts):
overview.write(11 + i, 5, dept, cell_fmt_ov)
overview.write(11 + i, 6, count, cell_fmt_ov)
# ============ Charts block (Sheet 1 總覽 右側 columns I-P) ============
# Chart 1: Status Pie (I6)
status_pie = wb.add_chart({"type": "pie"})
status_pie.add_series({
"name": "出勤狀態分佈",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order), 2],
"data_labels": {"percentage": True, "category": False, "position": "outside_end"},
})
status_pie.set_title({"name": "出勤狀態分佈 (Pie)"})
status_pie.set_style(10)
status_pie.set_size({"width": 480, "height": 320})
status_pie.set_legend({"position": "right"})
overview.insert_chart("I6", status_pie, {"x_offset": 5, "y_offset": 5})
# Chart 2: Department Bar TOP 10 (I22)
dept_bar = wb.add_chart({"type": "bar"})
dept_bar.add_series({
"name": "部門記錄數",
"categories": ["總覽", 11, 5, 20, 5],
"values": ["總覽", 11, 6, 20, 6],
"fill": {"color": "#3B82F6"},
"border": {"color": "#1E40AF"},
"data_labels": {"value": True},
})
dept_bar.set_title({"name": "部門記錄數 (TOP 10)"})
dept_bar.set_x_axis({"name": "記錄數"})
dept_bar.set_y_axis({"name": "部門", "reverse": True})
dept_bar.set_legend({"none": True})
dept_bar.set_size({"width": 480, "height": 320})
overview.insert_chart("I22", dept_bar, {"x_offset": 5, "y_offset": 5})
# Chart 3: Status Column (I42)
status_col_chart = wb.add_chart({"type": "column"})
status_col_chart.add_series({
"name": "出勤狀態 數量",
"categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1],
"values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2],
"fill": {"color": "#10B981"},
"border": {"color": "#065F46"},
"data_labels": {"value": True},
})
status_col_chart.set_title({"name": "出勤狀態 數量 (Column)"})
status_col_chart.set_x_axis({"name": "狀態"})
status_col_chart.set_y_axis({"name": "記錄數"})
status_col_chart.set_legend({"none": True})
status_col_chart.set_size({"width": 480, "height": 320})
overview.insert_chart("I42", status_col_chart, {"x_offset": 5, "y_offset": 5})
# Chart 4: Monthly Trend Line (I62)
trend_start_row = 60
overview.write(trend_start_row, 1, "月份", header_fmt_ov)
overview.write(trend_start_row, 2, "記錄數", header_fmt_ov)
from collections import OrderedDict
today = _dt.now().date()
months = []
for offset in range(11, -1, -1):
y = today.year
m = today.month - offset
while m <= 0:
m += 12
y -= 1
months.append(f"{y}-{m:02d}")
monthly_counts = OrderedDict((m, 0) for m in months)
for r in rows:
if r.date:
key = f"{r.date.year}-{r.date.month:02d}"
if key in monthly_counts:
monthly_counts[key] += 1
for i, (m, cnt) in enumerate(monthly_counts.items()):
overview.write(trend_start_row + 1 + i, 1, m, cell_fmt_ov)
overview.write(trend_start_row + 1 + i, 2, cnt, cell_fmt_ov)
n_months = len(months)
trend_end_row = trend_start_row + n_months
line_chart = wb.add_chart({"type": "line"})
line_chart.add_series({
"name": "每月記錄數",
"categories": ["總覽", trend_start_row + 1, 1, trend_end_row, 1],
"values": ["總覽", trend_start_row + 1, 2, trend_end_row, 2],
"line": {"color": "#10B981", "width": 2.25},
"marker": {"type": "circle", "size": 6,
"fill": {"color": "#10B981"},
"border": {"color": "#065F46"}},
"data_labels": {"value": True},
})
line_chart.set_title({"name": "每月記錄趨勢 (Last 12 months)"})
line_chart.set_x_axis({"name": "月份"})
line_chart.set_y_axis({"name": "記錄數"})
line_chart.set_legend({"none": True})
line_chart.set_size({"width": 480, "height": 320})
overview.insert_chart("I62", line_chart, {"x_offset": 5, "y_offset": 5})
# 員工索引 (row 80+)
nav_start_row = 80
overview.write(f"B{nav_start_row}", "員工出勤索引 (可 click → 跳去「明細」對應 row)", section_fmt)
overview.write(f"B{nav_start_row + 1}", "姓名", header_fmt_ov)
overview.write(f"C{nav_start_row + 1}", "部門", header_fmt_ov)
overview.write(f"D{nav_start_row + 1}", "日期", header_fmt_ov)
overview.write(f"E{nav_start_row + 1}", "狀態", header_fmt_ov)
overview.write(f"F{nav_start_row + 1}", "遲到 min", header_fmt_ov)
overview.write(f"G{nav_start_row + 1}", "→ 查明細", header_fmt_ov)
for idx, r in enumerate(rows):
row_n = nav_start_row + 2 + idx
overview.write(row_n, 1, r.employee_name or "", cell_fmt_ov)
overview.write(row_n, 2, r.department or "", cell_fmt_ov)
overview.write(row_n, 3, r.date.isoformat() if r.date else "", cell_fmt_ov)
overview.write(row_n, 4, r.status_text or "", cell_fmt_ov)
overview.write(row_n, 5, r.late_minutes or 0, cell_fmt_ov)
detail_row = idx + 2
overview.write_url(row_n, 6,
f"internal:明細!A{detail_row}",
link_fmt_ov,
string=f"#{idx + 1} → 查詢",
tip=f"跳到「明細」row {detail_row}")
overview.set_row(row_n, 18)
# ============ Sheet 2: 明細 (renamed from "Attendance") ============
ws = wb.add_worksheet("明細")
ws.set_tab_color("#10B981")
# Per-status formats (font color + bg color)
status_fmts = {
"normal": wb.add_format({"font_color": "#065F46", "bg_color": "#D1FAE5", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"late": wb.add_format({"font_color": "#92400E", "bg_color": "#FEF3C7", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"late_early": wb.add_format({"font_color": "#92400E", "bg_color": "#FED7AA", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"late_ot": wb.add_format({"font_color": "#7C2D12", "bg_color": "#FED7AA", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"early": wb.add_format({"font_color": "#1E40AF", "bg_color": "#DBEAFE", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"early_ot": wb.add_format({"font_color": "#1E40AF", "bg_color": "#E0E7FF", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"ot": wb.add_format({"font_color": "#5B21B6", "bg_color": "#EDE9FE", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"abnormal": wb.add_format({"font_color": "#7F1D1D", "bg_color": "#FEE2E2", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}),
"missing": wb.add_format({"font_color": "#374151", "bg_color": "#E5E7EB", "bold": True, "align": "center", "border": 1, "border_color": "#D1D5DB"}),
"default": wb.add_format({"align": "center"}),
}
# Header format
header_fmt = wb.add_format({
"font_color": "#1E3A8A",
"bold": True,
"align": "center",
"bg_color": "#DBEAFE",
"border": 1,
"border_color": "#93C5FD",
})
# Column widths
widths = [
("Staff Name", 18), ("Company", 12), ("Department", 14),
("Date", 12), ("Weekday", 10), ("Shift", 10),
("Expected In", 12), ("Expected Out", 12),
("Actual In", 12), ("Actual Out", 12),
("Status Code", 12), ("Status Text", 22),
("Late Min", 10), ("Early Min", 10), ("OT Min", 10),
("Manual Edit", 12),
]
# Write headers
for col, (label, w) in enumerate(widths):
ws.write(0, col, label, header_fmt)
ws.set_column(col, col, w)
ws.freeze_panes(1, 0) # Freeze header row
# Write data
for r_idx, r in enumerate(rows, start=1):
sc = (r.status_code or "").lower()
fmt = status_fmts.get(sc, status_fmts["default"])
ws.write(r_idx, 0, r.employee_name or "")
ws.write(r_idx, 1, r.company or "")
ws.write(r_idx, 2, r.department or "")
ws.write(r_idx, 3, r.date.isoformat() if r.date else "")
ws.write(r_idx, 4, r.weekday or "")
ws.write(r_idx, 5, r.shift_code or "")
ws.write(r_idx, 6, r.expected_in or "-")
ws.write(r_idx, 7, r.expected_out or "-")
ws.write(r_idx, 8, r.actual_in or "-")
ws.write(r_idx, 9, r.actual_out or "-")
ws.write(r_idx, 10, sc, fmt)
ws.write(r_idx, 11, r.status_text or "")
ws.write(r_idx, 12, r.late_minutes or 0)
ws.write(r_idx, 13, r.early_minutes or 0)
ws.write(r_idx, 14, r.ot_minutes or 0)
ws.write(r_idx, 15, "Yes" if r.is_manually_edited else "")
ws.set_row(r_idx, 20)
wb.close()
buf.seek(0)
date_suffix = ""
if date_from and date_to:
date_suffix = f"_{date_from}_{date_to}"
elif date_from:
date_suffix = f"_from_{date_from}"
elif date_to:
date_suffix = f"_to_{date_to}"
filename = f"attendance{date_suffix}.xlsx"
logger.info("attendance export to xlsx",
extra={"context": {"user_id": current_user.id, "rows": len(rows),
"date_from": date_from, "date_to": date_to}})
from fastapi.responses import StreamingResponse
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# Accident: legacy file
path = get_excel_path(section)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="No file found")
return FileResponse(
path,
filename=f"{section}_export.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={section}_export.xlsx"},
)
# ============ Roster / Shift Management ============
from fastapi.staticfiles import StaticFiles
import os as _os
# Serve built frontend (Vite output)
if _os.path.isdir("/app/static"):
app.mount("/assets", StaticFiles(directory="/app/static/assets"), name="assets")
@app.get("/")
async def root():
return RedirectResponse(url="/index.html")
@app.get("/index.html")
async def index_html():
"""Serve the built React app index.html"""
path = "/app/static/index.html"
if not _os.path.exists(path):
raise HTTPException(status_code=404, detail="Frontend not built")
return FileResponse(path, media_type="text/html")
@app.get("/favicon.ico")
async def favicon():
p = "/app/static/favicon.ico"
if _os.path.exists(p):
return FileResponse(p)
raise HTTPException(status_code=404)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# ============ Client Logs (frontend error reports) ============
class ClientLogEntry(BaseModel):
level: str = Field(..., pattern="^(debug|info|warn|error)$")
msg: str = Field(..., max_length=2000)
stack: Optional[str] = Field(None, max_length=8000)
page_url: Optional[str] = Field(None, max_length=2000)
app_version: Optional[str] = Field(None, max_length=64)
request_id: Optional[str] = Field(None, max_length=128)
browser: Optional[str] = Field(None, max_length=500)
ts: Optional[str] = Field(None, max_length=64)
@app.post("/api/client-logs")
async def client_logs(entry: ClientLogEntry, request: Request):
"""Receive error reports from frontend. No auth required."""
level_map = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warn": logging.WARNING,
"error": logging.ERROR,
}
log_level = level_map.get(entry.level, logging.INFO)
# Use server's request_id as authoritative if client sent one that looks fishy
server_rid = getattr(request.state, "request_id", None)
final_rid = entry.request_id or server_rid or "-"
extra = {
"route": entry.page_url or "-",
"page_url": entry.page_url,
"app_version": entry.app_version,
"browser": entry.browser,
"client_ip": request.client.host if request.client else None,
"context": {
"source": "client",
"request_id_from_client": entry.request_id,
"server_request_id": server_rid,
"ts_from_client": entry.ts,
},
}
if entry.stack:
# Log as if exception
logger.log(log_level, "client error: %s", entry.msg, extra={
**extra,
"context": {
**extra["context"],
"error": {"name": "ClientError", "message": entry.msg, "stack": entry.stack},
},
})
else:
logger.log(log_level, "client: %s", entry.msg, extra=extra)
return {"status": "ok", "request_id": server_rid}