"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure. Cosa fa Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare: - utenti adriano (admin), maker, capoturno, operatore (password nella costante UTENTI qui sotto, decise da Adriano per la fiera); - la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py) con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala); - FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato» (4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori tolleranza); - FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione (si crea in fiera dal pannello «Crea ricetta da DXF»). «Setup fatto» per il backend vuol dire: esiste almeno un utente admin (api/routers/setup.py::_admin_exists), quindi basta creare adriano. Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite) uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run] [--fine AAAA-MM-GG] Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione sono idempotenti: se esistono si aggiornano password, ruoli e camera. --dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso comando e stessa data di fine, stessi dati. Da cosa dipende src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy + aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL. """ import argparse import asyncio import random import sys from datetime import date, datetime, time, timedelta, timezone from pathlib import Path from zoneinfo import ZoneInfo from sqlalchemy import delete, event, select from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, ) # La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src.backend.database import async_session_factory # noqa: E402 from src.backend.models.orm.measurement import Measurement # noqa: E402 from src.backend.models.orm.production import ( # noqa: E402 ProductionEvent, ProductionRun, ) from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402 from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402 from src.backend.models.orm.station import ( # noqa: E402 Station, StationRecipeAssignment, ) from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402 from src.backend.models.orm.task_result import TaskResult # noqa: E402 from src.backend.models.orm.user import User # noqa: E402 from src.backend.services import auth_service # noqa: E402 from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402 CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF") STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI) CAMERA_URL = "http://127.0.0.1:8765" CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera SEED = 20261026 # seme fisso: dati ripetibili FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale GIORNI_STORICO = 28 # circa 4 settimane # (username, password, nome, ruoli, admin). Password decise da Adriano per la fiera. UTENTI = [ ("adriano", "!Adriano77", "Adriano Dal Pastro", ["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True), ("maker", "Fiera2026!", "Marco Ferrari (Maker)", ["Maker"], False), ("capoturno", "Fiera2026!", "Luca Bianchi (Capoturno)", ["Supervisor", "MeasurementTec"], False), ("operatore", "Fiera2026!", "Giulia Rossi (Operatore)", ["MeasurementTec"], False), ] def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3): """Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza.""" return dict( marker_number=marker, description=descrizione, nominal=nominale, ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec), uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec), unit=unita, ) # Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in # frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana). # Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0. FLANGIA = [ # (task, quota, scostamento, sigma, deriva) (0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace (0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite (1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana (1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2 (1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza ] ALBERO = [ (0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67 (0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33 (1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0 (1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07 ] def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime: """Ora locale di officina -> datetime UTC naive, come le salva il backend.""" locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte) return locale.astimezone(timezone.utc).replace(tzinfo=None) def _giorni_lavorativi(fine: date) -> list[date]: """Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso.""" giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)] return sorted(g for g in giorni if g.weekday() < 5) def _slot_giornalieri(rng: random.Random, n: int) -> list[int]: """n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto.""" mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)] scelti = sorted(rng.sample(mezz_ore, n)) return [m + rng.randint(0, 9) for m in scelti] async def _upsert_utenti(session: AsyncSession) -> dict[str, User]: """Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin.""" utenti = {} for username, password, nome, ruoli, admin in UTENTI: esistente = (await session.execute( select(User).where(User.username == username) )).scalar_one_or_none() if esistente is None: utenti[username] = await auth_service.create_user( session, username=username, password=password, display_name=nome, roles=ruoli, is_admin=admin, ) else: esistente.password_hash = auth_service.hash_password(password) esistente.display_name = nome esistente.roles = ruoli esistente.is_admin = admin esistente.active = True utenti[username] = esistente await session.flush() return utenti async def _upsert_stazione(session: AsyncSession, owner: User) -> Station: """La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera.""" stazione = (await session.execute( select(Station).where(Station.code == STATION_CODE) )).scalar_one_or_none() if stazione is None: stazione = Station( code=STATION_CODE, name="Postazione fiera", location="Stand", created_by=owner.id, ) session.add(stazione) stazione.camera_url = CAMERA_URL stazione.camera_source = CAMERA_SOURCE await session.flush() return stazione async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict: """Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi.""" conti = {"misure": 0, "produzioni": 0, "eventi": 0} for ricetta in ricette: versioni = (await session.execute( select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id) )).scalars().all() runs = (await session.execute( select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id) )).scalars().all() if versioni: conti["misure"] += (await session.execute( delete(Measurement).where(Measurement.version_id.in_(versioni)) )).rowcount await session.execute( # esiti dei task di queste versioni (FK sulle versioni) delete(TaskResult).where(TaskResult.version_id.in_(versioni)) ) if runs: conti["eventi"] += (await session.execute( delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs)) )).rowcount conti["produzioni"] += (await session.execute( delete(ProductionRun).where(ProductionRun.id.in_(runs)) )).rowcount await session.execute( delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id) ) await session.execute( delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id) ) # Versioni, task e quote se ne vanno con la cascade ORM della ricetta. await session.delete(ricetta) await session.flush() return conti async def _nuova_ricetta(session, owner, stazione, *, code, name, description, interval, requires_serial, note_versione): """Ricetta + versione 1 corrente + assegnazione alla stazione.""" ricetta = Recipe( code=code, name=name, description=description, created_by=owner.id, active=True, measurement_interval_minutes=interval, requires_lot=True, requires_serial=requires_serial, allow_manual_input=True, ) session.add(ricetta) await session.flush() versione = RecipeVersion( recipe_id=ricetta.id, version_number=1, is_current=True, created_by=owner.id, change_notes=note_versione, ) session.add(versione) session.add(StationRecipeAssignment( station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id, )) await session.flush() return ricetta, versione async def _task(session, versione, ordine, titolo, tipo, direttiva=None, descrizione=None, quote=()): task = RecipeTask( version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo, directive=direttiva, description=descrizione, ) session.add(task) await session.flush() sottotask = [] for q in quote: st = RecipeSubtask(task_id=task.id, **q) session.add(st) sottotask.append(st) await session.flush() return task, sottotask async def _ricetta_meccanica(session, owner, stazione, *, code, name, description, interval, requires_serial, profilo, titoli_task): """Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo.""" ricetta, versione = await _nuova_ricetta( session, owner, stazione, code=code, name=name, description=description, interval=interval, requires_serial=requires_serial, note_versione="Ricetta dimostrativa per la fiera", ) await _task(session, versione, 0, "Preparazione del pezzo", "note", direttiva="Prima di misurare", descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.") sottotask_per_quota = {} for indice, titolo in enumerate(titoli_task): quote = [q for (t, q, *_r) in profilo if t == indice] _t, sottotask = await _task( session, versione, indice + 1, titolo, "measure", direttiva="Misurare le quote in sequenza", quote=quote, ) for st in sottotask: sottotask_per_quota[st.marker_number] = st return ricetta, versione, sottotask_per_quota def _valore(rng, quota, scost, sigma, deriva, frazione_tempo): """Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana).""" tol = quota["utl"] - quota["nominal"] media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25)) return round(rng.gauss(media, tol * sigma), 6) async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione, sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale): """Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure.""" giorni = _giorni_lavorativi(fine) operatore, capoturno = utenti["operatore"], utenti["capoturno"] task_ids = {} # marker -> id del task di misura for marker, st in sottotask.items(): task_ids[marker] = st.task_id ordine_task = sorted(set(task_ids.values())) ultimo_task = ordine_task[-1] contatore = 0 totale = 0 for indice_giorno, giorno in enumerate(giorni): frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri n = rng.randint(*pezzi_giorno) slot = _slot_giornalieri(rng, n) lotto = f"{prefisso_lotto}{giorno:%y%m%d}" inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10) run = ProductionRun( station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id, operator_id=operatore.id, lot_number=lotto, status="closed", measurement_interval_minutes=ricetta.measurement_interval_minutes, cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id, active_station_id=None, ) session.add(run) await session.flush() session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id, created_at=inizio)) for numero, minuti in enumerate(slot, start=1): contatore += 1 seriale = f"{prefisso_seriale}{contatore:05d}" istante = _ora_utc(giorno, minuti) for marker in sorted(sottotask): st = sottotask[marker] _t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker) valore = _valore(rng, quota, scost, sigma, deriva, frazione) esito, scarto = calculate_pass_fail(valore, st) m = Measurement( subtask_id=st.id, version_id=versione.id, measured_by=operatore.id, value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto, serial_number=seriale, input_method="usb_caliper" if rng.random() < 0.75 else "manual", input_duration_ms=rng.randint(2500, 9000), production_run_id=run.id, # un fuori tolleranza passa solo con l'ok del capoturno (come in produzione) supervisor_id=capoturno.id if esito == "fail" else None, authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None, measured_at=istante + timedelta(seconds=20 * marker), ) session.add(m) totale += 1 # un task_measured per ogni task di misura non finale, poi il ciclo completato for tid in ordine_task: tipo = "cycle_completed" if tid == ultimo_task else "task_measured" session.add(ProductionEvent( run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid, created_at=istante + timedelta(minutes=1), )) session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id, supervisor_id=capoturno.id, created_at=fine_run)) await session.flush() return contatore, totale async def _ricetta_dxf(session, owner, stazione): """Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF).""" ricetta, versione = await _nuova_ricetta( session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)", description="Confronto di un profilo con il disegno DXF tramite la camera. " "La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».", interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera", ) await _task(session, versione, 0, "Preparazione del pezzo", "note", direttiva="Prima di misurare", descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.") await _task( session, versione, 1, "Confronto profilo con DXF", "dxf_compare", direttiva="Acquisire e confrontare", descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.", quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)], ) return ricetta async def seed(replace: bool, dry_run: bool, fine: date) -> int: """Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine.""" rng = random.Random(SEED) async with async_session_factory() as session: utenti = await _upsert_utenti(session) owner = utenti["adriano"] stazione = await _upsert_stazione(session, owner) esistenti = list((await session.execute( select(Recipe).where(Recipe.code.in_(CODES)) )).scalars().all()) if esistenti and not replace: print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): " "non duplico. Usa --replace per rifarle.") else: if esistenti: conti = await _conta_e_cancella(session, esistenti) print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} " f"({conti['misure']} misure, {conti['produzioni']} produzioni, " f"{conti['eventi']} eventi)") r1, v1, sub1 = await _ricetta_meccanica( session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80", description="Flangia tornita in alluminio: diametri, spessore, interasse fori " "e profondita' della lamatura. Lotto e seriale obbligatori.", interval=30, requires_serial=True, profilo=FLANGIA, titoli_task=["Misure diametrali", "Spessore e posizione fori"], ) n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1, FLANGIA, (10, 14), "LF", "FL") r2, v2, sub2 = await _ricetta_meccanica( session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato", description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e " "rugosita'. Tolleranze strette.", interval=30, requires_serial=False, profilo=ALBERO, titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"], ) n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2, ALBERO, (8, 11), "LA", "AL") await _ricetta_dxf(session, owner, stazione) print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure") if dry_run: await session.rollback() print("--dry-run: nessuna modifica salvata.") else: await session.commit() print(f"Seed fiera completato (stazione {stazione.code}, utenti: " f"{', '.join(u[0] for u in UTENTI)}).") return 0 def _factory_per_file(db: Path): """Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive).""" engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False) @event.listens_for(engine.sync_engine, "connect") def _pragma(dbapi_connection, _record) -> None: cur = dbapi_connection.cursor() cur.execute("PRAGMA foreign_keys=ON") cur.execute("PRAGMA busy_timeout=5000") cur.close() return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async def _main_async(args) -> int: global async_session_factory engine, async_session_factory = _factory_per_file(args.db) try: fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1) return await seed(args.replace, args.dry_run, fine) finally: await engine.dispose() def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato") parser.add_argument("--replace", action="store_true", help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'") parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla") parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)") args = parser.parse_args() if not args.db.is_file(): print(f"File non trovato: {args.db}") return 2 return asyncio.run(_main_async(args)) if __name__ == "__main__": raise SystemExit(main())