Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
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>
This commit is contained in:
@@ -14,6 +14,7 @@ from app.models import (
|
||||
Department,
|
||||
DeviationCategory,
|
||||
Ncr,
|
||||
RootCauseCategory,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@@ -178,6 +179,39 @@ async def patch_category(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/root-cause-categories", response_model=list[NamedLookupOut])
|
||||
async def list_root_cause_categories(
|
||||
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
rows = (
|
||||
(await db.execute(select(RootCauseCategory).order_by(RootCauseCategory.name)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/root-cause-categories", response_model=NamedLookupOut, status_code=201)
|
||||
async def create_root_cause_category(
|
||||
payload: LookupCreateIn,
|
||||
current: CurrentUser = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _create_lookup(RootCauseCategory, "Root cause category", payload, current, db)
|
||||
|
||||
|
||||
@router.patch("/root-cause-categories/{item_id}", response_model=NamedLookupOut)
|
||||
async def patch_root_cause_category(
|
||||
item_id: int,
|
||||
payload: LookupPatchIn,
|
||||
current: CurrentUser = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _patch_lookup(
|
||||
RootCauseCategory, "Root cause category", item_id, payload, current, db
|
||||
)
|
||||
|
||||
|
||||
async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
|
||||
exists = (
|
||||
await db.execute(select(model).where(model.name == payload.name.strip()))
|
||||
|
||||
@@ -4,41 +4,34 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import Department, DeviationCategory
|
||||
from app.models import Department, DeviationCategory, RootCauseCategory
|
||||
from app.schemas.lookup import LookupsOut, NamedLookupOut
|
||||
|
||||
router = APIRouter(tags=["lookups"])
|
||||
|
||||
|
||||
async def _active(db: AsyncSession, model) -> list[NamedLookupOut]:
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(model).where(model.is_active.is_(True)).order_by(model.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/lookups", response_model=LookupsOut)
|
||||
async def get_lookups(
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LookupsOut:
|
||||
"""Active departments and deviation categories for form dropdowns."""
|
||||
departments = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Department)
|
||||
.where(Department.is_active.is_(True))
|
||||
.order_by(Department.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
categories = (
|
||||
(
|
||||
await db.execute(
|
||||
select(DeviationCategory)
|
||||
.where(DeviationCategory.is_active.is_(True))
|
||||
.order_by(DeviationCategory.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
"""Active departments, deviation categories, and root cause categories
|
||||
for form dropdowns."""
|
||||
return LookupsOut(
|
||||
departments=[NamedLookupOut.model_validate(d) for d in departments],
|
||||
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories],
|
||||
departments=await _active(db, Department),
|
||||
deviation_categories=await _active(db, DeviationCategory),
|
||||
root_cause_categories=await _active(db, RootCauseCategory),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,9 @@ from app.models import (
|
||||
DeviationCategory,
|
||||
JobInfo,
|
||||
Ncr,
|
||||
NcrLink,
|
||||
NcrSecondaryAssignee,
|
||||
RootCauseCategory,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@@ -31,12 +33,14 @@ from app.schemas.ncr import (
|
||||
AttachmentOut,
|
||||
AuditEntryOut,
|
||||
AuditListOut,
|
||||
CapaIn,
|
||||
CostingIn,
|
||||
InitialDispositionIn,
|
||||
InspectionIn,
|
||||
JobInfoOut,
|
||||
NcrCreateIn,
|
||||
NcrDetailOut,
|
||||
NcrLinkOut,
|
||||
NcrListItem,
|
||||
NcrListOut,
|
||||
NcrMutationOut,
|
||||
@@ -111,6 +115,12 @@ def _is_secondary_assignee(ncr: Ncr, current: CurrentUser) -> bool:
|
||||
return any(row.user_id == current.id for row in ncr.secondary_assignee_rows)
|
||||
|
||||
|
||||
def _can_edit_capa(ncr: Ncr, current: CurrentUser) -> bool:
|
||||
return ncr.stage != Stage.CLOSED.value and current.has_role(
|
||||
Role.QC_INSPECTOR, Role.DISPOSITION_AUTHORITY
|
||||
)
|
||||
|
||||
|
||||
def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
actions: list[str] = []
|
||||
stage = Stage(ncr.stage)
|
||||
@@ -128,6 +138,8 @@ def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
actions.append("costing")
|
||||
if stage == Stage.CLOSED and current.is_admin:
|
||||
actions.append("reopen")
|
||||
if _can_edit_capa(ncr, current):
|
||||
actions.append("capa")
|
||||
if stage != Stage.CLOSED:
|
||||
actions.append("add_attachment")
|
||||
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically
|
||||
@@ -135,8 +147,38 @@ def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
return actions
|
||||
|
||||
|
||||
def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
async def _ncr_links(db: AsyncSession, ncr_id: int) -> tuple[list[NcrLinkOut], list[NcrLinkOut]]:
|
||||
"""Outgoing links (prior NCRs this one references) and incoming links
|
||||
(later NCRs that flagged this one as a recurrence)."""
|
||||
|
||||
async def _load(join_col, where_col) -> list[NcrLinkOut]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Ncr.id, Ncr.ncr_number, Ncr.job_number, Ncr.stage)
|
||||
.join(NcrLink, join_col == Ncr.id)
|
||||
.where(where_col == ncr_id)
|
||||
.order_by(Ncr.ncr_number)
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
NcrLinkOut(
|
||||
id=r.id,
|
||||
ncr_number=r.ncr_number,
|
||||
job_number=r.job_number,
|
||||
stage=r.stage,
|
||||
stage_label=STAGE_LABELS[Stage(r.stage)],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
related = await _load(NcrLink.related_ncr_id, NcrLink.ncr_id)
|
||||
referenced_by = await _load(NcrLink.ncr_id, NcrLink.related_ncr_id)
|
||||
return related, referenced_by
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
stage = Stage(ncr.stage)
|
||||
related_ncrs, referenced_by = await _ncr_links(db, ncr.id)
|
||||
return NcrDetailOut(
|
||||
id=ncr.id,
|
||||
ncr_number=ncr.ncr_number,
|
||||
@@ -174,6 +216,32 @@ def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
qc_closed_by=(
|
||||
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None
|
||||
),
|
||||
root_cause=ncr.root_cause,
|
||||
root_cause_category=(
|
||||
ncr.root_cause_category.name if ncr.root_cause_category else None
|
||||
),
|
||||
root_cause_category_id=ncr.root_cause_category_id,
|
||||
corrective_action_required=ncr.corrective_action_required,
|
||||
corrective_action_justification=ncr.corrective_action_justification,
|
||||
corrective_action_plan=ncr.corrective_action_plan,
|
||||
corrective_action_owner=(
|
||||
UserRef.model_validate(ncr.corrective_action_owner)
|
||||
if ncr.corrective_action_owner
|
||||
else None
|
||||
),
|
||||
corrective_action_due_date=ncr.corrective_action_due_date,
|
||||
corrective_action_opened_at=ncr.corrective_action_opened_at,
|
||||
effectiveness_result=ncr.effectiveness_result,
|
||||
effectiveness_notes=ncr.effectiveness_notes,
|
||||
effectiveness_verified_at=ncr.effectiveness_verified_at,
|
||||
effectiveness_verified_by=(
|
||||
UserRef.model_validate(ncr.effectiveness_verified_by)
|
||||
if ncr.effectiveness_verified_by
|
||||
else None
|
||||
),
|
||||
is_recurring=ncr.is_recurring,
|
||||
related_ncrs=related_ncrs,
|
||||
referenced_by=referenced_by,
|
||||
labor_cost=ncr.labor_cost,
|
||||
material_cost=ncr.material_cost,
|
||||
service_cost=ncr.service_cost,
|
||||
@@ -271,7 +339,7 @@ async def create_ncr(
|
||||
f"{current.user.display_name} submitted a new NCR and selected you as the "
|
||||
"disposition authority.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
# ── queues / search / export ────────────────────────────────────────────────
|
||||
@@ -398,8 +466,10 @@ _CSV_COLUMNS = [
|
||||
"ncr_number", "job_number", "department", "deviation_category", "requester",
|
||||
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
|
||||
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
|
||||
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost",
|
||||
"total_cost", "closed_at",
|
||||
"qc_closed", "root_cause_category", "corrective_action_required",
|
||||
"corrective_action_owner", "corrective_action_due_date", "effectiveness_result",
|
||||
"effectiveness_verified_at", "is_recurring", "labor_cost", "material_cost",
|
||||
"service_cost", "other_cost", "total_cost", "closed_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -449,7 +519,15 @@ async def export_ncrs_csv(
|
||||
STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
|
||||
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "",
|
||||
n.secondary_review_needed, n.operations_complete, n.qc_approval or "",
|
||||
n.qc_closed, n.labor_cost or "", n.material_cost or "",
|
||||
n.qc_closed,
|
||||
n.root_cause_category.name if n.root_cause_category else "",
|
||||
"" if n.corrective_action_required is None else n.corrective_action_required,
|
||||
n.corrective_action_owner.display_name if n.corrective_action_owner else "",
|
||||
n.corrective_action_due_date.isoformat() if n.corrective_action_due_date else "",
|
||||
n.effectiveness_result or "",
|
||||
n.effectiveness_verified_at.isoformat(sep=" ") if n.effectiveness_verified_at else "",
|
||||
n.is_recurring,
|
||||
n.labor_cost or "", n.material_cost or "",
|
||||
n.service_cost or "", n.other_cost or "", n.total_cost or "",
|
||||
n.closed_at.isoformat(sep=" ") if n.closed_at else "",
|
||||
]
|
||||
@@ -469,7 +547,7 @@ async def get_ncr(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrDetailOut:
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
return _detail(ncr, current)
|
||||
return await _detail(db, ncr, current)
|
||||
|
||||
|
||||
# ── stage actions ────────────────────────────────────────────────────────────
|
||||
@@ -542,7 +620,7 @@ async def initial_disposition(
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
warnings = await send_stage_notification(db, ncr, event, current, summary)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/secondary-disposition", response_model=NcrMutationOut)
|
||||
@@ -583,7 +661,7 @@ async def secondary_disposition(
|
||||
else:
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/operations-complete", response_model=NcrMutationOut)
|
||||
@@ -618,7 +696,7 @@ async def operations_complete(
|
||||
f"{current.user.display_name} marked operations complete; the NCR is ready "
|
||||
"for QC inspection.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/inspection", response_model=NcrMutationOut)
|
||||
@@ -655,7 +733,192 @@ async def inspection(
|
||||
else:
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/capa", response_model=NcrMutationOut)
|
||||
async def update_capa(
|
||||
ncr_ref: str,
|
||||
payload: CapaIn,
|
||||
current: CurrentUser = Depends(
|
||||
require_roles(Role.QC_INSPECTOR, Role.DISPOSITION_AUTHORITY)
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrMutationOut:
|
||||
"""Corrective & Preventive Action (API Q1 §5.9.1.2 / §6.4.2). Editable in
|
||||
any non-closed stage by QC Inspectors, Disposition Authorities, or Admins;
|
||||
the costing action refuses to close the NCR until this section is
|
||||
complete (see _capa_close_blockers)."""
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
if ncr.stage == Stage.CLOSED.value:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.",
|
||||
)
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True, exclude={"related_ncr_ids"})
|
||||
|
||||
# The Yes/No gate always carries a brief justification (either in this
|
||||
# request or already on record).
|
||||
if updates.get("corrective_action_required") is not None:
|
||||
justification = updates.get(
|
||||
"corrective_action_justification", ncr.corrective_action_justification
|
||||
)
|
||||
if not (justification or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="A brief justification is required when answering "
|
||||
"'Corrective Action Required?'.",
|
||||
)
|
||||
|
||||
if updates.get("root_cause_category_id") is not None:
|
||||
cat = await db.get(RootCauseCategory, updates["root_cause_category_id"])
|
||||
if cat is None or not cat.is_active:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Unknown or inactive root cause category."
|
||||
)
|
||||
|
||||
if updates.get("corrective_action_owner_id") is not None:
|
||||
owner = await db.get(User, updates["corrective_action_owner_id"])
|
||||
if owner is None or not owner.is_active:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Corrective action owner must be an active user."
|
||||
)
|
||||
|
||||
ca_required = updates.get(
|
||||
"corrective_action_required", ncr.corrective_action_required
|
||||
)
|
||||
new_result = updates.get("effectiveness_result", ncr.effectiveness_result)
|
||||
if new_result is not None and ca_required is not True:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Answer 'Corrective Action Required? = Yes' before recording "
|
||||
"effectiveness verification.",
|
||||
)
|
||||
|
||||
# Server-stamped bookkeeping: who/when verified, and when the CAPA opened.
|
||||
if (
|
||||
"effectiveness_result" in updates
|
||||
and updates["effectiveness_result"] != ncr.effectiveness_result
|
||||
):
|
||||
if updates["effectiveness_result"] is None:
|
||||
updates.update(
|
||||
{"effectiveness_verified_at": None, "effectiveness_verified_by_id": None}
|
||||
)
|
||||
else:
|
||||
updates.update(
|
||||
{
|
||||
"effectiveness_verified_at": utcnow(),
|
||||
"effectiveness_verified_by_id": current.id,
|
||||
}
|
||||
)
|
||||
if (
|
||||
updates.get("corrective_action_required") is True
|
||||
and ncr.corrective_action_opened_at is None
|
||||
):
|
||||
updates["corrective_action_opened_at"] = utcnow()
|
||||
|
||||
changes = apply_field_updates(db, ncr, current.id, updates, action="capa")
|
||||
|
||||
if payload.related_ncr_ids is not None:
|
||||
if ncr.id in payload.related_ncr_ids:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="An NCR cannot be linked to itself."
|
||||
)
|
||||
new_ids = set(payload.related_ncr_ids)
|
||||
if new_ids:
|
||||
found = {
|
||||
r[0]
|
||||
for r in (
|
||||
await db.execute(select(Ncr.id).where(Ncr.id.in_(new_ids)))
|
||||
).all()
|
||||
}
|
||||
if new_ids - found:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="One or more linked NCRs do not exist."
|
||||
)
|
||||
old_ids = {
|
||||
r[0]
|
||||
for r in (
|
||||
await db.execute(
|
||||
select(NcrLink.related_ncr_id).where(NcrLink.ncr_id == ncr.id)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if new_ids != old_ids:
|
||||
numbers = {
|
||||
r[0]: r[1]
|
||||
for r in (
|
||||
await db.execute(
|
||||
select(Ncr.id, Ncr.ncr_number).where(
|
||||
Ncr.id.in_(new_ids | old_ids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
await db.execute(delete(NcrLink).where(NcrLink.ncr_id == ncr.id))
|
||||
for rid in sorted(new_ids):
|
||||
db.add(NcrLink(ncr_id=ncr.id, related_ncr_id=rid))
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=current.id,
|
||||
action="capa",
|
||||
field_name="related_ncrs",
|
||||
old_value=", ".join(numbers[i] for i in sorted(old_ids)) or None,
|
||||
new_value=", ".join(numbers[i] for i in sorted(new_ids)) or None,
|
||||
)
|
||||
|
||||
# Captured before commit: _refetch expires the session, and unlike stage
|
||||
# transitions nothing re-loads the acting user afterwards.
|
||||
actor_name = current.user.display_name
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
|
||||
warnings: list[str] = []
|
||||
if "corrective_action_owner_id" in changes and ncr.corrective_action_owner:
|
||||
await db.refresh(current.user) # notification internals read actor email
|
||||
due = (
|
||||
f" Due date: {ncr.corrective_action_due_date.isoformat()}."
|
||||
if ncr.corrective_action_due_date
|
||||
else ""
|
||||
)
|
||||
warnings = await send_stage_notification(
|
||||
db,
|
||||
ncr,
|
||||
NotifyEvent.CAPA_ASSIGNED,
|
||||
current,
|
||||
f"{actor_name} assigned you as the corrective action "
|
||||
f"owner for this NCR.{due}",
|
||||
)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
def _capa_close_blockers(ncr: Ncr) -> list[str]:
|
||||
"""What still blocks closure under the API Q1 §6.4.2 gate. Empty when the
|
||||
CA question is answered 'No' (with justification, enforced at entry) or
|
||||
answered 'Yes' with a complete, verified-effective action plan."""
|
||||
if ncr.corrective_action_required is None:
|
||||
return ["'Corrective Action Required?' has not been answered"]
|
||||
if not ncr.corrective_action_required:
|
||||
return []
|
||||
problems = []
|
||||
if not (ncr.root_cause or "").strip():
|
||||
problems.append("root cause is missing")
|
||||
if ncr.root_cause_category_id is None:
|
||||
problems.append("root cause category is not set")
|
||||
if not (ncr.corrective_action_plan or "").strip():
|
||||
problems.append("corrective action plan is missing")
|
||||
if ncr.corrective_action_owner_id is None:
|
||||
problems.append("no corrective action owner is assigned")
|
||||
if ncr.corrective_action_due_date is None:
|
||||
problems.append("no corrective action due date is set")
|
||||
if ncr.effectiveness_result != "effective":
|
||||
problems.append(
|
||||
"effectiveness verification has not confirmed the corrective action "
|
||||
"as effective"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/costing", response_model=NcrMutationOut)
|
||||
@@ -665,10 +928,19 @@ async def costing(
|
||||
current: CurrentUser = Depends(require_roles(Role.COSTING)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrMutationOut:
|
||||
"""Stage 6 — Costing. Saving costs completes the workflow and closes the NCR."""
|
||||
"""Stage 6 — Costing. Saving costs completes the workflow and closes the
|
||||
NCR — provided the CAPA section passes the API Q1 closure gate."""
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
_ensure_stage(ncr, Stage.COSTING)
|
||||
|
||||
blockers = _capa_close_blockers(ncr)
|
||||
if blockers:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"{ncr.ncr_number} cannot be closed: " + "; ".join(blockers) + ". "
|
||||
"Complete the CAPA section first.",
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
apply_field_updates(
|
||||
db,
|
||||
@@ -697,7 +969,7 @@ async def costing(
|
||||
f"Costing is complete and your NCR has been closed. Total cost of "
|
||||
f"nonconformance: ${ncr.total_cost:,.2f}.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/reopen", response_model=NcrMutationOut)
|
||||
@@ -747,7 +1019,7 @@ async def reopen(
|
||||
f"{current.user.display_name} reopened this NCR to "
|
||||
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
def _do_transition(
|
||||
@@ -865,7 +1137,8 @@ async def ncr_pdf(
|
||||
from app.services.pdf import render_ncr_pdf
|
||||
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
pdf_bytes = await render_ncr_pdf(ncr)
|
||||
related_ncrs, _ = await _ncr_links(db, ncr.id)
|
||||
pdf_bytes = await render_ncr_pdf(ncr, [r.ncr_number for r in related_ncrs])
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 import Department, DeviationCategory, Ncr, RootCauseCategory, StageTransition
|
||||
from app.models.base import utcnow
|
||||
from app.schemas.report import (
|
||||
AgingBucket,
|
||||
@@ -70,9 +70,14 @@ async def reports_summary(
|
||||
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)}
|
||||
@@ -83,12 +88,38 @@ async def reports_summary(
|
||||
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")
|
||||
@@ -152,6 +183,24 @@ async def reports_summary(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user