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 @@
| 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 %} + | +