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:
9
backend/.dockerignore
Normal file
9
backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
.pytest_cache
|
||||
tests
|
||||
dev.db
|
||||
attachments
|
||||
.env
|
||||
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# WeasyPrint runtime libraries (Pango/Cairo) + curl for the container healthcheck.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi8 \
|
||||
shared-mime-info \
|
||||
fonts-dejavu-core \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY alembic.ini .
|
||||
COPY alembic ./alembic
|
||||
COPY app ./app
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh && mkdir -p /data/attachments
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
# URL is injected from app settings in alembic/env.py
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
43
backend/alembic/env.py
Normal file
43
backend/alembic/env.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
225
backend/alembic/versions/0001_initial_schema.py
Normal file
225
backend/alembic/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-07-13
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
MYSQL = {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("entra_oid", sa.String(64), nullable=True, unique=True),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("employee_id", sa.String(64), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("role", sa.String(40), primary_key=True),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"departments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"deviation_categories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ncr_sequences",
|
||||
sa.Column("year", sa.Integer(), primary_key=True, autoincrement=False),
|
||||
sa.Column("last_seq", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ncrs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("ncr_number", sa.String(20), nullable=False),
|
||||
sa.Column("ncr_year", sa.Integer(), nullable=False),
|
||||
sa.Column("ncr_seq", sa.Integer(), nullable=False),
|
||||
sa.Column("job_number", sa.String(100), nullable=False),
|
||||
sa.Column("department_id", sa.Integer(), sa.ForeignKey("departments.id"), nullable=False),
|
||||
sa.Column(
|
||||
"deviation_category_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("deviation_categories.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"disposition_authority_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||
),
|
||||
sa.Column("deviation_detail", sa.Text(), nullable=False),
|
||||
sa.Column("requester_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("stage", sa.String(30), nullable=False),
|
||||
sa.Column("stage_entered_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("qc_authority", sa.String(255), nullable=True),
|
||||
sa.Column("work_order", sa.String(100), nullable=True),
|
||||
sa.Column("disposition_notes", sa.Text(), nullable=True),
|
||||
sa.Column("secondary_review_needed", sa.Boolean(), nullable=True),
|
||||
sa.Column(
|
||||
"operations_complete", sa.Boolean(), nullable=False, server_default=sa.text("0")
|
||||
),
|
||||
sa.Column("operations_completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"operations_completed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True
|
||||
),
|
||||
sa.Column("qc_approval", sa.String(10), nullable=True),
|
||||
sa.Column("inspection_notes", sa.Text(), nullable=True),
|
||||
sa.Column("qc_closed", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("qc_closed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("qc_closed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("labor_cost", sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column("material_cost", sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column("service_cost", sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column("other_cost", sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column("costing_completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"costing_completed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True
|
||||
),
|
||||
sa.Column("closed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("closed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.UniqueConstraint("ncr_number", name="uq_ncrs_ncr_number"),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_ncrs_stage", "ncrs", ["stage"])
|
||||
op.create_index("ix_ncrs_job_number", "ncrs", ["job_number"])
|
||||
op.create_index("ix_ncrs_created_at", "ncrs", ["created_at"])
|
||||
|
||||
op.create_table(
|
||||
"ncr_secondary_assignees",
|
||||
sa.Column(
|
||||
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), primary_key=True),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"stage_transitions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("from_stage", sa.String(30), nullable=True),
|
||||
sa.Column("to_stage", sa.String(30), nullable=False),
|
||||
sa.Column("action", sa.String(40), nullable=False),
|
||||
sa.Column("acted_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("acted_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_stage_transitions_ncr", "stage_transitions", ["ncr_id", "acted_at"])
|
||||
|
||||
op.create_table(
|
||||
"job_info",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"ncr_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("ncrs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column("part_id", sa.String(30), nullable=True),
|
||||
sa.Column("part_description", sa.String(255), nullable=True),
|
||||
sa.Column("customer_name", sa.String(100), nullable=True),
|
||||
sa.Column("work_order_status", sa.String(20), nullable=True),
|
||||
sa.Column("source", sa.String(20), nullable=False),
|
||||
sa.Column("fetched_at", sa.DateTime(), nullable=False),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"attachments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||
sa.Column("stored_path", sa.String(300), nullable=False, unique=True),
|
||||
sa.Column("content_type", sa.String(100), nullable=False),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("is_image", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("uploaded_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("uploaded_at", sa.DateTime(), nullable=False),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="SET NULL"), nullable=True
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("action", sa.String(40), nullable=False),
|
||||
sa.Column("field_name", sa.String(100), nullable=True),
|
||||
sa.Column("old_value", sa.Text(), nullable=True),
|
||||
sa.Column("new_value", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.String(500), nullable=True),
|
||||
**MYSQL,
|
||||
)
|
||||
op.create_index("ix_audit_log_ncr", "audit_log", ["ncr_id", "created_at"])
|
||||
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
|
||||
|
||||
op.create_table(
|
||||
"app_settings",
|
||||
sa.Column("key", sa.String(100), primary_key=True),
|
||||
sa.Column("value", sa.String(500), nullable=False),
|
||||
**MYSQL,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in (
|
||||
"app_settings",
|
||||
"audit_log",
|
||||
"attachments",
|
||||
"job_info",
|
||||
"stage_transitions",
|
||||
"ncr_secondary_assignees",
|
||||
"ncrs",
|
||||
"ncr_sequences",
|
||||
"deviation_categories",
|
||||
"departments",
|
||||
"user_roles",
|
||||
"users",
|
||||
):
|
||||
op.drop_table(table)
|
||||
129
backend/alembic/versions/0002_reporting_views.py
Normal file
129
backend/alembic/versions/0002_reporting_views.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""read-only flattened reporting views for Power BI
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-07-13
|
||||
|
||||
The powerbi_ro MySQL user (created by db/init/01-powerbi-user.sh) has SELECT
|
||||
on exactly these three views and nothing else.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
VW_NCR_FULL = """
|
||||
CREATE OR REPLACE VIEW vw_ncr_full AS
|
||||
SELECT
|
||||
n.id AS ncr_id,
|
||||
n.ncr_number,
|
||||
n.ncr_year,
|
||||
n.ncr_seq,
|
||||
n.created_at,
|
||||
n.job_number,
|
||||
d.name AS department,
|
||||
dc.name AS deviation_category,
|
||||
req.display_name AS requester,
|
||||
req.email AS requester_email,
|
||||
da.display_name AS disposition_authority,
|
||||
n.stage,
|
||||
n.stage_entered_at,
|
||||
DATEDIFF(UTC_TIMESTAMP(), n.stage_entered_at) AS days_in_stage,
|
||||
n.deviation_detail,
|
||||
n.qc_authority,
|
||||
n.work_order,
|
||||
n.disposition_notes,
|
||||
n.secondary_review_needed,
|
||||
(SELECT GROUP_CONCAT(u2.display_name ORDER BY u2.display_name SEPARATOR '; ')
|
||||
FROM ncr_secondary_assignees sa2
|
||||
JOIN users u2 ON u2.id = sa2.user_id
|
||||
WHERE sa2.ncr_id = n.id) AS secondary_authorities,
|
||||
n.operations_complete,
|
||||
n.operations_completed_at,
|
||||
opu.display_name AS operations_completed_by,
|
||||
n.qc_approval,
|
||||
n.inspection_notes,
|
||||
n.qc_closed,
|
||||
n.qc_closed_at,
|
||||
qcu.display_name AS qc_closed_by,
|
||||
n.labor_cost,
|
||||
n.material_cost,
|
||||
n.service_cost,
|
||||
n.other_cost,
|
||||
COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0)
|
||||
+ COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost,
|
||||
n.costing_completed_at,
|
||||
n.closed_at,
|
||||
clu.display_name AS closed_by,
|
||||
ji.part_id,
|
||||
ji.part_description,
|
||||
ji.customer_name,
|
||||
ji.work_order_status,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.ncr_id = n.id) AS attachment_count
|
||||
FROM ncrs n
|
||||
JOIN departments d ON d.id = n.department_id
|
||||
JOIN deviation_categories dc ON dc.id = n.deviation_category_id
|
||||
JOIN users req ON req.id = n.requester_id
|
||||
JOIN users da ON da.id = n.disposition_authority_id
|
||||
LEFT JOIN users opu ON opu.id = n.operations_completed_by_id
|
||||
LEFT JOIN users qcu ON qcu.id = n.qc_closed_by_id
|
||||
LEFT JOIN users clu ON clu.id = n.closed_by_id
|
||||
LEFT JOIN job_info ji ON ji.ncr_id = n.id
|
||||
"""
|
||||
|
||||
VW_NCR_STAGE_HISTORY = """
|
||||
CREATE OR REPLACE VIEW vw_ncr_stage_history AS
|
||||
SELECT
|
||||
t.id AS transition_id,
|
||||
t.ncr_id,
|
||||
n.ncr_number,
|
||||
n.job_number,
|
||||
t.from_stage,
|
||||
t.to_stage,
|
||||
t.action,
|
||||
t.acted_at,
|
||||
u.display_name AS acted_by,
|
||||
u.email AS acted_by_email,
|
||||
t.note
|
||||
FROM stage_transitions t
|
||||
JOIN ncrs n ON n.id = t.ncr_id
|
||||
JOIN users u ON u.id = t.acted_by_id
|
||||
"""
|
||||
|
||||
VW_NCR_COSTS = """
|
||||
CREATE OR REPLACE VIEW vw_ncr_costs AS
|
||||
SELECT
|
||||
n.id AS ncr_id,
|
||||
n.ncr_number,
|
||||
n.ncr_year,
|
||||
n.job_number,
|
||||
d.name AS department,
|
||||
dc.name AS deviation_category,
|
||||
n.created_at,
|
||||
n.closed_at,
|
||||
n.stage,
|
||||
n.labor_cost,
|
||||
n.material_cost,
|
||||
n.service_cost,
|
||||
n.other_cost,
|
||||
COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0)
|
||||
+ COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost
|
||||
FROM ncrs n
|
||||
JOIN departments d ON d.id = n.department_id
|
||||
JOIN deviation_categories dc ON dc.id = n.deviation_category_id
|
||||
WHERE n.costing_completed_at IS NOT NULL
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(VW_NCR_FULL)
|
||||
op.execute(VW_NCR_STAGE_HISTORY)
|
||||
op.execute(VW_NCR_COSTS)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP VIEW IF EXISTS vw_ncr_costs")
|
||||
op.execute("DROP VIEW IF EXISTS vw_ncr_stage_history")
|
||||
op.execute("DROP VIEW IF EXISTS vw_ncr_full")
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
173
backend/app/auth/deps.py
Normal file
173
backend/app/auth/deps.py
Normal 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
124
backend/app/auth/entra.py
Normal 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
88
backend/app/config.py
Normal 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
68
backend/app/database.py
Normal 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
21
backend/app/dev_init.py
Normal 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
84
backend/app/domain.py
Normal 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
57
backend/app/main.py
Normal 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)
|
||||
29
backend/app/models/__init__.py
Normal file
29
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from app.models.base import Base
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.lookups import Department, DeviationCategory
|
||||
from app.models.ncr import (
|
||||
JobInfo,
|
||||
Ncr,
|
||||
NcrSecondaryAssignee,
|
||||
NcrSequence,
|
||||
StageTransition,
|
||||
)
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.app_setting import AppSetting
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Department",
|
||||
"DeviationCategory",
|
||||
"Ncr",
|
||||
"NcrSequence",
|
||||
"NcrSecondaryAssignee",
|
||||
"StageTransition",
|
||||
"JobInfo",
|
||||
"Attachment",
|
||||
"AuditLog",
|
||||
"AppSetting",
|
||||
]
|
||||
14
backend/app/models/app_setting.py
Normal file
14
backend/app/models/app_setting.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(String(500))
|
||||
|
||||
|
||||
NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"
|
||||
25
backend/app/models/attachment.py
Normal file
25
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
|
||||
original_filename: Mapped[str] = mapped_column(String(255))
|
||||
# Relative path under ATTACHMENTS_DIR: "<ncr_id>/<uuid><ext>"
|
||||
stored_path: Mapped[str] = mapped_column(String(300), unique=True)
|
||||
content_type: Mapped[str] = mapped_column(String(100))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
is_image: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
uploaded_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
ncr = relationship("Ncr", back_populates="attachments")
|
||||
uploaded_by: Mapped[User] = relationship(lazy="selectin")
|
||||
37
backend/app/models/audit.py
Normal file
37
backend/app/models/audit.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Immutable audit record. The application exposes no update or delete
|
||||
path for these rows; one row per changed field (field_name null for
|
||||
record-level events such as create/transition/attachment/reopen)."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_log_ncr", "ncr_id", "created_at"),
|
||||
Index("ix_audit_log_created_at", "created_at"),
|
||||
)
|
||||
|
||||
# BigInteger on MySQL; plain INTEGER on SQLite (required for autoincrement).
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
|
||||
)
|
||||
# Nullable so admin actions without an NCR (settings, role changes) are auditable too.
|
||||
ncr_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("ncrs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
action: Mapped[str] = mapped_column(String(40))
|
||||
field_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(lazy="selectin")
|
||||
12
backend/app/models/base.py
Normal file
12
backend/app/models/base.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Naive UTC timestamp — all datetimes are stored as UTC in MySQL DATETIME."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
22
backend/app/models/lookups.py
Normal file
22
backend/app/models/lookups.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
# Deactivated values are hidden from new-NCR forms but remain valid on
|
||||
# existing records; values referenced by NCRs are never hard-deleted.
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class DeviationCategory(Base):
|
||||
__tablename__ = "deviation_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
195
backend/app/models/ncr.py
Normal file
195
backend/app/models/ncr.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class NcrSequence(Base):
|
||||
"""Per-year NCR number allocator. Incremented atomically inside the
|
||||
NCR-creation transaction (row lock held until commit) so concurrent
|
||||
submissions can never produce the same number."""
|
||||
|
||||
__tablename__ = "ncr_sequences"
|
||||
|
||||
year: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
||||
last_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class Ncr(Base):
|
||||
__tablename__ = "ncrs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ncr_number", name="uq_ncrs_ncr_number"),
|
||||
Index("ix_ncrs_stage", "stage"),
|
||||
Index("ix_ncrs_job_number", "job_number"),
|
||||
Index("ix_ncrs_created_at", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
ncr_number: Mapped[str] = mapped_column(String(20))
|
||||
ncr_year: Mapped[int] = mapped_column(Integer)
|
||||
ncr_seq: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# ── Request (stage 1) ────────────────────────────────────────────────────
|
||||
job_number: Mapped[str] = mapped_column(String(100))
|
||||
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"))
|
||||
deviation_category_id: Mapped[int] = mapped_column(ForeignKey("deviation_categories.id"))
|
||||
disposition_authority_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
deviation_detail: Mapped[str] = mapped_column(Text)
|
||||
requester_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
|
||||
# ── Workflow state ───────────────────────────────────────────────────────
|
||||
stage: Mapped[str] = mapped_column(String(30))
|
||||
stage_entered_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# ── Disposition (initial + secondary) ────────────────────────────────────
|
||||
qc_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
work_order: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
disposition_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # sanitized HTML
|
||||
secondary_review_needed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
# ── Operations ───────────────────────────────────────────────────────────
|
||||
operations_complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
operations_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
operations_completed_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
# ── QC Inspection ────────────────────────────────────────────────────────
|
||||
qc_approval: Mapped[str | None] = mapped_column(String(10), nullable=True) # yes | no
|
||||
inspection_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
qc_closed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
qc_closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
qc_closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# ── Costing ──────────────────────────────────────────────────────────────
|
||||
labor_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
material_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
service_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
other_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
costing_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
costing_completed_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
# ── Closure ──────────────────────────────────────────────────────────────
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# ── Relationships ────────────────────────────────────────────────────────
|
||||
department = relationship("Department", lazy="selectin")
|
||||
deviation_category = relationship("DeviationCategory", lazy="selectin")
|
||||
requester: Mapped[User] = relationship(foreign_keys=[requester_id], lazy="selectin")
|
||||
disposition_authority: Mapped[User] = relationship(
|
||||
foreign_keys=[disposition_authority_id], lazy="selectin"
|
||||
)
|
||||
operations_completed_by: Mapped[User | None] = relationship(
|
||||
foreign_keys=[operations_completed_by_id], lazy="selectin"
|
||||
)
|
||||
qc_closed_by: Mapped[User | None] = relationship(
|
||||
foreign_keys=[qc_closed_by_id], lazy="selectin"
|
||||
)
|
||||
costing_completed_by: Mapped[User | None] = relationship(
|
||||
foreign_keys=[costing_completed_by_id], lazy="selectin"
|
||||
)
|
||||
closed_by: Mapped[User | None] = relationship(foreign_keys=[closed_by_id], lazy="selectin")
|
||||
|
||||
secondary_assignee_rows: Mapped[list["NcrSecondaryAssignee"]] = relationship(
|
||||
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
transitions: Mapped[list["StageTransition"]] = relationship(
|
||||
back_populates="ncr",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="StageTransition.acted_at",
|
||||
)
|
||||
attachments: Mapped[list["Attachment"]] = relationship( # noqa: F821
|
||||
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
job_info: Mapped["JobInfo | None"] = relationship(
|
||||
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin", uselist=False
|
||||
)
|
||||
|
||||
@property
|
||||
def secondary_authorities(self) -> list[User]:
|
||||
return [row.user for row in self.secondary_assignee_rows]
|
||||
|
||||
@property
|
||||
def total_cost(self) -> Decimal | None:
|
||||
costs = [self.labor_cost, self.material_cost, self.service_cost, self.other_cost]
|
||||
present = [c for c in costs if c is not None]
|
||||
if not present:
|
||||
return None
|
||||
return sum(present, Decimal("0"))
|
||||
|
||||
|
||||
class NcrSecondaryAssignee(Base):
|
||||
"""Users selected as 'Notify These People' for secondary disposition."""
|
||||
|
||||
__tablename__ = "ncr_secondary_assignees"
|
||||
|
||||
ncr_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||
|
||||
ncr: Mapped[Ncr] = relationship(back_populates="secondary_assignee_rows")
|
||||
user: Mapped[User] = relationship(lazy="selectin")
|
||||
|
||||
|
||||
class StageTransition(Base):
|
||||
"""One row per lifecycle event (create, stage change, reopen) — the basis
|
||||
for aging and cycle-time reporting."""
|
||||
|
||||
__tablename__ = "stage_transitions"
|
||||
__table_args__ = (Index("ix_stage_transitions_ncr", "ncr_id", "acted_at"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
|
||||
from_stage: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
to_stage: Mapped[str] = mapped_column(String(30))
|
||||
action: Mapped[str] = mapped_column(String(40))
|
||||
acted_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
acted_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True) # e.g. reopen reason
|
||||
|
||||
ncr: Mapped[Ncr] = relationship(back_populates="transitions")
|
||||
acted_by: Mapped[User] = relationship(lazy="selectin")
|
||||
|
||||
|
||||
class JobInfo(Base):
|
||||
"""Read-only enrichment for a job number, populated by a JobLookupService.
|
||||
|
||||
Stays empty under NullJobLookupService; the future VisualJobLookupService
|
||||
will fill it from Infor VISUAL (WORK_ORDER + customer order linkage).
|
||||
"""
|
||||
|
||||
__tablename__ = "job_info"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
ncr_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("ncrs.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
part_id: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
part_description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
customer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
work_order_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(20), default="null")
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
ncr: Mapped[Ncr] = relationship(back_populates="job_info")
|
||||
39
backend/app/models/user.py
Normal file
39
backend/app/models/user.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, utcnow
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
# Entra object id; null for dev-mode/seeded users.
|
||||
entra_oid: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255))
|
||||
employee_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
role_rows: Mapped[list["UserRole"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def roles(self) -> list[str]:
|
||||
return sorted(r.role for r in self.role_rows)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="role_rows")
|
||||
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
|
||||
]
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
19
backend/app/schemas/common.py
Normal file
19
backend/app/schemas/common.py
Normal 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)
|
||||
23
backend/app/schemas/lookup.py
Normal file
23
backend/app/schemas/lookup.py
Normal 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
187
backend/app/schemas/ncr.py
Normal 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
|
||||
54
backend/app/schemas/report.py
Normal file
54
backend/app/schemas/report.py
Normal 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]
|
||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal 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
243
backend/app/seed.py
Normal 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())
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
67
backend/app/services/audit.py
Normal file
67
backend/app/services/audit.py
Normal 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
|
||||
91
backend/app/services/graph.py
Normal file
91
backend/app/services/graph.py
Normal 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", [])
|
||||
116
backend/app/services/job_lookup.py
Normal file
116
backend/app/services/job_lookup.py
Normal 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
|
||||
156
backend/app/services/notifications.py
Normal file
156
backend/app/services/notifications.py
Normal 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}"
|
||||
]
|
||||
56
backend/app/services/numbering.py
Normal file
56
backend/app/services/numbering.py
Normal 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
|
||||
64
backend/app/services/pdf.py
Normal file
64
backend/app/services/pdf.py
Normal 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))
|
||||
31
backend/app/services/sanitize.py
Normal file
31
backend/app/services/sanitize.py
Normal 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
|
||||
87
backend/app/services/storage.py
Normal file
87
backend/app/services/storage.py
Normal 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
|
||||
76
backend/app/services/workflow.py
Normal file
76
backend/app/services/workflow.py
Normal 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",
|
||||
)
|
||||
204
backend/app/templates/ncr_pdf.html
Normal file
204
backend/app/templates/ncr_pdf.html
Normal 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>
|
||||
17
backend/entrypoint.sh
Normal file
17
backend/entrypoint.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[api] running database migrations..."
|
||||
attempt=0
|
||||
until alembic upgrade head; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 12 ]; then
|
||||
echo "[api] migrations failed after $attempt attempts, giving up." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[api] database not ready (attempt $attempt), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "[api] migrations complete."
|
||||
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||
6
backend/pyproject.toml
Normal file
6
backend/pyproject.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning:jose.*",
|
||||
]
|
||||
4
backend/requirements-dev.txt
Normal file
4
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.2
|
||||
pytest-asyncio>=0.24
|
||||
aiosqlite>=0.20
|
||||
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
sqlalchemy[asyncio]>=2.0.43
|
||||
alembic>=1.13
|
||||
aiomysql>=0.2.3
|
||||
PyMySQL>=1.1
|
||||
greenlet>=3.0
|
||||
pydantic>=2.9
|
||||
pydantic-settings>=2.4
|
||||
python-jose[cryptography]>=3.3
|
||||
msal>=1.31
|
||||
httpx>=0.27
|
||||
nh3>=0.2.18
|
||||
weasyprint>=62
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.9
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
89
backend/tests/conftest.py
Normal file
89
backend/tests/conftest.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Test configuration.
|
||||
|
||||
The environment MUST be set before any `app.*` import (settings are cached):
|
||||
tests run against a file-backed SQLite database with AUTH_MODE=dev, which
|
||||
exercises the same SQLAlchemy models, state machine, numbering, and
|
||||
permission code paths as MySQL. To run the suite against a real MySQL
|
||||
instance instead:
|
||||
|
||||
DATABASE_URL="mysql+aiomysql://user:pass@host/db_test?charset=utf8mb4" pytest
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
_TMPDIR = tempfile.mkdtemp(prefix="pesco-ncr-tests-")
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite+aiosqlite:///{_TMPDIR}/test.db")
|
||||
os.environ["AUTH_MODE"] = "dev"
|
||||
os.environ["ATTACHMENTS_DIR"] = os.path.join(_TMPDIR, "attachments")
|
||||
os.environ["INITIAL_ADMIN_EMAILS"] = ""
|
||||
os.environ["JOB_LOOKUP_PROVIDER"] = "null"
|
||||
# Disabled by default so mutation responses have empty `warnings`;
|
||||
# notification-specific tests flip the AppSetting row explicitly.
|
||||
os.environ["NOTIFICATIONS_ENABLED_DEFAULT"] = "false"
|
||||
|
||||
import pytest # noqa: E402
|
||||
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||
|
||||
from app.database import get_engine, get_session_factory # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import Base, Department, DeviationCategory, User, UserRole # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _create_schema():
|
||||
async def _run():
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with get_session_factory()() as db:
|
||||
db.add(Department(name="Machining", is_active=True))
|
||||
db.add(Department(name="Inactive Dept", is_active=False))
|
||||
db.add(DeviationCategory(name="Dimensional", is_active=True))
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(_run())
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
async def _make(roles: list[str], name: str | None = None) -> str:
|
||||
email = f"user-{uuid.uuid4().hex[:10]}@pescoinc.biz"
|
||||
async with get_session_factory()() as db:
|
||||
user = User(
|
||||
email=email,
|
||||
display_name=name or f"Test {email.split('@')[0]}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role=role))
|
||||
await db.commit()
|
||||
return email
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def team(make_user) -> dict[str, str]:
|
||||
"""One user per workflow role, fresh for each test."""
|
||||
return {
|
||||
"requester": await make_user(["requester"]),
|
||||
"dispo": await make_user(["requester", "disposition_authority"]),
|
||||
"second": await make_user(["requester", "secondary_disposition_authority"]),
|
||||
"second2": await make_user(["requester", "secondary_disposition_authority"]),
|
||||
"ops": await make_user(["requester", "operations"]),
|
||||
"qc": await make_user(["requester", "qc_inspector"]),
|
||||
"cost": await make_user(["requester", "costing"]),
|
||||
"admin": await make_user(["admin"]),
|
||||
}
|
||||
62
backend/tests/test_attachments.py
Normal file
62
backend/tests/test_attachments.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Attachment upload validation, metadata, download, and closure locking."""
|
||||
from .util import create_ncr, hdr, to_closed
|
||||
|
||||
TINY_PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
|
||||
b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_download_and_metadata(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/attachments",
|
||||
files=[
|
||||
("files", ("photo one.png", TINY_PNG, "image/png")),
|
||||
("files", ("notes.txt", b"observed at station 4", "text/plain")),
|
||||
],
|
||||
headers=hdr(team["requester"]),
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
items = r.json()
|
||||
assert len(items) == 2
|
||||
png = next(i for i in items if i["is_image"])
|
||||
assert png["original_filename"] == "photo one.png"
|
||||
assert png["uploaded_by"]["email"] == team["requester"]
|
||||
assert png["size_bytes"] == len(TINY_PNG)
|
||||
|
||||
r = await client.get(
|
||||
f"/api/attachments/{png['id']}/download", headers=hdr(team["ops"])
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content == TINY_PNG
|
||||
|
||||
# attachment add shows in detail + audit
|
||||
detail = (await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["qc"]))).json()
|
||||
assert len(detail["attachments"]) == 2
|
||||
audit = (
|
||||
await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||
).json()
|
||||
assert sum(1 for a in audit["items"] if a["action"] == "attachment_add") == 2
|
||||
|
||||
|
||||
async def test_disallowed_type_rejected(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/attachments",
|
||||
files=[("files", ("malware.exe", b"MZ...", "application/octet-stream"))],
|
||||
headers=hdr(team["requester"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "not allowed" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_attachments_locked_when_closed(client, team):
|
||||
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/attachments",
|
||||
files=[("files", ("late.png", TINY_PNG, "image/png"))],
|
||||
headers=hdr(team["requester"]),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
51
backend/tests/test_numbering.py
Normal file
51
backend/tests/test_numbering.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""NCR numbering: format, per-year sequence + rollover, and concurrency."""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_session_factory
|
||||
from app.services.numbering import allocate_ncr_number
|
||||
|
||||
from .util import create_ncr
|
||||
|
||||
NCR_RE = re.compile(r"^NCR-(\d{4})-(\d{4})$")
|
||||
|
||||
|
||||
async def test_number_format_and_sequence(client, team):
|
||||
first = await create_ncr(client, team)
|
||||
second = await create_ncr(client, team)
|
||||
|
||||
m1, m2 = NCR_RE.match(first["ncr_number"]), NCR_RE.match(second["ncr_number"])
|
||||
assert m1 and m2, (first["ncr_number"], second["ncr_number"])
|
||||
assert int(m1.group(1)) == datetime.now().year
|
||||
assert int(m2.group(2)) == int(m1.group(2)) + 1
|
||||
|
||||
|
||||
async def test_year_rollover_resets_sequence():
|
||||
async with get_session_factory()() as db:
|
||||
n1, year1, seq1 = await allocate_ncr_number(db, now=datetime(2098, 12, 31))
|
||||
n2, year2, seq2 = await allocate_ncr_number(db, now=datetime(2099, 1, 1))
|
||||
n3, _, seq3 = await allocate_ncr_number(db, now=datetime(2099, 6, 15))
|
||||
await db.rollback()
|
||||
|
||||
assert (year1, seq1) == (2098, 1) and n1 == "NCR-2098-0001"
|
||||
assert (year2, seq2) == (2099, 1) and n2 == "NCR-2099-0001"
|
||||
assert seq3 == 2 and n3 == "NCR-2099-0002"
|
||||
|
||||
|
||||
async def test_zero_padding():
|
||||
async with get_session_factory()() as db:
|
||||
number, _, _ = await allocate_ncr_number(db, now=datetime(2097, 3, 1))
|
||||
await db.rollback()
|
||||
assert number == "NCR-2097-0001"
|
||||
|
||||
|
||||
async def test_concurrent_submissions_never_collide(client, team):
|
||||
"""Twelve simultaneous submissions must all succeed with distinct numbers."""
|
||||
results = await asyncio.gather(
|
||||
*[create_ncr(client, team, job_number=f"J-CONC-{i}") for i in range(12)]
|
||||
)
|
||||
numbers = [r["ncr_number"] for r in results]
|
||||
assert len(set(numbers)) == 12, numbers
|
||||
seqs = sorted(int(NCR_RE.match(n).group(2)) for n in numbers)
|
||||
assert seqs == list(range(seqs[0], seqs[0] + 12))
|
||||
223
backend/tests/test_permissions.py
Normal file
223
backend/tests/test_permissions.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Server-side role enforcement per stage and admin-area authorization."""
|
||||
from .util import (
|
||||
create_ncr,
|
||||
do_initial_disposition,
|
||||
hdr,
|
||||
to_costing,
|
||||
to_operations,
|
||||
to_qc_inspection,
|
||||
user_id_by_email,
|
||||
)
|
||||
|
||||
|
||||
async def test_stage_actions_require_stage_role(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
|
||||
# a plain requester can't perform initial disposition
|
||||
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["requester"])
|
||||
assert r.status_code == 403
|
||||
# nor can operations/qc/costing roles
|
||||
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["ops"])
|
||||
assert r.status_code == 403
|
||||
|
||||
await to_operations(client, team, ncr["id"])
|
||||
# only operations can mark complete
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["requester"])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["qc"])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"])
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# only QC can edit inspection fields
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_approval": "yes"},
|
||||
headers=hdr(team["ops"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# only costing can cost
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_approval": "yes", "qc_closed": True},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_act_at_every_stage(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["admin"])
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["admin"])
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_approval": "yes", "qc_closed": True},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ncr"]["stage"] == "closed"
|
||||
|
||||
|
||||
async def test_secondary_restricted_to_assignees(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
second_id = await user_id_by_email(
|
||||
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||
)
|
||||
await do_initial_disposition(
|
||||
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||
)
|
||||
|
||||
# another user holding the secondary role but NOT assigned is rejected
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||
json={"release": True},
|
||||
headers=hdr(team["second2"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# the assignee is allowed
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||
json={"release": True},
|
||||
headers=hdr(team["second"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ncr"]["stage"] == "operations"
|
||||
|
||||
|
||||
async def test_secondary_queue_filtered_by_identity(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
second_id = await user_id_by_email(
|
||||
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||
)
|
||||
await do_initial_disposition(
|
||||
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||
)
|
||||
|
||||
r = await client.get("/api/ncrs?queue=secondary", headers=hdr(team["second"]))
|
||||
assert any(item["id"] == ncr["id"] for item in r.json()["items"])
|
||||
|
||||
r = await client.get("/api/ncrs?queue=secondary", headers=hdr(team["second2"]))
|
||||
assert not any(item["id"] == ncr["id"] for item in r.json()["items"])
|
||||
|
||||
|
||||
async def test_create_requires_valid_disposition_authority(client, team):
|
||||
from .util import lookup_ids
|
||||
|
||||
dept_id, cat_id = await lookup_ids(client, team["requester"])
|
||||
ops_id = await user_id_by_email(client, team["requester"], team["ops"], "operations")
|
||||
r = await client.post(
|
||||
"/api/ncrs",
|
||||
json={
|
||||
"job_number": "J1",
|
||||
"department_id": dept_id,
|
||||
"deviation_category_id": cat_id,
|
||||
"disposition_authority_id": ops_id, # lacks the role
|
||||
"deviation_detail": "Detail long enough.",
|
||||
},
|
||||
headers=hdr(team["requester"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_secondary_assignees_must_hold_role(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
ops_id = await user_id_by_email(client, team["dispo"], team["ops"], "operations")
|
||||
r = await do_initial_disposition(
|
||||
client, team, ncr["id"], secondary=True, secondary_ids=[ops_id]
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_audit_endpoint_restricted(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["requester"]))
|
||||
assert r.status_code == 403
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||
assert r.status_code == 200
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["admin"]))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_admin_area_requires_admin(client, team):
|
||||
for path in ("/api/admin/users", "/api/admin/departments", "/api/admin/settings",
|
||||
"/api/admin/audit"):
|
||||
r = await client.get(path, headers=hdr(team["requester"]))
|
||||
assert r.status_code == 403, path
|
||||
r = await client.get(path, headers=hdr(team["admin"]))
|
||||
assert r.status_code == 200, path
|
||||
|
||||
|
||||
async def test_role_assignment_and_lockout_protection(client, team, make_user):
|
||||
target = await make_user(["requester"])
|
||||
r = await client.get("/api/admin/users", headers=hdr(team["admin"]))
|
||||
target_id = next(u["id"] for u in r.json() if u["email"] == target)
|
||||
admin_id = next(u["id"] for u in r.json() if u["email"] == team["admin"])
|
||||
|
||||
r = await client.put(
|
||||
f"/api/admin/users/{target_id}/roles",
|
||||
json={"roles": ["requester", "qc_inspector"]},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert set(r.json()["roles"]) == {"requester", "qc_inspector"}
|
||||
|
||||
# unknown role rejected
|
||||
r = await client.put(
|
||||
f"/api/admin/users/{target_id}/roles",
|
||||
json={"roles": ["superuser"]},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# admin cannot remove their own admin role
|
||||
r = await client.put(
|
||||
f"/api/admin/users/{admin_id}/roles",
|
||||
json={"roles": ["requester"]},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_everyone_can_view_and_search(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["ops"]))
|
||||
assert r.status_code == 200
|
||||
# available_actions reflect the viewer's role
|
||||
assert "initial_disposition" not in r.json()["available_actions"]
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["dispo"]))
|
||||
assert "initial_disposition" in r.json()["available_actions"]
|
||||
|
||||
r = await client.get(
|
||||
f"/api/ncrs?q={ncr['ncr_number']}", headers=hdr(team["requester"])
|
||||
)
|
||||
assert r.json()["total"] >= 1
|
||||
|
||||
|
||||
async def test_unknown_dev_user_rejected(client):
|
||||
r = await client.get("/api/me", headers=hdr("ghost@pescoinc.biz"))
|
||||
assert r.status_code == 401
|
||||
244
backend/tests/test_state_machine.py
Normal file
244
backend/tests/test_state_machine.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Workflow state machine: happy paths, invalid transitions, closure locking,
|
||||
admin reopen, and rich-text sanitization."""
|
||||
from .util import (
|
||||
create_ncr,
|
||||
do_initial_disposition,
|
||||
hdr,
|
||||
to_closed,
|
||||
to_costing,
|
||||
to_operations,
|
||||
to_qc_inspection,
|
||||
user_id_by_email,
|
||||
)
|
||||
|
||||
|
||||
async def test_full_lifecycle_direct_to_operations(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
assert ncr["stage"] == "new_request"
|
||||
assert ncr["ncr_number"].startswith("NCR-")
|
||||
|
||||
ncr = await to_operations(client, team, ncr["id"])
|
||||
assert ncr["stage"] == "operations"
|
||||
assert ncr["secondary_review_needed"] is False
|
||||
assert ncr["qc_authority"] == "AS9100 8.7"
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"])
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "qc_inspection"
|
||||
assert body["operations_complete"] is True
|
||||
assert body["operations_completed_by"]["email"] == team["ops"]
|
||||
|
||||
# QC can save repeatedly without closing
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_approval": "no", "inspection_notes": "First pass failed."},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.json()["ncr"]["stage"] == "qc_inspection"
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_approval": "yes", "inspection_notes": "Rework verified.", "qc_closed": True},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "costing"
|
||||
assert body["qc_closed"] is True
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={
|
||||
"labor_cost": "100.00",
|
||||
"material_cost": "50.25",
|
||||
"service_cost": "0",
|
||||
"other_cost": "10",
|
||||
},
|
||||
headers=hdr(team["cost"]),
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "closed"
|
||||
assert body["total_cost"] == "160.25"
|
||||
assert body["closed_at"] is not None
|
||||
|
||||
# Transition history is complete and ordered
|
||||
stages = [t["to_stage"] for t in body["transitions"]]
|
||||
assert stages == ["new_request", "operations", "qc_inspection", "costing", "closed"]
|
||||
|
||||
|
||||
async def test_secondary_disposition_flow(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
second_id = await user_id_by_email(
|
||||
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||
)
|
||||
|
||||
# secondary review without assignees is rejected
|
||||
r = await do_initial_disposition(client, team, ncr["id"], secondary=True, secondary_ids=[])
|
||||
assert r.status_code == 422
|
||||
|
||||
r = await do_initial_disposition(
|
||||
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "secondary_disposition"
|
||||
assert [u["email"] for u in body["secondary_authorities"]] == [team["second"]]
|
||||
|
||||
# assignee saves without releasing
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||
json={"disposition_notes": "<p>Updated by secondary.</p>", "release": False},
|
||||
headers=hdr(team["second"]),
|
||||
)
|
||||
assert r.json()["ncr"]["stage"] == "secondary_disposition"
|
||||
|
||||
# then releases to operations
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||
json={"work_order": "WO-2002", "release": True},
|
||||
headers=hdr(team["second"]),
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "operations"
|
||||
assert body["work_order"] == "WO-2002"
|
||||
# earlier saved notes were not wiped by the release payload
|
||||
assert "Updated by secondary" in body["disposition_notes"]
|
||||
|
||||
|
||||
async def test_invalid_transitions_rejected(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
|
||||
# can't skip ahead from new_request
|
||||
r = await client.post(f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"]))
|
||||
assert r.status_code == 409
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/inspection",
|
||||
json={"qc_closed": True},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||
headers=hdr(team["cost"]),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# once in operations, initial disposition can't run again
|
||||
await to_operations(client, team, ncr["id"])
|
||||
r = await do_initial_disposition(client, team, ncr["id"])
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_closed_ncr_is_fully_locked(client, team):
|
||||
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||
assert ncr["stage"] == "closed"
|
||||
|
||||
for path, payload, user in [
|
||||
("initial-disposition", {"secondary_review_needed": False}, team["dispo"]),
|
||||
("secondary-disposition", {"release": True}, team["second"]),
|
||||
("operations-complete", None, team["ops"]),
|
||||
("inspection", {"qc_closed": True}, team["qc"]),
|
||||
("costing", {"labor_cost": "9", "material_cost": "9", "service_cost": "9", "other_cost": "9"}, team["cost"]),
|
||||
]:
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/{path}",
|
||||
json=payload,
|
||||
headers=hdr(user),
|
||||
)
|
||||
assert r.status_code == 409, f"{path}: {r.status_code} {r.text}"
|
||||
assert "closed" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_admin_reopen_with_reason(client, team):
|
||||
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||
|
||||
# non-admin cannot reopen
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/reopen",
|
||||
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
|
||||
headers=hdr(team["cost"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# reason is required (min length)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/reopen",
|
||||
json={"to_stage": "costing", "reason": ""},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# reopening to 'closed' is not a valid target
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/reopen",
|
||||
json={"to_stage": "closed", "reason": "does not make sense"},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/reopen",
|
||||
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
body = r.json()["ncr"]
|
||||
assert body["stage"] == "costing"
|
||||
assert body["closed_at"] is None
|
||||
# costs preserved for correction
|
||||
assert body["labor_cost"] == "125.50"
|
||||
|
||||
# reopen is recorded with its reason in the transition history + audit trail
|
||||
reopen_t = [t for t in body["transitions"] if t["action"] == "reopen"]
|
||||
assert len(reopen_t) == 1
|
||||
assert "Costs were entered incorrectly." in reopen_t[0]["note"]
|
||||
|
||||
audit = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["admin"]))
|
||||
actions = [a["action"] for a in audit.json()["items"]]
|
||||
assert "reopen" in actions
|
||||
|
||||
# workflow resumes: costing can close it again
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/costing",
|
||||
json={"labor_cost": "200", "material_cost": "0", "service_cost": "0", "other_cost": "0"},
|
||||
headers=hdr(team["cost"]),
|
||||
)
|
||||
assert r.json()["ncr"]["stage"] == "closed"
|
||||
|
||||
|
||||
async def test_reopen_only_from_closed(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr['id']}/reopen",
|
||||
json={"to_stage": "new_request", "reason": "not closed yet"},
|
||||
headers=hdr(team["admin"]),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_rich_text_is_sanitized(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
r = await do_initial_disposition(
|
||||
client,
|
||||
team,
|
||||
ncr["id"],
|
||||
notes='<p onclick="evil()">Keep</p><script>alert("xss")</script><a href="javascript:x()">link</a>',
|
||||
)
|
||||
notes = r.json()["ncr"]["disposition_notes"]
|
||||
assert "<script" not in notes
|
||||
assert "onclick" not in notes
|
||||
assert "javascript:" not in notes
|
||||
assert "Keep" in notes
|
||||
|
||||
|
||||
async def test_audit_trail_field_level(client, team):
|
||||
ncr = await create_ncr(client, team)
|
||||
await to_operations(client, team, ncr["id"])
|
||||
|
||||
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||
items = r.json()["items"]
|
||||
by_field = {i["field_name"]: i for i in items if i["field_name"]}
|
||||
assert by_field["stage"]["old_value"] == "new_request"
|
||||
assert by_field["stage"]["new_value"] == "operations"
|
||||
assert by_field["work_order"]["new_value"] == "WO-1001"
|
||||
assert any(i["action"] == "create" for i in items)
|
||||
108
backend/tests/util.py
Normal file
108
backend/tests/util.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Shared helpers: drive the API exactly the way the frontend does."""
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
def hdr(email: str) -> dict[str, str]:
|
||||
return {"X-Dev-User": email}
|
||||
|
||||
|
||||
async def lookup_ids(client: AsyncClient, email: str) -> tuple[int, int]:
|
||||
r = await client.get("/api/lookups", headers=hdr(email))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
return body["departments"][0]["id"], body["deviation_categories"][0]["id"]
|
||||
|
||||
|
||||
async def user_id_by_email(client: AsyncClient, as_email: str, email: str, role: str) -> int:
|
||||
r = await client.get(f"/api/users?role={role}", headers=hdr(as_email))
|
||||
assert r.status_code == 200, r.text
|
||||
for u in r.json():
|
||||
if u["email"] == email:
|
||||
return u["id"]
|
||||
raise AssertionError(f"user {email} with role {role} not found")
|
||||
|
||||
|
||||
async def create_ncr(client: AsyncClient, team: dict, **overrides) -> dict:
|
||||
dept_id, cat_id = await lookup_ids(client, team["requester"])
|
||||
dispo_id = await user_id_by_email(
|
||||
client, team["requester"], team["dispo"], "disposition_authority"
|
||||
)
|
||||
payload = {
|
||||
"job_number": "J12345",
|
||||
"department_id": dept_id,
|
||||
"deviation_category_id": cat_id,
|
||||
"disposition_authority_id": dispo_id,
|
||||
"deviation_detail": "Bore diameter out of tolerance on 3 pieces.",
|
||||
}
|
||||
payload.update(overrides)
|
||||
r = await client.post("/api/ncrs", json=payload, headers=hdr(team["requester"]))
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def do_initial_disposition(
|
||||
client: AsyncClient,
|
||||
team: dict,
|
||||
ncr_id: int,
|
||||
*,
|
||||
secondary: bool = False,
|
||||
secondary_ids: list[int] | None = None,
|
||||
as_user: str | None = None,
|
||||
notes: str = "<p>Rework per instructions.</p>",
|
||||
):
|
||||
body = {
|
||||
"qc_authority": "AS9100 8.7",
|
||||
"work_order": "WO-1001",
|
||||
"disposition_notes": notes,
|
||||
"secondary_review_needed": secondary,
|
||||
}
|
||||
if secondary_ids is not None:
|
||||
body["secondary_authority_ids"] = secondary_ids
|
||||
return await client.post(
|
||||
f"/api/ncrs/{ncr_id}/initial-disposition",
|
||||
json=body,
|
||||
headers=hdr(as_user or team["dispo"]),
|
||||
)
|
||||
|
||||
|
||||
async def to_operations(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
"""Walk a fresh NCR straight to the Operations stage."""
|
||||
r = await do_initial_disposition(client, team, ncr_id, secondary=False)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def to_qc_inspection(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
await to_operations(client, team, ncr_id)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr_id}/operations-complete", headers=hdr(team["ops"])
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def to_costing(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
await to_qc_inspection(client, team, ncr_id)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr_id}/inspection",
|
||||
json={"qc_approval": "yes", "inspection_notes": "All good.", "qc_closed": True},
|
||||
headers=hdr(team["qc"]),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["ncr"]
|
||||
|
||||
|
||||
async def to_closed(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||
await to_costing(client, team, ncr_id)
|
||||
r = await client.post(
|
||||
f"/api/ncrs/{ncr_id}/costing",
|
||||
json={
|
||||
"labor_cost": "125.50",
|
||||
"material_cost": "60.00",
|
||||
"service_cost": "0",
|
||||
"other_cost": "14.50",
|
||||
},
|
||||
headers=hdr(team["cost"]),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["ncr"]
|
||||
Reference in New Issue
Block a user