Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
29
backend/app/models/__init__.py
Normal file
29
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app.models.base import Base
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.lookups import Department, DeviationCategory
|
||||
from app.models.ncr import (
|
||||
JobInfo,
|
||||
Ncr,
|
||||
NcrSecondaryAssignee,
|
||||
NcrSequence,
|
||||
StageTransition,
|
||||
)
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.app_setting import AppSetting
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Department",
|
||||
"DeviationCategory",
|
||||
"Ncr",
|
||||
"NcrSequence",
|
||||
"NcrSecondaryAssignee",
|
||||
"StageTransition",
|
||||
"JobInfo",
|
||||
"Attachment",
|
||||
"AuditLog",
|
||||
"AppSetting",
|
||||
]
|
||||
14
backend/app/models/app_setting.py
Normal file
14
backend/app/models/app_setting.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(String(500))
|
||||
|
||||
|
||||
NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"
|
||||
25
backend/app/models/attachment.py
Normal file
25
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
|
||||
original_filename: Mapped[str] = mapped_column(String(255))
|
||||
# Relative path under ATTACHMENTS_DIR: "<ncr_id>/<uuid><ext>"
|
||||
stored_path: Mapped[str] = mapped_column(String(300), unique=True)
|
||||
content_type: Mapped[str] = mapped_column(String(100))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
is_image: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
uploaded_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
ncr = relationship("Ncr", back_populates="attachments")
|
||||
uploaded_by: Mapped[User] = relationship(lazy="selectin")
|
||||
37
backend/app/models/audit.py
Normal file
37
backend/app/models/audit.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Immutable audit record. The application exposes no update or delete
|
||||
path for these rows; one row per changed field (field_name null for
|
||||
record-level events such as create/transition/attachment/reopen)."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_log_ncr", "ncr_id", "created_at"),
|
||||
Index("ix_audit_log_created_at", "created_at"),
|
||||
)
|
||||
|
||||
# BigInteger on MySQL; plain INTEGER on SQLite (required for autoincrement).
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
|
||||
)
|
||||
# Nullable so admin actions without an NCR (settings, role changes) are auditable too.
|
||||
ncr_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("ncrs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
action: Mapped[str] = mapped_column(String(40))
|
||||
field_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(lazy="selectin")
|
||||
12
backend/app/models/base.py
Normal file
12
backend/app/models/base.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Naive UTC timestamp — all datetimes are stored as UTC in MySQL DATETIME."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
22
backend/app/models/lookups.py
Normal file
22
backend/app/models/lookups.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
# Deactivated values are hidden from new-NCR forms but remain valid on
|
||||
# existing records; values referenced by NCRs are never hard-deleted.
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class DeviationCategory(Base):
|
||||
__tablename__ = "deviation_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
195
backend/app/models/ncr.py
Normal file
195
backend/app/models/ncr.py
Normal file
@@ -0,0 +1,195 @@
|
||||
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")
|
||||
39
backend/app/models/user.py
Normal file
39
backend/app/models/user.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
# Entra object id; null for dev-mode/seeded users.
|
||||
entra_oid: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255))
|
||||
employee_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
role_rows: Mapped[list["UserRole"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def roles(self) -> list[str]:
|
||||
return sorted(r.role for r in self.role_rows)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="role_rows")
|
||||
Reference in New Issue
Block a user