Files
pesco-ncr/backend/app/seed.py

310 lines
13 KiB
Python
Raw Normal View History

"""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,
NcrLink,
NcrSecondaryAssignee,
RootCauseCategory,
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",
]
# Standard 6M root-cause categories (API Q1 §6.4.2 trend reporting).
ROOT_CAUSE_CATEGORIES = [
"Man", "Machine", "Method", "Material", "Measurement", "Environment",
]
ROOT_CAUSES = [
"Operator used a superseded revision of the work instruction.",
"Fixture clamping force drifted out of spec; no PM interval defined.",
"Incoming material certified to the wrong specification revision.",
"Measurement performed with a gauge past its calibration due date.",
"Setup sheet did not call out the datum change from the ECN.",
]
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))
existing = {
c.name
for c in (await db.execute(select(RootCauseCategory))).scalars().all()
}
for name in ROOT_CAUSE_CATEGORIES:
if name not in existing:
db.add(RootCauseCategory(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()
root_causes = (await db.execute(select(RootCauseCategory))).scalars().all()
rng = random.Random(42)
seeded_ncrs: list[Ncr] = []
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 i 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()
seeded_ncrs.append(ncr)
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
# CAPA — the API Q1 closure gate requires the CA question to be
# answered (and any required action verified) before costing
# can close the NCR, so seed it here.
ncr.root_cause = rng.choice(ROOT_CAUSES)
ncr.root_cause_category_id = rng.choice(root_causes).id
# Alternate yes/no so every dashboard metric has data.
if i % 2 == 0 or target == Stage.COSTING:
ncr.corrective_action_required = True
ncr.corrective_action_justification = (
"Recurrence risk without a process control update."
)
ncr.corrective_action_plan = (
"Update the work instruction and add an in-process check "
"at the affected operation."
)
ncr.corrective_action_owner_id = ops.id
ncr.corrective_action_opened_at = t
ncr.corrective_action_due_date = (t + timedelta(days=14)).date()
if target == Stage.CLOSED:
ncr.effectiveness_result = "effective"
ncr.effectiveness_notes = (
"No recurrence observed over three subsequent runs."
)
ncr.effectiveness_verified_by_id = qc.id
ncr.effectiveness_verified_at = hop()
else:
ncr.corrective_action_required = False
ncr.corrective_action_justification = (
"Isolated incident fully contained by the disposition."
)
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
# Recurring-issue demo: the newest open NCR references two closed ones.
closed = [n for n in seeded_ncrs if n.stage == Stage.CLOSED.value]
still_open = [n for n in seeded_ncrs if n.stage != Stage.CLOSED.value]
if closed and still_open:
repeat = still_open[0]
repeat.is_recurring = True
for prior in closed[:2]:
db.add(NcrLink(ncr_id=repeat.id, related_ncr_id=prior.id))
await db.commit()
log.info("Demo NCRs seeded. Done.")
if __name__ == "__main__":
asyncio.run(seed())