"""Built-in reports: counts, cost of nonconformance, aging, cycle times, top jobs. All queries respect the shared date-range/department/category filters.""" from collections import defaultdict from decimal import Decimal from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.deps import CurrentUser, get_current_user from app.database import get_db from app.domain import STAGE_LABELS, Stage from app.models import Department, DeviationCategory, Ncr, StageTransition from app.models.base import utcnow from app.schemas.report import ( AgingBucket, CostByMonth, CountByMonth, CountByName, ReportsSummaryOut, StageCycleTime, TopJob, ) router = APIRouter(tags=["reports"]) _AGING_BUCKETS = [(0, 7, "0–7 days"), (8, 14, "8–14 days"), (15, 30, "15–30 days"), (31, 60, "31–60 days"), (61, None, "60+ days")] def _base_filters(stmt, date_from, date_to, department_id, category_id): if date_from: stmt = stmt.where(Ncr.created_at >= date_from) if date_to: stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59") if department_id: stmt = stmt.where(Ncr.department_id == department_id) if category_id: stmt = stmt.where(Ncr.deviation_category_id == category_id) return stmt @router.get("/reports/summary", response_model=ReportsSummaryOut) async def reports_summary( date_from: str | None = Query(default=None, description="YYYY-MM-DD"), date_to: str | None = Query(default=None, description="YYYY-MM-DD"), department_id: int | None = None, category_id: int | None = None, _: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ReportsSummaryOut: filters = dict( date_from=date_from, date_to=date_to, department_id=department_id, category_id=category_id, ) # Load the filtered NCR set once; aggregate in Python. NCR volume is a few # thousand rows a year, so this stays cheap and keeps the SQL portable. ncrs = ( (await db.execute(_base_filters(select(Ncr), **filters))).scalars().unique().all() ) dept_names = { d.id: d.name for d in (await db.execute(select(Department))).scalars().all() } cat_names = { c.id: c.name for c in (await db.execute(select(DeviationCategory))).scalars().all() } by_dept: dict[str, int] = defaultdict(int) by_cat: dict[str, int] = defaultdict(int) by_month: dict[str, int] = defaultdict(int) cost_by_month: dict[str, dict[str, Decimal]] = defaultdict( lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)} ) aging_counts: dict[str, int] = {label: 0 for _, _, label in _AGING_BUCKETS} job_counts: dict[str, int] = defaultdict(int) total_cost = Decimal(0) open_count = 0 closed_count = 0 now = utcnow() for n in ncrs: by_dept[dept_names.get(n.department_id, "?")] += 1 by_cat[cat_names.get(n.deviation_category_id, "?")] += 1 by_month[n.created_at.strftime("%Y-%m")] += 1 job_counts[n.job_number] += 1 if n.stage == Stage.CLOSED.value: closed_count += 1 month = (n.closed_at or n.created_at).strftime("%Y-%m") bucket = cost_by_month[month] bucket["labor"] += n.labor_cost or 0 bucket["material"] += n.material_cost or 0 bucket["service"] += n.service_cost or 0 bucket["other"] += n.other_cost or 0 total_cost += n.total_cost or 0 else: open_count += 1 days = max(0, (now - n.stage_entered_at).days) for lo, hi, label in _AGING_BUCKETS: if days >= lo and (hi is None or days <= hi): aging_counts[label] += 1 break # ── cycle times from the transition history ───────────────────────────── ncr_ids = [n.id for n in ncrs] stage_durations: dict[str, list[float]] = defaultdict(list) end_to_end: list[float] = [] if ncr_ids: transitions = ( ( await db.execute( select(StageTransition) .where(StageTransition.ncr_id.in_(ncr_ids)) .order_by(StageTransition.ncr_id, StageTransition.acted_at) ) ) .scalars() .all() ) per_ncr: dict[int, list[StageTransition]] = defaultdict(list) for t in transitions: per_ncr[t.ncr_id].append(t) for items in per_ncr.values(): for prev, nxt in zip(items, items[1:]): delta_days = (nxt.acted_at - prev.acted_at).total_seconds() / 86400 stage_durations[prev.to_stage].append(delta_days) first, last = items[0], items[-1] if last.to_stage == Stage.CLOSED.value: end_to_end.append( (last.acted_at - first.acted_at).total_seconds() / 86400 ) cycle_times = [ StageCycleTime( stage=s.value, stage_label=STAGE_LABELS[s], avg_days=round(sum(v) / len(v), 2), samples=len(v), ) for s in Stage if s != Stage.CLOSED and (v := stage_durations.get(s.value)) ] months = sorted(set(by_month) | set(cost_by_month)) return ReportsSummaryOut( total_ncrs=len(ncrs), open_ncrs=open_count, closed_ncrs=closed_count, total_cost=total_cost, by_department=sorted( (CountByName(name=k, count=v) for k, v in by_dept.items()), key=lambda x: -x.count, ), by_category=sorted( (CountByName(name=k, count=v) for k, v in by_cat.items()), key=lambda x: -x.count, ), by_month=[CountByMonth(month=m, count=by_month.get(m, 0)) for m in months], cost_over_time=[ CostByMonth( month=m, labor=c["labor"], material=c["material"], service=c["service"], other=c["other"], total=c["labor"] + c["material"] + c["service"] + c["other"], ) for m in months if (c := cost_by_month.get(m)) ], aging=[AgingBucket(bucket=label, count=aging_counts[label]) for _, _, label in _AGING_BUCKETS], cycle_times=cycle_times, end_to_end_avg_days=( round(sum(end_to_end) / len(end_to_end), 2) if end_to_end else None ), top_jobs=sorted( (TopJob(job_number=j, count=c) for j, c in job_counts.items()), key=lambda x: -x.count, )[:10], )