From c1cd1bc9dfbd549522454526ef7848d799dc7d8e Mon Sep 17 00:00:00 2001 From: ang3l12 Date: Tue, 4 Aug 2026 13:49:08 -0600 Subject: [PATCH] Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 16 +- backend/alembic/versions/0003_capa_fields.py | 112 +++++ .../versions/0004_capa_reporting_view.py | 160 ++++++ backend/app/models/__init__.py | 5 +- backend/app/models/lookups.py | 12 + backend/app/models/ncr.py | 55 ++- backend/app/routers/admin.py | 34 ++ backend/app/routers/lookups.py | 45 +- backend/app/routers/ncrs.py | 301 +++++++++++- backend/app/routers/reports.py | 51 +- backend/app/schemas/lookup.py | 1 + backend/app/schemas/ncr.py | 49 ++ backend/app/schemas/report.py | 6 + backend/app/seed.py | 68 ++- backend/app/services/notifications.py | 4 + backend/app/services/pdf.py | 7 +- backend/app/templates/ncr_pdf.html | 55 +++ backend/tests/conftest.py | 11 +- backend/tests/test_capa.py | 295 +++++++++++ backend/tests/test_permissions.py | 9 + backend/tests/test_state_machine.py | 24 +- backend/tests/util.py | 15 + frontend/src/api/hooks.ts | 3 +- frontend/src/api/types.ts | 31 ++ frontend/src/pages/CapaSection.tsx | 457 ++++++++++++++++++ frontend/src/pages/NcrDetailPage.tsx | 5 + frontend/src/pages/ReportsPage.tsx | 51 ++ frontend/src/pages/admin/AdminListsPage.tsx | 9 +- 28 files changed, 1830 insertions(+), 61 deletions(-) create mode 100644 backend/alembic/versions/0003_capa_fields.py create mode 100644 backend/alembic/versions/0004_capa_reporting_view.py create mode 100644 backend/tests/test_capa.py create mode 100644 frontend/src/pages/CapaSection.tsx diff --git a/README.md b/README.md index a495eec..0596134 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ to send mail (`backend/app/services/graph.py`). | Operations complete | all QC Inspectors | operations user | | QC closed | all Costing users | QC inspector | | NCR closed | original requester | costing user | +| Corrective action assigned | the CA owner | user who assigned it | | Admin reopen | owners of the target stage + requester | admin | Fault tolerance: a Graph failure **never blocks a workflow transition** — the @@ -186,9 +187,18 @@ Notes: People" users (their personal queue) and Admins; they may update disposition fields and release to Operations. - **QC Inspection** can be saved repeatedly until *QC Closed* advances it. -- Saving **Costing** (Labor/Material/Service/Other) closes the NCR. Closed - NCRs are fully read-only — including attachments — until an Admin reopens - them (required reason, recorded in the audit trail). +- The **CAPA section** (API Q1 §5.9.1.2 / §6.4.2) sits outside the stage + sequence: QC Inspectors, Disposition Authorities, and Admins can edit it at + any point before closure (`POST /ncrs/{ref}/capa`). It captures the root + cause (+ 6M root cause category), the *Corrective Action Required?* Yes/No + gate with justification, the action plan (owner + due date, owner is + notified), effectiveness verification (server-stamped verifier/date), and + the recurring-issue flag with links to prior NCRs. +- Saving **Costing** (Labor/Material/Service/Other) closes the NCR — but only + once the CAPA gate passes: the CA question must be answered, and when the + answer is Yes the plan must be complete and verified *effective* (HTTP 409 + otherwise). Closed NCRs are fully read-only — including attachments — until + an Admin reopens them (required reason, recorded in the audit trail). - Every stage change writes a `stage_transitions` row (timestamp + acting user) — the basis for the aging and cycle-time reports — and every field change writes an immutable `audit_log` row (before/after values). The app diff --git a/backend/alembic/versions/0003_capa_fields.py b/backend/alembic/versions/0003_capa_fields.py new file mode 100644 index 0000000..07bff85 --- /dev/null +++ b/backend/alembic/versions/0003_capa_fields.py @@ -0,0 +1,112 @@ +"""CAPA fields for API Q1 §5.9.1.2 / §6.4.2 + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-08-04 + +Adds root cause capture, the corrective-action gate (required? + +justification), the corrective action plan (owner + due date), +effectiveness verification, the recurring-issue flag with NCR-to-NCR +links, and the 6M root-cause category lookup (seeded here so upgraded +deployments have the standard values without re-running app.seed). +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + +MYSQL = {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"} + +SIX_M_CATEGORIES = ["Man", "Machine", "Method", "Material", "Measurement", "Environment"] + + +def upgrade() -> None: + op.create_table( + "root_cause_categories", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False, unique=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + **MYSQL, + ) + categories = sa.table( + "root_cause_categories", sa.column("name", sa.String), sa.column("is_active", sa.Boolean) + ) + op.bulk_insert(categories, [{"name": n, "is_active": True} for n in SIX_M_CATEGORIES]) + + op.create_table( + "ncr_links", + sa.Column( + "ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True + ), + sa.Column( + "related_ncr_id", + sa.Integer(), + sa.ForeignKey("ncrs.id", ondelete="CASCADE"), + primary_key=True, + ), + **MYSQL, + ) + + op.add_column("ncrs", sa.Column("root_cause", sa.Text(), nullable=True)) + op.add_column( + "ncrs", + sa.Column( + "root_cause_category_id", + sa.Integer(), + sa.ForeignKey("root_cause_categories.id"), + nullable=True, + ), + ) + op.add_column("ncrs", sa.Column("corrective_action_required", sa.Boolean(), nullable=True)) + op.add_column( + "ncrs", sa.Column("corrective_action_justification", sa.Text(), nullable=True) + ) + op.add_column("ncrs", sa.Column("corrective_action_plan", sa.Text(), nullable=True)) + op.add_column( + "ncrs", + sa.Column( + "corrective_action_owner_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True + ), + ) + op.add_column("ncrs", sa.Column("corrective_action_due_date", sa.Date(), nullable=True)) + op.add_column("ncrs", sa.Column("corrective_action_opened_at", sa.DateTime(), nullable=True)) + op.add_column("ncrs", sa.Column("effectiveness_result", sa.String(20), nullable=True)) + op.add_column("ncrs", sa.Column("effectiveness_notes", sa.Text(), nullable=True)) + op.add_column("ncrs", sa.Column("effectiveness_verified_at", sa.DateTime(), nullable=True)) + op.add_column( + "ncrs", + sa.Column( + "effectiveness_verified_by_id", + sa.Integer(), + sa.ForeignKey("users.id"), + nullable=True, + ), + ) + op.add_column( + "ncrs", + sa.Column("is_recurring", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + + +def downgrade() -> None: + for column in ( + "is_recurring", + "effectiveness_verified_by_id", + "effectiveness_verified_at", + "effectiveness_notes", + "effectiveness_result", + "corrective_action_opened_at", + "corrective_action_due_date", + "corrective_action_owner_id", + "corrective_action_plan", + "corrective_action_justification", + "corrective_action_required", + "root_cause_category_id", + "root_cause", + ): + op.drop_column("ncrs", column) + op.drop_table("ncr_links") + op.drop_table("root_cause_categories") diff --git a/backend/alembic/versions/0004_capa_reporting_view.py b/backend/alembic/versions/0004_capa_reporting_view.py new file mode 100644 index 0000000..06720b3 --- /dev/null +++ b/backend/alembic/versions/0004_capa_reporting_view.py @@ -0,0 +1,160 @@ +"""add CAPA fields to the vw_ncr_full reporting view + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-08-04 +""" +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + +VW_NCR_FULL_CAPA = """ +CREATE OR REPLACE VIEW vw_ncr_full AS +SELECT + n.id AS ncr_id, + n.ncr_number, + n.ncr_year, + n.ncr_seq, + n.created_at, + n.job_number, + d.name AS department, + dc.name AS deviation_category, + req.display_name AS requester, + req.email AS requester_email, + da.display_name AS disposition_authority, + n.stage, + n.stage_entered_at, + DATEDIFF(UTC_TIMESTAMP(), n.stage_entered_at) AS days_in_stage, + n.deviation_detail, + n.qc_authority, + n.work_order, + n.disposition_notes, + n.secondary_review_needed, + (SELECT GROUP_CONCAT(u2.display_name ORDER BY u2.display_name SEPARATOR '; ') + FROM ncr_secondary_assignees sa2 + JOIN users u2 ON u2.id = sa2.user_id + WHERE sa2.ncr_id = n.id) AS secondary_authorities, + n.operations_complete, + n.operations_completed_at, + opu.display_name AS operations_completed_by, + n.qc_approval, + n.inspection_notes, + n.qc_closed, + n.qc_closed_at, + qcu.display_name AS qc_closed_by, + n.root_cause, + rcc.name AS root_cause_category, + n.corrective_action_required, + n.corrective_action_justification, + n.corrective_action_plan, + cao.display_name AS corrective_action_owner, + n.corrective_action_due_date, + n.corrective_action_opened_at, + n.effectiveness_result, + n.effectiveness_notes, + n.effectiveness_verified_at, + evu.display_name AS effectiveness_verified_by, + n.is_recurring, + (SELECT GROUP_CONCAT(rn.ncr_number ORDER BY rn.ncr_number SEPARATOR '; ') + FROM ncr_links nl + JOIN ncrs rn ON rn.id = nl.related_ncr_id + WHERE nl.ncr_id = n.id) AS related_ncrs, + n.labor_cost, + n.material_cost, + n.service_cost, + n.other_cost, + COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0) + + COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost, + n.costing_completed_at, + n.closed_at, + clu.display_name AS closed_by, + ji.part_id, + ji.part_description, + ji.customer_name, + ji.work_order_status, + (SELECT COUNT(*) FROM attachments a WHERE a.ncr_id = n.id) AS attachment_count +FROM ncrs n +JOIN departments d ON d.id = n.department_id +JOIN deviation_categories dc ON dc.id = n.deviation_category_id +JOIN users req ON req.id = n.requester_id +JOIN users da ON da.id = n.disposition_authority_id +LEFT JOIN users opu ON opu.id = n.operations_completed_by_id +LEFT JOIN users qcu ON qcu.id = n.qc_closed_by_id +LEFT JOIN users clu ON clu.id = n.closed_by_id +LEFT JOIN root_cause_categories rcc ON rcc.id = n.root_cause_category_id +LEFT JOIN users cao ON cao.id = n.corrective_action_owner_id +LEFT JOIN users evu ON evu.id = n.effectiveness_verified_by_id +LEFT JOIN job_info ji ON ji.ncr_id = n.id +""" + + +# The 0002 definition, restored on downgrade. +VW_NCR_FULL_PREV = """ +CREATE OR REPLACE VIEW vw_ncr_full AS +SELECT + n.id AS ncr_id, + n.ncr_number, + n.ncr_year, + n.ncr_seq, + n.created_at, + n.job_number, + d.name AS department, + dc.name AS deviation_category, + req.display_name AS requester, + req.email AS requester_email, + da.display_name AS disposition_authority, + n.stage, + n.stage_entered_at, + DATEDIFF(UTC_TIMESTAMP(), n.stage_entered_at) AS days_in_stage, + n.deviation_detail, + n.qc_authority, + n.work_order, + n.disposition_notes, + n.secondary_review_needed, + (SELECT GROUP_CONCAT(u2.display_name ORDER BY u2.display_name SEPARATOR '; ') + FROM ncr_secondary_assignees sa2 + JOIN users u2 ON u2.id = sa2.user_id + WHERE sa2.ncr_id = n.id) AS secondary_authorities, + n.operations_complete, + n.operations_completed_at, + opu.display_name AS operations_completed_by, + n.qc_approval, + n.inspection_notes, + n.qc_closed, + n.qc_closed_at, + qcu.display_name AS qc_closed_by, + n.labor_cost, + n.material_cost, + n.service_cost, + n.other_cost, + COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0) + + COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost, + n.costing_completed_at, + n.closed_at, + clu.display_name AS closed_by, + ji.part_id, + ji.part_description, + ji.customer_name, + ji.work_order_status, + (SELECT COUNT(*) FROM attachments a WHERE a.ncr_id = n.id) AS attachment_count +FROM ncrs n +JOIN departments d ON d.id = n.department_id +JOIN deviation_categories dc ON dc.id = n.deviation_category_id +JOIN users req ON req.id = n.requester_id +JOIN users da ON da.id = n.disposition_authority_id +LEFT JOIN users opu ON opu.id = n.operations_completed_by_id +LEFT JOIN users qcu ON qcu.id = n.qc_closed_by_id +LEFT JOIN users clu ON clu.id = n.closed_by_id +LEFT JOIN job_info ji ON ji.ncr_id = n.id +""" + + +def upgrade() -> None: + op.execute(VW_NCR_FULL_CAPA) + + +def downgrade() -> None: + op.execute(VW_NCR_FULL_PREV) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 22a5b60..ab8d763 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,9 +1,10 @@ from app.models.base import Base from app.models.user import User, UserRole -from app.models.lookups import Department, DeviationCategory +from app.models.lookups import Department, DeviationCategory, RootCauseCategory from app.models.ncr import ( JobInfo, Ncr, + NcrLink, NcrSecondaryAssignee, NcrSequence, StageTransition, @@ -18,7 +19,9 @@ __all__ = [ "UserRole", "Department", "DeviationCategory", + "RootCauseCategory", "Ncr", + "NcrLink", "NcrSequence", "NcrSecondaryAssignee", "StageTransition", diff --git a/backend/app/models/lookups.py b/backend/app/models/lookups.py index ab02e28..661bf66 100644 --- a/backend/app/models/lookups.py +++ b/backend/app/models/lookups.py @@ -20,3 +20,15 @@ class DeviationCategory(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(100), unique=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + +class RootCauseCategory(Base): + """Root-cause classification for CAPA trend reporting (API Q1 §6.4.2). + Seeded with the standard 6M categories; admin-extensible like the other + lookups.""" + + __tablename__ = "root_cause_categories" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/models/ncr.py b/backend/app/models/ncr.py index 5e0ae3d..19c13ff 100644 --- a/backend/app/models/ncr.py +++ b/backend/app/models/ncr.py @@ -1,8 +1,9 @@ -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( Boolean, + Date, DateTime, ForeignKey, Index, @@ -77,6 +78,36 @@ class Ncr(Base): qc_closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) qc_closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + # ── Corrective & Preventive Action (API Q1 §5.9.1.2 / §6.4.2) ──────────── + # Editable in any non-closed stage via POST /ncrs/{ref}/capa; the costing + # action refuses to close the NCR until the CA question is answered and, + # when corrective action is required, verified effective. + root_cause: Mapped[str | None] = mapped_column(Text, nullable=True) + root_cause_category_id: Mapped[int | None] = mapped_column( + ForeignKey("root_cause_categories.id"), nullable=True + ) + corrective_action_required: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + corrective_action_justification: Mapped[str | None] = mapped_column(Text, nullable=True) + corrective_action_plan: Mapped[str | None] = mapped_column(Text, nullable=True) + corrective_action_owner_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + corrective_action_due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + # Stamped when the CA question is first answered "yes"; feeds the average + # CAPA close-time metric (opened → verified effective). + corrective_action_opened_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True + ) + effectiveness_result: Mapped[str | None] = mapped_column( + String(20), nullable=True + ) # effective | not_effective + effectiveness_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + effectiveness_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + effectiveness_verified_by_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + is_recurring: Mapped[bool] = mapped_column(Boolean, default=False) + # ── Costing ────────────────────────────────────────────────────────────── labor_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) material_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) @@ -94,6 +125,13 @@ class Ncr(Base): # ── Relationships ──────────────────────────────────────────────────────── department = relationship("Department", lazy="selectin") deviation_category = relationship("DeviationCategory", lazy="selectin") + root_cause_category = relationship("RootCauseCategory", lazy="selectin") + corrective_action_owner: Mapped[User | None] = relationship( + foreign_keys=[corrective_action_owner_id], lazy="selectin" + ) + effectiveness_verified_by: Mapped[User | None] = relationship( + foreign_keys=[effectiveness_verified_by_id], lazy="selectin" + ) requester: Mapped[User] = relationship(foreign_keys=[requester_id], lazy="selectin") disposition_authority: Mapped[User] = relationship( foreign_keys=[disposition_authority_id], lazy="selectin" @@ -152,6 +190,21 @@ class NcrSecondaryAssignee(Base): user: Mapped[User] = relationship(lazy="selectin") +class NcrLink(Base): + """Directed link from an NCR to a prior similar NCR (recurring-issue + tracking). Intentionally relationship-free: the API loads the light + {id, ncr_number, ...} projections it needs with explicit queries.""" + + __tablename__ = "ncr_links" + + ncr_id: Mapped[int] = mapped_column( + ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True + ) + related_ncr_id: Mapped[int] = mapped_column( + ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True + ) + + class StageTransition(Base): """One row per lifecycle event (create, stage change, reopen) — the basis for aging and cycle-time reporting.""" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2e330d2..ede88f3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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())) diff --git a/backend/app/routers/lookups.py b/backend/app/routers/lookups.py index 427835f..f3f03b7 100644 --- a/backend/app/routers/lookups.py +++ b/backend/app/routers/lookups.py @@ -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), ) diff --git a/backend/app/routers/ncrs.py b/backend/app/routers/ncrs.py index caad419..7ff1f08 100644 --- a/backend/app/routers/ncrs.py +++ b/backend/app/routers/ncrs.py @@ -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", diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py index aa575cc..7176604 100644 --- a/backend/app/routers/reports.py +++ b/backend/app/routers/reports.py @@ -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, diff --git a/backend/app/schemas/lookup.py b/backend/app/schemas/lookup.py index 58bcc7e..cb1e13f 100644 --- a/backend/app/schemas/lookup.py +++ b/backend/app/schemas/lookup.py @@ -21,3 +21,4 @@ class LookupPatchIn(BaseModel): class LookupsOut(BaseModel): departments: list[NamedLookupOut] deviation_categories: list[NamedLookupOut] + root_cause_categories: list[NamedLookupOut] diff --git a/backend/app/schemas/ncr.py b/backend/app/schemas/ncr.py index 47d2205..d057e6f 100644 --- a/backend/app/schemas/ncr.py +++ b/backend/app/schemas/ncr.py @@ -1,3 +1,4 @@ +from datetime import date from decimal import Decimal from typing import Annotated, Literal @@ -43,6 +44,27 @@ class InspectionIn(BaseModel): qc_closed: bool = False +class CapaIn(BaseModel): + """Corrective & Preventive Action section (API Q1 §5.9.1.2 / §6.4.2). + + Only fields present in the request body are updated (exclude_unset), so + partial saves from different roles never clobber each other's entries. + """ + + root_cause: str | None = Field(default=None, max_length=20000) + root_cause_category_id: int | None = None + corrective_action_required: bool | None = None + corrective_action_justification: str | None = Field(default=None, max_length=2000) + corrective_action_plan: str | None = Field(default=None, max_length=20000) + corrective_action_owner_id: int | None = None + corrective_action_due_date: date | None = None + effectiveness_result: Literal["effective", "not_effective"] | None = None + effectiveness_notes: str | None = Field(default=None, max_length=20000) + is_recurring: bool | None = None + # Replaces the full set of linked prior NCRs when present. + related_ncr_ids: list[int] | None = None + + class CostingIn(BaseModel): labor_cost: Money material_cost: Money @@ -91,6 +113,16 @@ class JobInfoOut(AppModel): source: str +class NcrLinkOut(BaseModel): + """Light projection of a linked NCR (recurring-issue tracking).""" + + id: int + ncr_number: str + job_number: str + stage: str + stage_label: str + + class NcrListItem(BaseModel): id: int ncr_number: str @@ -146,6 +178,23 @@ class NcrDetailOut(BaseModel): qc_closed_at: UTCDateTime | None qc_closed_by: UserRef | None + root_cause: str | None + root_cause_category: str | None + root_cause_category_id: int | None + corrective_action_required: bool | None + corrective_action_justification: str | None + corrective_action_plan: str | None + corrective_action_owner: UserRef | None + corrective_action_due_date: date | None + corrective_action_opened_at: UTCDateTime | None + effectiveness_result: str | None + effectiveness_notes: str | None + effectiveness_verified_at: UTCDateTime | None + effectiveness_verified_by: UserRef | None + is_recurring: bool + related_ncrs: list[NcrLinkOut] + referenced_by: list[NcrLinkOut] + labor_cost: Decimal | None material_cost: Decimal | None service_cost: Decimal | None diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 90f2ea2..8f4eede 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -44,6 +44,12 @@ class ReportsSummaryOut(BaseModel): open_ncrs: int closed_ncrs: int total_cost: Decimal + # ── CAPA metrics (API Q1 §6.4.2) ───────────────────────────────────────── + root_cause_pct: float | None # % of NCRs with a completed root cause + effectiveness_verified_pct: float | None # % of CA-required NCRs verified effective + avg_capa_close_days: float | None # CA opened → verified effective + overdue_capa_count: int # CA required, past due, not yet verified effective + by_root_cause_category: list[CountByName] by_department: list[CountByName] by_category: list[CountByName] by_month: list[CountByMonth] diff --git a/backend/app/seed.py b/backend/app/seed.py index b364e0c..f28788e 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -23,7 +23,9 @@ from app.models import ( Department, DeviationCategory, Ncr, + NcrLink, NcrSecondaryAssignee, + RootCauseCategory, StageTransition, User, UserRole, @@ -45,6 +47,19 @@ CATEGORIES = [ "Process Deviation", "Supplier Nonconformance", "Damage / Handling", "Other", ] +# Standard 6M root-cause categories (API Q1 §6.4.2 trend reporting). +ROOT_CAUSE_CATEGORIES = [ + "Man", "Machine", "Method", "Material", "Measurement", "Environment", +] + +ROOT_CAUSES = [ + "Operator used a superseded revision of the work instruction.", + "Fixture clamping force drifted out of spec; no PM interval defined.", + "Incoming material certified to the wrong specification revision.", + "Measurement performed with a gauge past its calibration due date.", + "Setup sheet did not call out the datum change from the ECN.", +] + DEV_USERS = [ ("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)), ("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]), @@ -85,6 +100,13 @@ async def seed() -> None: for name in CATEGORIES: if name not in existing: db.add(DeviationCategory(name=name, is_active=True)) + existing = { + c.name + for c in (await db.execute(select(RootCauseCategory))).scalars().all() + } + for name in ROOT_CAUSE_CATEGORIES: + if name not in existing: + db.add(RootCauseCategory(name=name, is_active=True)) if await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY) is None: db.add( @@ -124,7 +146,9 @@ async def seed() -> None: departments = (await db.execute(select(Department))).scalars().all() categories = (await db.execute(select(DeviationCategory))).scalars().all() + root_causes = (await db.execute(select(RootCauseCategory))).scalars().all() rng = random.Random(42) + seeded_ncrs: list[Ncr] = [] dispo = users["dispo@pescoinc.biz"] second = users["second@pescoinc.biz"] @@ -144,7 +168,7 @@ async def seed() -> None: ] for target, count in plan: - for _ in range(count): + for i in range(count): days_ago = rng.randint(5, 120) created = utcnow() - timedelta(days=days_ago) number, year, seq = await allocate_ncr_number(db, now=created) @@ -166,6 +190,7 @@ async def seed() -> None: ) db.add(ncr) await db.flush() + seeded_ncrs.append(ncr) t = created db.add(StageTransition( @@ -222,6 +247,38 @@ async def seed() -> None: ncr.qc_closed_by_id = qc.id advance(Stage.COSTING, "qc_close", qc) ncr.qc_closed_at = t + + # CAPA — the API Q1 closure gate requires the CA question to be + # answered (and any required action verified) before costing + # can close the NCR, so seed it here. + ncr.root_cause = rng.choice(ROOT_CAUSES) + ncr.root_cause_category_id = rng.choice(root_causes).id + # Alternate yes/no so every dashboard metric has data. + if i % 2 == 0 or target == Stage.COSTING: + ncr.corrective_action_required = True + ncr.corrective_action_justification = ( + "Recurrence risk without a process control update." + ) + ncr.corrective_action_plan = ( + "Update the work instruction and add an in-process check " + "at the affected operation." + ) + ncr.corrective_action_owner_id = ops.id + ncr.corrective_action_opened_at = t + ncr.corrective_action_due_date = (t + timedelta(days=14)).date() + if target == Stage.CLOSED: + ncr.effectiveness_result = "effective" + ncr.effectiveness_notes = ( + "No recurrence observed over three subsequent runs." + ) + ncr.effectiveness_verified_by_id = qc.id + ncr.effectiveness_verified_at = hop() + else: + ncr.corrective_action_required = False + ncr.corrective_action_justification = ( + "Isolated incident fully contained by the disposition." + ) + if target == Stage.COSTING: continue @@ -235,6 +292,15 @@ async def seed() -> None: ncr.costing_completed_at = t ncr.closed_at = t + # Recurring-issue demo: the newest open NCR references two closed ones. + closed = [n for n in seeded_ncrs if n.stage == Stage.CLOSED.value] + still_open = [n for n in seeded_ncrs if n.stage != Stage.CLOSED.value] + if closed and still_open: + repeat = still_open[0] + repeat.is_recurring = True + for prior in closed[:2]: + db.add(NcrLink(ncr_id=repeat.id, related_ncr_id=prior.id)) + await db.commit() log.info("Demo NCRs seeded. Done.") diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index a1c24c0..19498a8 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -32,6 +32,7 @@ class NotifyEvent(str, Enum): QC_CLOSED = "qc_closed" CLOSED = "closed" REOPENED = "reopened" + CAPA_ASSIGNED = "capa_assigned" _EVENT_SUBJECT = { @@ -42,6 +43,7 @@ _EVENT_SUBJECT = { NotifyEvent.QC_CLOSED: "QC closed — costing needed", NotifyEvent.CLOSED: "Your NCR has been closed", NotifyEvent.REOPENED: "NCR reopened by an administrator", + NotifyEvent.CAPA_ASSIGNED: "Corrective action assigned to you", } @@ -74,6 +76,8 @@ async def _recipients(db: AsyncSession, ncr: Ncr, event: NotifyEvent) -> list[st return await _role_emails(db, Role.COSTING) if event == NotifyEvent.CLOSED: return [ncr.requester.email] + if event == NotifyEvent.CAPA_ASSIGNED: + return [ncr.corrective_action_owner.email] if ncr.corrective_action_owner else [] if event == NotifyEvent.REOPENED: owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage)) emails = await _role_emails(db, owner_role) if owner_role else [] diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index e58645c..96a63af 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -23,7 +23,7 @@ _env = Environment( ) -def _render_html(ncr: Ncr) -> str: +def _render_html(ncr: Ncr, related_ncr_numbers: list[str]) -> str: images = [] other_files = [] for att in ncr.attachments: @@ -48,6 +48,7 @@ def _render_html(ncr: Ncr) -> str: Stage=Stage, images=images, other_files=other_files, + related_ncr_numbers=related_ncr_numbers, generated_at=utcnow(), ) @@ -58,7 +59,7 @@ def _html_to_pdf(html: str) -> bytes: return HTML(string=html).write_pdf() -async def render_ncr_pdf(ncr: Ncr) -> bytes: - html = _render_html(ncr) +async def render_ncr_pdf(ncr: Ncr, related_ncr_numbers: list[str] | None = None) -> bytes: + html = _render_html(ncr, related_ncr_numbers or []) # WeasyPrint rendering is CPU-bound; keep it off the event loop. return await anyio.to_thread.run_sync(partial(_html_to_pdf, html)) diff --git a/backend/app/templates/ncr_pdf.html b/backend/app/templates/ncr_pdf.html index 460be5a..66ab71e 100644 --- a/backend/app/templates/ncr_pdf.html +++ b/backend/app/templates/ncr_pdf.html @@ -134,6 +134,61 @@
{{ ncr.inspection_notes }}
{% endif %} +

