import os
import secret
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta
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]
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
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
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items()))
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
else:
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) tuple.
"""
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
for i, h in enumerate(headers):
if h == '班次':
shift_col = i
break
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:
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)
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, **totals}
@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}
# ============ 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
existing.hours = r["hours"]
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(),
}
@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))
employee_name = _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 employee_name:
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.employee_name = employee_name
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,
employee_name=employee_name,
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: "employee_name",
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",
"employee_name",
"department",
"description",
"severity",
"time",
"location",
"patient_or_staff",
"long_description",
"incident_photos",
"action_taken",
"needs_escalation",
"incident_score",
"date",
# DB-only (not in Excel)
"responsible_person",
"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_responsible = {}
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.responsible_person:
by_responsible[r.responsible_person] = by_responsible.get(r.responsible_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_responsible": by_responsible,
"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_responsible),
"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_responsible = {}
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.employee_name 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.responsible_person or "未分類"
by_responsible.setdefault(rp, {"name": rp, "count": 0, "severity_sum": 0, "avg_severity": 0})
by_responsible[rp]["count"] += 1
by_responsible[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_responsible": sorted(finalize(by_responsible), 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.employee_name.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.employee_name,
"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.responsible_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):
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.employee_name,
"employee_id": r.employee_id,
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.responsible_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 = {
"employee_name", "department", "description", "severity",
"time", "location", "patient_or_staff", "long_description",
"incident_photos", "action_taken", "needs_escalation",
"incident_score", "responsible_person", "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.employee_name,
"employee_id": r.employee_id,
"department": r.department,
"description": r.description,
"severity": r.severity,
"medical_report": r.medical_report,
"action_taken": r.action_taken,
"responsible_person": r.responsible_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