Files
pesco-ncr/backend/app/models/ncr.py
ang3l12 c1cd1bc9df Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:49:08 -06:00

249 lines
12 KiB
Python

from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, utcnow
from app.models.user import User
class NcrSequence(Base):
"""Per-year NCR number allocator. Incremented atomically inside the
NCR-creation transaction (row lock held until commit) so concurrent
submissions can never produce the same number."""
__tablename__ = "ncr_sequences"
year: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
last_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class Ncr(Base):
__tablename__ = "ncrs"
__table_args__ = (
UniqueConstraint("ncr_number", name="uq_ncrs_ncr_number"),
Index("ix_ncrs_stage", "stage"),
Index("ix_ncrs_job_number", "job_number"),
Index("ix_ncrs_created_at", "created_at"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ncr_number: Mapped[str] = mapped_column(String(20))
ncr_year: Mapped[int] = mapped_column(Integer)
ncr_seq: Mapped[int] = mapped_column(Integer)
# ── Request (stage 1) ────────────────────────────────────────────────────
job_number: Mapped[str] = mapped_column(String(100))
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"))
deviation_category_id: Mapped[int] = mapped_column(ForeignKey("deviation_categories.id"))
disposition_authority_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
deviation_detail: Mapped[str] = mapped_column(Text)
requester_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
# ── Workflow state ───────────────────────────────────────────────────────
stage: Mapped[str] = mapped_column(String(30))
stage_entered_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
# ── Disposition (initial + secondary) ────────────────────────────────────
qc_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
work_order: Mapped[str | None] = mapped_column(String(100), nullable=True)
disposition_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # sanitized HTML
secondary_review_needed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# ── Operations ───────────────────────────────────────────────────────────
operations_complete: Mapped[bool] = mapped_column(Boolean, default=False)
operations_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
operations_completed_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
# ── QC Inspection ────────────────────────────────────────────────────────
qc_approval: Mapped[str | None] = mapped_column(String(10), nullable=True) # yes | no
inspection_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
qc_closed: Mapped[bool] = mapped_column(Boolean, default=False)
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)
service_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
other_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
costing_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
costing_completed_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
# ── Closure ──────────────────────────────────────────────────────────────
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
# ── 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"
)
operations_completed_by: Mapped[User | None] = relationship(
foreign_keys=[operations_completed_by_id], lazy="selectin"
)
qc_closed_by: Mapped[User | None] = relationship(
foreign_keys=[qc_closed_by_id], lazy="selectin"
)
costing_completed_by: Mapped[User | None] = relationship(
foreign_keys=[costing_completed_by_id], lazy="selectin"
)
closed_by: Mapped[User | None] = relationship(foreign_keys=[closed_by_id], lazy="selectin")
secondary_assignee_rows: Mapped[list["NcrSecondaryAssignee"]] = relationship(
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
)
transitions: Mapped[list["StageTransition"]] = relationship(
back_populates="ncr",
cascade="all, delete-orphan",
lazy="selectin",
order_by="StageTransition.acted_at",
)
attachments: Mapped[list["Attachment"]] = relationship( # noqa: F821
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
)
job_info: Mapped["JobInfo | None"] = relationship(
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin", uselist=False
)
@property
def secondary_authorities(self) -> list[User]:
return [row.user for row in self.secondary_assignee_rows]
@property
def total_cost(self) -> Decimal | None:
costs = [self.labor_cost, self.material_cost, self.service_cost, self.other_cost]
present = [c for c in costs if c is not None]
if not present:
return None
return sum(present, Decimal("0"))
class NcrSecondaryAssignee(Base):
"""Users selected as 'Notify These People' for secondary disposition."""
__tablename__ = "ncr_secondary_assignees"
ncr_id: Mapped[int] = mapped_column(
ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
ncr: Mapped[Ncr] = relationship(back_populates="secondary_assignee_rows")
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."""
__tablename__ = "stage_transitions"
__table_args__ = (Index("ix_stage_transitions_ncr", "ncr_id", "acted_at"),)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
from_stage: Mapped[str | None] = mapped_column(String(30), nullable=True)
to_stage: Mapped[str] = mapped_column(String(30))
action: Mapped[str] = mapped_column(String(40))
acted_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
acted_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
note: Mapped[str | None] = mapped_column(Text, nullable=True) # e.g. reopen reason
ncr: Mapped[Ncr] = relationship(back_populates="transitions")
acted_by: Mapped[User] = relationship(lazy="selectin")
class JobInfo(Base):
"""Read-only enrichment for a job number, populated by a JobLookupService.
Stays empty under NullJobLookupService; the future VisualJobLookupService
will fill it from Infor VISUAL (WORK_ORDER + customer order linkage).
"""
__tablename__ = "job_info"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ncr_id: Mapped[int] = mapped_column(
ForeignKey("ncrs.id", ondelete="CASCADE"), unique=True
)
part_id: Mapped[str | None] = mapped_column(String(30), nullable=True)
part_description: Mapped[str | None] = mapped_column(String(255), nullable=True)
customer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
work_order_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
source: Mapped[str] = mapped_column(String(20), default="null")
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
ncr: Mapped[Ncr] = relationship(back_populates="job_info")