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>
This commit is contained in:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
"""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],
)