Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification

Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ang3l12
2026-08-04 13:49:08 -06:00
parent 8d48f774bb
commit c1cd1bc9df
28 changed files with 1830 additions and 61 deletions

View File

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