Corrective & Preventive Action (API Q1)

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Root Cause Category{{ ncr.root_cause_category.name if ncr.root_cause_category else "—" }}Recurring Issue + {% if ncr.is_recurring %}Yes{% if related_ncr_numbers %} — + {{ related_ncr_numbers | join(", ") }}{% endif %} + {% else %}No{% endif %} +
Corrective Action Required + {% if ncr.corrective_action_required is none %}Not answered + {% elif ncr.corrective_action_required %}Yes{% else %}No{% endif %} + Justification{{ ncr.corrective_action_justification or "—" }}
Action Owner{{ ncr.corrective_action_owner.display_name if ncr.corrective_action_owner else "—" }}Due Date{{ ncr.corrective_action_due_date.strftime("%Y-%m-%d") if ncr.corrective_action_due_date else "—" }}
Effectiveness + {% if ncr.effectiveness_result == "effective" %}Verified Effective + {% elif ncr.effectiveness_result == "not_effective" %}Not Effective + {% else %}Pending verification{% endif %} + Verified By / At + {% if ncr.effectiveness_verified_by %} + {{ ncr.effectiveness_verified_by.display_name }} — + {{ ncr.effectiveness_verified_at.strftime("%Y-%m-%d %H:%M") }} UTC + {% else %}—{% endif %} +
+{% if ncr.root_cause %} +
Root Cause: {{ ncr.root_cause }}
+{% else %} +
No root cause recorded.
+{% endif %} +{% if ncr.corrective_action_plan %} +
Corrective Action Plan: {{ ncr.corrective_action_plan }}
+{% endif %} +{% if ncr.effectiveness_notes %} +
Verification Notes: {{ ncr.effectiveness_notes }}
+{% endif %} +

