from datetime import date from math import ceil from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.database import get_db from app.deps import require_roles from app.models import Equipment, Personnel, ProductionReport, ReportStatus, Role from app.schemas import ( PageResponse, UsageStatsDailyRow, UsageStatsDetailOut, UsageStatsReportRow, UsageStatsRow, ) from app.services.attendance_points import accessible_point_names, require_attendance_point_access from app.services.common import round2 from app.services.report_lifecycle import purge_expired_voided_reports from app.services.usage_stats import UsageStatRow, build_usage_stats from app.services.usage_stats_export import export_usage_stats_rows router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"]) def _clean(value: str | None) -> str: return str(value or "").strip() def _requested_points(db: Session, user: Personnel, attendance_point_name: str | None) -> list[str]: requested_name = _clean(attendance_point_name) if requested_name: try: return [require_attendance_point_access(db, user, requested_name)] except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except PermissionError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc return accessible_point_names(db, user) def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]: if not point_names: return {} rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all() return {(_clean(row.attendance_point_name), _clean(row.device_no)): row.device_type for row in rows} def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None): query = ( select(ProductionReport) .options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items)) .where( ProductionReport.status == ReportStatus.approved, ProductionReport.is_voided.is_(False), ProductionReport.attendance_point_name.in_(point_names), ) ) if start_date: query = query.where(ProductionReport.report_date >= start_date) if end_date: query = query.where(ProductionReport.report_date <= end_date) return query.order_by(ProductionReport.report_date.desc(), ProductionReport.id.desc()) def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]: if sort_by == "count": return sorted(rows, key=lambda row: (-row.report_count, -row.value, row.name)) return sorted(rows, key=lambda row: (-row.value, -row.report_count, row.name)) def _usage_row_search_text(row: UsageStatRow) -> str: values = [ row.id, row.category, row.attendance_point_name, row.name, row.object_type, row.metric_kind, row.product_name, row.process_name, row.stamping_method, row.value, row.report_count, *row.tags, ] return " ".join(str(value or "") for value in values).lower() def _filter_usage_rows(rows: list[UsageStatRow], keyword: str | None) -> list[UsageStatRow]: text = _clean(keyword).lower() if not text: return rows return [row for row in rows if text in _usage_row_search_text(row)] def _to_schema_row(row: UsageStatRow) -> UsageStatsRow: return UsageStatsRow( id=row.id, category=row.category, attendance_point_name=row.attendance_point_name, name=row.name, object_type=row.object_type, metric_kind=row.metric_kind, value=row.value, report_count=row.report_count, product_name=row.product_name, process_name=row.process_name, stamping_method=row.stamping_method, tags=list(row.tags), ) def _to_detail_out( row: UsageStatRow, *, daily_rows: list[UsageStatsDailyRow] | None = None, report_rows: list[UsageStatsReportRow] | None = None, ) -> UsageStatsDetailOut: return UsageStatsDetailOut( id=row.id, category=row.category, attendance_point_name=row.attendance_point_name, name=row.name, object_type=row.object_type, metric_kind=row.metric_kind, value=row.value, report_count=row.report_count, product_name=row.product_name, process_name=row.process_name, stamping_method=row.stamping_method, tags=list(row.tags), daily_rows=daily_rows or [], report_rows=report_rows or [], ) def _matches_target( row: UsageStatRow, *, id: str, category: str, attendance_point_name: str, name: str, product_name: str, process_name: str, stamping_method: str, metric_kind: str, ) -> bool: if id: return row.id == id if row.category != category: return False if attendance_point_name and row.attendance_point_name != attendance_point_name: return False if metric_kind and row.metric_kind != metric_kind: return False if category == "device": return row.name == name return ( row.product_name == product_name and row.process_name == process_name and row.stamping_method == stamping_method ) def _build_usage_stats_export_response(device_rows: list[UsageStatRow], mold_rows: list[UsageStatRow]) -> Response: content = export_usage_stats_rows(device_rows, mold_rows) return Response( content=content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'}, ) @router.get("/summary", response_model=PageResponse) def usage_stats_summary( category: str = Query("device", pattern="^(device|mold)$"), start_date: date | None = None, end_date: date | None = None, attendance_point_name: str = "", keyword: str = "", sort_by: str = Query("value", pattern="^(value|count)$"), page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), user: Personnel = Depends(require_roles(Role.admin, Role.manager)), db: Session = Depends(get_db), ) -> PageResponse: purge_expired_voided_reports(db) point_names = _requested_points(db, user, attendance_point_name) reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() rows = _sort_usage_rows( _filter_usage_rows( build_usage_stats( reports=reports, category=category, equipment_type_by_key=_equipment_type_map(db, point_names), ), keyword, ), sort_by, ) safe_page_size = min(100, max(1, page_size)) total = len(rows) total_pages = max(1, ceil(total / safe_page_size)) start = (page - 1) * safe_page_size return PageResponse( page=page, page_size=safe_page_size, total=total, total_pages=total_pages, rows=[_to_schema_row(row) for row in rows[start : start + safe_page_size]], ) @router.get("/export") def export_usage_stats_excel( start_date: date | None = None, end_date: date | None = None, attendance_point_name: str = "", keyword: str = "", sort_by: str = Query("value", pattern="^(value|count)$"), user: Personnel = Depends(require_roles(Role.admin, Role.manager)), db: Session = Depends(get_db), ) -> Response: purge_expired_voided_reports(db) point_names = _requested_points(db, user, attendance_point_name) equipment_type_by_key = _equipment_type_map(db, point_names) reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() device_rows = _sort_usage_rows( _filter_usage_rows( build_usage_stats( reports=reports, category="device", equipment_type_by_key=equipment_type_by_key, ), keyword, ), sort_by, ) mold_rows = _sort_usage_rows( _filter_usage_rows( build_usage_stats( reports=reports, category="mold", equipment_type_by_key=equipment_type_by_key, ), keyword, ), sort_by, ) return _build_usage_stats_export_response(device_rows, mold_rows) @router.get("/detail", response_model=UsageStatsDetailOut) def usage_stats_detail( category: str = Query("device", pattern="^(device|mold)$"), id: str = "", metric_kind: str = "", start_date: date | None = None, end_date: date | None = None, attendance_point_name: str = "", name: str = "", product_name: str = "", process_name: str = "", stamping_method: str = "", user: Personnel = Depends(require_roles(Role.admin, Role.manager)), db: Session = Depends(get_db), ) -> UsageStatsDetailOut: purge_expired_voided_reports(db) point_names = _requested_points(db, user, attendance_point_name) equipment_type_by_key = _equipment_type_map(db, point_names) reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() target_args = { "id": _clean(id), "category": category, "attendance_point_name": _clean(attendance_point_name), "name": _clean(name), "product_name": _clean(product_name), "process_name": _clean(process_name), "stamping_method": _clean(stamping_method), "metric_kind": _clean(metric_kind), } rows = build_usage_stats( reports=reports, category=category, equipment_type_by_key=equipment_type_by_key, ) target = next((row for row in rows if _matches_target(row, **target_args)), None) if target is None: return UsageStatsDetailOut( id=target_args["id"], category=category, attendance_point_name=target_args["attendance_point_name"], name=target_args["name"], product_name=target_args["product_name"], process_name=target_args["process_name"], stamping_method=target_args["stamping_method"], metric_kind=target_args["metric_kind"], ) daily_rows: dict[date, UsageStatsDailyRow] = {} report_rows: list[UsageStatsReportRow] = [] for report in reports: report_matches = build_usage_stats( reports=[report], category=category, equipment_type_by_key=equipment_type_by_key, ) match = next((row for row in report_matches if _matches_target(row, **target_args)), None) if match is None: continue day = daily_rows.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date)) day.value = round2(day.value + match.value) day.report_count += match.report_count report_rows.append( UsageStatsReportRow( report_id=report.id, report_date=report.report_date, employee_phone=report.employee_phone, employee_name=report.employee.name if report.employee else "", display_name=target.name, value=match.value, report_count=match.report_count, ) ) return _to_detail_out( target, daily_rows=sorted(daily_rows.values(), key=lambda row: row.report_date), report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True), )