JhHardwareWRS_BackPoint/app/routers/usage_stats.py
2026-06-24 15:19:14 +08:00

335 lines
12 KiB
Python

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),
)