from datetime import datetime from decimal import Decimal from sqlalchemy import ( Boolean, 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) # ── 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") 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 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")