296 lines
12 KiB
Python
296 lines
12 KiB
Python
|
|
"""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
|