Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification #1
16
README.md
16
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
|
||||
|
||||
112
backend/alembic/versions/0003_capa_fields.py
Normal file
112
backend/alembic/versions/0003_capa_fields.py
Normal 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")
|
||||
160
backend/alembic/versions/0004_capa_reporting_view.py
Normal file
160
backend/alembic/versions/0004_capa_reporting_view.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models import (
|
||||
Department,
|
||||
DeviationCategory,
|
||||
Ncr,
|
||||
RootCauseCategory,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@@ -178,6 +179,39 @@ async def patch_category(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/root-cause-categories", response_model=list[NamedLookupOut])
|
||||
async def list_root_cause_categories(
|
||||
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
rows = (
|
||||
(await db.execute(select(RootCauseCategory).order_by(RootCauseCategory.name)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/root-cause-categories", response_model=NamedLookupOut, status_code=201)
|
||||
async def create_root_cause_category(
|
||||
payload: LookupCreateIn,
|
||||
current: CurrentUser = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _create_lookup(RootCauseCategory, "Root cause category", payload, current, db)
|
||||
|
||||
|
||||
@router.patch("/root-cause-categories/{item_id}", response_model=NamedLookupOut)
|
||||
async def patch_root_cause_category(
|
||||
item_id: int,
|
||||
payload: LookupPatchIn,
|
||||
current: CurrentUser = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _patch_lookup(
|
||||
RootCauseCategory, "Root cause category", item_id, payload, current, db
|
||||
)
|
||||
|
||||
|
||||
async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
|
||||
exists = (
|
||||
await db.execute(select(model).where(model.name == payload.name.strip()))
|
||||
|
||||
@@ -4,41 +4,34 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import Department, DeviationCategory
|
||||
from app.models import Department, DeviationCategory, RootCauseCategory
|
||||
from app.schemas.lookup import LookupsOut, NamedLookupOut
|
||||
|
||||
router = APIRouter(tags=["lookups"])
|
||||
|
||||
|
||||
async def _active(db: AsyncSession, model) -> list[NamedLookupOut]:
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(model).where(model.is_active.is_(True)).order_by(model.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/lookups", response_model=LookupsOut)
|
||||
async def get_lookups(
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LookupsOut:
|
||||
"""Active departments and deviation categories for form dropdowns."""
|
||||
departments = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Department)
|
||||
.where(Department.is_active.is_(True))
|
||||
.order_by(Department.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
categories = (
|
||||
(
|
||||
await db.execute(
|
||||
select(DeviationCategory)
|
||||
.where(DeviationCategory.is_active.is_(True))
|
||||
.order_by(DeviationCategory.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
"""Active departments, deviation categories, and root cause categories
|
||||
for form dropdowns."""
|
||||
return LookupsOut(
|
||||
departments=[NamedLookupOut.model_validate(d) for d in departments],
|
||||
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories],
|
||||
departments=await _active(db, Department),
|
||||
deviation_categories=await _active(db, DeviationCategory),
|
||||
root_cause_categories=await _active(db, RootCauseCategory),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,9 @@ from app.models import (
|
||||
DeviationCategory,
|
||||
JobInfo,
|
||||
Ncr,
|
||||
NcrLink,
|
||||
NcrSecondaryAssignee,
|
||||
RootCauseCategory,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@@ -31,12 +33,14 @@ from app.schemas.ncr import (
|
||||
AttachmentOut,
|
||||
AuditEntryOut,
|
||||
AuditListOut,
|
||||
CapaIn,
|
||||
CostingIn,
|
||||
InitialDispositionIn,
|
||||
InspectionIn,
|
||||
JobInfoOut,
|
||||
NcrCreateIn,
|
||||
NcrDetailOut,
|
||||
NcrLinkOut,
|
||||
NcrListItem,
|
||||
NcrListOut,
|
||||
NcrMutationOut,
|
||||
@@ -111,6 +115,12 @@ def _is_secondary_assignee(ncr: Ncr, current: CurrentUser) -> bool:
|
||||
return any(row.user_id == current.id for row in ncr.secondary_assignee_rows)
|
||||
|
||||
|
||||
def _can_edit_capa(ncr: Ncr, current: CurrentUser) -> bool:
|
||||
return ncr.stage != Stage.CLOSED.value and current.has_role(
|
||||
Role.QC_INSPECTOR, Role.DISPOSITION_AUTHORITY
|
||||
)
|
||||
|
||||
|
||||
def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
actions: list[str] = []
|
||||
stage = Stage(ncr.stage)
|
||||
@@ -128,6 +138,8 @@ def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
actions.append("costing")
|
||||
if stage == Stage.CLOSED and current.is_admin:
|
||||
actions.append("reopen")
|
||||
if _can_edit_capa(ncr, current):
|
||||
actions.append("capa")
|
||||
if stage != Stage.CLOSED:
|
||||
actions.append("add_attachment")
|
||||
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically
|
||||
@@ -135,8 +147,38 @@ def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||
return actions
|
||||
|
||||
|
||||
def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
async def _ncr_links(db: AsyncSession, ncr_id: int) -> tuple[list[NcrLinkOut], list[NcrLinkOut]]:
|
||||
"""Outgoing links (prior NCRs this one references) and incoming links
|
||||
(later NCRs that flagged this one as a recurrence)."""
|
||||
|
||||
async def _load(join_col, where_col) -> list[NcrLinkOut]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Ncr.id, Ncr.ncr_number, Ncr.job_number, Ncr.stage)
|
||||
.join(NcrLink, join_col == Ncr.id)
|
||||
.where(where_col == ncr_id)
|
||||
.order_by(Ncr.ncr_number)
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
NcrLinkOut(
|
||||
id=r.id,
|
||||
ncr_number=r.ncr_number,
|
||||
job_number=r.job_number,
|
||||
stage=r.stage,
|
||||
stage_label=STAGE_LABELS[Stage(r.stage)],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
related = await _load(NcrLink.related_ncr_id, NcrLink.ncr_id)
|
||||
referenced_by = await _load(NcrLink.ncr_id, NcrLink.related_ncr_id)
|
||||
return related, referenced_by
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
stage = Stage(ncr.stage)
|
||||
related_ncrs, referenced_by = await _ncr_links(db, ncr.id)
|
||||
return NcrDetailOut(
|
||||
id=ncr.id,
|
||||
ncr_number=ncr.ncr_number,
|
||||
@@ -174,6 +216,32 @@ def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||
qc_closed_by=(
|
||||
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None
|
||||
),
|
||||
root_cause=ncr.root_cause,
|
||||
root_cause_category=(
|
||||
ncr.root_cause_category.name if ncr.root_cause_category else None
|
||||
),
|
||||
root_cause_category_id=ncr.root_cause_category_id,
|
||||
corrective_action_required=ncr.corrective_action_required,
|
||||
corrective_action_justification=ncr.corrective_action_justification,
|
||||
corrective_action_plan=ncr.corrective_action_plan,
|
||||
corrective_action_owner=(
|
||||
UserRef.model_validate(ncr.corrective_action_owner)
|
||||
if ncr.corrective_action_owner
|
||||
else None
|
||||
),
|
||||
corrective_action_due_date=ncr.corrective_action_due_date,
|
||||
corrective_action_opened_at=ncr.corrective_action_opened_at,
|
||||
effectiveness_result=ncr.effectiveness_result,
|
||||
effectiveness_notes=ncr.effectiveness_notes,
|
||||
effectiveness_verified_at=ncr.effectiveness_verified_at,
|
||||
effectiveness_verified_by=(
|
||||
UserRef.model_validate(ncr.effectiveness_verified_by)
|
||||
if ncr.effectiveness_verified_by
|
||||
else None
|
||||
),
|
||||
is_recurring=ncr.is_recurring,
|
||||
related_ncrs=related_ncrs,
|
||||
referenced_by=referenced_by,
|
||||
labor_cost=ncr.labor_cost,
|
||||
material_cost=ncr.material_cost,
|
||||
service_cost=ncr.service_cost,
|
||||
@@ -271,7 +339,7 @@ async def create_ncr(
|
||||
f"{current.user.display_name} submitted a new NCR and selected you as the "
|
||||
"disposition authority.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
# ── queues / search / export ────────────────────────────────────────────────
|
||||
@@ -398,8 +466,10 @@ _CSV_COLUMNS = [
|
||||
"ncr_number", "job_number", "department", "deviation_category", "requester",
|
||||
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
|
||||
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
|
||||
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost",
|
||||
"total_cost", "closed_at",
|
||||
"qc_closed", "root_cause_category", "corrective_action_required",
|
||||
"corrective_action_owner", "corrective_action_due_date", "effectiveness_result",
|
||||
"effectiveness_verified_at", "is_recurring", "labor_cost", "material_cost",
|
||||
"service_cost", "other_cost", "total_cost", "closed_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -449,7 +519,15 @@ async def export_ncrs_csv(
|
||||
STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
|
||||
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "",
|
||||
n.secondary_review_needed, n.operations_complete, n.qc_approval or "",
|
||||
n.qc_closed, n.labor_cost or "", n.material_cost or "",
|
||||
n.qc_closed,
|
||||
n.root_cause_category.name if n.root_cause_category else "",
|
||||
"" if n.corrective_action_required is None else n.corrective_action_required,
|
||||
n.corrective_action_owner.display_name if n.corrective_action_owner else "",
|
||||
n.corrective_action_due_date.isoformat() if n.corrective_action_due_date else "",
|
||||
n.effectiveness_result or "",
|
||||
n.effectiveness_verified_at.isoformat(sep=" ") if n.effectiveness_verified_at else "",
|
||||
n.is_recurring,
|
||||
n.labor_cost or "", n.material_cost or "",
|
||||
n.service_cost or "", n.other_cost or "", n.total_cost or "",
|
||||
n.closed_at.isoformat(sep=" ") if n.closed_at else "",
|
||||
]
|
||||
@@ -469,7 +547,7 @@ async def get_ncr(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrDetailOut:
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
return _detail(ncr, current)
|
||||
return await _detail(db, ncr, current)
|
||||
|
||||
|
||||
# ── stage actions ────────────────────────────────────────────────────────────
|
||||
@@ -542,7 +620,7 @@ async def initial_disposition(
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
warnings = await send_stage_notification(db, ncr, event, current, summary)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/secondary-disposition", response_model=NcrMutationOut)
|
||||
@@ -583,7 +661,7 @@ async def secondary_disposition(
|
||||
else:
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/operations-complete", response_model=NcrMutationOut)
|
||||
@@ -618,7 +696,7 @@ async def operations_complete(
|
||||
f"{current.user.display_name} marked operations complete; the NCR is ready "
|
||||
"for QC inspection.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/inspection", response_model=NcrMutationOut)
|
||||
@@ -655,7 +733,192 @@ async def inspection(
|
||||
else:
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/capa", response_model=NcrMutationOut)
|
||||
async def update_capa(
|
||||
ncr_ref: str,
|
||||
payload: CapaIn,
|
||||
current: CurrentUser = Depends(
|
||||
require_roles(Role.QC_INSPECTOR, Role.DISPOSITION_AUTHORITY)
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrMutationOut:
|
||||
"""Corrective & Preventive Action (API Q1 §5.9.1.2 / §6.4.2). Editable in
|
||||
any non-closed stage by QC Inspectors, Disposition Authorities, or Admins;
|
||||
the costing action refuses to close the NCR until this section is
|
||||
complete (see _capa_close_blockers)."""
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
if ncr.stage == Stage.CLOSED.value:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.",
|
||||
)
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True, exclude={"related_ncr_ids"})
|
||||
|
||||
# The Yes/No gate always carries a brief justification (either in this
|
||||
# request or already on record).
|
||||
if updates.get("corrective_action_required") is not None:
|
||||
justification = updates.get(
|
||||
"corrective_action_justification", ncr.corrective_action_justification
|
||||
)
|
||||
if not (justification or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="A brief justification is required when answering "
|
||||
"'Corrective Action Required?'.",
|
||||
)
|
||||
|
||||
if updates.get("root_cause_category_id") is not None:
|
||||
cat = await db.get(RootCauseCategory, updates["root_cause_category_id"])
|
||||
if cat is None or not cat.is_active:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Unknown or inactive root cause category."
|
||||
)
|
||||
|
||||
if updates.get("corrective_action_owner_id") is not None:
|
||||
owner = await db.get(User, updates["corrective_action_owner_id"])
|
||||
if owner is None or not owner.is_active:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Corrective action owner must be an active user."
|
||||
)
|
||||
|
||||
ca_required = updates.get(
|
||||
"corrective_action_required", ncr.corrective_action_required
|
||||
)
|
||||
new_result = updates.get("effectiveness_result", ncr.effectiveness_result)
|
||||
if new_result is not None and ca_required is not True:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Answer 'Corrective Action Required? = Yes' before recording "
|
||||
"effectiveness verification.",
|
||||
)
|
||||
|
||||
# Server-stamped bookkeeping: who/when verified, and when the CAPA opened.
|
||||
if (
|
||||
"effectiveness_result" in updates
|
||||
and updates["effectiveness_result"] != ncr.effectiveness_result
|
||||
):
|
||||
if updates["effectiveness_result"] is None:
|
||||
updates.update(
|
||||
{"effectiveness_verified_at": None, "effectiveness_verified_by_id": None}
|
||||
)
|
||||
else:
|
||||
updates.update(
|
||||
{
|
||||
"effectiveness_verified_at": utcnow(),
|
||||
"effectiveness_verified_by_id": current.id,
|
||||
}
|
||||
)
|
||||
if (
|
||||
updates.get("corrective_action_required") is True
|
||||
and ncr.corrective_action_opened_at is None
|
||||
):
|
||||
updates["corrective_action_opened_at"] = utcnow()
|
||||
|
||||
changes = apply_field_updates(db, ncr, current.id, updates, action="capa")
|
||||
|
||||
if payload.related_ncr_ids is not None:
|
||||
if ncr.id in payload.related_ncr_ids:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="An NCR cannot be linked to itself."
|
||||
)
|
||||
new_ids = set(payload.related_ncr_ids)
|
||||
if new_ids:
|
||||
found = {
|
||||
r[0]
|
||||
for r in (
|
||||
await db.execute(select(Ncr.id).where(Ncr.id.in_(new_ids)))
|
||||
).all()
|
||||
}
|
||||
if new_ids - found:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="One or more linked NCRs do not exist."
|
||||
)
|
||||
old_ids = {
|
||||
r[0]
|
||||
for r in (
|
||||
await db.execute(
|
||||
select(NcrLink.related_ncr_id).where(NcrLink.ncr_id == ncr.id)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if new_ids != old_ids:
|
||||
numbers = {
|
||||
r[0]: r[1]
|
||||
for r in (
|
||||
await db.execute(
|
||||
select(Ncr.id, Ncr.ncr_number).where(
|
||||
Ncr.id.in_(new_ids | old_ids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
await db.execute(delete(NcrLink).where(NcrLink.ncr_id == ncr.id))
|
||||
for rid in sorted(new_ids):
|
||||
db.add(NcrLink(ncr_id=ncr.id, related_ncr_id=rid))
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=current.id,
|
||||
action="capa",
|
||||
field_name="related_ncrs",
|
||||
old_value=", ".join(numbers[i] for i in sorted(old_ids)) or None,
|
||||
new_value=", ".join(numbers[i] for i in sorted(new_ids)) or None,
|
||||
)
|
||||
|
||||
# Captured before commit: _refetch expires the session, and unlike stage
|
||||
# transitions nothing re-loads the acting user afterwards.
|
||||
actor_name = current.user.display_name
|
||||
await db.commit()
|
||||
ncr = await _refetch(db, ncr.id)
|
||||
|
||||
warnings: list[str] = []
|
||||
if "corrective_action_owner_id" in changes and ncr.corrective_action_owner:
|
||||
await db.refresh(current.user) # notification internals read actor email
|
||||
due = (
|
||||
f" Due date: {ncr.corrective_action_due_date.isoformat()}."
|
||||
if ncr.corrective_action_due_date
|
||||
else ""
|
||||
)
|
||||
warnings = await send_stage_notification(
|
||||
db,
|
||||
ncr,
|
||||
NotifyEvent.CAPA_ASSIGNED,
|
||||
current,
|
||||
f"{actor_name} assigned you as the corrective action "
|
||||
f"owner for this NCR.{due}",
|
||||
)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
def _capa_close_blockers(ncr: Ncr) -> list[str]:
|
||||
"""What still blocks closure under the API Q1 §6.4.2 gate. Empty when the
|
||||
CA question is answered 'No' (with justification, enforced at entry) or
|
||||
answered 'Yes' with a complete, verified-effective action plan."""
|
||||
if ncr.corrective_action_required is None:
|
||||
return ["'Corrective Action Required?' has not been answered"]
|
||||
if not ncr.corrective_action_required:
|
||||
return []
|
||||
problems = []
|
||||
if not (ncr.root_cause or "").strip():
|
||||
problems.append("root cause is missing")
|
||||
if ncr.root_cause_category_id is None:
|
||||
problems.append("root cause category is not set")
|
||||
if not (ncr.corrective_action_plan or "").strip():
|
||||
problems.append("corrective action plan is missing")
|
||||
if ncr.corrective_action_owner_id is None:
|
||||
problems.append("no corrective action owner is assigned")
|
||||
if ncr.corrective_action_due_date is None:
|
||||
problems.append("no corrective action due date is set")
|
||||
if ncr.effectiveness_result != "effective":
|
||||
problems.append(
|
||||
"effectiveness verification has not confirmed the corrective action "
|
||||
"as effective"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/costing", response_model=NcrMutationOut)
|
||||
@@ -665,10 +928,19 @@ async def costing(
|
||||
current: CurrentUser = Depends(require_roles(Role.COSTING)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NcrMutationOut:
|
||||
"""Stage 6 — Costing. Saving costs completes the workflow and closes the NCR."""
|
||||
"""Stage 6 — Costing. Saving costs completes the workflow and closes the
|
||||
NCR — provided the CAPA section passes the API Q1 closure gate."""
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
_ensure_stage(ncr, Stage.COSTING)
|
||||
|
||||
blockers = _capa_close_blockers(ncr)
|
||||
if blockers:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"{ncr.ncr_number} cannot be closed: " + "; ".join(blockers) + ". "
|
||||
"Complete the CAPA section first.",
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
apply_field_updates(
|
||||
db,
|
||||
@@ -697,7 +969,7 @@ async def costing(
|
||||
f"Costing is complete and your NCR has been closed. Total cost of "
|
||||
f"nonconformance: ${ncr.total_cost:,.2f}.",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/ncrs/{ncr_ref}/reopen", response_model=NcrMutationOut)
|
||||
@@ -747,7 +1019,7 @@ async def reopen(
|
||||
f"{current.user.display_name} reopened this NCR to "
|
||||
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}",
|
||||
)
|
||||
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||
return NcrMutationOut(ncr=await _detail(db, ncr, current), warnings=warnings)
|
||||
|
||||
|
||||
def _do_transition(
|
||||
@@ -865,7 +1137,8 @@ async def ncr_pdf(
|
||||
from app.services.pdf import render_ncr_pdf
|
||||
|
||||
ncr = await _get_ncr(db, ncr_ref)
|
||||
pdf_bytes = await render_ncr_pdf(ncr)
|
||||
related_ncrs, _ = await _ncr_links(db, ncr.id)
|
||||
pdf_bytes = await render_ncr_pdf(ncr, [r.ncr_number for r in related_ncrs])
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.database import get_db
|
||||
from app.domain import STAGE_LABELS, Stage
|
||||
from app.models import Department, DeviationCategory, Ncr, StageTransition
|
||||
from app.models import Department, DeviationCategory, Ncr, RootCauseCategory, StageTransition
|
||||
from app.models.base import utcnow
|
||||
from app.schemas.report import (
|
||||
AgingBucket,
|
||||
@@ -70,9 +70,14 @@ async def reports_summary(
|
||||
c.id: c.name
|
||||
for c in (await db.execute(select(DeviationCategory))).scalars().all()
|
||||
}
|
||||
rcc_names = {
|
||||
c.id: c.name
|
||||
for c in (await db.execute(select(RootCauseCategory))).scalars().all()
|
||||
}
|
||||
|
||||
by_dept: dict[str, int] = defaultdict(int)
|
||||
by_cat: dict[str, int] = defaultdict(int)
|
||||
by_rcc: dict[str, int] = defaultdict(int)
|
||||
by_month: dict[str, int] = defaultdict(int)
|
||||
cost_by_month: dict[str, dict[str, Decimal]] = defaultdict(
|
||||
lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)}
|
||||
@@ -83,12 +88,38 @@ async def reports_summary(
|
||||
open_count = 0
|
||||
closed_count = 0
|
||||
now = utcnow()
|
||||
today = now.date()
|
||||
|
||||
# ── CAPA metrics (API Q1 §6.4.2) ─────────────────────────────────────────
|
||||
root_cause_done = 0
|
||||
ca_required_count = 0
|
||||
ca_verified_effective = 0
|
||||
capa_close_days: list[float] = []
|
||||
overdue_capa = 0
|
||||
|
||||
for n in ncrs:
|
||||
by_dept[dept_names.get(n.department_id, "?")] += 1
|
||||
by_cat[cat_names.get(n.deviation_category_id, "?")] += 1
|
||||
by_month[n.created_at.strftime("%Y-%m")] += 1
|
||||
job_counts[n.job_number] += 1
|
||||
if (n.root_cause or "").strip():
|
||||
root_cause_done += 1
|
||||
if n.root_cause_category_id is not None:
|
||||
by_rcc[rcc_names.get(n.root_cause_category_id, "?")] += 1
|
||||
if n.corrective_action_required:
|
||||
ca_required_count += 1
|
||||
effective = n.effectiveness_result == "effective"
|
||||
if effective:
|
||||
ca_verified_effective += 1
|
||||
if n.corrective_action_opened_at and n.effectiveness_verified_at:
|
||||
capa_close_days.append(
|
||||
(
|
||||
n.effectiveness_verified_at - n.corrective_action_opened_at
|
||||
).total_seconds()
|
||||
/ 86400
|
||||
)
|
||||
elif n.corrective_action_due_date and n.corrective_action_due_date < today:
|
||||
overdue_capa += 1
|
||||
if n.stage == Stage.CLOSED.value:
|
||||
closed_count += 1
|
||||
month = (n.closed_at or n.created_at).strftime("%Y-%m")
|
||||
@@ -152,6 +183,24 @@ async def reports_summary(
|
||||
open_ncrs=open_count,
|
||||
closed_ncrs=closed_count,
|
||||
total_cost=total_cost,
|
||||
root_cause_pct=(
|
||||
round(100 * root_cause_done / len(ncrs), 1) if ncrs else None
|
||||
),
|
||||
effectiveness_verified_pct=(
|
||||
round(100 * ca_verified_effective / ca_required_count, 1)
|
||||
if ca_required_count
|
||||
else None
|
||||
),
|
||||
avg_capa_close_days=(
|
||||
round(sum(capa_close_days) / len(capa_close_days), 2)
|
||||
if capa_close_days
|
||||
else None
|
||||
),
|
||||
overdue_capa_count=overdue_capa,
|
||||
by_root_cause_category=sorted(
|
||||
(CountByName(name=k, count=v) for k, v in by_rcc.items()),
|
||||
key=lambda x: -x.count,
|
||||
),
|
||||
by_department=sorted(
|
||||
(CountByName(name=k, count=v) for k, v in by_dept.items()),
|
||||
key=lambda x: -x.count,
|
||||
|
||||
@@ -21,3 +21,4 @@ class LookupPatchIn(BaseModel):
|
||||
class LookupsOut(BaseModel):
|
||||
departments: list[NamedLookupOut]
|
||||
deviation_categories: list[NamedLookupOut]
|
||||
root_cause_categories: list[NamedLookupOut]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -134,6 +134,61 @@
|
||||
<div class="notes">{{ ncr.inspection_notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Corrective & 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>
|
||||
<table class="fields costs">
|
||||
<tr>
|
||||
|
||||
@@ -28,7 +28,14 @@ from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||
|
||||
from app.database import get_engine, get_session_factory # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import Base, Department, DeviationCategory, User, UserRole # noqa: E402
|
||||
from app.models import ( # noqa: E402
|
||||
Base,
|
||||
Department,
|
||||
DeviationCategory,
|
||||
RootCauseCategory,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -41,6 +48,8 @@ def _create_schema():
|
||||
db.add(Department(name="Machining", is_active=True))
|
||||
db.add(Department(name="Inactive Dept", is_active=False))
|
||||
db.add(DeviationCategory(name="Dimensional", is_active=True))
|
||||
db.add(RootCauseCategory(name="Method", is_active=True))
|
||||
db.add(RootCauseCategory(name="Retired Cause", is_active=False))
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
295
backend/tests/test_capa.py
Normal file
295
backend/tests/test_capa.py
Normal 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
|
||||
@@ -73,6 +73,15 @@ async def test_admin_can_act_at_every_stage(client, team):
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/capa",
|
||||
json={
|
||||
"corrective_action_required": False,
|
||||
"corrective_action_justification": "Isolated incident; no systemic cause.",
|
||||
},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Workflow state machine: happy paths, invalid transitions, closure locking,
|
||||
admin reopen, and rich-text sanitization."""
|
||||
from .util import (
|
||||
answer_capa_no,
|
||||
create_ncr,
|
||||
do_initial_disposition,
|
||||
hdr,
|
||||
@@ -47,15 +48,22 @@ async def test_full_lifecycle_direct_to_operations(client, team):
|
||||
assert body["stage"] == "costing"
|
||||
assert body["qc_closed"] is True
|
||||
|
||||
# the API Q1 CAPA gate blocks closure until the CA question is answered
|
||||
costing_body = {
|
||||
"labor_cost": "100.00",
|
||||
"material_cost": "50.25",
|
||||
"service_cost": "0",
|
||||
"other_cost": "10",
|
||||
}
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={
|
||||
"labor_cost": "100.00",
|
||||
"material_cost": "50.25",
|
||||
"service_cost": "0",
|
||||
"other_cost": "10",
|
||||
},
|
||||
headers=hdr(team["cost"]),
|
||||
f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"])
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "Corrective Action Required" in r.json()["detail"]
|
||||
await answer_capa_no(client, team, ncr["id"])
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing", json=costing_body, headers=hdr(team["cost"])
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "closed"
|
||||
|
||||
@@ -92,8 +92,23 @@ async def to_costing(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def answer_capa_no(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
"""Answer the API Q1 corrective-action gate with 'No' so the NCR can close."""
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr_id}/capa",
|
||||
json={
|
||||
"corrective_action_required": False,
|
||||
"corrective_action_justification": "Isolated incident; contained by disposition.",
|
||||
},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def to_closed(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
await to_costing(client, team, ncr_id)
|
||||
await answer_capa_no(client, team, ncr_id)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr_id}/costing",
|
||||
json={
|
||||
|
||||
@@ -35,10 +35,11 @@ export function useLookups() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pass an empty role to list every active user (e.g. CA owner picker). */
|
||||
export function useUsersByRole(role: string) {
|
||||
return useQuery({
|
||||
queryKey: ["users", role],
|
||||
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`),
|
||||
queryFn: () => api<UserOut[]>(role ? `/api/users?role=${role}` : "/api/users"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface NamedLookup {
|
||||
export interface LookupsOut {
|
||||
departments: NamedLookup[];
|
||||
deviation_categories: NamedLookup[];
|
||||
root_cause_categories: NamedLookup[];
|
||||
}
|
||||
|
||||
export interface AttachmentOut {
|
||||
@@ -128,10 +129,19 @@ export type NcrAction =
|
||||
| "operations_complete"
|
||||
| "inspection"
|
||||
| "costing"
|
||||
| "capa"
|
||||
| "reopen"
|
||||
| "add_attachment"
|
||||
| "view_audit";
|
||||
|
||||
export interface NcrLinkOut {
|
||||
id: number;
|
||||
ncr_number: string;
|
||||
job_number: string;
|
||||
stage: StageValue;
|
||||
stage_label: string;
|
||||
}
|
||||
|
||||
export interface NcrDetail {
|
||||
id: number;
|
||||
ncr_number: string;
|
||||
@@ -161,6 +171,22 @@ export interface NcrDetail {
|
||||
qc_closed: boolean;
|
||||
qc_closed_at: string | null;
|
||||
qc_closed_by: UserRef | null;
|
||||
root_cause: string | null;
|
||||
root_cause_category: string | null;
|
||||
root_cause_category_id: number | null;
|
||||
corrective_action_required: boolean | null;
|
||||
corrective_action_justification: string | null;
|
||||
corrective_action_plan: string | null;
|
||||
corrective_action_owner: UserRef | null;
|
||||
corrective_action_due_date: string | null;
|
||||
corrective_action_opened_at: string | null;
|
||||
effectiveness_result: "effective" | "not_effective" | null;
|
||||
effectiveness_notes: string | null;
|
||||
effectiveness_verified_at: string | null;
|
||||
effectiveness_verified_by: UserRef | null;
|
||||
is_recurring: boolean;
|
||||
related_ncrs: NcrLinkOut[];
|
||||
referenced_by: NcrLinkOut[];
|
||||
labor_cost: string | null;
|
||||
material_cost: string | null;
|
||||
service_cost: string | null;
|
||||
@@ -213,6 +239,11 @@ export interface ReportsSummary {
|
||||
open_ncrs: number;
|
||||
closed_ncrs: number;
|
||||
total_cost: string;
|
||||
root_cause_pct: number | null;
|
||||
effectiveness_verified_pct: number | null;
|
||||
avg_capa_close_days: number | null;
|
||||
overdue_capa_count: number;
|
||||
by_root_cause_category: { name: string; count: number }[];
|
||||
by_department: { name: string; count: number }[];
|
||||
by_category: { name: string; count: number }[];
|
||||
by_month: { month: string; count: number }[];
|
||||
|
||||
457
frontend/src/pages/CapaSection.tsx
Normal file
457
frontend/src/pages/CapaSection.tsx
Normal 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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { RichTextView } from "../components/RichTextView";
|
||||
import { StageChip } from "../components/StageChip";
|
||||
import { StageStepper } from "../components/StageStepper";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { CapaSection } from "./CapaSection";
|
||||
import {
|
||||
CostingForm,
|
||||
InitialDispositionForm,
|
||||
@@ -262,6 +263,10 @@ function DetailBody({ ncr }: { ncr: NcrDetail }) {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Corrective & Preventive Action (API Q1)">
|
||||
<CapaSection ncr={ncr} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Costing">
|
||||
<Grid container spacing={2}>
|
||||
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>
|
||||
|
||||
@@ -220,6 +220,31 @@ export function ReportsPage() {
|
||||
/>
|
||||
</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 item xs={12} md={6}>
|
||||
<ChartCard title="NCRs by Month">
|
||||
@@ -340,6 +365,32 @@ export function ReportsPage() {
|
||||
</ChartCard>
|
||||
</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}>
|
||||
<ChartCard title="Open NCR Aging" subheader="Days in current stage">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
|
||||
@@ -70,7 +70,7 @@ function LookupManager({
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={`New ${title.toLowerCase().replace(/s$/, "")}`}
|
||||
label={`New ${title.toLowerCase().replace(/ies$/, "y").replace(/s$/, "")}`}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
fullWidth
|
||||
@@ -139,6 +139,13 @@ export function AdminListsPage() {
|
||||
helper="Shown in the Deviation Category dropdown on new NCRs"
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user