Costing

diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4bd9c69..6b0663e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,7 +28,14 @@ from httpx import ASGITransport, AsyncClient # noqa: E402 from app.database import get_engine, get_session_factory # noqa: E402 from app.main import app # noqa: E402 -from app.models import Base, Department, DeviationCategory, User, UserRole # noqa: E402 +from app.models import ( # noqa: E402 + Base, + Department, + DeviationCategory, + RootCauseCategory, + User, + UserRole, +) @pytest.fixture(scope="session", autouse=True) @@ -41,6 +48,8 @@ def _create_schema(): db.add(Department(name="Machining", is_active=True)) db.add(Department(name="Inactive Dept", is_active=False)) db.add(DeviationCategory(name="Dimensional", is_active=True)) + db.add(RootCauseCategory(name="Method", is_active=True)) + db.add(RootCauseCategory(name="Retired Cause", is_active=False)) await db.commit() asyncio.run(_run()) diff --git a/backend/tests/test_capa.py b/backend/tests/test_capa.py new file mode 100644 index 0000000..3fceb56 --- /dev/null +++ b/backend/tests/test_capa.py @@ -0,0 +1,295 @@ +"""CAPA section (API Q1 §5.9.1.2 / §6.4.2): field capture with audit, the +closure gate at costing, effectiveness verification, recurring-issue links, +role enforcement, and the report metrics.""" +from .util import ( + answer_capa_no, + create_ncr, + hdr, + to_closed, + to_costing, + user_id_by_email, +) + + +async def _capa(client, team, ncr_id: int, body: dict, as_user: str | None = None): + return await client.post( + f"/api/ncrs/{ncr_id}/capa", json=body, headers=hdr(as_user or team["qc"]) + ) + + +async def _lookup_root_cause_ids(client, email: str) -> dict[str, int]: + r = await client.get("/api/lookups", headers=hdr(email)) + assert r.status_code == 200, r.text + return {c["name"]: c["id"] for c in r.json()["root_cause_categories"]} + + +async def _full_capa_yes(client, team, ncr_id: int, *, verify: str | None = None): + """Answer 'CA required = yes' with a complete plan (optionally verified).""" + rcc = await _lookup_root_cause_ids(client, team["qc"]) + owner_id = await user_id_by_email(client, team["qc"], team["ops"], "operations") + body = { + "root_cause": "Fixture PM interval was never defined.", + "root_cause_category_id": rcc["Method"], + "corrective_action_required": True, + "corrective_action_justification": "Systemic cause; will recur without a fix.", + "corrective_action_plan": "Define quarterly PM for fixture; update WI-204.", + "corrective_action_owner_id": owner_id, + "corrective_action_due_date": "2026-09-01", + } + if verify is not None: + body["effectiveness_result"] = verify + return await _capa(client, team, ncr_id, body) + + +# ── roles & availability ───────────────────────────────────────────────────── +async def test_capa_requires_qc_or_disposition_role(client, team): + ncr = await create_ncr(client, team) + body = {"root_cause": "Some cause."} + + for user in (team["requester"], team["ops"], team["cost"]): + r = await _capa(client, team, ncr["id"], body, as_user=user) + assert r.status_code == 403, user + + for user in (team["qc"], team["dispo"], team["admin"]): + r = await _capa(client, team, ncr["id"], body, as_user=user) + assert r.status_code == 200, user + + # available_actions advertises capa only to those roles + r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["qc"])) + assert "capa" in r.json()["available_actions"] + r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["requester"])) + assert "capa" not in r.json()["available_actions"] + + +async def test_capa_locked_once_closed(client, team): + ncr = await to_closed(client, team, (await create_ncr(client, team))["id"]) + r = await _capa(client, team, ncr["id"], {"root_cause": "Too late."}) + assert r.status_code == 409 + assert "closed" in r.json()["detail"].lower() + + +# ── the CA question ────────────────────────────────────────────────────────── +async def test_ca_question_requires_justification(client, team): + ncr = await create_ncr(client, team) + + r = await _capa(client, team, ncr["id"], {"corrective_action_required": True}) + assert r.status_code == 422 + assert "justification" in r.json()["detail"].lower() + + r = await _capa( + client, + team, + ncr["id"], + { + "corrective_action_required": True, + "corrective_action_justification": "Repeat risk without process change.", + }, + ) + assert r.status_code == 200 + body = r.json()["ncr"] + assert body["corrective_action_required"] is True + # answering "yes" stamps the CAPA-opened timestamp + assert body["corrective_action_opened_at"] is not None + + # once a justification is on record, flipping the answer alone is fine + r = await _capa(client, team, ncr["id"], {"corrective_action_required": False}) + assert r.status_code == 200 + + # field-level audit rows were written + audit = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"])) + fields = {a["field_name"] for a in audit.json()["items"] if a["action"] == "capa"} + assert "corrective_action_required" in fields + assert "corrective_action_justification" in fields + + +async def test_capa_field_validation(client, team): + ncr = await create_ncr(client, team) + rcc = await _lookup_root_cause_ids(client, team["qc"]) + + # inactive/unknown root cause category rejected (lookups only lists active) + assert "Retired Cause" not in rcc + r = await _capa(client, team, ncr["id"], {"root_cause_category_id": 99999}) + assert r.status_code == 422 + + # unknown owner rejected + r = await _capa(client, team, ncr["id"], {"corrective_action_owner_id": 99999}) + assert r.status_code == 422 + + # effectiveness verification requires CA required = yes + r = await _capa(client, team, ncr["id"], {"effectiveness_result": "effective"}) + assert r.status_code == 422 + + +# ── the closure gate ───────────────────────────────────────────────────────── +async def test_close_blocked_until_ca_question_answered(client, team): + ncr = await create_ncr(client, team) + await to_costing(client, team, ncr["id"]) + + costing_body = { + "labor_cost": "10", "material_cost": "0", "service_cost": "0", "other_cost": "0", + } + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 409 + assert "Corrective Action Required" in r.json()["detail"] + + await answer_capa_no(client, team, ncr["id"]) + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 200 + assert r.json()["ncr"]["stage"] == "closed" + + +async def test_close_blocked_until_verified_effective(client, team): + ncr = await create_ncr(client, team) + await to_costing(client, team, ncr["id"]) + + r = await _full_capa_yes(client, team, ncr["id"]) # complete plan, unverified + assert r.status_code == 200, r.text + + costing_body = { + "labor_cost": "10", "material_cost": "0", "service_cost": "0", "other_cost": "0", + } + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 409 + assert "effectiveness" in r.json()["detail"].lower() + + # a failed verification does not satisfy the gate + r = await _capa(client, team, ncr["id"], {"effectiveness_result": "not_effective"}) + assert r.status_code == 200 + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 409 + + # verified effective → server-stamps who/when, and the NCR can close + r = await _capa(client, team, ncr["id"], {"effectiveness_result": "effective"}) + body = r.json()["ncr"] + assert body["effectiveness_verified_by"]["email"] == team["qc"] + assert body["effectiveness_verified_at"] is not None + + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 200 + assert r.json()["ncr"]["stage"] == "closed" + + +async def test_close_blocked_when_plan_incomplete(client, team): + ncr = await create_ncr(client, team) + await to_costing(client, team, ncr["id"]) + # CA = yes but no plan/owner/due date/root cause + r = await _capa( + client, + team, + ncr["id"], + { + "corrective_action_required": True, + "corrective_action_justification": "Needs a systemic fix.", + }, + ) + assert r.status_code == 200 + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", + json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"}, + headers=hdr(team["cost"]), + ) + assert r.status_code == 409 + detail = r.json()["detail"] + for fragment in ("root cause", "plan", "owner", "due date"): + assert fragment in detail, detail + + +# ── recurring-issue links ──────────────────────────────────────────────────── +async def test_recurring_issue_links(client, team): + prior = await create_ncr(client, team) + ncr = await create_ncr(client, team) + + # self-link and unknown ids rejected + r = await _capa( + client, team, ncr["id"], {"is_recurring": True, "related_ncr_ids": [ncr["id"]]} + ) + assert r.status_code == 422 + r = await _capa(client, team, ncr["id"], {"related_ncr_ids": [999999]}) + assert r.status_code == 422 + + r = await _capa( + client, + team, + ncr["id"], + {"is_recurring": True, "related_ncr_ids": [prior["id"]]}, + ) + assert r.status_code == 200 + body = r.json()["ncr"] + assert body["is_recurring"] is True + assert [l["ncr_number"] for l in body["related_ncrs"]] == [prior["ncr_number"]] + + # the prior NCR shows the reverse reference + r = await client.get(f"/api/ncrs/{prior['id']}", headers=hdr(team["requester"])) + assert [l["ncr_number"] for l in r.json()["referenced_by"]] == [ncr["ncr_number"]] + + # clearing the links removes them + r = await _capa(client, team, ncr["id"], {"related_ncr_ids": []}) + assert r.json()["ncr"]["related_ncrs"] == [] + + +# ── metrics ────────────────────────────────────────────────────────────────── +async def test_capa_metrics_in_reports_summary(client, team): + ncr = await create_ncr(client, team) + rcc = await _lookup_root_cause_ids(client, team["qc"]) + owner_id = await user_id_by_email(client, team["qc"], team["ops"], "operations") + + # an overdue CAPA: required, past due, not verified effective + r = await _capa( + client, + team, + ncr["id"], + { + "root_cause": "Gauge past calibration due date.", + "root_cause_category_id": rcc["Method"], + "corrective_action_required": True, + "corrective_action_justification": "Calibration program gap.", + "corrective_action_plan": "Add gauge to the calibration recall system.", + "corrective_action_owner_id": owner_id, + "corrective_action_due_date": "2020-01-01", + }, + ) + assert r.status_code == 200, r.text + + r = await client.get("/api/reports/summary", headers=hdr(team["qc"])) + assert r.status_code == 200 + data = r.json() + assert data["overdue_capa_count"] >= 1 + assert data["root_cause_pct"] is not None and data["root_cause_pct"] > 0 + # this CA-required NCR is not verified, so the pct must be < 100 when present + if data["effectiveness_verified_pct"] is not None: + assert data["effectiveness_verified_pct"] < 100 + assert any(c["name"] == "Method" for c in data["by_root_cause_category"]) + + # close a verified-effective CAPA and the avg close time appears + ncr2 = await create_ncr(client, team) + await to_costing(client, team, ncr2["id"]) + r = await _full_capa_yes(client, team, ncr2["id"], verify="effective") + assert r.status_code == 200, r.text + r = await client.get("/api/reports/summary", headers=hdr(team["qc"])) + assert r.json()["avg_capa_close_days"] is not None + + +async def test_csv_export_includes_capa_columns(client, team): + await create_ncr(client, team) + r = await client.get("/api/ncrs/export.csv", headers=hdr(team["qc"])) + assert r.status_code == 200 + header = r.text.splitlines()[0] + for col in ( + "root_cause_category", + "corrective_action_required", + "corrective_action_owner", + "corrective_action_due_date", + "effectiveness_result", + "is_recurring", + ): + assert col in header diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index eced2b2..d1fd8e7 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -73,6 +73,15 @@ async def test_admin_can_act_at_every_stage(client, team): headers=hdr(team["admin"]), ) assert r.status_code == 200 + r = await client.post( + f"/api/ncrs/{ncr['id']}/capa", + json={ + "corrective_action_required": False, + "corrective_action_justification": "Isolated incident; no systemic cause.", + }, + headers=hdr(team["admin"]), + ) + assert r.status_code == 200 r = await client.post( f"/api/ncrs/{ncr['id']}/costing", json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"}, diff --git a/backend/tests/test_state_machine.py b/backend/tests/test_state_machine.py index 1765025..4cac52c 100644 --- a/backend/tests/test_state_machine.py +++ b/backend/tests/test_state_machine.py @@ -1,6 +1,7 @@ """Workflow state machine: happy paths, invalid transitions, closure locking, admin reopen, and rich-text sanitization.""" from .util import ( + answer_capa_no, create_ncr, do_initial_disposition, hdr, @@ -47,15 +48,22 @@ async def test_full_lifecycle_direct_to_operations(client, team): assert body["stage"] == "costing" assert body["qc_closed"] is True + # the API Q1 CAPA gate blocks closure until the CA question is answered + costing_body = { + "labor_cost": "100.00", + "material_cost": "50.25", + "service_cost": "0", + "other_cost": "10", + } r = await client.post( - f"/api/ncrs/{ncr['id']}/costing", - json={ - "labor_cost": "100.00", - "material_cost": "50.25", - "service_cost": "0", - "other_cost": "10", - }, - headers=hdr(team["cost"]), + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) + ) + assert r.status_code == 409 + assert "Corrective Action Required" in r.json()["detail"] + await answer_capa_no(client, team, ncr["id"]) + + r = await client.post( + f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"]) ) body = r.json()["ncr"] assert body["stage"] == "closed" diff --git a/backend/tests/util.py b/backend/tests/util.py index 8b3e5f0..0207b3c 100644 --- a/backend/tests/util.py +++ b/backend/tests/util.py @@ -92,8 +92,23 @@ async def to_costing(client: AsyncClient, team: dict, ncr_id: int) -> dict: return r.json()["ncr"] +async def answer_capa_no(client: AsyncClient, team: dict, ncr_id: int) -> dict: + """Answer the API Q1 corrective-action gate with 'No' so the NCR can close.""" + r = await client.post( + f"/api/ncrs/{ncr_id}/capa", + json={ + "corrective_action_required": False, + "corrective_action_justification": "Isolated incident; contained by disposition.", + }, + headers=hdr(team["qc"]), + ) + assert r.status_code == 200, r.text + return r.json()["ncr"] + + async def to_closed(client: AsyncClient, team: dict, ncr_id: int) -> dict: await to_costing(client, team, ncr_id) + await answer_capa_no(client, team, ncr_id) r = await client.post( f"/api/ncrs/{ncr_id}/costing", json={ diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index fb91c0f..2c0af6d 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -35,10 +35,11 @@ export function useLookups() { }); } +/** Pass an empty role to list every active user (e.g. CA owner picker). */ export function useUsersByRole(role: string) { return useQuery({ queryKey: ["users", role], - queryFn: () => api(`/api/users?role=${role}`), + queryFn: () => api(role ? `/api/users?role=${role}` : "/api/users"), staleTime: 60_000, }); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6441a9a..85256e5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -71,6 +71,7 @@ export interface NamedLookup { export interface LookupsOut { departments: NamedLookup[]; deviation_categories: NamedLookup[]; + root_cause_categories: NamedLookup[]; } export interface AttachmentOut { @@ -128,10 +129,19 @@ export type NcrAction = | "operations_complete" | "inspection" | "costing" + | "capa" | "reopen" | "add_attachment" | "view_audit"; +export interface NcrLinkOut { + id: number; + ncr_number: string; + job_number: string; + stage: StageValue; + stage_label: string; +} + export interface NcrDetail { id: number; ncr_number: string; @@ -161,6 +171,22 @@ export interface NcrDetail { qc_closed: boolean; qc_closed_at: string | null; qc_closed_by: UserRef | null; + root_cause: string | null; + root_cause_category: string | null; + root_cause_category_id: number | null; + corrective_action_required: boolean | null; + corrective_action_justification: string | null; + corrective_action_plan: string | null; + corrective_action_owner: UserRef | null; + corrective_action_due_date: string | null; + corrective_action_opened_at: string | null; + effectiveness_result: "effective" | "not_effective" | null; + effectiveness_notes: string | null; + effectiveness_verified_at: string | null; + effectiveness_verified_by: UserRef | null; + is_recurring: boolean; + related_ncrs: NcrLinkOut[]; + referenced_by: NcrLinkOut[]; labor_cost: string | null; material_cost: string | null; service_cost: string | null; @@ -213,6 +239,11 @@ export interface ReportsSummary { open_ncrs: number; closed_ncrs: number; total_cost: string; + root_cause_pct: number | null; + effectiveness_verified_pct: number | null; + avg_capa_close_days: number | null; + overdue_capa_count: number; + by_root_cause_category: { name: string; count: number }[]; by_department: { name: string; count: number }[]; by_category: { name: string; count: number }[]; by_month: { month: string; count: number }[]; diff --git a/frontend/src/pages/CapaSection.tsx b/frontend/src/pages/CapaSection.tsx new file mode 100644 index 0000000..5687fe4 --- /dev/null +++ b/frontend/src/pages/CapaSection.tsx @@ -0,0 +1,457 @@ +/** Corrective & Preventive Action section (API Q1 §5.9.1.2 / §6.4.2). + * + * Read-only summary for everyone; QC Inspectors / Disposition Authorities / + * Admins (server action "capa") get the editable form. Partial saves are + * allowed at any non-closed stage — the API enforces the closure gate when + * Costing tries to close the NCR. */ +import SaveIcon from "@mui/icons-material/Save"; +import { + Alert, + Autocomplete, + Button, + Checkbox, + Chip, + CircularProgress, + Divider, + FormControlLabel, + Grid, + MenuItem, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { api } from "../api/client"; +import { useLookups, useNcrMutation, useQueue } from "../api/hooks"; +import type { NcrDetail, NcrLinkOut, NcrMutationOut, UserOut } from "../api/types"; +import { FieldRow } from "../components/FieldRow"; +import { useToast } from "../components/Toast"; +import { UserPicker } from "../components/UserPicker"; + +function NcrChips({ links }: { links: NcrLinkOut[] }) { + return ( + + {links.map((l) => ( + + ))} + + ); +} + +function EffectivenessChip({ ncr }: { ncr: NcrDetail }) { + if (ncr.effectiveness_result === "effective") + return ; + if (ncr.effectiveness_result === "not_effective") + return ; + return ; +} + +/** Mirrors the server's _capa_close_blockers so users see the closure gate + * status before Costing runs into it. */ +function gateBlockers(ncr: NcrDetail): string[] { + if (ncr.corrective_action_required === null) + return ["'Corrective Action Required?' has not been answered"]; + if (!ncr.corrective_action_required) return []; + const missing: string[] = []; + if (!ncr.root_cause?.trim()) missing.push("root cause"); + if (!ncr.root_cause_category_id) missing.push("root cause category"); + if (!ncr.corrective_action_plan?.trim()) missing.push("action plan"); + if (!ncr.corrective_action_owner) missing.push("action owner"); + if (!ncr.corrective_action_due_date) missing.push("due date"); + if (ncr.effectiveness_result !== "effective") + missing.push("effectiveness verification (verified effective)"); + return missing; +} + +function GateStatus({ ncr }: { ncr: NcrDetail }) { + if (ncr.stage === "closed") return null; + const blockers = gateBlockers(ncr); + if (blockers.length === 0) + return ( + + CAPA section complete — the closure gate is satisfied. + + ); + return ( + + Before this NCR can be closed: {blockers.join(", ")}. + + ); +} + +function fmt(iso: string | null): string { + return iso ? new Date(iso).toLocaleString() : "—"; +} + +function CapaSummary({ ncr }: { ncr: NcrDetail }) { + return ( + + + {ncr.corrective_action_required === null + ? "Not answered" + : ncr.corrective_action_required + ? "Yes" + : "No"} + + {ncr.corrective_action_justification} + {ncr.root_cause_category} + + {ncr.corrective_action_owner?.display_name} + + {ncr.corrective_action_due_date} + + + + {ncr.effectiveness_verified_by && ( + + {ncr.effectiveness_verified_by.display_name},{" "} + {fmt(ncr.effectiveness_verified_at)} + + )} + + + {ncr.root_cause && ( + + + Root Cause + + {ncr.root_cause} + + )} + {ncr.corrective_action_plan && ( + + + Corrective Action Plan + + + {ncr.corrective_action_plan} + + + )} + {ncr.effectiveness_notes && ( + + + Verification Notes + + + {ncr.effectiveness_notes} + + + )} + {ncr.is_recurring ? "Yes" : "No"} + {ncr.related_ncrs.length > 0 && ( + + + Linked Prior NCRs + + + + )} + + ); +} + +/** Multi-select of other NCRs, searched by NCR/job number. */ +function NcrLinkPicker({ + selfId, + value, + onChange, +}: { + selfId: number; + value: NcrLinkOut[]; + onChange: (v: NcrLinkOut[]) => void; +}) { + const [search, setSearch] = useState(""); + const results = useQueue("all", { q: search || undefined }, 1, 10); + const options = useMemo(() => { + const items = (results.data?.items ?? []) + .filter((i) => i.id !== selfId) + .map((i) => ({ + id: i.id, + ncr_number: i.ncr_number, + job_number: i.job_number, + stage: i.stage, + stage_label: i.stage_label, + })); + // Keep already-selected values valid options so MUI can render them. + const seen = new Set(items.map((i) => i.id)); + return [...value.filter((v) => !seen.has(v.id)), ...items]; + }, [results.data, selfId, value]); + + return ( + x} + onChange={(_, v) => onChange(v)} + onInputChange={(_, v, reason) => { + if (reason === "input") setSearch(v); + }} + getOptionLabel={(o) => `${o.ncr_number} — ${o.job_number}`} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + ); +} + +function CapaForm({ ncr }: { ncr: NcrDetail }) { + const { warnings, toast } = useToast(); + const lookups = useLookups(); + + const [rootCause, setRootCause] = useState(ncr.root_cause ?? ""); + const [categoryId, setCategoryId] = useState( + ncr.root_cause_category_id ?? "", + ); + const [required, setRequired] = useState<"yes" | "no" | null>( + ncr.corrective_action_required === null + ? null + : ncr.corrective_action_required + ? "yes" + : "no", + ); + const [justification, setJustification] = useState( + ncr.corrective_action_justification ?? "", + ); + const [plan, setPlan] = useState(ncr.corrective_action_plan ?? ""); + const [owner, setOwner] = useState( + (ncr.corrective_action_owner as UserOut | null) ?? null, + ); + const [dueDate, setDueDate] = useState(ncr.corrective_action_due_date ?? ""); + const [effResult, setEffResult] = useState<"effective" | "not_effective" | null>( + ncr.effectiveness_result, + ); + const [effNotes, setEffNotes] = useState(ncr.effectiveness_notes ?? ""); + const [recurring, setRecurring] = useState(ncr.is_recurring); + const [links, setLinks] = useState(ncr.related_ncrs); + + const justificationMissing = required !== null && !justification.trim(); + + const mutation = useNcrMutation( + () => + api(`/api/ncrs/${ncr.id}/capa`, { + method: "POST", + body: { + root_cause: rootCause || null, + root_cause_category_id: categoryId || null, + corrective_action_required: required === null ? null : required === "yes", + corrective_action_justification: justification || null, + corrective_action_plan: plan || null, + corrective_action_owner_id: owner?.id ?? null, + corrective_action_due_date: dueDate || null, + effectiveness_result: required === "yes" ? effResult : null, + effectiveness_notes: effNotes || null, + is_recurring: recurring, + related_ncr_ids: links.map((l) => l.id), + }, + }), + warnings, + ); + + return ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + + setRootCause(e.target.value)} + multiline + minRows={2} + helperText="Distinct from the Deviation Detail (what was found)." + /> + + setCategoryId(e.target.value === "" ? "" : Number(e.target.value)) + } + sx={{ maxWidth: 320 }} + > + — Not set — + {(lookups.data?.root_cause_categories ?? []).map((c) => ( + + {c.name} + + ))} + + + + Corrective Action Required? + setRequired(v)} + size="small" + > + + Yes + + + No + + + + setJustification(e.target.value)} + multiline + minRows={2} + required={required !== null} + error={justificationMissing} + helperText={ + justificationMissing + ? "A brief justification is required when answering the question above." + : "Why corrective action is (or is not) needed." + } + /> + + {required === "yes" && ( + <> + + + + setPlan(e.target.value)} + multiline + minRows={3} + /> + +
+ setOwner((v as UserOut) ?? null)} + helperText="Owns the corrective action and receives the assignment notification." + /> +
+ setDueDate(e.target.value)} + sx={{ minWidth: 200 }} + /> +
+ + + + + {ncr.effectiveness_verified_by && ( + + Last verified by {ncr.effectiveness_verified_by.display_name} on{" "} + {fmt(ncr.effectiveness_verified_at)}. + + )} + + Result: + setEffResult(v)} + size="small" + > + + Effective + + + Not Effective + + + + setEffNotes(e.target.value)} + multiline + minRows={2} + helperText="How effectiveness was confirmed (e.g. re-inspection results, recurrence check)." + /> + + )} + + + + + setRecurring(e.target.checked)} + /> + } + label="This is a recurring issue (seen on prior NCRs)" + /> + + + +
+ ); +} + +export function CapaSection({ ncr }: { ncr: NcrDetail }) { + const canEdit = ncr.available_actions.includes("capa"); + return ( + <> + + + {ncr.referenced_by.length > 0 && ( + + + + Later NCRs flagged this one as a recurring issue — the corrective + action here may not have been effective: + + + + + )} + {canEdit && ( + <> + + + + + + )} + + ); +} diff --git a/frontend/src/pages/NcrDetailPage.tsx b/frontend/src/pages/NcrDetailPage.tsx index a5d0c22..0c9daee 100644 --- a/frontend/src/pages/NcrDetailPage.tsx +++ b/frontend/src/pages/NcrDetailPage.tsx @@ -35,6 +35,7 @@ import { RichTextView } from "../components/RichTextView"; import { StageChip } from "../components/StageChip"; import { StageStepper } from "../components/StageStepper"; import { useToast } from "../components/Toast"; +import { CapaSection } from "./CapaSection"; import { CostingForm, InitialDispositionForm, @@ -262,6 +263,10 @@ function DetailBody({ ncr }: { ncr: NcrDetail }) { )} + + + + {money(ncr.labor_cost)} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 5933782..e6ad35a 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -220,6 +220,31 @@ export function ReportsPage() { /> + {/* CAPA metrics (API Q1 §6.4.2) */} + + + + + + + @@ -340,6 +365,32 @@ export function ReportsPage() { + + + {data.by_root_cause_category.length === 0 ? ( + + No root cause categories recorded yet. + + ) : ( + + + + + + + + + + )} + + + diff --git a/frontend/src/pages/admin/AdminListsPage.tsx b/frontend/src/pages/admin/AdminListsPage.tsx index a8ef7b5..d846259 100644 --- a/frontend/src/pages/admin/AdminListsPage.tsx +++ b/frontend/src/pages/admin/AdminListsPage.tsx @@ -70,7 +70,7 @@ function LookupManager({ setNewName(e.target.value)} fullWidth @@ -139,6 +139,13 @@ export function AdminListsPage() { helper="Shown in the Deviation Category dropdown on new NCRs" /> + + + ); -- 2.49.1