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:
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
|
||||
Reference in New Issue
Block a user