Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2: - Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/ Material/Measurement/Environment), separate from Deviation Detail/Category - "Corrective Action Required?" Yes/No gate on every NCR with a required justification - Corrective action plan with owner + due date; owner is notified by email - Effectiveness verification (result, notes, server-stamped verifier/date) required before an NCR can close when corrective action is required — costing returns 409 listing the missing pieces - Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show a warning when later NCRs reference them - Dashboard metrics: % root cause completed, % CAPA verified effective, avg CAPA close time, overdue CAPA count, NCRs by root cause category - CAPA section in the NCR detail UI, printable PDF, CSV export, and the vw_ncr_full Power BI view; admin list manager for root cause categories - Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh); demo seed data exercises every metric Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
235 lines
8.7 KiB
Python
235 lines
8.7 KiB
Python
"""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, RootCauseCategory, 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()
|
||
}
|
||
rcc_names = {
|
||
c.id: c.name
|
||
for c in (await db.execute(select(RootCauseCategory))).scalars().all()
|
||
}
|
||
|
||
by_dept: dict[str, int] = defaultdict(int)
|
||
by_cat: dict[str, int] = defaultdict(int)
|
||
by_rcc: 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()
|
||
today = now.date()
|
||
|
||
# ── CAPA metrics (API Q1 §6.4.2) ─────────────────────────────────────────
|
||
root_cause_done = 0
|
||
ca_required_count = 0
|
||
ca_verified_effective = 0
|
||
capa_close_days: list[float] = []
|
||
overdue_capa = 0
|
||
|
||
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.root_cause or "").strip():
|
||
root_cause_done += 1
|
||
if n.root_cause_category_id is not None:
|
||
by_rcc[rcc_names.get(n.root_cause_category_id, "?")] += 1
|
||
if n.corrective_action_required:
|
||
ca_required_count += 1
|
||
effective = n.effectiveness_result == "effective"
|
||
if effective:
|
||
ca_verified_effective += 1
|
||
if n.corrective_action_opened_at and n.effectiveness_verified_at:
|
||
capa_close_days.append(
|
||
(
|
||
n.effectiveness_verified_at - n.corrective_action_opened_at
|
||
).total_seconds()
|
||
/ 86400
|
||
)
|
||
elif n.corrective_action_due_date and n.corrective_action_due_date < today:
|
||
overdue_capa += 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,
|
||
root_cause_pct=(
|
||
round(100 * root_cause_done / len(ncrs), 1) if ncrs else None
|
||
),
|
||
effectiveness_verified_pct=(
|
||
round(100 * ca_verified_effective / ca_required_count, 1)
|
||
if ca_required_count
|
||
else None
|
||
),
|
||
avg_capa_close_days=(
|
||
round(sum(capa_close_days) / len(capa_close_days), 2)
|
||
if capa_close_days
|
||
else None
|
||
),
|
||
overdue_capa_count=overdue_capa,
|
||
by_root_cause_category=sorted(
|
||
(CountByName(name=k, count=v) for k, v in by_rcc.items()),
|
||
key=lambda x: -x.count,
|
||
),
|
||
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],
|
||
)
|