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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
312
backend/app/routers/admin.py
Normal file
312
backend/app/routers/admin.py
Normal 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,
|
||||
)
|
||||
18
backend/app/routers/health.py
Normal file
18
backend/app/routers/health.py
Normal 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"}
|
||||
28
backend/app/routers/jobs.py
Normal file
28
backend/app/routers/jobs.py
Normal 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,
|
||||
}
|
||||
44
backend/app/routers/lookups.py
Normal file
44
backend/app/routers/lookups.py
Normal 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
866
backend/app/routers/ncrs.py
Normal 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"'
|
||||
},
|
||||
)
|
||||
185
backend/app/routers/reports.py
Normal file
185
backend/app/routers/reports.py
Normal 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, "0–7 days"), (8, 14, "8–14 days"), (15, 30, "15–30 days"),
|
||||
(31, 60, "31–60 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],
|
||||
)
|
||||
55
backend/app/routers/users.py
Normal file
55
backend/app/routers/users.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user