Files
pesco-ncr/backend/app/routers/reports.py
ang3l12 dea316b113 Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:41:22 -06:00

186 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, "07 days"), (8, 14, "814 days"), (15, 30, "1530 days"),
(31, 60, "3160 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],
)