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

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