8e0d73c37e
- Add last_edited_at + last_edited_by columns to holidays + leave_records - Migration: ALTER TABLE adds columns (idempotent) - PUT endpoints set last_edited_at on every manual edit - POST /api/leave/upload: skip if last_edited_at NOT NULL, update otherwise - New POST /api/holidays/upload: same logic, admin only - LeaveOut + HolidayOut schemas include last_edited_at - Response includes added/updated/skipped counters - Verified: edited Jay Lam SL preserved, not-edited May Chan SL updated
3445 lines
134 KiB
Python
3445 lines
134 KiB
Python
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.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
|
||
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)
|
||
db.commit()
|
||
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:
|
||
# 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.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 <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", "分數"),
|
||
("responsible_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):
|
||
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 "",
|
||
"location": r.location or "",
|
||
"employee_name": r.employee_name or "",
|
||
"employee_id": r.employee_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 "",
|
||
"responsible_person": r.responsible_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}
|
||
|
||
|