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:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

173
backend/app/auth/deps.py Normal file
View File

@@ -0,0 +1,173 @@
"""Request authentication + authorization dependencies.
AUTH_MODE=entra: validates the bearer token, enforces the front-door group,
and auto-provisions a local user (OID, name, email) on first login.
AUTH_MODE=dev: trusts an X-Dev-User email header against seeded users.
Local development only.
"""
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.entra import AuthError, ensure_group_membership, validate_access_token
from app.config import get_settings
from app.database import get_db
from app.domain import Role
from app.models import User, UserRole
from app.models.base import utcnow
logger = logging.getLogger(__name__)
_bearer = HTTPBearer(auto_error=False)
DEV_DEFAULT_USER = "admin@pescoinc.biz"
@dataclass
class CurrentUser:
user: User
roles: set[str] = field(default_factory=set)
token: str | None = None # raw API access token (used for Graph OBO)
claims: dict = field(default_factory=dict)
@property
def id(self) -> int:
return self.user.id
@property
def is_admin(self) -> bool:
return Role.ADMIN.value in self.roles
def has_role(self, *roles: Role) -> bool:
return self.is_admin or any(r.value in self.roles for r in roles)
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email.lower()))
return result.scalar_one_or_none()
async def _provision_entra_user(db: AsyncSession, claims: dict) -> User:
settings = get_settings()
oid = claims.get("oid") or claims.get("sub")
email = (
claims.get("preferred_username")
or claims.get("email")
or claims.get("upn")
or ""
).lower()
name = claims.get("name") or email or "Unknown User"
employee_id = claims.get("employeeid") or claims.get("employee_id")
user = (
await db.execute(select(User).where(User.entra_oid == oid))
).scalar_one_or_none()
if user is None and email:
user = await _load_user_by_email(db, email)
if user is not None and user.entra_oid is None:
user.entra_oid = oid # link pre-seeded user to their Entra identity
if user is None:
if not email:
raise AuthError("Token has no usable email/UPN claim.", 403)
user = User(
entra_oid=oid,
email=email,
display_name=name,
employee_id=employee_id,
)
db.add(user)
try:
await db.flush()
db.add(UserRole(user_id=user.id, role=Role.REQUESTER.value))
if email in settings.initial_admin_email_set:
db.add(UserRole(user_id=user.id, role=Role.ADMIN.value))
user.last_login_at = utcnow()
await db.commit()
logger.info("Auto-provisioned user %s", email)
except IntegrityError:
# Concurrent first login for the same user — use the winner's row.
await db.rollback()
user = (
await db.execute(select(User).where(User.entra_oid == oid))
).scalar_one()
await db.refresh(user)
return user
# Keep profile fresh; throttle last_login writes to one per 15 minutes.
dirty = False
if name and user.display_name != name:
user.display_name = name
dirty = True
if email and user.email != email:
user.email = email
dirty = True
if employee_id and user.employee_id != employee_id:
user.employee_id = employee_id
dirty = True
if user.last_login_at is None or utcnow() - user.last_login_at > timedelta(minutes=15):
user.last_login_at = utcnow()
dirty = True
if dirty:
await db.commit()
await db.refresh(user)
return user
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
db: AsyncSession = Depends(get_db),
) -> CurrentUser:
settings = get_settings()
if settings.auth_mode == "dev":
email = request.headers.get("X-Dev-User", DEV_DEFAULT_USER)
user = await _load_user_by_email(db, email)
if user is None or not user.is_active:
raise HTTPException(
status_code=401,
detail=f"Unknown dev user '{email}'. Run `python -m app.seed` "
"or pass a seeded email in the X-Dev-User header.",
)
return CurrentUser(user=user, roles=set(user.roles), token=None, claims={})
if credentials is None:
raise HTTPException(status_code=401, detail="Missing bearer token.")
token = credentials.credentials
try:
claims = await validate_access_token(token)
await ensure_group_membership(claims, token)
except AuthError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
user = await _provision_entra_user(db, claims)
if not user.is_active:
raise HTTPException(status_code=403, detail="This account has been deactivated.")
return CurrentUser(user=user, roles=set(user.roles), token=token, claims=claims)
def require_roles(*roles: Role):
"""Dependency factory: caller must hold one of `roles` (Admin always passes)."""
async def dependency(
current: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if current.has_role(*roles):
return current
needed = ", ".join(r.value for r in roles)
raise HTTPException(
status_code=403, detail=f"This action requires one of the roles: {needed}."
)
return dependency
require_admin = require_roles(Role.ADMIN)

124
backend/app/auth/entra.py Normal file
View File

@@ -0,0 +1,124 @@
"""Entra ID access-token validation (python-jose + tenant JWKS)."""
import logging
import time
import httpx
from jose import JWTError, jwt
from app.config import get_settings
logger = logging.getLogger(__name__)
class AuthError(Exception):
def __init__(self, message: str, status_code: int = 401):
self.message = message
self.status_code = status_code
super().__init__(message)
_jwks: dict[str, dict] = {}
_jwks_fetched_at: float = 0.0
_JWKS_TTL = 60 * 60 * 12
async def _fetch_jwks() -> None:
global _jwks, _jwks_fetched_at
settings = get_settings()
url = (
f"https://login.microsoftonline.com/{settings.entra_tenant_id}"
"/discovery/v2.0/keys"
)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
_jwks = {k["kid"]: k for k in resp.json().get("keys", [])}
_jwks_fetched_at = time.time()
logger.info("Fetched %d Entra signing keys", len(_jwks))
async def _get_signing_key(kid: str) -> dict:
stale = time.time() - _jwks_fetched_at > _JWKS_TTL
if kid not in _jwks or stale:
await _fetch_jwks()
key = _jwks.get(kid)
if key is None:
raise AuthError("Token signed with an unknown key.")
return key
async def validate_access_token(token: str) -> dict:
"""Validate signature, expiry, audience, and issuer; return claims."""
settings = get_settings()
if not settings.entra_tenant_id or not settings.entra_client_id:
raise AuthError(
"Entra ID is not configured (ENTRA_TENANT_ID / ENTRA_CLIENT_ID).", 503
)
try:
header = jwt.get_unverified_header(token)
key = await _get_signing_key(header.get("kid", ""))
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
options={"verify_aud": False}, # audience list checked below
)
except AuthError:
raise
except JWTError as exc:
raise AuthError(f"Invalid token: {exc}") from exc
aud = claims.get("aud")
if aud not in settings.api_audiences:
raise AuthError("Token audience does not match this API.")
tid = settings.entra_tenant_id
valid_issuers = {
f"https://login.microsoftonline.com/{tid}/v2.0",
f"https://sts.windows.net/{tid}/",
}
if claims.get("iss") not in valid_issuers:
raise AuthError("Token issuer does not match the configured tenant.")
return claims
async def ensure_group_membership(claims: dict, token: str) -> None:
"""Front-door gate: require membership in ENTRA_ALLOWED_GROUP_ID.
Uses the `groups` claim when present; on claim overage falls back to a
delegated Graph checkMemberGroups call.
"""
settings = get_settings()
group_id = settings.entra_allowed_group_id
if not group_id:
return # gate disabled by configuration
groups = claims.get("groups")
if groups is not None:
if group_id in groups:
return
raise AuthError(
"Your account is not a member of the NCR access group.", 403
)
claim_names = claims.get("_claim_names") or {}
if "groups" in claim_names:
# Group overage: too many groups to embed in the token.
from app.services.graph import check_member_group
try:
if await check_member_group(token, group_id):
return
except Exception as exc:
logger.warning("Group overage Graph check failed: %s", exc)
raise AuthError(
"Could not verify group membership (group overage). Ensure the "
"app has delegated GroupMember.Read.All consent, or scope the "
"group claim to 'Groups assigned to the application'.", 403
) from exc
raise AuthError("Your account is not a member of the NCR access group.", 403)
raise AuthError(
"Access token has no groups claim. Add the groups claim in the app "
"registration (Token configuration → Add groups claim).", 403
)

88
backend/app/config.py Normal file
View File

@@ -0,0 +1,88 @@
"""Application configuration, sourced from environment variables (see .env.example)."""
from functools import lru_cache
from typing import Literal
from urllib.parse import quote_plus
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "PESCO NCR"
app_base_url: str = "http://localhost:8080"
log_level: str = "INFO"
# ── Auth ────────────────────────────────────────────────────────────────
# "entra" validates Entra ID JWTs; "dev" trusts an X-Dev-User header and
# must never be used outside local development.
auth_mode: Literal["entra", "dev"] = "entra"
entra_tenant_id: str = ""
entra_client_id: str = ""
entra_client_secret: str = ""
entra_allowed_group_id: str = ""
entra_api_audience: str = ""
initial_admin_emails: str = ""
# ── Database ────────────────────────────────────────────────────────────
# Full SQLAlchemy URL override (used by tests); otherwise assembled from
# the MYSQL_* parts below.
database_url: str = ""
mysql_host: str = "mysql"
mysql_port: int = 3306
mysql_database: str = "pesco_ncr"
mysql_user: str = "ncr_app"
mysql_password: str = ""
# ── Attachments ─────────────────────────────────────────────────────────
attachments_dir: str = "/data/attachments"
max_upload_mb: int = 25
# ── Notifications ───────────────────────────────────────────────────────
notifications_enabled_default: bool = True
# ── Job lookup (future VISUAL integration) ──────────────────────────────
job_lookup_provider: Literal["null", "visual"] = "null"
visual_db_host: str = ""
visual_db_port: int = 1433
visual_db_name: str = ""
visual_db_user: str = ""
visual_db_password: str = ""
visual_site_id: str = ""
seed_demo_data: bool = False
@property
def effective_database_url(self) -> str:
if self.database_url:
return self.database_url
return (
f"mysql+aiomysql://{quote_plus(self.mysql_user)}:{quote_plus(self.mysql_password)}"
f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}?charset=utf8mb4"
)
@property
def sync_database_url(self) -> str:
"""Synchronous-driver URL for Alembic."""
return self.effective_database_url.replace("+aiomysql", "+pymysql").replace(
"+aiosqlite", ""
)
@property
def api_audiences(self) -> list[str]:
if self.entra_api_audience:
return [self.entra_api_audience]
return [f"api://{self.entra_client_id}", self.entra_client_id]
@property
def initial_admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.initial_admin_emails.split(",") if e.strip()}
@property
def max_upload_bytes(self) -> int:
return self.max_upload_mb * 1024 * 1024
@lru_cache
def get_settings() -> Settings:
return Settings()

68
backend/app/database.py Normal file
View File

@@ -0,0 +1,68 @@
"""Async SQLAlchemy engine/session setup.
The engine is created lazily so importing the app (e.g. in tests that override
`get_db`) never requires a reachable MySQL server or its driver.
"""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import get_settings
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def get_engine() -> AsyncEngine:
global _engine
if _engine is None:
settings = get_settings()
url = settings.effective_database_url
if url.startswith("sqlite"):
# Test runs: fresh connection per checkout (no cross-event-loop
# reuse) and a generous busy timeout for concurrent writers.
from sqlalchemy import event
from sqlalchemy.pool import NullPool
_engine = create_async_engine(
url, poolclass=NullPool, connect_args={"timeout": 30}
)
# SQLite (rollback-journal) deadlocks when a transaction upgrades
# from read to write while another writer waits. Taking the write
# lock up front (BEGIN IMMEDIATE) serializes transactions cleanly,
# mirroring the row-lock semantics InnoDB gives us in production.
@event.listens_for(_engine.sync_engine, "connect")
def _sqlite_autocommit(dbapi_conn, _record):
dbapi_conn.isolation_level = None
@event.listens_for(_engine.sync_engine, "begin")
def _sqlite_begin_immediate(conn):
conn.exec_driver_sql("BEGIN IMMEDIATE")
else:
_engine = create_async_engine(
url,
pool_pre_ping=True,
pool_recycle=1800,
echo=False,
)
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(
get_engine(), expire_on_commit=False, autoflush=False
)
return _session_factory
async def get_db() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as session:
yield session

21
backend/app/dev_init.py Normal file
View File

@@ -0,0 +1,21 @@
"""Create the schema directly from the models — for LOCAL SQLite development
only (`python -m app.dev_init`). Real MySQL deployments use Alembic
(`alembic upgrade head`), which also creates the Power BI reporting views.
"""
import asyncio
from app.config import get_settings
from app.database import get_engine
from app.models import Base
async def main() -> None:
url = get_settings().effective_database_url
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print(f"Schema created for {url}")
if __name__ == "__main__":
asyncio.run(main())

84
backend/app/domain.py Normal file
View File

