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:
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())
|
||||
Reference in New Issue
Block a user