Compare commits

...

2 Commits

Author SHA1 Message Date
281536fd73 Merge pull request 'Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification' (#1) from feature/api-q1-capa into main
Reviewed-on: #1
2026-08-04 19:52:19 +00:00
ang3l12
c1cd1bc9df Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

- Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/
  Material/Measurement/Environment), separate from Deviation Detail/Category
- "Corrective Action Required?" Yes/No gate on every NCR with a required
  justification
- Corrective action plan with owner + due date; owner is notified by email
- Effectiveness verification (result, notes, server-stamped verifier/date)
  required before an NCR can close when corrective action is required —
  costing returns 409 listing the missing pieces
- Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show
  a warning when later NCRs reference them
- Dashboard metrics: % root cause completed, % CAPA verified effective,
  avg CAPA close time, overdue CAPA count, NCRs by root cause category
- CAPA section in the NCR detail UI, printable PDF, CSV export, and the
  vw_ncr_full Power BI view; admin list manager for root cause categories
- Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh);
  demo seed data exercises every metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:49:08 -06:00
28 changed files with 1830 additions and 61 deletions

View File

@@ -160,6 +160,7 @@ to send mail (`backend/app/services/graph.py`).
| Operations complete | all QC Inspectors | operations user | | Operations complete | all QC Inspectors | operations user |
| QC closed | all Costing users | QC inspector | | QC closed | all Costing users | QC inspector |
| NCR closed | original requester | costing user | | 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 | | Admin reopen | owners of the target stage + requester | admin |
Fault tolerance: a Graph failure **never blocks a workflow transition** — the 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 People" users (their personal queue) and Admins; they may update disposition
fields and release to Operations. fields and release to Operations.
- **QC Inspection** can be saved repeatedly until *QC Closed* advances it. - **QC Inspection** can be saved repeatedly until *QC Closed* advances it.
- Saving **Costing** (Labor/Material/Service/Other) closes the NCR. Closed - The **CAPA section** (API Q1 §5.9.1.2 / §6.4.2) sits outside the stage
NCRs are fully read-only — including attachments — until an Admin reopens sequence: QC Inspectors, Disposition Authorities, and Admins can edit it at
them (required reason, recorded in the audit trail). 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 - Every stage change writes a `stage_transitions` row (timestamp + acting
user) — the basis for the aging and cycle-time reports — and every field 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 change writes an immutable `audit_log` row (before/after values). The app

View File

@@ -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")

View File

@@ -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)

View File