@@ -0,0 +1,84 @@
"""Domain constants: roles, workflow stages, and the transition map.
Workflow note: an NCR in the "New Request" stage is awaiting Initial
Disposition — the initial-disposition review is the action a Disposition
Authority performs on a New Request, and it moves the NCR either to
Secondary Disposition (when secondary review is required) or straight to
Operations. All transitions are validated server-side against
ALLOWED_TRANSITIONS; anything else is rejected with HTTP 409.
"""
from enum import Enum
class Role(str, Enum):
REQUESTER = "requester"
DISPOSITION_AUTHORITY = "disposition_authority"
SECONDARY_DISPOSITION_AUTHORITY = "secondary_disposition_authority"
OPERATIONS = "operations"
QC_INSPECTOR = "qc_inspector"
COSTING = "costing"
ADMIN = "admin"
ALL_ROLES: set[str] = {r.value for r in Role}
ROLE_LABELS: dict[str, str] = {
Role.REQUESTER: "Requester",
Role.DISPOSITION_AUTHORITY: "Disposition Authority",
Role.SECONDARY_DISPOSITION_AUTHORITY: "Secondary Disposition Authority",
Role.OPERATIONS: "Operations",
Role.QC_INSPECTOR: "QC Inspector",
Role.COSTING: "Costing",
Role.ADMIN: "Admin",
}
class Stage(str, Enum):
NEW_REQUEST = "new_request"
SECONDARY_DISPOSITION = "secondary_disposition"
OPERATIONS = "operations"
QC_INSPECTION = "qc_inspection"
COSTING = "costing"
CLOSED = "closed"
STAGE_LABELS: dict[str, str] = {
Stage.NEW_REQUEST: "New Request",
Stage.SECONDARY_DISPOSITION: "Secondary Disposition",
Stage.OPERATIONS: "Operations",
Stage.QC_INSPECTION: "QC Inspection",
Stage.COSTING: "Costing",
Stage.CLOSED: "Closed",
}
# Stages an admin may reopen a closed NCR back into.
REOPEN_TARGET_STAGES: list[Stage] = [
Stage.NEW_REQUEST,
Stage.SECONDARY_DISPOSITION,
Stage.OPERATIONS,
Stage.QC_INSPECTION,
Stage.COSTING,
]
ALLOWED_TRANSITIONS: dict[Stage, set[Stage]] = {
Stage.NEW_REQUEST: {Stage.SECONDARY_DISPOSITION, Stage.OPERATIONS},
Stage.SECONDARY_DISPOSITION: {Stage.OPERATIONS},
Stage.OPERATIONS: {Stage.QC_INSPECTION},
Stage.QC_INSPECTION: {Stage.COSTING},
Stage.COSTING: {Stage.CLOSED},
# Reopen (admin only, reason required) — enforced separately.
Stage.CLOSED: set(REOPEN_TARGET_STAGES),
}
# Role allowed to act on the NCR in each stage (Admin is always allowed;
# Secondary Disposition additionally requires being an assigned authority).
STAGE_ACTING_ROLE: dict[Stage, Role] = {
Stage.NEW_REQUEST: Role.DISPOSITION_AUTHORITY,
Stage.SECONDARY_DISPOSITION: Role.SECONDARY_DISPOSITION_AUTHORITY,
Stage.OPERATIONS: Role.OPERATIONS,
Stage.QC_INSPECTION: Role.QC_INSPECTOR,
Stage.COSTING: Role.COSTING,
}
# Owners to notify when an admin reopens an NCR into a given stage.
STAGE_OWNER_ROLE: dict[Stage, Role] = STAGE_ACTING_ROLE

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.routers import admin, health, jobs, lookups, ncrs, reports, users
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
Path(settings.attachments_dir).mkdir(parents=True, exist_ok=True)
if settings.auth_mode == "dev":
logging.getLogger(__name__).warning(
"AUTH_MODE=dev — authentication is BYPASSED. Never use in production."
)
# Fail fast on a misconfigured job-lookup provider.
from app.services.job_lookup import get_job_lookup_service
get_job_lookup_service()
yield
app = FastAPI(
title="PESCO NCR API",
version="1.0.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
redoc_url=None,
lifespan=lifespan,
)
# In production nginx serves the SPA and proxies /api same-origin, so CORS is
# only exercised by the Vite dev server.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
API = "/api"
app.include_router(health.router, prefix=API)
app.include_router(users.router, prefix=API)
app.include_router(lookups.router, prefix=API)
app.include_router(jobs.router, prefix=API)
app.include_router(ncrs.router, prefix=API)
app.include_router(reports.router, prefix=API)
app.include_router(admin.router, prefix=API)

View 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",
]

View 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"

View 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")

View 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")

View 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

View 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
View 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")

View 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")

View File

View File

