196 lines
9.6 KiB
Python
196 lines
9.6 KiB
Python
|
|
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")
|