@@ -1,9 +1,10 @@
from app.models.base import Base from app.models.base import Base
from app.models.user import User, UserRole 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 ( from app.models.ncr import (
JobInfo, JobInfo,
Ncr, Ncr,
NcrLink,
NcrSecondaryAssignee, NcrSecondaryAssignee,
NcrSequence, NcrSequence,
StageTransition, StageTransition,
@@ -18,7 +19,9 @@ __all__ = [
"UserRole", "UserRole",
"Department", "Department",
"DeviationCategory", "DeviationCategory",
"RootCauseCategory",
"Ncr", "Ncr",
"NcrLink",
"NcrSequence", "NcrSequence",
"NcrSecondaryAssignee", "NcrSecondaryAssignee",
"StageTransition", "StageTransition",

View File

@@ -20,3 +20,15 @@ class DeviationCategory(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True) name: Mapped[str] = mapped_column(String(100), unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=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)

View File

@@ -1,8 +1,9 @@
from datetime import datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Date,
DateTime, DateTime,
ForeignKey, ForeignKey,
Index, Index,
@@ -77,6 +78,36 @@ class Ncr(Base):
qc_closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) 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) 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 ────────────────────────────────────────────────────────────── # ── Costing ──────────────────────────────────────────────────────────────
labor_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) labor_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
material_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 ──────────────────────────────────────────────────────── # ── Relationships ────────────────────────────────────────────────────────
department = relationship("Department", lazy="selectin") department = relationship("Department", lazy="selectin")
deviation_category = relationship("DeviationCategory", 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") requester: Mapped[User] = relationship(foreign_keys=[requester_id], lazy="selectin")
disposition_authority: Mapped[User] = relationship( disposition_authority: Mapped[User] = relationship(
foreign_keys=[disposition_authority_id], lazy="selectin" foreign_keys=[disposition_authority_id], lazy="selectin"
@@ -152,6 +190,21 @@ class NcrSecondaryAssignee(Base):
user: Mapped[User] = relationship(lazy="selectin") 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): class StageTransition(Base):
"""One row per lifecycle event (create, stage change, reopen) — the basis """One row per lifecycle event (create, stage change, reopen) — the basis
for aging and cycle-time reporting.""" for aging and cycle-time reporting."""

View File

@@ -14,6 +14,7 @@ from app.models import (
Department, Department,
DeviationCategory, DeviationCategory,
Ncr, Ncr,
RootCauseCategory,
User, User,
UserRole, 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: async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
exists = ( exists = (
await db.execute(select(model).where(model.name == payload.name.strip())) await db.execute(select(model).where(model.name == payload.name.strip()))

View File

@@ -4,41 +4,34 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db 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 from app.schemas.lookup import LookupsOut, NamedLookupOut
router = APIRouter(tags=["lookups"]) 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) @router.get("/lookups", response_model=LookupsOut)
async def get_lookups( async def get_lookups(
_: CurrentUser = Depends(get_current_user), _: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> LookupsOut: ) -> LookupsOut:
"""Active departments and deviation categories for form dropdowns.""" """Active departments, deviation categories, and root cause categories
departments = ( for form dropdowns."""
(
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()
)
return LookupsOut( return LookupsOut(
departments=[NamedLookupOut.model_validate(d) for d in departments], departments=await _active(db, Department),
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories], deviation_categories=await _active(db, DeviationCategory),
root_cause_categories=await _active(db, RootCauseCategory),
) )

View File

@@ -22,7 +22,9 @@ from app.models import (
DeviationCategory, DeviationCategory,
JobInfo, JobInfo,
Ncr, Ncr,
NcrLink,
NcrSecondaryAssignee, NcrSecondaryAssignee,
RootCauseCategory,
User, User,
UserRole, UserRole,
) )
@@ -31,12 +33,14 @@ from app.schemas.ncr import (
AttachmentOut, AttachmentOut,
AuditEntryOut, AuditEntryOut,
AuditListOut, AuditListOut,
CapaIn,
CostingIn, CostingIn,
InitialDispositionIn, InitialDispositionIn,
InspectionIn, InspectionIn,
JobInfoOut, JobInfoOut,
NcrCreateIn, NcrCreateIn,
NcrDetailOut, NcrDetailOut,
NcrLinkOut,
NcrListItem, NcrListItem,
NcrListOut, NcrListOut,
NcrMutationOut, 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) 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]: def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
actions: list[str] = [] actions: list[str] = []
stage = Stage(ncr.stage) stage = Stage(ncr.stage)
@@ -128,6 +138,8 @@ def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
actions.append("costing") actions.append("costing")
if stage == Stage.CLOSED and current.is_admin: if stage == Stage.CLOSED and current.is_admin:
actions.append("reopen") actions.append("reopen")
if _can_edit_capa(ncr, current):
actions.append("capa")
if stage != Stage.CLOSED: if stage != Stage.CLOSED:
actions.append("add_attachment") actions.append("add_attachment")
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically 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 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) stage = Stage(ncr.stage)
related_ncrs, referenced_by = await _ncr_links(db, ncr.id)
return NcrDetailOut( return NcrDetailOut(
id=ncr.id, id=ncr.id,
ncr_number=ncr.ncr_number, ncr_number=ncr.ncr_number,
@@ -174,6 +216,32 @@ def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
qc_closed_by=( qc_closed_by=(
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None 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, labor_cost=ncr.labor_cost,
material_cost=ncr.material_cost, material_cost=ncr.material_cost,
service_cost=ncr.service_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 " f"{current.user.display_name} submitted a new NCR and selected you as the "
"disposition authority.", "disposition authority.",
) )
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings) return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
# ── queues / search / export ──────────────────────────────────────────────── # ── queues / search / export ────────────────────────────────────────────────
@@ -398,8 +466,10 @@ _CSV_COLUMNS = [
"ncr_number", "job_number", "department", "deviation_category", "requester", "ncr_number", "job_number", "department", "deviation_category", "requester",
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order", "disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval", "qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost", "qc_closed", "root_cause_category", "corrective_action_required",
"total_cost", "closed_at", "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), STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "", 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.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.service_cost or "", n.other_cost or "", n.total_cost or "",
n.closed_at.isoformat(sep=" ") if n.closed_at else "", n.closed_at.isoformat(sep=" ") if n.closed_at else "",
] ]
@@ -469,7 +547,7 @@ async def get_ncr(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> NcrDetailOut: ) -> NcrDetailOut:
ncr = await _get_ncr(db, ncr_ref) ncr = await _get_ncr(db, ncr_ref)
return _detail(ncr, current) return await _detail(db, ncr, current)
# ── stage actions ──────────────────────────────────────────────────────────── # ── stage actions ────────────────────────────────────────────────────────────
@@ -542,7 +620,7 @@ async def initial_disposition(
await db.commit() await db.commit()
ncr = await _refetch(db, ncr.id) ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(db, ncr, event, current, summary) 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) @router.post("/ncrs/{ncr_ref}/secondary-disposition", response_model=NcrMutationOut)
@@ -583,7 +661,7 @@ async def secondary_disposition(
else: else:
await db.commit() await db.commit()
ncr = await _refetch(db, ncr.id) 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) @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 " f"{current.user.display_name} marked operations complete; the NCR is ready "
"for QC inspection.", "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) @router.post("/ncrs/{ncr_ref}/inspection", response_model=NcrMutationOut)
@@ -655,7 +733,192 @@ async def inspection(
else: else:
await db.commit() await db.commit()
ncr = await _refetch(db, ncr.id) 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) @router.post("/ncrs/{ncr_ref}/costing", response_model=NcrMutationOut)
@@ -665,10 +928,19 @@ async def costing(
current: CurrentUser = Depends(require_roles(Role.COSTING)), current: CurrentUser = Depends(require_roles(Role.COSTING)),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> NcrMutationOut: ) -> 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) ncr = await _get_ncr(db, ncr_ref)
_ensure_stage(ncr, Stage.COSTING) _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() now = utcnow()
apply_field_updates( apply_field_updates(
db, db,
@@ -697,7 +969,7 @@ async def costing(
f"Costing is complete and your NCR has been closed. Total cost of " f"Costing is complete and your NCR has been closed. Total cost of "
f"nonconformance: ${ncr.total_cost:,.2f}.", 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) @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"{current.user.display_name} reopened this NCR to "
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}", 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( def _do_transition(
@@ -865,7 +1137,8 @@ async def ncr_pdf(
from app.services.pdf import render_ncr_pdf from app.services.pdf import render_ncr_pdf
ncr = await _get_ncr(db, ncr_ref) 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( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",

View File

@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db from app.database import get_db
from app.domain import STAGE_LABELS, Stage 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.models.base import utcnow
from app.schemas.report import ( from app.schemas.report import (
AgingBucket, AgingBucket,
@@ -70,9 +70,14 @@ async def reports_summary(
c.id: c.name c.id: c.name
for c in (await db.execute(select(DeviationCategory))).scalars().all() 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_dept: dict[str, int] = defaultdict(int)
by_cat: 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) by_month: dict[str, int] = defaultdict(int)
cost_by_month: dict[str, dict[str, Decimal]] = defaultdict( cost_by_month: dict[str, dict[str, Decimal]] = defaultdict(
lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)} lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)}
@@ -83,12 +88,38 @@ async def reports_summary(
open_count = 0 open_count = 0
closed_count = 0 closed_count = 0
now = utcnow() 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: for n in ncrs:
by_dept[dept_names.get(n.department_id, "?")] += 1 by_dept[dept_names.get(n.department_id, "?")] += 1
by_cat[cat_names.get(n.deviation_category_id, "?")] += 1 by_cat[cat_names.get(n.deviation_category_id, "?")] += 1
by_month[n.created_at.strftime("%Y-%m")] += 1 by_month[n.created_at.strftime("%Y-%m")] += 1
job_counts[n.job_number] += 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: if n.stage == Stage.CLOSED.value:
closed_count += 1 closed_count += 1
month = (n.closed_at or n.created_at).strftime("%Y-%m") month = (n.closed_at or n.created_at).strftime("%Y-%m")
@@ -152,6 +183,24 @@ async def reports_summary(
open_ncrs=open_count, open_ncrs=open_count,
closed_ncrs=closed_count, closed_ncrs=closed_count,
total_cost=total_cost, 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( by_department=sorted(
(CountByName(name=k, count=v) for k, v in by_dept.items()), (CountByName(name=k, count=v) for k, v in by_dept.items()),
key=lambda x: -x.count, key=lambda x: -x.count,

View File

@@ -21,3 +21,4 @@ class LookupPatchIn(BaseModel):
class LookupsOut(BaseModel): class LookupsOut(BaseModel):
departments: list[NamedLookupOut] departments: list[NamedLookupOut]
deviation_categories: list[NamedLookupOut] deviation_categories: list[NamedLookupOut]
root_cause_categories: list[NamedLookupOut]

View File

@@ -1,3 +1,4 @@
from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -43,6 +44,27 @@ class InspectionIn(BaseModel):
qc_closed: bool = False 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): class CostingIn(BaseModel):
labor_cost: Money labor_cost: Money
material_cost: Money material_cost: Money
@@ -91,6 +113,16 @@ class JobInfoOut(AppModel):
source: str 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): class NcrListItem(BaseModel):
id: int id: int
ncr_number: str ncr_number: str
@@ -146,6 +178,23 @@ class NcrDetailOut(BaseModel):
qc_closed_at: UTCDateTime | None qc_closed_at: UTCDateTime | None
qc_closed_by: UserRef | 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 labor_cost: Decimal | None
material_cost: Decimal | None material_cost: Decimal | None
service_cost: Decimal | None service_cost: Decimal | None

View File

@@ -44,6 +44,12 @@ class ReportsSummaryOut(BaseModel):
open_ncrs: int open_ncrs: int
closed_ncrs: int closed_ncrs: int
total_cost: Decimal 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_department: list[CountByName]
by_category: list[CountByName] by_category: list[CountByName]
by_month: list[CountByMonth] by_month: list[CountByMonth]

View File

@@ -23,7 +23,9 @@ from app.models import (
Department, Department,
DeviationCategory, DeviationCategory,
Ncr, Ncr,
NcrLink,
NcrSecondaryAssignee, NcrSecondaryAssignee,
RootCauseCategory,
StageTransition, StageTransition,
User, User,
UserRole, UserRole,
@@ -45,6 +47,19 @@ CATEGORIES = [
"Process Deviation", "Supplier Nonconformance", "Damage / Handling", "Other", "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 = [ DEV_USERS = [
("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)), ("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)),
("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]), ("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]),
@@ -85,6 +100,13 @@ async def seed() -> None:
for name in CATEGORIES: for name in CATEGORIES:
if name not in existing: if name not in existing:
db.add(DeviationCategory(name=name, is_active=True)) 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: if await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY) is None:
db.add( db.add(
@@ -124,7 +146,9 @@ async def seed() -> None:
departments = (await db.execute(select(Department))).scalars().all() departments = (await db.execute(select(Department))).scalars().all()
categories = (await db.execute(select(DeviationCategory))).scalars().all() categories = (await db.execute(select(DeviationCategory))).scalars().all()
root_causes = (await db.execute(select(RootCauseCategory))).scalars().all()
rng = random.Random(42) rng = random.Random(42)
seeded_ncrs: list[Ncr] = []
dispo = users["dispo@pescoinc.biz"] dispo = users["dispo@pescoinc.biz"]
second = users["second@pescoinc.biz"] second = users["second@pescoinc.biz"]
@@ -144,7 +168,7 @@ async def seed() -> None:
] ]
for target, count in plan: for target, count in plan:
for _ in range(count): for i in range(count):
days_ago = rng.randint(5, 120) days_ago = rng.randint(5, 120)
created = utcnow() - timedelta(days=days_ago) created = utcnow() - timedelta(days=days_ago)
number, year, seq = await allocate_ncr_number(db, now=created) number, year, seq = await allocate_ncr_number(db, now=created)
@@ -166,6 +190,7 @@ async def seed() -> None:
) )
db.add(ncr) db.add(ncr)
await db.flush() await db.flush()
seeded_ncrs.append(ncr)
t = created t = created
db.add(StageTransition( db.add(StageTransition(
@@ -222,6 +247,38 @@ async def seed() -> None:
ncr.qc_closed_by_id = qc.id ncr.qc_closed_by_id = qc.id
advance(Stage.COSTING, "qc_close", qc) advance(Stage.COSTING, "qc_close", qc)
ncr.qc_closed_at = t 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: if target == Stage.COSTING:
continue continue
@@ -235,6 +292,15 @@ async def seed() -> None:
ncr.costing_completed_at = t ncr.costing_completed_at = t
ncr.closed_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() await db.commit()
log.info("Demo NCRs seeded. Done.") log.info("Demo NCRs seeded. Done.")

View File

@@ -32,6 +32,7 @@ class NotifyEvent(str, Enum):
QC_CLOSED = "qc_closed" QC_CLOSED = "qc_closed"
CLOSED = "closed" CLOSED = "closed"
REOPENED = "reopened" REOPENED = "reopened"
CAPA_ASSIGNED = "capa_assigned"
_EVENT_SUBJECT = { _EVENT_SUBJECT = {
@@ -42,6 +43,7 @@ _EVENT_SUBJECT = {
NotifyEvent.QC_CLOSED: "QC closed — costing needed", NotifyEvent.QC_CLOSED: "QC closed — costing needed",
NotifyEvent.CLOSED: "Your NCR has been closed", NotifyEvent.CLOSED: "Your NCR has been closed",
NotifyEvent.REOPENED: "NCR reopened by an administrator", 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) return await _role_emails(db, Role.COSTING)
if event == NotifyEvent.CLOSED: if event == NotifyEvent.CLOSED:
return [ncr.requester.email] 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: if event == NotifyEvent.REOPENED:
owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage)) owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage))
emails = await _role_emails(db, owner_role) if owner_role else [] emails = await _role_emails(db, owner_role) if owner_role else []

View File

@@ -23,7 +23,7 @@ _env = Environment(
) )
def _render_html(ncr: Ncr) -> str: def _render_html(ncr: Ncr, related_ncr_numbers: list[str]) -> str:
images = [] images = []
other_files = [] other_files = []
for att in ncr.attachments: for att in ncr.attachments:
@@ -48,6 +48,7 @@ def _render_html(ncr: Ncr) -> str:
Stage=Stage, Stage=Stage,
images=images, images=images,
other_files=other_files, other_files=other_files,
related_ncr_numbers=related_ncr_numbers,
generated_at=utcnow(), generated_at=utcnow(),
) )
@@ -58,7 +59,7 @@ def _html_to_pdf(html: str) -> bytes:
return HTML(string=html).write_pdf() return HTML(string=html).write_pdf()
async def render_ncr_pdf(ncr: Ncr) -> bytes: async def render_ncr_pdf(ncr: Ncr, related_ncr_numbers: list[str] | None = None) -> bytes:
html = _render_html(ncr) html = _render_html(ncr, related_ncr_numbers or [])
# WeasyPrint rendering is CPU-bound; keep it off the event loop. # WeasyPrint rendering is CPU-bound; keep it off the event loop.
return await anyio.to_thread.run_sync(partial(_html_to_pdf, html)) return await anyio.to_thread.run_sync(partial(_html_to_pdf, html))

View File

@@ -134,6 +134,61 @@
<div class="notes">{{ ncr.inspection_notes }}</div> <div class="notes">{{ ncr.inspection_notes }}</div>
{% endif %} {% endif %}
<h2>Corrective &amp; Preventive Action (API Q1)</h2>
<table class="fields">
<tr>
<td class="lbl">Root Cause Category</td>
<td>{{ ncr.root_cause_category.name if ncr.root_cause_category else "—" }}</td>
<td class="lbl">Recurring Issue</td>
<td>
{% if ncr.is_recurring %}Yes{% if related_ncr_numbers %} —
{{ related_ncr_numbers | join(", ") }}{% endif %}
{% else %}No{% endif %}
</td>
</tr>
<tr>
<td class="lbl">Corrective Action Required</td>
<td>
{% if ncr.corrective_action_required is none %}<span class="pending">Not answered</span>
{% elif ncr.corrective_action_required %}Yes{% else %}No{% endif %}
</td>
<td class="lbl">Justification</td>
<td>{{ ncr.corrective_action_justification or "—" }}</td>
</tr>
<tr>
<td class="lbl">Action Owner</td>
<td>{{ ncr.corrective_action_owner.display_name if ncr.corrective_action_owner else "—" }}</td>
<td class="lbl">Due Date</td>
<td>{{ ncr.corrective_action_due_date.strftime("%Y-%m-%d") if ncr.corrective_action_due_date else "—" }}</td>
</tr>
<tr>
<td class="lbl">Effectiveness</td>
<td>
{% if ncr.effectiveness_result == "effective" %}Verified Effective
{% elif ncr.effectiveness_result == "not_effective" %}Not Effective
{% else %}<span class="pending">Pending verification</span>{% endif %}
</td>
<td class="lbl">Verified By / At</td>
<td>
{% if ncr.effectiveness_verified_by %}
{{ ncr.effectiveness_verified_by.display_name }} —
{{ ncr.effectiveness_verified_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}—{% endif %}
</td>
</tr>
</table>
{% if ncr.root_cause %}
<div class="notes"><strong>Root Cause:</strong> {{ ncr.root_cause }}</div>
{% else %}
<div class="notes pending">No root cause recorded.</div>
{% endif %}
{% if ncr.corrective_action_plan %}
<div class="notes"><strong>Corrective Action Plan:</strong> {{ ncr.corrective_action_plan }}</div>
{% endif %}
{% if ncr.effectiveness_notes %}
<div class="notes"><strong>Verification Notes:</strong> {{ ncr.effectiveness_notes }}</div>
{% endif %}
<h2>Costing</h2> <h2>Costing</h2>
<table class="fields costs"> <table class="fields costs">
<tr> <tr>

View File

@@ -28,7 +28,14 @@ from httpx import ASGITransport, AsyncClient # noqa: E402
from app.database import get_engine, get_session_factory # noqa: E402 from app.database import get_engine, get_session_factory # noqa: E402
from app.main import app # 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) @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="Machining", is_active=True))
db.add(Department(name="Inactive Dept", is_active=False)) db.add(Department(name="Inactive Dept", is_active=False))
db.add(DeviationCategory(name="Dimensional", is_active=True)) 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() await db.commit()
asyncio.run(_run()) asyncio.run(_run())

295
backend/tests/test_capa.py Normal file
View File

@@ -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

View File

@@ -73,6 +73,15 @@ async def test_admin_can_act_at_every_stage(client, team):
headers=hdr(team["admin"]), headers=hdr(team["admin"]),
) )
assert r.status_code == 200 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( r = await client.post(
f"/api/ncrs/{ncr['id']}/costing", f"/api/ncrs/{ncr['id']}/costing",
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"}, json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},

View File

@@ -1,6 +1,7 @@
"""Workflow state machine: happy paths, invalid transitions, closure locking, """Workflow state machine: happy paths, invalid transitions, closure locking,
admin reopen, and rich-text sanitization.""" admin reopen, and rich-text sanitization."""
from .util import ( from .util import (
answer_capa_no,
create_ncr, create_ncr,
do_initial_disposition, do_initial_disposition,
hdr, hdr,
@@ -47,15 +48,22 @@ async def test_full_lifecycle_direct_to_operations(client, team):
assert body["stage"] == "costing" assert body["stage"] == "costing"
assert body["qc_closed"] is True assert body["qc_closed"] is True
r = await client.post( # the API Q1 CAPA gate blocks closure until the CA question is answered
f"/api/ncrs/{ncr['id']}/costing", costing_body = {
json={
"labor_cost": "100.00", "labor_cost": "100.00",
"material_cost": "50.25", "material_cost": "50.25",
"service_cost": "0", "service_cost": "0",
"other_cost": "10", "other_cost": "10",
}, }
headers=hdr(team["cost"]), 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"])
) )
body = r.json()["ncr"] body = r.json()["ncr"]
assert body["stage"] == "closed" assert body["stage"] == "closed"

View File

@@ -92,8 +92,23 @@ async def to_costing(client: AsyncClient, team: dict, ncr_id: int) -> dict:
return r.json()["ncr"] 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: async def to_closed(client: AsyncClient, team: dict, ncr_id: int) -> dict:
await to_costing(client, team, ncr_id) await to_costing(client, team, ncr_id)
await answer_capa_no(client, team, ncr_id)
r = await client.post( r = await client.post(
f"/api/ncrs/{ncr_id}/costing", f"/api/ncrs/{ncr_id}/costing",
json={ json={

View File

@@ -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) { export function useUsersByRole(role: string) {
return useQuery({ return useQuery({
queryKey: ["users", role], queryKey: ["users", role],
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`), queryFn: () => api<UserOut[]>(role ? `/api/users?role=${role}` : "/api/users"),
staleTime: 60_000, staleTime: 60_000,
}); });
} }

View File

@@ -71,6 +71,7 @@ export interface NamedLookup {
export interface LookupsOut { export interface LookupsOut {
departments: NamedLookup[]; departments: NamedLookup[];
deviation_categories: NamedLookup[]; deviation_categories: NamedLookup[];
root_cause_categories: NamedLookup[];
} }
export interface AttachmentOut { export interface AttachmentOut {
@@ -128,10 +129,19 @@ export type NcrAction =
| "operations_complete" | "operations_complete"
| "inspection" | "inspection"
| "costing" | "costing"
| "capa"
| "reopen" | "reopen"
| "add_attachment" | "add_attachment"
| "view_audit"; | "view_audit";
export interface NcrLinkOut {
id: number;
ncr_number: string;
job_number: string;
stage: StageValue;
stage_label: string;
}
export interface NcrDetail { export interface NcrDetail {
id: number; id: number;
ncr_number: string; ncr_number: string;
@@ -161,6 +171,22 @@ export interface NcrDetail {
qc_closed: boolean; qc_closed: boolean;
qc_closed_at: string | null; qc_closed_at: string | null;
qc_closed_by: UserRef | 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; labor_cost: string | null;
material_cost: string | null; material_cost: string | null;
service_cost: string | null; service_cost: string | null;
@@ -213,6 +239,11 @@ export interface ReportsSummary {
open_ncrs: number; open_ncrs: number;
closed_ncrs: number; closed_ncrs: number;
total_cost: string; 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_department: { name: string; count: number }[];
by_category: { name: string; count: number }[]; by_category: { name: string; count: number }[];
by_month: { month: string; count: number }[]; by_month: { month: string; count: number }[];

View File

@@ -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 (
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{links.map((l) => (
<Chip
key={l.id}
size="small"
clickable
component={RouterLink}
to={`/ncrs/${l.ncr_number}`}
label={`${l.ncr_number} (${l.stage_label})`}
/>
))}
</Stack>
);
}
function EffectivenessChip({ ncr }: { ncr: NcrDetail }) {
if (ncr.effectiveness_result === "effective")
return <Chip size="small" color="success" label="Verified Effective" />;
if (ncr.effectiveness_result === "not_effective")
return <Chip size="small" color="error" label="Not Effective" />;
return <Chip size="small" variant="outlined" label="Pending Verification" />;
}
/** 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 (
<Alert severity="success" sx={{ mb: 2 }}>
CAPA section complete the closure gate is satisfied.
</Alert>
);
return (
<Alert severity={ncr.stage === "costing" ? "warning" : "info"} sx={{ mb: 2 }}>
Before this NCR can be closed: {blockers.join(", ")}.
</Alert>
);
}
function fmt(iso: string | null): string {
return iso ? new Date(iso).toLocaleString() : "—";
}
function CapaSummary({ ncr }: { ncr: NcrDetail }) {
return (
<Grid container spacing={2}>
<FieldRow label="Corrective Action Required">
{ncr.corrective_action_required === null
? "Not answered"
: ncr.corrective_action_required
? "Yes"
: "No"}
</FieldRow>
<FieldRow label="Justification">{ncr.corrective_action_justification}</FieldRow>
<FieldRow label="Root Cause Category">{ncr.root_cause_category}</FieldRow>
<FieldRow label="Action Owner">
{ncr.corrective_action_owner?.display_name}
</FieldRow>
<FieldRow label="Due Date">{ncr.corrective_action_due_date}</FieldRow>
<FieldRow label="Effectiveness">
<Stack direction="row" spacing={1} alignItems="center">
<EffectivenessChip ncr={ncr} />
{ncr.effectiveness_verified_by && (
<Typography variant="body2" color="text.secondary">
{ncr.effectiveness_verified_by.display_name},{" "}
{fmt(ncr.effectiveness_verified_at)}
</Typography>
)}
</Stack>
</FieldRow>
{ncr.root_cause && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Root Cause
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.root_cause}</Typography>
</Grid>
)}
{ncr.corrective_action_plan && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Corrective Action Plan
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>
{ncr.corrective_action_plan}
</Typography>
</Grid>
)}
{ncr.effectiveness_notes && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Verification Notes
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>
{ncr.effectiveness_notes}
</Typography>
</Grid>
)}
<FieldRow label="Recurring Issue">{ncr.is_recurring ? "Yes" : "No"}</FieldRow>
{ncr.related_ncrs.length > 0 && (
<Grid item xs={12} sm={6} md={8}>
<Typography variant="caption" color="text.secondary" display="block">
Linked Prior NCRs
</Typography>
<NcrChips links={ncr.related_ncrs} />
</Grid>
)}
</Grid>
);
}
/** 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 (
<Autocomplete
multiple
options={options}
loading={results.isLoading}
value={value}
filterOptions={(x) => 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) => (
<TextField
{...params}
label="Linked prior NCRs"
placeholder="Search by NCR or job number"
helperText="Link this NCR to earlier occurrences of the same issue."
/>
)}
/>
);
}
function CapaForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useToast();
const lookups = useLookups();
const [rootCause, setRootCause] = useState(ncr.root_cause ?? "");
const [categoryId, setCategoryId] = useState<number | "">(
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<UserOut | null>(
(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<NcrLinkOut[]>(ncr.related_ncrs);
const justificationMissing = required !== null && !justification.trim();
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/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 (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<TextField
label="Root Cause — why did this happen?"
value={rootCause}
onChange={(e) => setRootCause(e.target.value)}
multiline
minRows={2}
helperText="Distinct from the Deviation Detail (what was found)."
/>
<TextField
select
label="Root Cause Category"
value={categoryId}
onChange={(e) =>
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
}
sx={{ maxWidth: 320 }}
>
<MenuItem value=""> Not set </MenuItem>
{(lookups.data?.root_cause_categories ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2} alignItems="center">
<Typography>Corrective Action Required?</Typography>
<ToggleButtonGroup
exclusive
value={required}
onChange={(_, v) => setRequired(v)}
size="small"
>
<ToggleButton value="yes" color="warning">
Yes
</ToggleButton>
<ToggleButton value="no" color="success">
No
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<TextField
label="Justification"
value={justification}
onChange={(e) => 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" && (
<>
<Divider>
<Chip label="Corrective Action Plan" size="small" />
</Divider>
<TextField
label="Action Plan"
value={plan}
onChange={(e) => setPlan(e.target.value)}
multiline
minRows={3}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<div style={{ flexGrow: 1 }}>
<UserPicker
role=""
label="Action Owner"
value={owner}
onChange={(v) => setOwner((v as UserOut) ?? null)}
helperText="Owns the corrective action and receives the assignment notification."
/>
</div>
<TextField
type="date"
label="Due Date"
InputLabelProps={{ shrink: true }}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
sx={{ minWidth: 200 }}
/>
</Stack>
<Divider>
<Chip label="Effectiveness Verification" size="small" />
</Divider>
{ncr.effectiveness_verified_by && (
<Typography variant="body2" color="text.secondary">
Last verified by {ncr.effectiveness_verified_by.display_name} on{" "}
{fmt(ncr.effectiveness_verified_at)}.
</Typography>
)}
<Stack direction="row" spacing={2} alignItems="center">
<Typography>Result:</Typography>
<ToggleButtonGroup
exclusive
value={effResult}
onChange={(_, v) => setEffResult(v)}
size="small"
>
<ToggleButton value="effective" color="success">
Effective
</ToggleButton>
<ToggleButton value="not_effective" color="error">
Not Effective
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<TextField
label="Verification Notes"
value={effNotes}
onChange={(e) => setEffNotes(e.target.value)}
multiline
minRows={2}
helperText="How effectiveness was confirmed (e.g. re-inspection results, recurrence check)."
/>
</>
)}
<Divider>
<Chip label="Recurring Issue" size="small" />
</Divider>
<FormControlLabel
control={
<Checkbox
checked={recurring}
onChange={(e) => setRecurring(e.target.checked)}
/>
}
label="This is a recurring issue (seen on prior NCRs)"
/>
<NcrLinkPicker selfId={ncr.id} value={links} onChange={setLinks} />
<Button
variant="contained"
sx={{ alignSelf: "flex-start" }}
disabled={mutation.isPending || justificationMissing}
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SaveIcon />}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () => toast("CAPA saved."),
})
}
>
Save CAPA
</Button>
</Stack>
);
}
export function CapaSection({ ncr }: { ncr: NcrDetail }) {
const canEdit = ncr.available_actions.includes("capa");
return (
<>
<GateStatus ncr={ncr} />
<CapaSummary ncr={ncr} />
{ncr.referenced_by.length > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Stack spacing={0.5}>
<span>
Later NCRs flagged this one as a recurring issue the corrective
action here may not have been effective:
</span>
<NcrChips links={ncr.referenced_by} />
</Stack>
</Alert>
)}
{canEdit && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="CAPA — your action" color="primary" size="small" />
</Divider>
<CapaForm ncr={ncr} />
</>
)}
</>
);
}

View File

@@ -35,6 +35,7 @@ import { RichTextView } from "../components/RichTextView";
import { StageChip } from "../components/StageChip"; import { StageChip } from "../components/StageChip";
import { StageStepper } from "../components/StageStepper"; import { StageStepper } from "../components/StageStepper";
import { useToast } from "../components/Toast"; import { useToast } from "../components/Toast";
import { CapaSection } from "./CapaSection";
import { import {
CostingForm, CostingForm,
InitialDispositionForm, InitialDispositionForm,
@@ -262,6 +263,10 @@ function DetailBody({ ncr }: { ncr: NcrDetail }) {
)} )}
</SectionCard> </SectionCard>
<SectionCard title="Corrective & Preventive Action (API Q1)">
<CapaSection ncr={ncr} />
</SectionCard>
<SectionCard title="Costing"> <SectionCard title="Costing">
<Grid container spacing={2}> <Grid container spacing={2}>
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow> <FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>

View File

@@ -220,6 +220,31 @@ export function ReportsPage() {
/> />
</Stack> </Stack>
{/* CAPA metrics (API Q1 §6.4.2) */}
<Stack direction="row" spacing={1.5} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
<StatTile
label="Root Cause Completed"
value={data.root_cause_pct !== null ? `${data.root_cause_pct}%` : "—"}
/>
<StatTile
label="CAPA Verified Effective"
value={
data.effectiveness_verified_pct !== null
? `${data.effectiveness_verified_pct}%`
: "—"
}
/>
<StatTile
label="Avg CAPA Close Time"
value={
data.avg_capa_close_days !== null
? `${data.avg_capa_close_days} days`
: "—"
}
/>
<StatTile label="Overdue CAPAs" value={String(data.overdue_capa_count)} />
</Stack>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<ChartCard title="NCRs by Month"> <ChartCard title="NCRs by Month">
@@ -340,6 +365,32 @@ export function ReportsPage() {
</ChartCard> </ChartCard>
</Grid> </Grid>
<Grid item xs={12} md={6}>
<ChartCard
title="NCRs by Root Cause Category"
subheader="6M classification (API Q1)"
>
{data.by_root_cause_category.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
No root cause categories recorded yet.
</Typography>
) : (
<ResponsiveContainer
width="100%"
height={Math.max(200, data.by_root_cause_category.length * 34)}
>
<BarChart data={data.by_root_cause_category} layout="vertical">
<CartesianGrid stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
<YAxis type="category" dataKey="name" width={130} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
</Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<ChartCard title="Open NCR Aging" subheader="Days in current stage"> <ChartCard title="Open NCR Aging" subheader="Days in current stage">
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>

View File

@@ -70,7 +70,7 @@ function LookupManager({
<Stack direction="row" spacing={1} sx={{ mb: 2 }}> <Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField <TextField
size="small" size="small"
label={`New ${title.toLowerCase().replace(/s$/, "")}`} label={`New ${title.toLowerCase().replace(/ies$/, "y").replace(/s$/, "")}`}
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
fullWidth fullWidth
@@ -139,6 +139,13 @@ export function AdminListsPage() {
helper="Shown in the Deviation Category dropdown on new NCRs" helper="Shown in the Deviation Category dropdown on new NCRs"
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}>
<LookupManager
title="Root Cause Categories"
endpoint="/api/admin/root-cause-categories"
helper="6M classification used in the CAPA section (API Q1 trend reporting)"
/>
</Grid>
</Grid> </Grid>
</> </>
); );