@@ -0,0 +1,312 @@
"""Admin area: role management, department/category lists, notification
toggle, and the global audit log. All endpoints are Admin-only."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, require_admin
from app.database import get_db
from app.domain import Role
from app.models import (
AppSetting,
AuditLog,
Department,
DeviationCategory,
Ncr,
User,
UserRole,
)
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
from app.schemas.lookup import LookupCreateIn, LookupPatchIn, NamedLookupOut
from app.schemas.ncr import AuditEntryOut
from app.schemas.user import RolesUpdateIn, UserOut
from app.services.audit import audit_event
from app.services.notifications import notifications_enabled
router = APIRouter(prefix="/admin", tags=["admin"])
def _user_out(u: User) -> UserOut:
return UserOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=u.roles,
last_login_at=u.last_login_at,
)
# ── users & roles ────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[UserOut])
async def list_all_users(
search: str | None = None,
_: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> list[UserOut]:
stmt = select(User).order_by(User.display_name)
if search:
like = f"%{search.strip()}%"
stmt = stmt.where(User.display_name.like(like) | User.email.like(like))
users = (await db.execute(stmt)).scalars().unique().all()
return [_user_out(u) for u in users]
@router.put("/users/{user_id}/roles", response_model=UserOut)
async def set_user_roles(
user_id: int,
payload: RolesUpdateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> UserOut:
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == current.id and Role.ADMIN.value not in payload.roles:
raise HTTPException(
status_code=422,
detail="You cannot remove your own Admin role (lockout protection).",
)
old_roles = user.roles
user.role_rows = [UserRole(user_id=user.id, role=r) for r in payload.roles]
audit_event(
db,
user_id=current.id,
action="roles_update",
field_name=f"user:{user.email}",
old_value=", ".join(old_roles) or "(none)",
new_value=", ".join(payload.roles) or "(none)",
)
await db.commit()
await db.refresh(user)
return _user_out(user)
class ActivePatchIn(BaseModel):
is_active: bool
@router.put("/users/{user_id}/active", response_model=UserOut)
async def set_user_active(
user_id: int,
payload: ActivePatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> UserOut:
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == current.id and not payload.is_active:
raise HTTPException(status_code=422, detail="You cannot deactivate yourself.")
if user.is_active != payload.is_active:
audit_event(
db,
user_id=current.id,
action="user_active",
field_name=f"user:{user.email}",
old_value=user.is_active,
new_value=payload.is_active,
)
user.is_active = payload.is_active
await db.commit()
await db.refresh(user)
return _user_out(user)
# ── departments & deviation categories ──────────────────────────────────────
# No hard-delete endpoints exist by design: values referenced by existing
# NCRs are only ever deactivated.
@router.get("/departments", response_model=list[NamedLookupOut])
async def list_departments(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
):
rows = (await db.execute(select(Department).order_by(Department.name))).scalars().all()
return [NamedLookupOut.model_validate(r) for r in rows]
@router.post("/departments", response_model=NamedLookupOut, status_code=201)
async def create_department(
payload: LookupCreateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _create_lookup(Department, "Department", payload, current, db)
@router.patch("/departments/{item_id}", response_model=NamedLookupOut)
async def patch_department(
item_id: int,
payload: LookupPatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _patch_lookup(Department, "Department", item_id, payload, current, db)
@router.get("/categories", response_model=list[NamedLookupOut])
async def list_categories(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
):
rows = (
(await db.execute(select(DeviationCategory).order_by(DeviationCategory.name)))
.scalars()
.all()
)
return [NamedLookupOut.model_validate(r) for r in rows]
@router.post("/categories", response_model=NamedLookupOut, status_code=201)
async def create_category(
payload: LookupCreateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _create_lookup(DeviationCategory, "Deviation category", payload, current, db)
@router.patch("/categories/{item_id}", response_model=NamedLookupOut)
async def patch_category(
item_id: int,
payload: LookupPatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _patch_lookup(
DeviationCategory, "Deviation category", item_id, payload, current, db
)
async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
exists = (
await db.execute(select(model).where(model.name == payload.name.strip()))
).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail=f"{label} already exists.")
row = model(name=payload.name.strip(), is_active=True)
db.add(row)
audit_event(
db, user_id=current.id, action="lookup_create", field_name=label, new_value=payload.name
)
await db.commit()
await db.refresh(row)
return NamedLookupOut.model_validate(row)
async def _patch_lookup(model, label, item_id, payload, current, db) -> NamedLookupOut:
row = await db.get(model, item_id)
if row is None:
raise HTTPException(status_code=404, detail=f"{label} not found.")
if payload.name is not None and payload.name.strip() != row.name:
audit_event(
db,
user_id=current.id,
action="lookup_rename",
field_name=label,
old_value=row.name,
new_value=payload.name.strip(),
)
row.name = payload.name.strip()
if payload.is_active is not None and payload.is_active != row.is_active:
audit_event(
db,
user_id=current.id,
action="lookup_active",
field_name=f"{label}: {row.name}",
old_value=row.is_active,
new_value=payload.is_active,
)
row.is_active = payload.is_active
await db.commit()
await db.refresh(row)
return NamedLookupOut.model_validate(row)
# ── settings ─────────────────────────────────────────────────────────────────
class SettingsOut(BaseModel):
notifications_enabled: bool
@router.get("/settings", response_model=SettingsOut)
async def get_admin_settings(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
) -> SettingsOut:
return SettingsOut(notifications_enabled=await notifications_enabled(db))
@router.put("/settings", response_model=SettingsOut)
async def put_admin_settings(
payload: SettingsOut,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> SettingsOut:
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
old = await notifications_enabled(db)
if row is None:
row = AppSetting(
key=NOTIFICATIONS_ENABLED_KEY,
value="true" if payload.notifications_enabled else "false",
)
db.add(row)
else:
row.value = "true" if payload.notifications_enabled else "false"
if old != payload.notifications_enabled:
audit_event(
db,
user_id=current.id,
action="settings_update",
field_name=NOTIFICATIONS_ENABLED_KEY,
old_value=old,
new_value=payload.notifications_enabled,
)
await db.commit()
return SettingsOut(notifications_enabled=payload.notifications_enabled)
# ── global audit log ─────────────────────────────────────────────────────────
class GlobalAuditOut(BaseModel):
items: list[AuditEntryOut]
total: int
page: int
page_size: int
@router.get("/audit", response_model=GlobalAuditOut)
async def global_audit(
ncr_number: str | None = None,
action: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
_: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> GlobalAuditOut:
stmt = select(AuditLog)
if ncr_number:
stmt = stmt.where(
AuditLog.ncr_id.in_(
select(Ncr.id).where(Ncr.ncr_number.like(f"%{ncr_number.strip()}%"))
)
)
if action:
stmt = stmt.where(AuditLog.action == action)
total = (
await db.execute(select(func.count()).select_from(stmt.subquery()))
).scalar_one()
rows = (
(
await db.execute(
stmt.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
.scalars()
.all()
)
return GlobalAuditOut(
items=[AuditEntryOut.model_validate(r) for r in rows],
total=total,
page=page,
page_size=page_size,
)

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.get("/health/db")
async def health_db(db: AsyncSession = Depends(get_db)) -> dict:
await db.execute(text("SELECT 1"))
return {"status": "ok", "database": "ok"}

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from app.auth.deps import CurrentUser, get_current_user
from app.services.job_lookup import get_job_lookup_service
router = APIRouter(tags=["jobs"])
@router.get("/jobs/{job_number}/lookup")
async def lookup_job(
job_number: str,
_: CurrentUser = Depends(get_current_user),
) -> dict:
"""Job-number enrichment endpoint. Returns {found: false} under the
default NullJobLookupService; a future VisualJobLookupService will return
part/customer/work-order data from Infor VISUAL without frontend changes."""
info = await get_job_lookup_service().lookup(job_number)
if info is None:
return {"found": False, "job_number": job_number}
return {
"found": True,
"job_number": job_number,
"part_id": info.part_id,
"part_description": info.part_description,
"customer_name": info.customer_name,
"work_order_status": info.work_order_status,
"source": info.source,
}

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db
from app.models import Department, DeviationCategory
from app.schemas.lookup import LookupsOut, NamedLookupOut
router = APIRouter(tags=["lookups"])
@router.get("/lookups", response_model=LookupsOut)
async def get_lookups(
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> LookupsOut:
"""Active departments and deviation categories for form dropdowns."""
departments = (
(
await db.execute(
select(Department)
.where(Department.is_active.is_(True))
.order_by(Department.name)
)
)
.scalars()
.all()
)
categories = (
(
await db.execute(
select(DeviationCategory)
.where(DeviationCategory.is_active.is_(True))
.order_by(DeviationCategory.name)
)
)
.scalars()
.all()
)
return LookupsOut(
departments=[NamedLookupOut.model_validate(d) for d in departments],
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories],
)

866
backend/app/routers/ncrs.py Normal file
View File

@@ -0,0 +1,866 @@
"""NCR endpoints: creation, queues/search, stage actions (the workflow state
machine), attachments, audit history, CSV export, and the printable PDF.
Every stage action re-validates BOTH the caller's role and the NCR's current
stage server-side; the frontend's `available_actions` hints are advisory only.
"""
import csv
import io
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user, require_roles
from app.database import get_db
from app.domain import STAGE_LABELS, Role, Stage
from app.models import (
Attachment,
Department,
DeviationCategory,
JobInfo,
Ncr,
NcrSecondaryAssignee,
User,
UserRole,
)
from app.models.base import utcnow
from app.schemas.ncr import (
AttachmentOut,
AuditEntryOut,
AuditListOut,
CostingIn,
InitialDispositionIn,
InspectionIn,
JobInfoOut,
NcrCreateIn,
NcrDetailOut,
NcrListItem,
NcrListOut,
NcrMutationOut,
ReopenIn,
SecondaryDispositionIn,
TransitionOut,
)
from app.schemas.user import UserRef
from app.services.audit import apply_field_updates, audit_event
from app.services.job_lookup import get_job_lookup_service
from app.services.notifications import NotifyEvent, send_stage_notification
from app.services.numbering import allocate_ncr_number
from app.services.sanitize import sanitize_html
from app.services.storage import (
UploadValidationError,
attachment_abs_path,
save_attachment,
)
from app.services.workflow import InvalidTransitionError, record_creation, transition
logger = logging.getLogger(__name__)
router = APIRouter(tags=["ncrs"])
_STAGE_ORDER = [
Stage.NEW_REQUEST,
Stage.SECONDARY_DISPOSITION,
Stage.OPERATIONS,
Stage.QC_INSPECTION,
Stage.COSTING,
Stage.CLOSED,
]
# ── helpers ──────────────────────────────────────────────────────────────────
async def _get_ncr(db: AsyncSession, ncr_id: int) -> Ncr:
ncr = await db.get(Ncr, ncr_id)
if ncr is None:
raise HTTPException(status_code=404, detail="NCR not found.")
return ncr
def _days_in_stage(ncr: Ncr) -> int:
return max(0, (utcnow() - ncr.stage_entered_at).days)
def _ensure_stage(ncr: Ncr, expected: Stage) -> None:
if ncr.stage == Stage.CLOSED.value and expected != Stage.CLOSED:
raise HTTPException(
status_code=409,
detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.",
)
if ncr.stage != expected.value:
raise HTTPException(
status_code=409,
detail=(
f"{ncr.ncr_number} is in stage '{STAGE_LABELS[Stage(ncr.stage)]}', "
f"but this action requires '{STAGE_LABELS[expected]}'."
),
)
def _is_secondary_assignee(ncr: Ncr, current: CurrentUser) -> bool:
return any(row.user_id == current.id for row in ncr.secondary_assignee_rows)
def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
actions: list[str] = []
stage = Stage(ncr.stage)
if stage == Stage.NEW_REQUEST and current.has_role(Role.DISPOSITION_AUTHORITY):
actions.append("initial_disposition")
if stage == Stage.SECONDARY_DISPOSITION and (
current.is_admin or _is_secondary_assignee(ncr, current)
):
actions.append("secondary_disposition")
if stage == Stage.OPERATIONS and current.has_role(Role.OPERATIONS):
actions.append("operations_complete")
if stage == Stage.QC_INSPECTION and current.has_role(Role.QC_INSPECTOR):
actions.append("inspection")
if stage == Stage.COSTING and current.has_role(Role.COSTING):
actions.append("costing")
if stage == Stage.CLOSED and current.is_admin:
actions.append("reopen")
if stage != Stage.CLOSED:
actions.append("add_attachment")
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically
actions.append("view_audit")
return actions
def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
stage = Stage(ncr.stage)
return NcrDetailOut(
id=ncr.id,
ncr_number=ncr.ncr_number,
job_number=ncr.job_number,
created_at=ncr.created_at,
stage=stage.value,
stage_label=STAGE_LABELS[stage],
stage_entered_at=ncr.stage_entered_at,
days_in_stage=_days_in_stage(ncr),
department=ncr.department.name,
department_id=ncr.department_id,
deviation_category=ncr.deviation_category.name,
deviation_category_id=ncr.deviation_category_id,
deviation_detail=ncr.deviation_detail,
requester=UserRef.model_validate(ncr.requester),
disposition_authority=UserRef.model_validate(ncr.disposition_authority),
qc_authority=ncr.qc_authority,
work_order=ncr.work_order,
disposition_notes=ncr.disposition_notes,
secondary_review_needed=ncr.secondary_review_needed,
secondary_authorities=[
UserRef.model_validate(u) for u in ncr.secondary_authorities
],
operations_complete=ncr.operations_complete,
operations_completed_at=ncr.operations_completed_at,
operations_completed_by=(
UserRef.model_validate(ncr.operations_completed_by)
if ncr.operations_completed_by
else None
),
qc_approval=ncr.qc_approval,
inspection_notes=ncr.inspection_notes,
qc_closed=ncr.qc_closed,
qc_closed_at=ncr.qc_closed_at,
qc_closed_by=(
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None
),
labor_cost=ncr.labor_cost,
material_cost=ncr.material_cost,
service_cost=ncr.service_cost,
other_cost=ncr.other_cost,
total_cost=ncr.total_cost,
costing_completed_at=ncr.costing_completed_at,
costing_completed_by=(
UserRef.model_validate(ncr.costing_completed_by)
if ncr.costing_completed_by
else None
),
closed_at=ncr.closed_at,
closed_by=UserRef.model_validate(ncr.closed_by) if ncr.closed_by else None,
job_info=JobInfoOut.model_validate(ncr.job_info) if ncr.job_info else None,
attachments=[AttachmentOut.model_validate(a) for a in ncr.attachments],
transitions=[TransitionOut.model_validate(t) for t in ncr.transitions],
available_actions=_available_actions(ncr, current),
)
async def _refetch(db: AsyncSession, ncr_id: int) -> Ncr:
"""Reload the NCR with fresh relationship collections after a commit."""
db.expire_all()
return await _get_ncr(db, ncr_id)
# ── create ───────────────────────────────────────────────────────────────────
@router.post("/ncrs", response_model=NcrMutationOut, status_code=201)
async def create_ncr(
payload: NcrCreateIn,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 1 — New Request. Open to every authenticated user."""
dept = await db.get(Department, payload.department_id)
if dept is None or not dept.is_active:
raise HTTPException(status_code=422, detail="Unknown or inactive department.")
cat = await db.get(DeviationCategory, payload.deviation_category_id)
if cat is None or not cat.is_active:
raise HTTPException(status_code=422, detail="Unknown or inactive deviation category.")
authority = await db.get(User, payload.disposition_authority_id)
if (
authority is None
or not authority.is_active
or Role.DISPOSITION_AUTHORITY.value not in authority.roles
):
raise HTTPException(
status_code=422,
detail="Selected disposition authority does not hold the Disposition Authority role.",
)
# External enrichment BEFORE the numbering lock so a slow ERP lookup can
# never serialize submissions. NullJobLookupService returns instantly.
job_info_data = None
try:
job_info_data = await get_job_lookup_service().lookup(payload.job_number)
except Exception:
logger.exception("Job lookup failed for %s (non-blocking)", payload.job_number)
ncr_number, year, seq = await allocate_ncr_number(db)
ncr = Ncr(
ncr_number=ncr_number,
ncr_year=year,
ncr_seq=seq,
job_number=payload.job_number.strip(),
department_id=payload.department_id,
deviation_category_id=payload.deviation_category_id,
disposition_authority_id=payload.disposition_authority_id,
deviation_detail=payload.deviation_detail,
requester_id=current.id,
stage=Stage.NEW_REQUEST.value,
)
db.add(ncr)
await db.flush()
record_creation(db, ncr, current.id)
if job_info_data is not None:
db.add(
JobInfo(
ncr_id=ncr.id,
part_id=job_info_data.part_id,
part_description=job_info_data.part_description,
customer_name=job_info_data.customer_name,
work_order_status=job_info_data.work_order_status,
source=job_info_data.source,
)
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.CREATED,
current,
f"{current.user.display_name} submitted a new NCR and selected you as the "
"disposition authority.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
# ── queues / search / export ────────────────────────────────────────────────
def _apply_filters(
stmt,
*,
q: str | None,
job_number: str | None,
department_id: int | None,
category_id: int | None,
stage: str | None,
date_from: str | None,
date_to: str | None,
disposition_authority_id: int | None,
):
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(or_(Ncr.ncr_number.like(like), Ncr.job_number.like(like)))
if job_number:
stmt = stmt.where(Ncr.job_number.like(f"%{job_number.strip()}%"))
if department_id:
stmt = stmt.where(Ncr.department_id == department_id)
if category_id:
stmt = stmt.where(Ncr.deviation_category_id == category_id)
if stage:
stmt = stmt.where(Ncr.stage == stage)
if date_from:
stmt = stmt.where(Ncr.created_at >= date_from)
if date_to:
stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59")
if disposition_authority_id:
stmt = stmt.where(Ncr.disposition_authority_id == disposition_authority_id)
return stmt
def _queue_filter(stmt, queue: str, current: CurrentUser):
if queue == "my_requests":
return stmt.where(Ncr.requester_id == current.id)
if queue == "new_requests":
return stmt.where(Ncr.stage == Stage.NEW_REQUEST.value)
if queue == "secondary":
return stmt.where(
Ncr.stage == Stage.SECONDARY_DISPOSITION.value,
Ncr.id.in_(
select(NcrSecondaryAssignee.ncr_id).where(
NcrSecondaryAssignee.user_id == current.id
)
),
)
if queue == "operations":
return stmt.where(Ncr.stage == Stage.OPERATIONS.value)
if queue == "inspection":
return stmt.where(Ncr.stage == Stage.QC_INSPECTION.value)
if queue == "costing":
return stmt.where(Ncr.stage == Stage.COSTING.value)
if queue == "recently_closed":
return stmt.where(Ncr.stage == Stage.CLOSED.value)
if queue in ("all", ""):
return stmt
raise HTTPException(status_code=422, detail=f"Unknown queue '{queue}'.")
def _list_item(ncr: Ncr) -> NcrListItem:
return NcrListItem(
id=ncr.id,
ncr_number=ncr.ncr_number,
job_number=ncr.job_number,
department=ncr.department.name,
deviation_category=ncr.deviation_category.name,
requester=ncr.requester.display_name,
disposition_authority=ncr.disposition_authority.display_name,
stage=ncr.stage,
stage_label=STAGE_LABELS[Stage(ncr.stage)],
days_in_stage=_days_in_stage(ncr),
created_at=ncr.created_at,
)
@router.get("/ncrs", response_model=NcrListOut)
async def list_ncrs(
queue: str = Query(default="all"),
q: str | None = None,
job_number: str | None = None,
department_id: int | None = None,
category_id: int | None = None,
stage: str | None = None,
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
disposition_authority_id: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=200),
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrListOut:
stmt = select(Ncr)
stmt = _queue_filter(stmt, queue, current)
stmt = _apply_filters(
stmt,
q=q,
job_number=job_number,
department_id=department_id,
category_id=category_id,
stage=stage,
date_from=date_from,
date_to=date_to,
disposition_authority_id=disposition_authority_id,
)
total = (
await db.execute(select(func.count()).select_from(stmt.subquery()))
).scalar_one()
order = Ncr.closed_at.desc() if queue == "recently_closed" else Ncr.created_at.desc()
rows = (
(await db.execute(stmt.order_by(order).offset((page - 1) * page_size).limit(page_size)))
.scalars()
.unique()
.all()
)
return NcrListOut(
items=[_list_item(n) for n in rows], total=total, page=page, page_size=page_size
)
_CSV_COLUMNS = [
"ncr_number", "job_number", "department", "deviation_category", "requester",
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost",
"total_cost", "closed_at",
]
@router.get("/ncrs/export.csv")
async def export_ncrs_csv(
queue: str = Query(default="all"),
q: str | None = None,
job_number: str | None = None,
department_id: int | None = None,
category_id: int | None = None,
stage: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
disposition_authority_id: int | None = None,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
"""CSV export of any queue/search view (same filters as GET /ncrs)."""
stmt = select(Ncr)
stmt = _queue_filter(stmt, queue, current)
stmt = _apply_filters(
stmt,
q=q,
job_number=job_number,
department_id=department_id,
category_id=category_id,
stage=stage,
date_from=date_from,
date_to=date_to,
disposition_authority_id=disposition_authority_id,
)
rows = (
(await db.execute(stmt.order_by(Ncr.created_at.desc()).limit(20000)))
.scalars()
.unique()
.all()
)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
for n in rows:
writer.writerow(
[
n.ncr_number, n.job_number, n.department.name, n.deviation_category.name,
n.requester.display_name, n.disposition_authority.display_name,
STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "",
n.secondary_review_needed, n.operations_complete, n.qc_approval or "",
n.qc_closed, n.labor_cost or "", n.material_cost or "",
n.service_cost or "", n.other_cost or "", n.total_cost or "",
n.closed_at.isoformat(sep=" ") if n.closed_at else "",
]
)
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="ncr-export.csv"'},
)
@router.get("/ncrs/{ncr_id}", response_model=NcrDetailOut)
async def get_ncr(
ncr_id: int,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrDetailOut:
ncr = await _get_ncr(db, ncr_id)
return _detail(ncr, current)
# ── stage actions ────────────────────────────────────────────────────────────
@router.post("/ncrs/{ncr_id}/initial-disposition", response_model=NcrMutationOut)
async def initial_disposition(
ncr_id: int,
payload: InitialDispositionIn,
current: CurrentUser = Depends(require_roles(Role.DISPOSITION_AUTHORITY)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 2 — Initial Disposition, performed on a New Request. Routes to
Secondary Disposition (when secondary review is needed) or Operations."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.NEW_REQUEST)
assignees: list[User] = []
if payload.secondary_review_needed:
if not payload.secondary_authority_ids:
raise HTTPException(
status_code=422,
detail="Secondary review requires at least one person in 'Notify These People'.",
)
for uid in set(payload.secondary_authority_ids):
u = await db.get(User, uid)
if (
u is None
or not u.is_active
or Role.SECONDARY_DISPOSITION_AUTHORITY.value not in u.roles
):
raise HTTPException(
status_code=422,
detail="All selected people must hold the Secondary Disposition Authority role.",
)
assignees.append(u)
updates = payload.model_dump(exclude_unset=True, exclude={"secondary_authority_ids"})
if "disposition_notes" in updates:
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
updates["secondary_review_needed"] = payload.secondary_review_needed
apply_field_updates(db, ncr, current.id, updates, action="initial_disposition")
if payload.secondary_review_needed:
await db.execute(
delete(NcrSecondaryAssignee).where(NcrSecondaryAssignee.ncr_id == ncr.id)
)
for u in assignees:
db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=u.id))
audit_event(
db,
ncr_id=ncr.id,
user_id=current.id,
action="initial_disposition",
field_name="secondary_authorities",
new_value=", ".join(u.display_name for u in assignees),
)
_do_transition(db, ncr, Stage.SECONDARY_DISPOSITION, "initial_disposition", current)
event, summary = (
NotifyEvent.SECONDARY_ASSIGNED,
f"{current.user.display_name} completed initial disposition and assigned "
"you for secondary disposition review.",
)
else:
_do_transition(db, ncr, Stage.OPERATIONS, "initial_disposition", current)
event, summary = (
NotifyEvent.RELEASED_TO_OPERATIONS,
f"{current.user.display_name} completed initial disposition; the NCR is "
"ready for Operations.",
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(db, ncr, event, current, summary)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/secondary-disposition", response_model=NcrMutationOut)
async def secondary_disposition(
ncr_id: int,
payload: SecondaryDispositionIn,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 3 — Secondary Disposition. Only the assigned secondary
authorities (or an Admin) may update or release to Operations."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.SECONDARY_DISPOSITION)
if not (current.is_admin or _is_secondary_assignee(ncr, current)):
raise HTTPException(
status_code=403,
detail="Only the assigned secondary disposition authority can act on this NCR.",
)
updates = payload.model_dump(exclude_unset=True, exclude={"release"})
if "disposition_notes" in updates:
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
apply_field_updates(db, ncr, current.id, updates, action="secondary_disposition")
warnings: list[str] = []
if payload.release:
_do_transition(db, ncr, Stage.OPERATIONS, "secondary_release", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.RELEASED_TO_OPERATIONS,
current,
f"{current.user.display_name} completed secondary disposition review and "
"released the NCR to Operations.",
)
else:
await db.commit()
ncr = await _refetch(db, ncr.id)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/operations-complete", response_model=NcrMutationOut)
async def operations_complete(
ncr_id: int,
current: CurrentUser = Depends(require_roles(Role.OPERATIONS)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 4 — Operations marks rework complete; NCR moves to QC Inspection."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.OPERATIONS)
apply_field_updates(
db,
ncr,
current.id,
{
"operations_complete": True,
"operations_completed_at": utcnow(),
"operations_completed_by_id": current.id,
},
action="operations_complete",
)
_do_transition(db, ncr, Stage.QC_INSPECTION, "operations_complete", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.OPERATIONS_COMPLETE,
current,
f"{current.user.display_name} marked operations complete; the NCR is ready "
"for QC inspection.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/inspection", response_model=NcrMutationOut)
async def inspection(
ncr_id: int,
payload: InspectionIn,
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 5 — QC Inspection. QC can save repeatedly; checking QC Closed
advances the NCR to Costing."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.QC_INSPECTION)
updates = payload.model_dump(exclude_unset=True, exclude={"qc_closed"})
if payload.qc_closed:
updates.update(
{"qc_closed": True, "qc_closed_at": utcnow(), "qc_closed_by_id": current.id}
)
apply_field_updates(db, ncr, current.id, updates, action="inspection")
warnings: list[str] = []
if payload.qc_closed:
_do_transition(db, ncr, Stage.COSTING, "qc_close", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.QC_CLOSED,
current,
f"{current.user.display_name} closed QC inspection; the NCR is awaiting costing.",
)
else:
await db.commit()
ncr = await _refetch(db, ncr.id)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/costing", response_model=NcrMutationOut)
async def costing(
ncr_id: int,
payload: CostingIn,
current: CurrentUser = Depends(require_roles(Role.COSTING)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 6 — Costing. Saving costs completes the workflow and closes the NCR."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.COSTING)
now = utcnow()
apply_field_updates(
db,
ncr,
current.id,
{
"labor_cost": payload.labor_cost,
"material_cost": payload.material_cost,
"service_cost": payload.service_cost,
"other_cost": payload.other_cost,
"costing_completed_at": now,
"costing_completed_by_id": current.id,
"closed_at": now,
"closed_by_id": current.id,
},
action="costing",
)
_do_transition(db, ncr, Stage.CLOSED, "complete_costing", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.CLOSED,
current,
f"Costing is complete and your NCR has been closed. Total cost of "
f"nonconformance: ${ncr.total_cost:,.2f}.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/reopen", response_model=NcrMutationOut)
async def reopen(
ncr_id: int,
payload: ReopenIn,
current: CurrentUser = Depends(require_roles(Role.ADMIN)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Admin-only: reopen a closed NCR into a chosen prior stage. The reason
is required and recorded in the audit trail and transition history."""
ncr = await _get_ncr(db, ncr_id)
if ncr.stage != Stage.CLOSED.value:
raise HTTPException(status_code=409, detail="Only closed NCRs can be reopened.")
if payload.to_stage == Stage.SECONDARY_DISPOSITION and not ncr.secondary_assignee_rows:
raise HTTPException(
status_code=422,
detail="This NCR has no secondary authorities assigned; reopen it to "
"New Request so a disposition authority can assign them.",
)
target_idx = _STAGE_ORDER.index(payload.to_stage)
resets: dict = {"closed_at": None, "closed_by_id": None}
if target_idx <= _STAGE_ORDER.index(Stage.OPERATIONS):
resets.update(
{
"operations_complete": False,
"operations_completed_at": None,
"operations_completed_by_id": None,
}
)
if target_idx <= _STAGE_ORDER.index(Stage.QC_INSPECTION):
resets.update({"qc_closed": False, "qc_closed_at": None, "qc_closed_by_id": None})
if target_idx <= _STAGE_ORDER.index(Stage.COSTING):
resets.update({"costing_completed_at": None, "costing_completed_by_id": None})
apply_field_updates(db, ncr, current.id, resets, action="reopen")
_do_transition(
db, ncr, payload.to_stage, "reopen", current, note=f"Reopen reason: {payload.reason}"
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.REOPENED,
current,
f"{current.user.display_name} reopened this NCR to "
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
def _do_transition(
db: AsyncSession,
ncr: Ncr,
to_stage: Stage,
action: str,
current: CurrentUser,
note: str | None = None,
) -> None:
try:
transition(db, ncr, to_stage, action=action, actor_id=current.id, note=note)
except InvalidTransitionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# ── attachments ──────────────────────────────────────────────────────────────
@router.post("/ncrs/{ncr_id}/attachments", response_model=list[AttachmentOut], status_code=201)
async def upload_attachments(
ncr_id: int,
files: list[UploadFile],
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[AttachmentOut]:
"""Photo/file attachments (multiple per request; camera capture on
tablets posts here too). Blocked once the NCR is closed."""
ncr = await _get_ncr(db, ncr_id)
if ncr.stage == Stage.CLOSED.value:
raise HTTPException(
status_code=409, detail="This NCR is closed; attachments are locked."
)
if not files:
raise HTTPException(status_code=422, detail="No files provided.")
saved: list[Attachment] = []
for f in files:
try:
meta = await save_attachment(f, ncr.id)
except UploadValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
att = Attachment(ncr_id=ncr.id, uploaded_by_id=current.id, **meta)
db.add(att)
audit_event(
db,
ncr_id=ncr.id,
user_id=current.id,
action="attachment_add",
field_name="attachments",
new_value=meta["original_filename"],
detail=f"{meta['size_bytes']} bytes, {meta['content_type']}",
)
saved.append(att)
await db.commit()
for att in saved:
await db.refresh(att)
return [AttachmentOut.model_validate(a) for a in saved]
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: int,
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> FileResponse:
att = await db.get(Attachment, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="Attachment not found.")
path = attachment_abs_path(att.stored_path)
if not path.is_file():
raise HTTPException(status_code=404, detail="Attachment file missing from storage.")
return FileResponse(
path,
media_type=att.content_type,
filename=att.original_filename,
content_disposition_type="inline" if att.is_image else "attachment",
)
# ── audit history ────────────────────────────────────────────────────────────
@router.get("/ncrs/{ncr_id}/audit", response_model=AuditListOut)
async def ncr_audit(
ncr_id: int,
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
db: AsyncSession = Depends(get_db),
) -> AuditListOut:
"""Audit History tab — Admin and QC roles."""
from app.models import AuditLog
await _get_ncr(db, ncr_id)
rows = (
(
await db.execute(
select(AuditLog)
.where(AuditLog.ncr_id == ncr_id)
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
)
)
.scalars()
.all()
)
return AuditListOut(
items=[AuditEntryOut.model_validate(r) for r in rows], total=len(rows)
)
# ── printable PDF ────────────────────────────────────────────────────────────
@router.get("/ncrs/{ncr_id}/pdf")
async def ncr_pdf(
ncr_id: int,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Clean single-document rendering of the complete NCR for hard-copy
travelers and audits."""
from app.services.pdf import render_ncr_pdf
ncr = await _get_ncr(db, ncr_id)
pdf_bytes = await render_ncr_pdf(ncr)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{ncr.ncr_number}.pdf"'
},
)

View File

@@ -0,0 +1,185 @@
"""Built-in reports: counts, cost of nonconformance, aging, cycle times,
top jobs. All queries respect the shared date-range/department/category
filters."""
from collections import defaultdict
from decimal import Decimal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db
from app.domain import STAGE_LABELS, Stage
from app.models import Department, DeviationCategory, Ncr, StageTransition
from app.models.base import utcnow
from app.schemas.report import (
AgingBucket,
CostByMonth,
CountByMonth,
CountByName,
ReportsSummaryOut,
StageCycleTime,
TopJob,
)
router = APIRouter(tags=["reports"])
_AGING_BUCKETS = [(0, 7, "07 days"), (8, 14, "814 days"), (15, 30, "1530 days"),
(31, 60, "3160 days"), (61, None, "60+ days")]
def _base_filters(stmt, date_from, date_to, department_id, category_id):
if date_from:
stmt = stmt.where(Ncr.created_at >= date_from)
if date_to:
stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59")
if department_id:
stmt = stmt.where(Ncr.department_id == department_id)
if category_id:
stmt = stmt.where(Ncr.deviation_category_id == category_id)
return stmt
@router.get("/reports/summary", response_model=ReportsSummaryOut)
async def reports_summary(
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
department_id: int | None = None,
category_id: int | None = None,
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ReportsSummaryOut:
filters = dict(
date_from=date_from,
date_to=date_to,
department_id=department_id,
category_id=category_id,
)
# Load the filtered NCR set once; aggregate in Python. NCR volume is a few
# thousand rows a year, so this stays cheap and keeps the SQL portable.
ncrs = (
(await db.execute(_base_filters(select(Ncr), **filters))).scalars().unique().all()
)
dept_names = {
d.id: d.name for d in (await db.execute(select(Department))).scalars().all()
}
cat_names = {
c.id: c.name
for c in (await db.execute(select(DeviationCategory))).scalars().all()
}
by_dept: dict[str, int] = defaultdict(int)
by_cat: dict[str, int] = defaultdict(int)
by_month: dict[str, int] = defaultdict(int)
cost_by_month: dict[str, dict[str, Decimal]] = defaultdict(
lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)}
)
aging_counts: dict[str, int] = {label: 0 for _, _, label in _AGING_BUCKETS}
job_counts: dict[str, int] = defaultdict(int)
total_cost = Decimal(0)
open_count = 0
closed_count = 0
now = utcnow()
for n in ncrs:
by_dept[dept_names.get(n.department_id, "?")] += 1
by_cat[cat_names.get(n.deviation_category_id, "?")] += 1
by_month[n.created_at.strftime("%Y-%m")] += 1
job_counts[n.job_number] += 1
if n.stage == Stage.CLOSED.value:
closed_count += 1
month = (n.closed_at or n.created_at).strftime("%Y-%m")
bucket = cost_by_month[month]
bucket["labor"] += n.labor_cost or 0
bucket["material"] += n.material_cost or 0
bucket["service"] += n.service_cost or 0
bucket["other"] += n.other_cost or 0
total_cost += n.total_cost or 0
else:
open_count += 1
days = max(0, (now - n.stage_entered_at).days)
for lo, hi, label in _AGING_BUCKETS:
if days >= lo and (hi is None or days <= hi):
aging_counts[label] += 1
break
# ── cycle times from the transition history ─────────────────────────────
ncr_ids = [n.id for n in ncrs]
stage_durations: dict[str, list[float]] = defaultdict(list)
end_to_end: list[float] = []
if ncr_ids:
transitions = (
(
await db.execute(
select(StageTransition)
.where(StageTransition.ncr_id.in_(ncr_ids))
.order_by(StageTransition.ncr_id, StageTransition.acted_at)
)
)
.scalars()
.all()
)
per_ncr: dict[int, list[StageTransition]] = defaultdict(list)
for t in transitions:
per_ncr[t.ncr_id].append(t)
for items in per_ncr.values():
for prev, nxt in zip(items, items[1:]):
delta_days = (nxt.acted_at - prev.acted_at).total_seconds() / 86400
stage_durations[prev.to_stage].append(delta_days)
first, last = items[0], items[-1]
if last.to_stage == Stage.CLOSED.value:
end_to_end.append(
(last.acted_at - first.acted_at).total_seconds() / 86400
)
cycle_times = [
StageCycleTime(
stage=s.value,
stage_label=STAGE_LABELS[s],
avg_days=round(sum(v) / len(v), 2),
samples=len(v),
)
for s in Stage
if s != Stage.CLOSED and (v := stage_durations.get(s.value))
]
months = sorted(set(by_month) | set(cost_by_month))
return ReportsSummaryOut(
total_ncrs=len(ncrs),
open_ncrs=open_count,
closed_ncrs=closed_count,
total_cost=total_cost,
by_department=sorted(
(CountByName(name=k, count=v) for k, v in by_dept.items()),
key=lambda x: -x.count,
),
by_category=sorted(
(CountByName(name=k, count=v) for k, v in by_cat.items()),
key=lambda x: -x.count,
),
by_month=[CountByMonth(month=m, count=by_month.get(m, 0)) for m in months],
cost_over_time=[
CostByMonth(
month=m,
labor=c["labor"],
material=c["material"],
service=c["service"],
other=c["other"],
total=c["labor"] + c["material"] + c["service"] + c["other"],
)
for m in months
if (c := cost_by_month.get(m))
],
aging=[AgingBucket(bucket=label, count=aging_counts[label]) for _, _, label in _AGING_BUCKETS],
cycle_times=cycle_times,
end_to_end_avg_days=(
round(sum(end_to_end) / len(end_to_end), 2) if end_to_end else None
),
top_jobs=sorted(
(TopJob(job_number=j, count=c) for j, c in job_counts.items()),
key=lambda x: -x.count,
)[:10],
)

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.config import get_settings
from app.database import get_db
from app.domain import ALL_ROLES
from app.models import User, UserRole
from app.schemas.user import MeOut, UserOut
router = APIRouter(tags=["users"])
@router.get("/me", response_model=MeOut)
async def get_me(current: CurrentUser = Depends(get_current_user)) -> MeOut:
u = current.user
return MeOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=sorted(current.roles),
last_login_at=u.last_login_at,
auth_mode=get_settings().auth_mode,
)
@router.get("/users", response_model=list[UserOut])
async def list_users(
role: str | None = Query(default=None, description="Filter to users holding this role"),
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[UserOut]:
"""User directory for pickers (e.g. Disposition Authority dropdown,
'Notify These People'). Only active users are returned."""
stmt = select(User).where(User.is_active.is_(True)).order_by(User.display_name)
if role:
if role not in ALL_ROLES:
return []
stmt = stmt.join(UserRole, UserRole.user_id == User.id).where(UserRole.role == role)
users = (await db.execute(stmt)).scalars().unique().all()
return [
UserOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=u.roles,
last_login_at=u.last_login_at,
)
for u in users
]

View File

View File

@@ -0,0 +1,19 @@
from datetime import datetime, timezone
from typing import Annotated
from pydantic import BaseModel, ConfigDict, PlainSerializer
def _serialize_utc(dt: datetime) -> str:
"""All DB datetimes are naive UTC; emit RFC3339 with Z so browsers parse
them into the user's local timezone."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat().replace("+00:00", "Z")
UTCDateTime = Annotated[datetime, PlainSerializer(_serialize_utc, return_type=str)]
class AppModel(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel, Field
from app.schemas.common import AppModel
class NamedLookupOut(AppModel):
id: int
name: str
is_active: bool
class LookupCreateIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
class LookupPatchIn(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
is_active: bool | None = None
class LookupsOut(BaseModel):
departments: list[NamedLookupOut]
deviation_categories: list[NamedLookupOut]

187
backend/app/schemas/ncr.py Normal file
View File

@@ -0,0 +1,187 @@
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
from app.domain import REOPEN_TARGET_STAGES, Stage
from app.schemas.common import AppModel, UTCDateTime
from app.schemas.user import UserRef
Money = Annotated[Decimal, Field(ge=0, max_digits=12, decimal_places=2)]
# ── Inputs ───────────────────────────────────────────────────────────────────
class NcrCreateIn(BaseModel):
job_number: str = Field(min_length=1, max_length=100)
department_id: int
deviation_category_id: int
disposition_authority_id: int
deviation_detail: str = Field(min_length=5, max_length=20000)
class InitialDispositionIn(BaseModel):
qc_authority: str | None = Field(default=None, max_length=255)
work_order: str | None = Field(default=None, max_length=100)
disposition_notes: str | None = Field(default=None, max_length=100000)
secondary_review_needed: bool
# "Notify These People" — required when secondary_review_needed is true.
secondary_authority_ids: list[int] = []
class SecondaryDispositionIn(BaseModel):
qc_authority: str | None = Field(default=None, max_length=255)
work_order: str | None = Field(default=None, max_length=100)
disposition_notes: str | None = Field(default=None, max_length=100000)
# False = save updates and keep in my queue; True = release to Operations.
release: bool = False
class InspectionIn(BaseModel):
qc_approval: Literal["yes", "no"] | None = None
inspection_notes: str | None = Field(default=None, max_length=20000)
# False = save and revisit later; True = advance to Costing.
qc_closed: bool = False
class CostingIn(BaseModel):
labor_cost: Money
material_cost: Money
service_cost: Money
other_cost: Money
class ReopenIn(BaseModel):
to_stage: Stage
reason: str = Field(min_length=5, max_length=2000)
@field_validator("to_stage")
@classmethod
def _valid_target(cls, v: Stage) -> Stage:
if v not in REOPEN_TARGET_STAGES:
raise ValueError("Reopen target must be a prior (non-closed) stage.")
return v
# ── Outputs ──────────────────────────────────────────────────────────────────
class AttachmentOut(AppModel):
id: int
original_filename: str
content_type: str
size_bytes: int
is_image: bool
uploaded_at: UTCDateTime
uploaded_by: UserRef
class TransitionOut(AppModel):
id: int
from_stage: str | None
to_stage: str
action: str
acted_at: UTCDateTime
acted_by: UserRef
note: str | None
class JobInfoOut(AppModel):
part_id: str | None
part_description: str | None
customer_name: str | None
work_order_status: str | None
source: str
class NcrListItem(BaseModel):
id: int
ncr_number: str
job_number: str
department: str
deviation_category: str
requester: str
disposition_authority: str
stage: str
stage_label: str
days_in_stage: int
created_at: UTCDateTime
class NcrListOut(BaseModel):
items: list[NcrListItem]
total: int
page: int
page_size: int
class NcrDetailOut(BaseModel):
id: int
ncr_number: str
job_number: str
created_at: UTCDateTime
stage: str
stage_label: str
stage_entered_at: UTCDateTime
days_in_stage: int
department: str
department_id: int
deviation_category: str
deviation_category_id: int
deviation_detail: str
requester: UserRef
disposition_authority: UserRef
qc_authority: str | None
work_order: str | None
disposition_notes: str | None
secondary_review_needed: bool | None
secondary_authorities: list[UserRef]
operations_complete: bool
operations_completed_at: UTCDateTime | None
operations_completed_by: UserRef | None
qc_approval: str | None
inspection_notes: str | None
qc_closed: bool
qc_closed_at: UTCDateTime | None
qc_closed_by: UserRef | None
labor_cost: Decimal | None
material_cost: Decimal | None
service_cost: Decimal | None
other_cost: Decimal | None
total_cost: Decimal | None
costing_completed_at: UTCDateTime | None
costing_completed_by: UserRef | None
closed_at: UTCDateTime | None
closed_by: UserRef | None
job_info: JobInfoOut | None
attachments: list[AttachmentOut]
transitions: list[TransitionOut]
# Actions the *current* user may take right now (informs the UI; the API
# re-enforces every one of these server-side).
available_actions: list[str]
class NcrMutationOut(BaseModel):
ncr: NcrDetailOut
warnings: list[str] = []
class AuditEntryOut(AppModel):
id: int
created_at: UTCDateTime
user: UserRef
action: str
field_name: str | None
old_value: str | None
new_value: str | None
detail: str | None
class AuditListOut(BaseModel):
items: list[AuditEntryOut]
total: int

View File

@@ -0,0 +1,54 @@
from decimal import Decimal
from pydantic import BaseModel
class CountByName(BaseModel):
name: str
count: int
class CountByMonth(BaseModel):
month: str # YYYY-MM
count: int
class CostByMonth(BaseModel):
month: str
labor: Decimal
material: Decimal
service: Decimal
other: Decimal
total: Decimal
class AgingBucket(BaseModel):
bucket: str
count: int
class StageCycleTime(BaseModel):
stage: str
stage_label: str
avg_days: float
samples: int
class TopJob(BaseModel):
job_number: str
count: int
class ReportsSummaryOut(BaseModel):
total_ncrs: int
open_ncrs: int
closed_ncrs: int
total_cost: Decimal
by_department: list[CountByName]
by_category: list[CountByName]
by_month: list[CountByMonth]
cost_over_time: list[CostByMonth]
aging: list[AgingBucket]
cycle_times: list[StageCycleTime]
end_to_end_avg_days: float | None
top_jobs: list[TopJob]

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel, field_validator
from app.domain import ALL_ROLES
from app.schemas.common import AppModel, UTCDateTime
class UserRef(AppModel):
id: int
display_name: str
email: str
class UserOut(UserRef):
employee_id: str | None = None
is_active: bool
roles: list[str]
last_login_at: UTCDateTime | None = None
class MeOut(UserOut):
auth_mode: str = "entra"
class RolesUpdateIn(BaseModel):
roles: list[str]
@field_validator("roles")
@classmethod
def _valid_roles(cls, v: list[str]) -> list[str]:
unknown = set(v) - ALL_ROLES
if unknown:
raise ValueError(f"Unknown roles: {', '.join(sorted(unknown))}")
return sorted(set(v))

243
backend/app/seed.py Normal file
View File

@@ -0,0 +1,243 @@
"""Idempotent seed script.
docker compose exec api python -m app.seed
Always ensures the default departments/deviation categories and the
notifications setting. When SEED_DEMO_DATA=true it also creates dev users
(one per role — usable directly with AUTH_MODE=dev) and a spread of sample
NCRs across every workflow stage for development and demos.
"""
import asyncio
import logging
import random
from datetime import timedelta
from decimal import Decimal
from sqlalchemy import select
from app.config import get_settings
from app.database import get_session_factory
from app.domain import Role, Stage
from app.models import (
AppSetting,
Department,
DeviationCategory,
Ncr,
NcrSecondaryAssignee,
StageTransition,
User,
UserRole,
)
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
from app.models.base import utcnow
from app.services.numbering import allocate_ncr_number
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("seed")
DEPARTMENTS = [
"Machining", "Welding", "Fabrication", "Assembly", "Paint & Coating",
"Shipping / Receiving", "Engineering", "Quality",
]
CATEGORIES = [
"Dimensional", "Material Defect", "Weld Defect", "Documentation",
"Process Deviation", "Supplier Nonconformance", "Damage / Handling", "Other",
]
DEV_USERS = [
("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)),
("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]),
("second@pescoinc.biz", "Sam Secondary", [Role.REQUESTER.value, Role.SECONDARY_DISPOSITION_AUTHORITY.value]),
("ops@pescoinc.biz", "Owen Operations", [Role.REQUESTER.value, Role.OPERATIONS.value]),
("qc@pescoinc.biz", "Quinn Inspector", [Role.REQUESTER.value, Role.QC_INSPECTOR.value]),
("cost@pescoinc.biz", "Casey Costing", [Role.REQUESTER.value, Role.COSTING.value]),
("req@pescoinc.biz", "Riley Requester", [Role.REQUESTER.value]),
]
DETAILS = [
"Bore diameter measured 0.008\" over drawing tolerance on 3 of 12 pieces.",
"Weld porosity found on the underside seam during visual inspection.",
"Wrong material grade pulled from stock; heat number does not match the traveler.",
"Paint runs and inadequate coverage on exterior panels after first coat.",
"Fixture shifted during machining; datum surfaces out of parallel by 0.015\".",
"Supplier-provided casting shows shrinkage cavity at the flange face.",
"Part dropped during transfer between stations; visible dent on sealing surface.",
"Traveler missing signed inspection step for operation 40.",
]
async def seed() -> None:
settings = get_settings()
session_factory = get_session_factory()
async with session_factory() as db:
# ── lookups ──────────────────────────────────────────────────────────
existing = {
d.name for d in (await db.execute(select(Department))).scalars().all()
}
for name in DEPARTMENTS:
if name not in existing:
db.add(Department(name=name, is_active=True))
existing = {
c.name
for c in (await db.execute(select(DeviationCategory))).scalars().all()
}
for name in CATEGORIES:
if name not in existing:
db.add(DeviationCategory(name=name, is_active=True))
if await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY) is None:
db.add(
AppSetting(
key=NOTIFICATIONS_ENABLED_KEY,
value="true" if settings.notifications_enabled_default else "false",
)
)
await db.commit()
log.info("Lookups + settings seeded.")
if not settings.seed_demo_data:
log.info("SEED_DEMO_DATA is false — skipping demo users/NCRs. Done.")
return
# ── dev users ────────────────────────────────────────────────────────
users: dict[str, User] = {}
for email, name, roles in DEV_USERS:
user = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if user is None:
user = User(email=email, display_name=name, is_active=True)
db.add(user)
await db.flush()
for role in roles:
db.add(UserRole(user_id=user.id, role=role))
users[email] = user
await db.commit()
log.info("Dev users seeded: %s", ", ".join(u for u, _, _ in DEV_USERS))
# ── demo NCRs ────────────────────────────────────────────────────────
ncr_count = (await db.execute(select(Ncr.id).limit(1))).first()
if ncr_count is not None:
log.info("NCRs already exist — skipping demo NCR creation. Done.")
return
departments = (await db.execute(select(Department))).scalars().all()
categories = (await db.execute(select(DeviationCategory))).scalars().all()
rng = random.Random(42)
dispo = users["dispo@pescoinc.biz"]
second = users["second@pescoinc.biz"]
ops = users["ops@pescoinc.biz"]
qc = users["qc@pescoinc.biz"]
cost = users["cost@pescoinc.biz"]
req = users["req@pescoinc.biz"]
# (target_stage, count)
plan = [
(Stage.NEW_REQUEST, 3),
(Stage.SECONDARY_DISPOSITION, 2),
(Stage.OPERATIONS, 3),
(Stage.QC_INSPECTION, 2),
(Stage.COSTING, 2),
(Stage.CLOSED, 4),
]
for target, count in plan:
for _ in range(count):
days_ago = rng.randint(5, 120)
created = utcnow() - timedelta(days=days_ago)
number, year, seq = await allocate_ncr_number(db, now=created)
use_secondary = rng.random() < 0.4 or target == Stage.SECONDARY_DISPOSITION
ncr = Ncr(
ncr_number=number,
ncr_year=year,
ncr_seq=seq,
job_number=f"J{rng.randint(10000, 49999)}",
department_id=rng.choice(departments).id,
deviation_category_id=rng.choice(categories).id,
disposition_authority_id=dispo.id,
deviation_detail=rng.choice(DETAILS),
requester_id=req.id,
stage=Stage.NEW_REQUEST.value,
created_at=created,
stage_entered_at=created,
updated_at=created,
)
db.add(ncr)
await db.flush()
t = created
db.add(StageTransition(
ncr_id=ncr.id, from_stage=None, to_stage=Stage.NEW_REQUEST.value,
action="create", acted_by_id=req.id, acted_at=t,
))
def hop(days_lo=1, days_hi=4):
nonlocal t
t = min(utcnow(), t + timedelta(days=rng.randint(days_lo, days_hi),
hours=rng.randint(0, 8)))
return t
def advance(to_stage: Stage, action: str, actor: User, note=None):
db.add(StageTransition(
ncr_id=ncr.id, from_stage=ncr.stage, to_stage=to_stage.value,
action=action, acted_by_id=actor.id, acted_at=hop(), note=note,
))
ncr.stage = to_stage.value
ncr.stage_entered_at = t
if target == Stage.NEW_REQUEST:
continue
# initial disposition
ncr.qc_authority = "AS9100 8.7"
ncr.work_order = f"WO-{rng.randint(1000, 9999)}"
ncr.disposition_notes = (
"<p><strong>Disposition:</strong> Rework per attached instructions. "
"Re-inspect all affected features.</p>"
)
ncr.secondary_review_needed = use_secondary
if use_secondary:
db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=second.id))
advance(Stage.SECONDARY_DISPOSITION, "initial_disposition", dispo)
if target == Stage.SECONDARY_DISPOSITION:
continue
advance(Stage.OPERATIONS, "secondary_release", second)
else:
advance(Stage.OPERATIONS, "initial_disposition", dispo)
if target == Stage.OPERATIONS:
continue
ncr.operations_complete = True
ncr.operations_completed_by_id = ops.id
advance(Stage.QC_INSPECTION, "operations_complete", ops)
ncr.operations_completed_at = t
if target == Stage.QC_INSPECTION:
continue
ncr.qc_approval = "yes"
ncr.inspection_notes = "Reworked features re-inspected; all within tolerance."
ncr.qc_closed = True
ncr.qc_closed_by_id = qc.id
advance(Stage.COSTING, "qc_close", qc)
ncr.qc_closed_at = t
if target == Stage.COSTING:
continue
ncr.labor_cost = Decimal(rng.randint(80, 2400))
ncr.material_cost = Decimal(rng.randint(0, 1800))
ncr.service_cost = Decimal(rng.choice([0, 0, 150, 450, 900]))
ncr.other_cost = Decimal(rng.choice([0, 0, 0, 75, 200]))
ncr.costing_completed_by_id = cost.id
ncr.closed_by_id = cost.id
advance(Stage.CLOSED, "complete_costing", cost)
ncr.costing_completed_at = t
ncr.closed_at = t
await db.commit()
log.info("Demo NCRs seeded. Done.")
if __name__ == "__main__":
asyncio.run(seed())

View File

View File

@@ -0,0 +1,67 @@
"""Audit trail helpers. Audit rows are append-only: the application exposes
no endpoint that updates or deletes them."""
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog, Ncr
def _fmt(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def audit_event(
db: AsyncSession,
*,
user_id: int,
action: str,
ncr_id: int | None = None,
field_name: str | None = None,
old_value: Any = None,
new_value: Any = None,
detail: str | None = None,
) -> None:
db.add(
AuditLog(
ncr_id=ncr_id,
user_id=user_id,
action=action,
field_name=field_name,
old_value=_fmt(old_value),
new_value=_fmt(new_value),
detail=detail,
)
)
def apply_field_updates(
db: AsyncSession,
ncr: Ncr,
user_id: int,
updates: dict[str, Any],
action: str = "update",
) -> dict[str, tuple[Any, Any]]:
"""Set attributes on the NCR, writing one audit row per actually-changed
field. Returns {field: (old, new)} for the fields that changed."""
changes: dict[str, tuple[Any, Any]] = {}
for field, new_value in updates.items():
old_value = getattr(ncr, field)
if old_value == new_value:
continue
setattr(ncr, field, new_value)
changes[field] = (old_value, new_value)
audit_event(
db,
ncr_id=ncr.id,
user_id=user_id,
action=action,
field_name=field,
old_value=old_value,
new_value=new_value,
)
return changes

View File

@@ -0,0 +1,91 @@
"""Microsoft Graph helpers.
Delegated access uses the OAuth2 On-Behalf-Of (OBO) flow: the SPA sends the
API its access token (audience = this API); the API exchanges it with Entra ID
for a Graph token carrying the *signed-in user's* identity, so mail goes out
from that user's own mailbox. This keeps Graph scopes off the frontend and
needs no extra token plumbing on state-changing requests.
"""
import logging
from functools import partial
import anyio
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
MAIL_SEND_SCOPE = "https://graph.microsoft.com/Mail.Send"
GROUP_READ_SCOPE = "https://graph.microsoft.com/GroupMember.Read.All"
_cca = None
def _get_cca():
global _cca
if _cca is None:
import msal # imported lazily so tests never need Entra config
settings = get_settings()
_cca = msal.ConfidentialClientApplication(
settings.entra_client_id,
authority=f"https://login.microsoftonline.com/{settings.entra_tenant_id}",
client_credential=settings.entra_client_secret,
)
return _cca
def _acquire_obo_sync(user_token: str, scopes: list[str]) -> str:
result = _get_cca().acquire_token_on_behalf_of(
user_assertion=user_token, scopes=scopes
)
if "access_token" in result:
return result["access_token"]
raise RuntimeError(
f"OBO token exchange failed: {result.get('error')}: "
f"{result.get('error_description')}"
)
async def acquire_obo_token(user_token: str, scopes: list[str]) -> str:
"""Exchange the caller's API access token for a delegated Graph token."""
return await anyio.to_thread.run_sync(partial(_acquire_obo_sync, user_token, scopes))
async def send_mail_as_user(
user_token: str, subject: str, html_body: str, to_emails: list[str]
) -> None:
"""Send an email from the signed-in user's mailbox (delegated Mail.Send)."""
graph_token = await acquire_obo_token(user_token, [MAIL_SEND_SCOPE])
payload = {
"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html_body},
"toRecipients": [{"emailAddress": {"address": e}} for e in to_emails],
},
"saveToSentItems": True,
}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{GRAPH_BASE}/me/sendMail",
json=payload,
headers={"Authorization": f"Bearer {graph_token}"},
)
if resp.status_code != 202:
raise RuntimeError(f"Graph sendMail returned {resp.status_code}: {resp.text[:300]}")
async def check_member_group(user_token: str, group_id: str) -> bool:
"""Group-overage fallback: ask Graph whether the signed-in user is in the
gate group. Requires delegated GroupMember.Read.All (see README)."""
graph_token = await acquire_obo_token(user_token, [GROUP_READ_SCOPE])
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{GRAPH_BASE}/me/checkMemberGroups",
json={"groupIds": [group_id]},
headers={"Authorization": f"Bearer {graph_token}"},
)
resp.raise_for_status()
return group_id in resp.json().get("value", [])

View File

@@ -0,0 +1,116 @@
"""Job number lookup abstraction — the seam for the future Infor VISUAL ERP
integration.
Today, Job Number is free text: the default NullJobLookupService accepts any
value and returns no enrichment. When PESCO is ready to integrate VISUAL,
implement VisualJobLookupService below, set JOB_LOOKUP_PROVIDER=visual (plus
the VISUAL_DB_* variables) in .env, and restart — no schema or frontend
changes required:
* the NCR schema already stores the job number exactly as entered, plus a
related `job_info` row (part_id, part_description, customer_name,
work_order_status) that any provider can populate at NCR creation;
* the frontend job-number field already calls GET /api/jobs/{job_number}/lookup
as the user types and displays whatever enrichment comes back, so
validation/autocomplete light up automatically with a real provider.
"""
import logging
from dataclasses import dataclass
from typing import Protocol
from app.config import get_settings
logger = logging.getLogger(__name__)
@dataclass
class JobInfoData:
part_id: str | None = None
part_description: str | None = None
customer_name: str | None = None
work_order_status: str | None = None
source: str = "null"
class JobLookupService(Protocol):
async def lookup(self, job_number: str) -> JobInfoData | None:
"""Return read-only enrichment for a job number, or None when the job
is unknown / the provider has nothing to add. Implementations must
never raise for a merely-unknown job number."""
...
class NullJobLookupService:
"""Default provider: job numbers are accepted as-is, no enrichment."""
async def lookup(self, job_number: str) -> JobInfoData | None: # noqa: ARG002
return None
class VisualJobLookupService:
"""PLACEHOLDER for the future Infor VISUAL Manufacturing (SQL Server)
integration. Not implemented yet — selecting JOB_LOOKUP_PROVIDER=visual
today raises at startup with a pointer here.
Implementation notes (verified against PESCO's VISUAL 10 schema):
* Connect read-only to the VISUAL SQL Server database (VISUAL_DB_* env
vars) with a dedicated SELECT-only SQL login. Use `aioodbc` or `pymssql`.
NEVER write to VISUAL tables — hundreds of triggers maintain derived
values and direct writes bypass application validation.
* A PESCO "job number" corresponds to a work order base id (typically
with lot/split/sub qualifiers). WORK_ORDER's primary key is composite:
(TYPE, BASE_ID, LOT_ID, SPLIT_ID, SUB_ID); manufacturing work orders
have TYPE = 'W'. Parse the entered job number into BASE_ID (and LOT_ID
when the shop uses BASE/LOT notation, e.g. "12345/1") and query:
SELECT TOP 1 wo.BASE_ID, wo.LOT_ID, wo.SUB_ID, wo.PART_ID,
wo.STATUS, wo.DESIRED_QTY, wo.CREATE_DATE,
p.DESCRIPTION AS PART_DESCRIPTION
FROM WORK_ORDER wo
LEFT JOIN PART p ON p.ID = wo.PART_ID
WHERE wo.TYPE = 'W' AND wo.BASE_ID = :base_id
ORDER BY wo.LOT_ID, wo.SPLIT_ID, wo.SUB_ID
STATUS is a one-char code (R=released, C=closed, etc.) — map it to a
readable label for work_order_status.
* Customer enrichment goes through the demand/supply linkage:
DEMAND_SUPPLY_LINK rows with SUPPLY_TYPE='WO' and SUPPLY_BASE_ID =
wo.BASE_ID (match SUPPLY_LOT_ID/SUPPLY_SPLIT_ID/SUPPLY_SUB_ID when
present) point at customer-order demand (DEMAND_TYPE='CO',
DEMAND_BASE_ID = CUST_ORDER_LINE.CUST_ORDER_ID, DEMAND_SEQ_NO = line
no). Join CUSTOMER_ORDER -> CUSTOMER for the customer name.
* Return JobInfoData(part_id=..., part_description=...,
customer_name=..., work_order_status=..., source="visual").
Return None when no WORK_ORDER row matches. Wrap connection errors in
logging + return None so an ERP outage never blocks NCR entry.
"""
def __init__(self) -> None:
settings = get_settings()
raise NotImplementedError(
"VisualJobLookupService is a documented stub. Implement it per the "
"notes in app/services/job_lookup.py, or set JOB_LOOKUP_PROVIDER=null. "
f"(Configured VISUAL host: {settings.visual_db_host or 'unset'})"
)
async def lookup(self, job_number: str) -> JobInfoData | None:
raise NotImplementedError
_service: JobLookupService | None = None
def get_job_lookup_service() -> JobLookupService:
global _service
if _service is None:
provider = get_settings().job_lookup_provider
if provider == "visual":
_service = VisualJobLookupService() # raises: intentionally loud
else:
_service = NullJobLookupService()
logger.info("Job lookup provider: %s", provider)
return _service

View File

@@ -0,0 +1,156 @@
"""Stage-transition email notifications via Microsoft Graph delegated
Mail.Send. Mail is sent FROM the mailbox of the user whose action triggered
the transition (OBO flow — see services/graph.py).
Fault tolerance contract: a Graph/network failure must never block a workflow
transition. Every failure path logs and returns a human-readable warning that
the API surfaces in the response `warnings` array; the transition itself has
already been committed by the caller.
"""
import html
import logging
from enum import Enum
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser
from app.config import get_settings
from app.domain import STAGE_LABELS, STAGE_OWNER_ROLE, Role, Stage
from app.models import AppSetting, Ncr, User, UserRole
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
from app.services.graph import send_mail_as_user
logger = logging.getLogger(__name__)
class NotifyEvent(str, Enum):
CREATED = "created"
SECONDARY_ASSIGNED = "secondary_assigned"
RELEASED_TO_OPERATIONS = "released_to_operations"
OPERATIONS_COMPLETE = "operations_complete"
QC_CLOSED = "qc_closed"
CLOSED = "closed"
REOPENED = "reopened"
_EVENT_SUBJECT = {
NotifyEvent.CREATED: "New NCR submitted — disposition needed",
NotifyEvent.SECONDARY_ASSIGNED: "Secondary disposition review assigned to you",
NotifyEvent.RELEASED_TO_OPERATIONS: "NCR released to Operations",
NotifyEvent.OPERATIONS_COMPLETE: "Operations complete — QC inspection needed",
NotifyEvent.QC_CLOSED: "QC closed — costing needed",
NotifyEvent.CLOSED: "Your NCR has been closed",
NotifyEvent.REOPENED: "NCR reopened by an administrator",
}
async def notifications_enabled(db: AsyncSession) -> bool:
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
if row is None:
return get_settings().notifications_enabled_default
return row.value == "true"
async def _role_emails(db: AsyncSession, role: Role) -> list[str]:
result = await db.execute(
select(User.email)
.join(UserRole, UserRole.user_id == User.id)
.where(UserRole.role == role.value, User.is_active.is_(True))
)
return [r[0] for r in result.all()]
async def _recipients(db: AsyncSession, ncr: Ncr, event: NotifyEvent) -> list[str]:
if event == NotifyEvent.CREATED:
return [ncr.disposition_authority.email]
if event == NotifyEvent.SECONDARY_ASSIGNED:
return [u.email for u in ncr.secondary_authorities]
if event == NotifyEvent.RELEASED_TO_OPERATIONS:
return await _role_emails(db, Role.OPERATIONS)
if event == NotifyEvent.OPERATIONS_COMPLETE:
return await _role_emails(db, Role.QC_INSPECTOR)
if event == NotifyEvent.QC_CLOSED:
return await _role_emails(db, Role.COSTING)
if event == NotifyEvent.CLOSED:
return [ncr.requester.email]
if event == NotifyEvent.REOPENED:
owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage))
emails = await _role_emails(db, owner_role) if owner_role else []
if ncr.requester.email not in emails:
emails.append(ncr.requester.email)
return emails
return []
def _build_body(ncr: Ncr, event: NotifyEvent, summary: str) -> str:
e = html.escape
link = f"{get_settings().app_base_url}/ncrs/{ncr.id}"
rows = [
("NCR Number", ncr.ncr_number),
("Job Number", ncr.job_number),
("Department", ncr.department.name if ncr.department else ""),
("Deviation Category", ncr.deviation_category.name if ncr.deviation_category else ""),
("Current Stage", STAGE_LABELS.get(Stage(ncr.stage), ncr.stage)),
("Requester", ncr.requester.display_name if ncr.requester else ""),
]
table = "".join(
f"<tr><td style='padding:4px 12px 4px 0;color:#555'>{e(k)}</td>"
f"<td style='padding:4px 0'><strong>{e(v or '')}</strong></td></tr>"
for k, v in rows
)
return f"""
<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">
<h2 style="margin:0 0 4px">{e(_EVENT_SUBJECT[event])}</h2>
<p style="margin:4px 0 12px">{e(summary)}</p>
<table style="border-collapse:collapse">{table}</table>
<p style="margin:16px 0">
<a href="{e(link)}" style="background:#1a5fb4;color:#fff;padding:10px 18px;
border-radius:4px;text-decoration:none">Open {e(ncr.ncr_number)}</a>
</p>
<p style="color:#888;font-size:12px">Sent automatically by the PESCO NCR system.</p>
</div>
"""
async def send_stage_notification(
db: AsyncSession,
ncr: Ncr,
event: NotifyEvent,
actor: CurrentUser,
summary: str,
) -> list[str]:
"""Best-effort notification. Returns a list of non-blocking warnings
(empty on success or when notifications are disabled)."""
try:
if not await notifications_enabled(db):
logger.info("Notifications disabled; skipping %s for %s", event, ncr.ncr_number)
return []
recipients = sorted(set(await _recipients(db, ncr, event)))
if not recipients:
logger.info("No recipients for %s on %s", event, ncr.ncr_number)
return []
if actor.token is None:
# dev auth mode: no real user token to send on behalf of
logger.info(
"[dev] Would send '%s' for %s from %s to %s",
event.value, ncr.ncr_number, actor.user.email, recipients,
)
return [
f"Email not sent (dev auth mode): '{_EVENT_SUBJECT[event]}' "
f"to {', '.join(recipients)}."
]
subject = f"[{ncr.ncr_number}] {_EVENT_SUBJECT[event]}"
body = _build_body(ncr, event, summary)
await send_mail_as_user(actor.token, subject, body, recipients)
logger.info(
"Sent %s notification for %s from %s to %s",
event.value, ncr.ncr_number, actor.user.email, recipients,
)
return []
except Exception as exc: # noqa: BLE001 — must never block the workflow
logger.exception("Notification failed for %s (%s)", ncr.ncr_number, event.value)
return [
f"The workflow change was saved, but the notification email could not "
f"be sent: {exc}"
]

View File

@@ -0,0 +1,56 @@
"""Atomic NCR number allocation.
Format: NCR-YYYY-NNNN (zero-padded, per-calendar-year sequence).
Strategy: UPDATE-first on the per-year row in ncr_sequences. The UPDATE takes
a row lock (InnoDB) / reserved write lock (SQLite) that is held until the
enclosing transaction commits, so two concurrent submissions serialize and can
never read the same sequence value. If the year row doesn't exist yet
(first NCR of a new year), it is inserted inside a SAVEPOINT; a losing racer
gets an IntegrityError, rolls back only the savepoint, and proceeds to the
UPDATE which now finds the winner's row.
"""
from datetime import datetime
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import NcrSequence
from app.models.base import utcnow
async def allocate_ncr_number(
db: AsyncSession, now: datetime | None = None
) -> tuple[str, int, int]:
"""Allocate the next NCR number inside the caller's transaction.
Returns (ncr_number, year, seq). Must be called within the same
transaction that inserts the NCR so the sequence row lock is held
until commit.
"""
year = (now or utcnow()).year
result = await db.execute(
update(NcrSequence)
.where(NcrSequence.year == year)
.values(last_seq=NcrSequence.last_seq + 1)
)
if result.rowcount == 0:
# First NCR of this calendar year — create the sequence row.
try:
async with db.begin_nested():
db.add(NcrSequence(year=year, last_seq=0))
await db.flush()
except IntegrityError:
pass # another request created it first; fall through to UPDATE
await db.execute(
update(NcrSequence)
.where(NcrSequence.year == year)
.values(last_seq=NcrSequence.last_seq + 1)
)
seq = (
await db.execute(select(NcrSequence.last_seq).where(NcrSequence.year == year))
).scalar_one()
return f"NCR-{year}-{seq:04d}", year, seq

View File

@@ -0,0 +1,64 @@
"""Printable NCR PDF (WeasyPrint) — a clean single-document rendering of the
complete NCR for hard-copy travelers and audits.
WeasyPrint is imported lazily so environments without the Pango/Cairo system
libraries (e.g. unit tests) can still import the app.
"""
from functools import partial
from pathlib import Path
import anyio
from jinja2 import Environment, FileSystemLoader, select_autoescape
from app.domain import STAGE_LABELS, Stage
from app.models import Ncr
from app.models.base import utcnow
from app.services.storage import attachment_abs_path
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
_env = Environment(
loader=FileSystemLoader(_TEMPLATES_DIR),
autoescape=select_autoescape(["html"]),
)
def _render_html(ncr: Ncr) -> str:
images = []
other_files = []
for att in ncr.attachments:
entry = {
"filename": att.original_filename,
"uploaded_by": att.uploaded_by.display_name,
"uploaded_at": att.uploaded_at,
"size_kb": max(1, att.size_bytes // 1024),
}
path = attachment_abs_path(att.stored_path)
if att.is_image and path.is_file():
entry["src"] = path.as_uri()
images.append(entry)
else:
other_files.append(entry)
template = _env.get_template("ncr_pdf.html")
return template.render(
ncr=ncr,
stage_label=STAGE_LABELS[Stage(ncr.stage)],
stage_labels=STAGE_LABELS,
Stage=Stage,
images=images,
other_files=other_files,
generated_at=utcnow(),
)
def _html_to_pdf(html: str) -> bytes:
from weasyprint import HTML # lazy: needs Pango/Cairo system libs
return HTML(string=html).write_pdf()
async def render_ncr_pdf(ncr: Ncr) -> bytes:
html = _render_html(ncr)
# WeasyPrint rendering is CPU-bound; keep it off the event loop.
return await anyio.to_thread.run_sync(partial(_html_to_pdf, html))

View File

@@ -0,0 +1,31 @@
"""Rich-text HTML sanitization (XSS defense) using nh3 (ammonia bindings).
Applied server-side to every rich-text field before it is stored."""
import nh3
_ALLOWED_TAGS = {
"p", "br", "div", "span",
"strong", "b", "em", "i", "u", "s", "sub", "sup",
"ul", "ol", "li",
"h1", "h2", "h3", "h4",
"blockquote", "pre", "code",
"a", "hr", "table", "thead", "tbody", "tr", "th", "td",
}
_ALLOWED_ATTRIBUTES = {
"a": {"href", "title"},
"th": {"colspan", "rowspan"},
"td": {"colspan", "rowspan"},
}
def sanitize_html(value: str | None) -> str | None:
if value is None:
return None
cleaned = nh3.clean(
value,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
link_rel="noopener noreferrer",
url_schemes={"http", "https", "mailto"},
)
return cleaned

View File

@@ -0,0 +1,87 @@
"""Attachment storage on the local filesystem (a named Docker volume in
production). Files live at ATTACHMENTS_DIR/<ncr_id>/<uuid><ext>; metadata is
kept in the attachments table."""
import re
import uuid
from pathlib import Path
from fastapi import UploadFile
from app.config import get_settings
ALLOWED_EXTENSIONS = {
# images (camera capture on tablets produces jpg/png/heic)
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
# documents
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt", ".msg", ".eml",
}
IMAGE_EXTENSIONS = {
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
}
CHUNK_SIZE = 1024 * 1024
class UploadValidationError(Exception):
pass
def _safe_filename(name: str) -> str:
name = Path(name or "upload").name
return re.sub(r"[^\w.\- ()]", "_", name)[:255] or "upload"
async def save_attachment(upload: UploadFile, ncr_id: int) -> dict:
"""Validate and persist an uploaded file. Returns metadata for the
Attachment row. Raises UploadValidationError on type/size violations."""
settings = get_settings()
original = _safe_filename(upload.filename or "upload")
ext = Path(original).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise UploadValidationError(
f"File type '{ext or 'unknown'}' is not allowed. "
f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
)
stored_rel = f"{ncr_id}/{uuid.uuid4().hex}{ext}"
dest = Path(settings.attachments_dir) / stored_rel
dest.parent.mkdir(parents=True, exist_ok=True)
size = 0
max_bytes = settings.max_upload_bytes
try:
with dest.open("wb") as out:
while chunk := await upload.read(CHUNK_SIZE):
size += len(chunk)
if size > max_bytes:
raise UploadValidationError(
f"File exceeds the {settings.max_upload_mb} MB limit."
)
out.write(chunk)
except UploadValidationError:
dest.unlink(missing_ok=True)
raise
except Exception:
dest.unlink(missing_ok=True)
raise
if size == 0:
dest.unlink(missing_ok=True)
raise UploadValidationError("Uploaded file is empty.")
return {
"original_filename": original,
"stored_path": stored_rel,
"content_type": upload.content_type or "application/octet-stream",
"size_bytes": size,
"is_image": ext in IMAGE_EXTENSIONS,
}
def attachment_abs_path(stored_path: str) -> Path:
settings = get_settings()
base = Path(settings.attachments_dir).resolve()
p = (base / stored_path).resolve()
if not str(p).startswith(str(base)):
raise UploadValidationError("Invalid attachment path.")
return p

View File

@@ -0,0 +1,76 @@
"""Server-side workflow state machine. Every stage change flows through
`transition()`, which validates against ALLOWED_TRANSITIONS and records both
a StageTransition row (timestamps + acting user, for aging/cycle-time
reporting) and an audit entry."""
from app.domain import ALLOWED_TRANSITIONS, Stage
from app.models import Ncr, StageTransition
from app.models.base import utcnow
from app.services.audit import audit_event
from sqlalchemy.ext.asyncio import AsyncSession
class InvalidTransitionError(Exception):
def __init__(self, from_stage: str, to_stage: str):
self.from_stage = from_stage
self.to_stage = to_stage
super().__init__(f"Invalid stage transition: {from_stage} -> {to_stage}")
def transition(
db: AsyncSession,
ncr: Ncr,
to_stage: Stage,
*,
action: str,
actor_id: int,
note: str | None = None,
) -> None:
from_stage = Stage(ncr.stage)
if to_stage not in ALLOWED_TRANSITIONS.get(from_stage, set()):
raise InvalidTransitionError(from_stage.value, to_stage.value)
now = utcnow()
ncr.stage = to_stage.value
ncr.stage_entered_at = now
db.add(
StageTransition(
ncr_id=ncr.id,
from_stage=from_stage.value,
to_stage=to_stage.value,
action=action,
acted_by_id=actor_id,
acted_at=now,
note=note,
)
)
audit_event(
db,
ncr_id=ncr.id,
user_id=actor_id,
action=action,
field_name="stage",
old_value=from_stage.value,
new_value=to_stage.value,
detail=note,
)
def record_creation(db: AsyncSession, ncr: Ncr, actor_id: int) -> None:
db.add(
StageTransition(
ncr_id=ncr.id,
from_stage=None,
to_stage=Stage.NEW_REQUEST.value,
action="create",
acted_by_id=actor_id,
acted_at=ncr.created_at,
)
)
audit_event(
db,
ncr_id=ncr.id,
user_id=actor_id,
action="create",
detail=f"NCR {ncr.ncr_number} created",
)

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page {
size: letter;
margin: 18mm 14mm 20mm 14mm;
@bottom-left { content: "{{ ncr.ncr_number }} — Non-Conformance Report"; font-size: 8pt; color: #777; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; color: #777; }
}
body { font-family: "DejaVu Sans", sans-serif; font-size: 9.5pt; color: #1a1a1a; }
h1 { font-size: 17pt; margin: 0; }
h2 {
font-size: 10.5pt; text-transform: uppercase; letter-spacing: 0.06em;
background: #eef2f7; border-left: 4px solid #1a5fb4; padding: 4px 8px;
margin: 16px 0 6px;
}
.header { display: flex; justify-content: space-between; align-items: flex-start;
border-bottom: 3px solid #1a5fb4; padding-bottom: 8px; }
.brand { font-size: 13pt; font-weight: bold; color: #1a5fb4; }
.doc-meta { text-align: right; font-size: 9pt; color: #444; }
.ncr-number { font-size: 15pt; font-weight: bold; }
.stage-chip { display: inline-block; background: #1a5fb4; color: #fff;
padding: 2px 10px; border-radius: 10px; font-size: 9pt; }
table.fields { width: 100%; border-collapse: collapse; margin: 4px 0; }
table.fields td { border: 1px solid #ccd4de; padding: 5px 7px; vertical-align: top; }
table.fields td.lbl { width: 24%; background: #f6f8fa; color: #555; font-size: 8.5pt;
text-transform: uppercase; letter-spacing: 0.04em; }
.notes { border: 1px solid #ccd4de; padding: 7px; min-height: 30px; }
.pending { color: #999; font-style: italic; }
table.history { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
table.history th { background: #f6f8fa; border: 1px solid #ccd4de; padding: 4px 6px;
text-align: left; }
table.history td { border: 1px solid #ccd4de; padding: 4px 6px; }
.thumb-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.thumb { width: 30%; border: 1px solid #ccd4de; padding: 4px; }
.thumb img { width: 100%; max-height: 150px; object-fit: contain; }
.thumb .cap { font-size: 7.5pt; color: #666; margin-top: 2px; }
.costs td.num { text-align: right; font-variant-numeric: tabular-nums; }
.costs tr.total td { font-weight: bold; background: #eef2f7; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="brand">PESCO</div>
<h1>Non-Conformance Report</h1>
</div>
<div class="doc-meta">
<div class="ncr-number">{{ ncr.ncr_number }}</div>
<div>Stage: <span class="stage-chip">{{ stage_label }}</span></div>
<div>Generated {{ generated_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
</div>
</div>
<h2>Request</h2>
<table class="fields">
<tr>
<td class="lbl">NCR Number</td><td>{{ ncr.ncr_number }}</td>
<td class="lbl">Date</td><td>{{ ncr.created_at.strftime("%Y-%m-%d") }}</td>
</tr>
<tr>
<td class="lbl">Job Number</td><td>{{ ncr.job_number }}</td>
<td class="lbl">Department</td><td>{{ ncr.department.name }}</td>
</tr>
<tr>
<td class="lbl">Deviation Category</td><td>{{ ncr.deviation_category.name }}</td>
<td class="lbl">Requester</td><td>{{ ncr.requester.display_name }}</td>
</tr>
<tr>
<td class="lbl">Disposition Authority</td><td>{{ ncr.disposition_authority.display_name }}</td>
<td class="lbl">Work Order</td><td>{{ ncr.work_order or "—" }}</td>
</tr>
{% if ncr.job_info %}
<tr>
<td class="lbl">Part (ERP)</td>
<td>{{ ncr.job_info.part_id or "—" }} {{ ncr.job_info.part_description or "" }}</td>
<td class="lbl">Customer (ERP)</td><td>{{ ncr.job_info.customer_name or "—" }}</td>
</tr>
{% endif %}
</table>
<div class="notes">{{ ncr.deviation_detail }}</div>
<h2>Disposition</h2>
<table class="fields">
<tr>
<td class="lbl">QC Authority</td><td>{{ ncr.qc_authority or "—" }}</td>
<td class="lbl">Secondary Review</td>
<td>
{% if ncr.secondary_review_needed is none %}—
{% elif ncr.secondary_review_needed %}Yes —
{{ ncr.secondary_authorities | map(attribute="display_name") | join(", ") or "unassigned" }}
{% else %}No{% endif %}
</td>
</tr>
</table>
{% if ncr.disposition_notes %}
<div class="notes">{{ ncr.disposition_notes | safe }}</div>
{% else %}
<div class="notes pending">No disposition notes recorded.</div>
{% endif %}
<h2>Operations</h2>
<table class="fields">
<tr>
<td class="lbl">Operations Complete</td>
<td>{% if ncr.operations_complete %}Yes{% else %}<span class="pending">Pending</span>{% endif %}</td>
<td class="lbl">Completed By / At</td>
<td>
{% if ncr.operations_completed_by %}
{{ ncr.operations_completed_by.display_name }} —
{{ ncr.operations_completed_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}—{% endif %}
</td>
</tr>
</table>
<h2>QC Inspection</h2>
<table class="fields">
<tr>
<td class="lbl">QC Approval</td>
<td>{{ ncr.qc_approval | capitalize if ncr.qc_approval else "—" }}</td>
<td class="lbl">QC Closed</td>
<td>
{% if ncr.qc_closed %}Yes — {{ ncr.qc_closed_by.display_name }},
{{ ncr.qc_closed_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}<span class="pending">Pending</span>{% endif %}
</td>
</tr>
</table>
{% if ncr.inspection_notes %}
<div class="notes">{{ ncr.inspection_notes }}</div>
{% endif %}
<h2>Costing</h2>
<table class="fields costs">
<tr>
<td class="lbl">Labor</td>
<td class="num">{% if ncr.labor_cost is not none %}${{ "%.2f" | format(ncr.labor_cost) }}{% else %}—{% endif %}</td>
<td class="lbl">Material</td>
<td class="num">{% if ncr.material_cost is not none %}${{ "%.2f" | format(ncr.material_cost) }}{% else %}—{% endif %}</td>
</tr>
<tr>
<td class="lbl">Service</td>
<td class="num">{% if ncr.service_cost is not none %}${{ "%.2f" | format(ncr.service_cost) }}{% else %}—{% endif %}</td>
<td class="lbl">Other</td>
<td class="num">{% if ncr.other_cost is not none %}${{ "%.2f" | format(ncr.other_cost) }}{% else %}—{% endif %}</td>
</tr>
<tr class="total">
<td class="lbl">Total Cost of Nonconformance</td>
<td class="num" colspan="3">
{% if ncr.total_cost is not none %}${{ "%.2f" | format(ncr.total_cost) }}{% else %}—{% endif %}
</td>
</tr>
</table>
{% if ncr.closed_at %}
<p>Closed by {{ ncr.closed_by.display_name }} on {{ ncr.closed_at.strftime("%Y-%m-%d %H:%M") }} UTC.</p>
{% endif %}
{% if images or other_files %}
<h2>Attachments ({{ images | length + other_files | length }})</h2>
{% if images %}
<div class="thumb-grid">
{% for img in images %}
<div class="thumb">
<img src="{{ img.src }}" alt="{{ img.filename }}">
<div class="cap">{{ img.filename }} — {{ img.uploaded_by }},
{{ img.uploaded_at.strftime("%Y-%m-%d") }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if other_files %}
<table class="history" style="margin-top:6px">
<tr><th>File</th><th>Uploaded By</th><th>Date</th><th>Size</th></tr>
{% for f in other_files %}
<tr>
<td>{{ f.filename }}</td><td>{{ f.uploaded_by }}</td>
<td>{{ f.uploaded_at.strftime("%Y-%m-%d %H:%M") }}</td><td>{{ f.size_kb }} KB</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endif %}
<h2>Workflow History</h2>
<table class="history">
<tr><th>Date (UTC)</th><th>Action</th><th>From</th><th>To</th><th>By</th><th>Note</th></tr>
{% for t in ncr.transitions %}
<tr>
<td>{{ t.acted_at.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ t.action.replace("_", " ") | title }}</td>
<td>{{ stage_labels[Stage(t.from_stage)] if t.from_stage else "—" }}</td>
<td>{{ stage_labels[Stage(t.to_stage)] }}</td>
<td>{{ t.acted_by.display_name }}</td>
<td>{{ t.note or "" }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>