"""NCR endpoints: creation, queues/search, stage actions (the workflow state machine), attachments, audit history, CSV export, and the printable PDF. Every stage action re-validates BOTH the caller's role and the NCR's current stage server-side; the frontend's `available_actions` hints are advisory only. """ import csv import io import logging from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, Response, StreamingResponse from sqlalchemy import delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.deps import CurrentUser, get_current_user, require_roles from app.database import get_db from app.domain import STAGE_LABELS, Role, Stage from app.models import ( Attachment, Department, DeviationCategory, JobInfo, Ncr, NcrLink, NcrSecondaryAssignee, RootCauseCategory, User, UserRole, ) from app.models.base import utcnow from app.schemas.ncr import ( AttachmentOut, AuditEntryOut, AuditListOut, CapaIn, CostingIn, InitialDispositionIn, InspectionIn, JobInfoOut, NcrCreateIn, NcrDetailOut, NcrLinkOut, NcrListItem, NcrListOut, NcrMutationOut, ReopenIn, SecondaryDispositionIn, TransitionOut, ) from app.schemas.user import UserRef from app.services.audit import apply_field_updates, audit_event from app.services.job_lookup import get_job_lookup_service from app.services.notifications import NotifyEvent, send_stage_notification from app.services.numbering import allocate_ncr_number from app.services.sanitize import sanitize_html from app.services.storage import ( UploadValidationError, attachment_abs_path, save_attachment, ) from app.services.workflow import InvalidTransitionError, record_creation, transition logger = logging.getLogger(__name__) router = APIRouter(tags=["ncrs"]) _STAGE_ORDER = [ Stage.NEW_REQUEST, Stage.SECONDARY_DISPOSITION, Stage.OPERATIONS, Stage.QC_INSPECTION, Stage.COSTING, Stage.CLOSED, ] # ── helpers ────────────────────────────────────────────────────────────────── async def _get_ncr(db: AsyncSession, ref: str | int) -> Ncr: """Resolve an NCR by its business identifier (e.g. NCR-2026-0021, case-insensitive) or — for backward compatibility with older links — by numeric database id.""" ref = str(ref).strip() if ref.isdigit(): ncr = await db.get(Ncr, int(ref)) else: ncr = ( await db.execute(select(Ncr).where(Ncr.ncr_number == ref.upper())) ).scalar_one_or_none() if ncr is None: raise HTTPException(status_code=404, detail="NCR not found.") return ncr def _days_in_stage(ncr: Ncr) -> int: return max(0, (utcnow() - ncr.stage_entered_at).days) def _ensure_stage(ncr: Ncr, expected: Stage) -> None: if ncr.stage == Stage.CLOSED.value and expected != Stage.CLOSED: raise HTTPException( status_code=409, detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.", ) if ncr.stage != expected.value: raise HTTPException( status_code=409, detail=( f"{ncr.ncr_number} is in stage '{STAGE_LABELS[Stage(ncr.stage)]}', " f"but this action requires '{STAGE_LABELS[expected]}'." ), ) 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) if stage == Stage.NEW_REQUEST and current.has_role(Role.DISPOSITION_AUTHORITY): actions.append("initial_disposition") if stage == Stage.SECONDARY_DISPOSITION and ( current.is_admin or _is_secondary_assignee(ncr, current) ): actions.append("secondary_disposition") if stage == Stage.OPERATIONS and current.has_role(Role.OPERATIONS): actions.append("operations_complete") if stage == Stage.QC_INSPECTION and current.has_role(Role.QC_INSPECTOR): actions.append("inspection") if stage == Stage.COSTING and current.has_role(Role.COSTING): 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 actions.append("view_audit") return actions 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, job_number=ncr.job_number, created_at=ncr.created_at, stage=stage.value, stage_label=STAGE_LABELS[stage], stage_entered_at=ncr.stage_entered_at, days_in_stage=_days_in_stage(ncr), department=ncr.department.name, department_id=ncr.department_id, deviation_category=ncr.deviation_category.name, deviation_category_id=ncr.deviation_category_id, deviation_detail=ncr.deviation_detail, requester=UserRef.model_validate(ncr.requester), disposition_authority=UserRef.model_validate(ncr.disposition_authority), qc_authority=ncr.qc_authority, work_order=ncr.work_order, disposition_notes=ncr.disposition_notes, secondary_review_needed=ncr.secondary_review_needed, secondary_authorities=[ UserRef.model_validate(u) for u in ncr.secondary_authorities ], operations_complete=ncr.operations_complete, operations_completed_at=ncr.operations_completed_at, operations_completed_by=( UserRef.model_validate(ncr.operations_completed_by) if ncr.operations_completed_by else None ), qc_approval=ncr.qc_approval, inspection_notes=ncr.inspection_notes, qc_closed=ncr.qc_closed, qc_closed_at=ncr.qc_closed_at, 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, other_cost=ncr.other_cost, total_cost=ncr.total_cost, costing_completed_at=ncr.costing_completed_at, costing_completed_by=( UserRef.model_validate(ncr.costing_completed_by) if ncr.costing_completed_by else None ), closed_at=ncr.closed_at, closed_by=UserRef.model_validate(ncr.closed_by) if ncr.closed_by else None, job_info=JobInfoOut.model_validate(ncr.job_info) if ncr.job_info else None, attachments=[AttachmentOut.model_validate(a) for a in ncr.attachments], transitions=[TransitionOut.model_validate(t) for t in ncr.transitions], available_actions=_available_actions(ncr, current), ) async def _refetch(db: AsyncSession, ncr_id: int) -> Ncr: """Reload the NCR with fresh relationship collections after a commit.""" db.expire_all() return await _get_ncr(db, ncr_id) # ── create ─────────────────────────────────────────────────────────────────── @router.post("/ncrs", response_model=NcrMutationOut, status_code=201) async def create_ncr( payload: NcrCreateIn, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Stage 1 — New Request. Open to every authenticated user.""" dept = await db.get(Department, payload.department_id) if dept is None or not dept.is_active: raise HTTPException(status_code=422, detail="Unknown or inactive department.") cat = await db.get(DeviationCategory, payload.deviation_category_id) if cat is None or not cat.is_active: raise HTTPException(status_code=422, detail="Unknown or inactive deviation category.") authority = await db.get(User, payload.disposition_authority_id) if ( authority is None or not authority.is_active or Role.DISPOSITION_AUTHORITY.value not in authority.roles ): raise HTTPException( status_code=422, detail="Selected disposition authority does not hold the Disposition Authority role.", ) # External enrichment BEFORE the numbering lock so a slow ERP lookup can # never serialize submissions. NullJobLookupService returns instantly. job_info_data = None try: job_info_data = await get_job_lookup_service().lookup(payload.job_number) except Exception: logger.exception("Job lookup failed for %s (non-blocking)", payload.job_number) ncr_number, year, seq = await allocate_ncr_number(db) ncr = Ncr( ncr_number=ncr_number, ncr_year=year, ncr_seq=seq, job_number=payload.job_number.strip(), department_id=payload.department_id, deviation_category_id=payload.deviation_category_id, disposition_authority_id=payload.disposition_authority_id, deviation_detail=payload.deviation_detail, requester_id=current.id, stage=Stage.NEW_REQUEST.value, ) db.add(ncr) await db.flush() record_creation(db, ncr, current.id) if job_info_data is not None: db.add( JobInfo( ncr_id=ncr.id, part_id=job_info_data.part_id, part_description=job_info_data.part_description, customer_name=job_info_data.customer_name, work_order_status=job_info_data.work_order_status, source=job_info_data.source, ) ) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.CREATED, current, f"{current.user.display_name} submitted a new NCR and selected you as the " "disposition authority.", ) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) # ── queues / search / export ──────────────────────────────────────────────── def _apply_filters( stmt, *, q: str | None, job_number: str | None, department_id: int | None, category_id: int | None, stage: str | None, date_from: str | None, date_to: str | None, disposition_authority_id: int | None, ): if q: like = f"%{q.strip()}%" stmt = stmt.where(or_(Ncr.ncr_number.like(like), Ncr.job_number.like(like))) if job_number: stmt = stmt.where(Ncr.job_number.like(f"%{job_number.strip()}%")) if department_id: stmt = stmt.where(Ncr.department_id == department_id) if category_id: stmt = stmt.where(Ncr.deviation_category_id == category_id) if stage: stmt = stmt.where(Ncr.stage == stage) 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 disposition_authority_id: stmt = stmt.where(Ncr.disposition_authority_id == disposition_authority_id) return stmt def _queue_filter(stmt, queue: str, current: CurrentUser): if queue == "my_requests": return stmt.where(Ncr.requester_id == current.id) if queue == "new_requests": return stmt.where(Ncr.stage == Stage.NEW_REQUEST.value) if queue == "secondary": return stmt.where( Ncr.stage == Stage.SECONDARY_DISPOSITION.value, Ncr.id.in_( select(NcrSecondaryAssignee.ncr_id).where( NcrSecondaryAssignee.user_id == current.id ) ), ) if queue == "operations": return stmt.where(Ncr.stage == Stage.OPERATIONS.value) if queue == "inspection": return stmt.where(Ncr.stage == Stage.QC_INSPECTION.value) if queue == "costing": return stmt.where(Ncr.stage == Stage.COSTING.value) if queue == "recently_closed": return stmt.where(Ncr.stage == Stage.CLOSED.value) if queue in ("all", ""): return stmt raise HTTPException(status_code=422, detail=f"Unknown queue '{queue}'.") def _list_item(ncr: Ncr) -> NcrListItem: return NcrListItem( id=ncr.id, ncr_number=ncr.ncr_number, job_number=ncr.job_number, department=ncr.department.name, deviation_category=ncr.deviation_category.name, requester=ncr.requester.display_name, disposition_authority=ncr.disposition_authority.display_name, stage=ncr.stage, stage_label=STAGE_LABELS[Stage(ncr.stage)], days_in_stage=_days_in_stage(ncr), created_at=ncr.created_at, ) @router.get("/ncrs", response_model=NcrListOut) async def list_ncrs( queue: str = Query(default="all"), q: str | None = None, job_number: str | None = None, department_id: int | None = None, category_id: int | None = None, stage: str | None = None, date_from: str | None = Query(default=None, description="YYYY-MM-DD"), date_to: str | None = Query(default=None, description="YYYY-MM-DD"), disposition_authority_id: int | None = None, page: int = Query(default=1, ge=1), page_size: int = Query(default=25, ge=1, le=200), current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> NcrListOut: stmt = select(Ncr) stmt = _queue_filter(stmt, queue, current) stmt = _apply_filters( stmt, q=q, job_number=job_number, department_id=department_id, category_id=category_id, stage=stage, date_from=date_from, date_to=date_to, disposition_authority_id=disposition_authority_id, ) total = ( await db.execute(select(func.count()).select_from(stmt.subquery())) ).scalar_one() order = Ncr.closed_at.desc() if queue == "recently_closed" else Ncr.created_at.desc() rows = ( (await db.execute(stmt.order_by(order).offset((page - 1) * page_size).limit(page_size))) .scalars() .unique() .all() ) return NcrListOut( items=[_list_item(n) for n in rows], total=total, page=page, page_size=page_size ) _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", "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", ] @router.get("/ncrs/export.csv") async def export_ncrs_csv( queue: str = Query(default="all"), q: str | None = None, job_number: str | None = None, department_id: int | None = None, category_id: int | None = None, stage: str | None = None, date_from: str | None = None, date_to: str | None = None, disposition_authority_id: int | None = None, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> StreamingResponse: """CSV export of any queue/search view (same filters as GET /ncrs).""" stmt = select(Ncr) stmt = _queue_filter(stmt, queue, current) stmt = _apply_filters( stmt, q=q, job_number=job_number, department_id=department_id, category_id=category_id, stage=stage, date_from=date_from, date_to=date_to, disposition_authority_id=disposition_authority_id, ) rows = ( (await db.execute(stmt.order_by(Ncr.created_at.desc()).limit(20000))) .scalars() .unique() .all() ) buf = io.StringIO() writer = csv.writer(buf) writer.writerow(_CSV_COLUMNS) for n in rows: writer.writerow( [ n.ncr_number, n.job_number, n.department.name, n.deviation_category.name, n.requester.display_name, n.disposition_authority.display_name, 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.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 "", ] ) buf.seek(0) return StreamingResponse( iter([buf.getvalue()]), media_type="text/csv", headers={"Content-Disposition": 'attachment; filename="ncr-export.csv"'}, ) @router.get("/ncrs/{ncr_ref}", response_model=NcrDetailOut) async def get_ncr( ncr_ref: str, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> NcrDetailOut: ncr = await _get_ncr(db, ncr_ref) return await _detail(db, ncr, current) # ── stage actions ──────────────────────────────────────────────────────────── @router.post("/ncrs/{ncr_ref}/initial-disposition", response_model=NcrMutationOut) async def initial_disposition( ncr_ref: str, payload: InitialDispositionIn, current: CurrentUser = Depends(require_roles(Role.DISPOSITION_AUTHORITY)), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Stage 2 — Initial Disposition, performed on a New Request. Routes to Secondary Disposition (when secondary review is needed) or Operations.""" ncr = await _get_ncr(db, ncr_ref) _ensure_stage(ncr, Stage.NEW_REQUEST) assignees: list[User] = [] if payload.secondary_review_needed: if not payload.secondary_authority_ids: raise HTTPException( status_code=422, detail="Secondary review requires at least one person in 'Notify These People'.", ) for uid in set(payload.secondary_authority_ids): u = await db.get(User, uid) if ( u is None or not u.is_active or Role.SECONDARY_DISPOSITION_AUTHORITY.value not in u.roles ): raise HTTPException( status_code=422, detail="All selected people must hold the Secondary Disposition Authority role.", ) assignees.append(u) updates = payload.model_dump(exclude_unset=True, exclude={"secondary_authority_ids"}) if "disposition_notes" in updates: updates["disposition_notes"] = sanitize_html(updates["disposition_notes"]) updates["secondary_review_needed"] = payload.secondary_review_needed apply_field_updates(db, ncr, current.id, updates, action="initial_disposition") if payload.secondary_review_needed: await db.execute( delete(NcrSecondaryAssignee).where(NcrSecondaryAssignee.ncr_id == ncr.id) ) for u in assignees: db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=u.id)) audit_event( db, ncr_id=ncr.id, user_id=current.id, action="initial_disposition", field_name="secondary_authorities", new_value=", ".join(u.display_name for u in assignees), ) _do_transition(db, ncr, Stage.SECONDARY_DISPOSITION, "initial_disposition", current) event, summary = ( NotifyEvent.SECONDARY_ASSIGNED, f"{current.user.display_name} completed initial disposition and assigned " "you for secondary disposition review.", ) else: _do_transition(db, ncr, Stage.OPERATIONS, "initial_disposition", current) event, summary = ( NotifyEvent.RELEASED_TO_OPERATIONS, f"{current.user.display_name} completed initial disposition; the NCR is " "ready for Operations.", ) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification(db, ncr, event, current, summary) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) @router.post("/ncrs/{ncr_ref}/secondary-disposition", response_model=NcrMutationOut) async def secondary_disposition( ncr_ref: str, payload: SecondaryDispositionIn, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Stage 3 — Secondary Disposition. Only the assigned secondary authorities (or an Admin) may update or release to Operations.""" ncr = await _get_ncr(db, ncr_ref) _ensure_stage(ncr, Stage.SECONDARY_DISPOSITION) if not (current.is_admin or _is_secondary_assignee(ncr, current)): raise HTTPException( status_code=403, detail="Only the assigned secondary disposition authority can act on this NCR.", ) updates = payload.model_dump(exclude_unset=True, exclude={"release"}) if "disposition_notes" in updates: updates["disposition_notes"] = sanitize_html(updates["disposition_notes"]) apply_field_updates(db, ncr, current.id, updates, action="secondary_disposition") warnings: list[str] = [] if payload.release: _do_transition(db, ncr, Stage.OPERATIONS, "secondary_release", current) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.RELEASED_TO_OPERATIONS, current, f"{current.user.display_name} completed secondary disposition review and " "released the NCR to Operations.", ) else: await db.commit() ncr = await _refetch(db, ncr.id) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) @router.post("/ncrs/{ncr_ref}/operations-complete", response_model=NcrMutationOut) async def operations_complete( ncr_ref: str, current: CurrentUser = Depends(require_roles(Role.OPERATIONS)), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Stage 4 — Operations marks rework complete; NCR moves to QC Inspection.""" ncr = await _get_ncr(db, ncr_ref) _ensure_stage(ncr, Stage.OPERATIONS) apply_field_updates( db, ncr, current.id, { "operations_complete": True, "operations_completed_at": utcnow(), "operations_completed_by_id": current.id, }, action="operations_complete", ) _do_transition(db, ncr, Stage.QC_INSPECTION, "operations_complete", current) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.OPERATIONS_COMPLETE, current, f"{current.user.display_name} marked operations complete; the NCR is ready " "for QC inspection.", ) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) @router.post("/ncrs/{ncr_ref}/inspection", response_model=NcrMutationOut) async def inspection( ncr_ref: str, payload: InspectionIn, current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Stage 5 — QC Inspection. QC can save repeatedly; checking QC Closed advances the NCR to Costing.""" ncr = await _get_ncr(db, ncr_ref) _ensure_stage(ncr, Stage.QC_INSPECTION) updates = payload.model_dump(exclude_unset=True, exclude={"qc_closed"}) if payload.qc_closed: updates.update( {"qc_closed": True, "qc_closed_at": utcnow(), "qc_closed_by_id": current.id} ) apply_field_updates(db, ncr, current.id, updates, action="inspection") warnings: list[str] = [] if payload.qc_closed: _do_transition(db, ncr, Stage.COSTING, "qc_close", current) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.QC_CLOSED, current, f"{current.user.display_name} closed QC inspection; the NCR is awaiting costing.", ) else: await db.commit() ncr = await _refetch(db, ncr.id) 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) async def costing( ncr_ref: str, payload: CostingIn, 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 — 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, ncr, current.id, { "labor_cost": payload.labor_cost, "material_cost": payload.material_cost, "service_cost": payload.service_cost, "other_cost": payload.other_cost, "costing_completed_at": now, "costing_completed_by_id": current.id, "closed_at": now, "closed_by_id": current.id, }, action="costing", ) _do_transition(db, ncr, Stage.CLOSED, "complete_costing", current) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.CLOSED, current, f"Costing is complete and your NCR has been closed. Total cost of " f"nonconformance: ${ncr.total_cost:,.2f}.", ) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) @router.post("/ncrs/{ncr_ref}/reopen", response_model=NcrMutationOut) async def reopen( ncr_ref: str, payload: ReopenIn, current: CurrentUser = Depends(require_roles(Role.ADMIN)), db: AsyncSession = Depends(get_db), ) -> NcrMutationOut: """Admin-only: reopen a closed NCR into a chosen prior stage. The reason is required and recorded in the audit trail and transition history.""" ncr = await _get_ncr(db, ncr_ref) if ncr.stage != Stage.CLOSED.value: raise HTTPException(status_code=409, detail="Only closed NCRs can be reopened.") if payload.to_stage == Stage.SECONDARY_DISPOSITION and not ncr.secondary_assignee_rows: raise HTTPException( status_code=422, detail="This NCR has no secondary authorities assigned; reopen it to " "New Request so a disposition authority can assign them.", ) target_idx = _STAGE_ORDER.index(payload.to_stage) resets: dict = {"closed_at": None, "closed_by_id": None} if target_idx <= _STAGE_ORDER.index(Stage.OPERATIONS): resets.update( { "operations_complete": False, "operations_completed_at": None, "operations_completed_by_id": None, } ) if target_idx <= _STAGE_ORDER.index(Stage.QC_INSPECTION): resets.update({"qc_closed": False, "qc_closed_at": None, "qc_closed_by_id": None}) if target_idx <= _STAGE_ORDER.index(Stage.COSTING): resets.update({"costing_completed_at": None, "costing_completed_by_id": None}) apply_field_updates(db, ncr, current.id, resets, action="reopen") _do_transition( db, ncr, payload.to_stage, "reopen", current, note=f"Reopen reason: {payload.reason}" ) await db.commit() ncr = await _refetch(db, ncr.id) warnings = await send_stage_notification( db, ncr, NotifyEvent.REOPENED, current, f"{current.user.display_name} reopened this NCR to " f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}", ) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings) def _do_transition( db: AsyncSession, ncr: Ncr, to_stage: Stage, action: str, current: CurrentUser, note: str | None = None, ) -> None: try: transition(db, ncr, to_stage, action=action, actor_id=current.id, note=note) except InvalidTransitionError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc # ── attachments ────────────────────────────────────────────────────────────── @router.post("/ncrs/{ncr_ref}/attachments", response_model=list[AttachmentOut], status_code=201) async def upload_attachments( ncr_ref: str, files: list[UploadFile], current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[AttachmentOut]: """Photo/file attachments (multiple per request; camera capture on tablets posts here too). Blocked once the NCR is closed.""" ncr = await _get_ncr(db, ncr_ref) if ncr.stage == Stage.CLOSED.value: raise HTTPException( status_code=409, detail="This NCR is closed; attachments are locked." ) if not files: raise HTTPException(status_code=422, detail="No files provided.") saved: list[Attachment] = [] for f in files: try: meta = await save_attachment(f, ncr.id) except UploadValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc att = Attachment(ncr_id=ncr.id, uploaded_by_id=current.id, **meta) db.add(att) audit_event( db, ncr_id=ncr.id, user_id=current.id, action="attachment_add", field_name="attachments", new_value=meta["original_filename"], detail=f"{meta['size_bytes']} bytes, {meta['content_type']}", ) saved.append(att) await db.commit() for att in saved: await db.refresh(att) return [AttachmentOut.model_validate(a) for a in saved] @router.get("/attachments/{attachment_id}/download") async def download_attachment( attachment_id: int, _: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> FileResponse: att = await db.get(Attachment, attachment_id) if att is None: raise HTTPException(status_code=404, detail="Attachment not found.") path = attachment_abs_path(att.stored_path) if not path.is_file(): raise HTTPException(status_code=404, detail="Attachment file missing from storage.") return FileResponse( path, media_type=att.content_type, filename=att.original_filename, content_disposition_type="inline" if att.is_image else "attachment", ) # ── audit history ──────────────────────────────────────────────────────────── @router.get("/ncrs/{ncr_ref}/audit", response_model=AuditListOut) async def ncr_audit( ncr_ref: str, current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)), db: AsyncSession = Depends(get_db), ) -> AuditListOut: """Audit History tab — Admin and QC roles.""" from app.models import AuditLog ncr = await _get_ncr(db, ncr_ref) rows = ( ( await db.execute( select(AuditLog) .where(AuditLog.ncr_id == ncr.id) .order_by(AuditLog.created_at.desc(), AuditLog.id.desc()) ) ) .scalars() .all() ) return AuditListOut( items=[AuditEntryOut.model_validate(r) for r in rows], total=len(rows) ) # ── printable PDF ──────────────────────────────────────────────────────────── @router.get("/ncrs/{ncr_ref}/pdf") async def ncr_pdf( ncr_ref: str, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: """Clean single-document rendering of the complete NCR for hard-copy travelers and audits.""" from app.services.pdf import render_ncr_pdf ncr = await _get_ncr(db, ncr_ref) 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", headers={ "Content-Disposition": f'inline; filename="{ncr.ncr_number}.pdf"' }, )