from __future__ import annotations import asyncio from contextlib import asynccontextmanager from datetime import date, datetime, timezone from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, select from sqlalchemy.orm import Session from .config import get_settings from .database import Base, SessionLocal, engine, get_db from .models import Cycle, Downtime, DowntimeReason, Machine, MachineEvent, ProductionOrder, Scrap, ScrapReason from .schemas import DowntimeQualify, MachineConfigUpdate, ProductionOrderCreate, ScrapCreate from .services.dashboard import build_dashboard_snapshot, serialize_machine_card, serialize_machine_config from .services.events import detect_missing_cycles, get_open_downtime, get_running_order from .services.mqtt_listener import MqttListener from .services.oee import compute_daily_oee, compute_machine_oee from .services.realtime import hub settings = get_settings() mqtt_listener = MqttListener(hub.notify) UTC = timezone.utc def seed_defaults(session: Session) -> None: if session.execute(select(func.count(Machine.id))).scalar_one() == 0: session.add_all( [ Machine(code="INJ-01", name="INJ-01", brand="Haitian", model="Mars II"), Machine(code="INJ-02", name="INJ-02", brand="Engel", model="e-victory"), Machine(code="INJ-03", name="INJ-03", brand="Arburg", model="Allrounder"), ] ) if session.execute(select(func.count(DowntimeReason.code))).scalar_one() == 0: session.add_all( [ DowntimeReason(code="changement_moule", label="Changement moule"), DowntimeReason(code="reglage", label="Reglage"), DowntimeReason(code="attente_matiere", label="Attente matiere"), DowntimeReason(code="attente_operateur", label="Attente operateur"), DowntimeReason(code="panne_machine", label="Panne machine"), DowntimeReason(code="panne_moule", label="Panne moule"), DowntimeReason(code="attente_qualite", label="Attente qualite"), DowntimeReason(code="nettoyage", label="Nettoyage"), DowntimeReason(code="pause", label="Pause"), DowntimeReason(code="micro_arret", label="Micro arret"), DowntimeReason(code="autre", label="Autre"), ] ) if session.execute(select(func.count(ScrapReason.code))).scalar_one() == 0: session.add_all( [ ScrapReason(code="bavure", label="Bavure"), ScrapReason(code="manque_matiere", label="Manque matiere"), ScrapReason(code="brulure", label="Brulure"), ScrapReason(code="retassure", label="Retassure"), ScrapReason(code="deformation", label="Deformation"), ScrapReason(code="point_noir", label="Point noir"), ScrapReason(code="humidite", label="Humidite"), ScrapReason(code="couleur_non_conforme", label="Couleur non conforme"), ScrapReason(code="collage_moule", label="Collage moule"), ScrapReason(code="casse_piece", label="Casse piece"), ScrapReason(code="defaut_dimensionnel", label="Defaut dimensionnel"), ScrapReason(code="autre", label="Autre"), ] ) if session.execute(select(func.count(ProductionOrder.id))).scalar_one() == 0: machine_1 = session.execute(select(Machine).where(Machine.code == "INJ-01")).scalar_one() machine_2 = session.execute(select(Machine).where(Machine.code == "INJ-02")).scalar_one() now = datetime.now(UTC) session.add_all( [ ProductionOrder( order_number="OF-1001", machine_id=machine_1.id, article_ref="ART-001", article_name="Boitier 1L", mold_ref="M-001", material_ref="PP-NEUTRAL", planned_qty=4000, cavities=2, theoretical_cycle_time_sec=18.5, status="running", started_at=now, ), ProductionOrder( order_number="OF-1002", machine_id=machine_2.id, article_ref="ART-002", article_name="Capot Technique", mold_ref="M-002", material_ref="ABS-BLACK", planned_qty=2500, cavities=1, theoretical_cycle_time_sec=24.0, status="running", started_at=now, ), ] ) session.commit() async def downtime_monitor() -> None: while True: with SessionLocal() as session: changes = detect_missing_cycles(session) for change in changes: hub.notify({"type": "refresh", "source": "downtime-monitor", **change}) await asyncio.sleep(settings.downtime_check_interval_sec) @asynccontextmanager async def lifespan(app: FastAPI): Base.metadata.create_all(bind=engine) with SessionLocal() as session: seed_defaults(session) hub.bind_loop(asyncio.get_running_loop()) mqtt_listener.start() app.state.downtime_task = asyncio.create_task(downtime_monitor()) try: yield finally: mqtt_listener.stop() app.state.downtime_task.cancel() app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/health") def healthcheck() -> dict: return {"status": "ok"} @app.get("/api/dashboard") def get_dashboard(db: Session = Depends(get_db)) -> dict: return build_dashboard_snapshot(db) @app.get("/api/reference-data") def get_reference_data(db: Session = Depends(get_db)) -> dict: return { "downtime_reasons": [ {"code": row.code, "label": row.label} for row in db.execute(select(DowntimeReason).order_by(DowntimeReason.label)).scalars().all() ], "scrap_reasons": [ {"code": row.code, "label": row.label} for row in db.execute(select(ScrapReason).order_by(ScrapReason.label)).scalars().all() ], } @app.get("/api/machines") def list_machines(db: Session = Depends(get_db)) -> list[dict]: machines = db.execute(select(Machine).order_by(Machine.code)).scalars().all() return [serialize_machine_card(db, machine) for machine in machines] @app.get("/api/machines/{machine_id}") def get_machine(machine_id: int, db: Session = Depends(get_db)) -> dict: machine = db.get(Machine, machine_id) if machine is None: raise HTTPException(status_code=404, detail="Machine not found") return serialize_machine_card(db, machine) @app.get("/api/machines/{machine_id}/config") def get_machine_config(machine_id: int, db: Session = Depends(get_db)) -> dict: machine = db.get(Machine, machine_id) if machine is None: raise HTTPException(status_code=404, detail="Machine not found") return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)} @app.patch("/api/machines/{machine_id}/config") def update_machine_config(machine_id: int, payload: MachineConfigUpdate, db: Session = Depends(get_db)) -> dict: machine = db.get(Machine, machine_id) if machine is None: raise HTTPException(status_code=404, detail="Machine not found") updates = payload.model_dump(exclude_unset=True) if "cycle_signal_edge" in updates and updates["cycle_signal_edge"] not in {"rising", "falling"}: raise HTTPException(status_code=400, detail="cycle_signal_edge must be rising or falling") min_cycle = updates.get("min_cycle_time_sec", machine.min_cycle_time_sec) max_cycle = updates.get("max_cycle_time_sec", machine.max_cycle_time_sec) if min_cycle > max_cycle: raise HTTPException(status_code=400, detail="min_cycle_time_sec must be less than or equal to max_cycle_time_sec") for field, value in updates.items(): setattr(machine, field, value) db.commit() db.refresh(machine) hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "machine_config_updated"}) return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)} @app.get("/api/machines/{machine_id}/status") def get_machine_status(machine_id: int, db: Session = Depends(get_db)) -> dict: machine = db.get(Machine, machine_id) if machine is None: raise HTTPException(status_code=404, detail="Machine not found") running_order = get_running_order(db, machine.id) open_downtime = get_open_downtime(db, machine.id) return { "machine_id": machine.id, "code": machine.code, "status": machine.status, "powered_on": machine.powered_on, "last_cycle_at": machine.last_cycle_at.isoformat() if machine.last_cycle_at else None, "active_order_id": running_order.id if running_order else None, "open_downtime_id": open_downtime.id if open_downtime else None, } @app.get("/api/machines/{machine_id}/events") def get_machine_events(machine_id: int, limit: int = Query(default=50, le=200), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute( select(MachineEvent).where(MachineEvent.machine_id == machine_id).order_by(MachineEvent.timestamp.desc()).limit(limit) ).scalars() return [ { "id": row.id, "event_type": row.event_type, "timestamp": row.timestamp.isoformat(), "payload": row.payload, } for row in rows ] @app.get("/api/machines/{machine_id}/cycles") def get_machine_cycles(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute( select(Cycle).where(Cycle.machine_id == machine_id).order_by(Cycle.timestamp.desc()).limit(limit) ).scalars() return [ { "id": row.id, "timestamp": row.timestamp.isoformat(), "cycle_time_sec": row.cycle_time_sec, "produced_qty": row.produced_qty, "is_valid": row.is_valid, } for row in rows ] @app.get("/api/machines/{machine_id}/downtimes") def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute( select(Downtime).where(Downtime.machine_id == machine_id).order_by(Downtime.start_time.desc()).limit(limit) ).scalars() return [ { "id": row.id, "start_time": row.start_time.isoformat(), "end_time": row.end_time.isoformat() if row.end_time else None, "duration_sec": row.duration_sec, "reason_code": row.reason_code, "comment": row.comment, "auto_detected": row.auto_detected, "qualified_by": row.qualified_by, } for row in rows ] @app.get("/api/production-orders") def list_production_orders(db: Session = Depends(get_db)) -> list[dict]: rows = db.execute(select(ProductionOrder).order_by(ProductionOrder.created_at.desc())).scalars() return [ { "id": row.id, "order_number": row.order_number, "machine_id": row.machine_id, "article_ref": row.article_ref, "article_name": row.article_name, "mold_ref": row.mold_ref, "material_ref": row.material_ref, "planned_qty": row.planned_qty, "cavities": row.cavities, "theoretical_cycle_time_sec": row.theoretical_cycle_time_sec, "status": row.status, "started_at": row.started_at.isoformat() if row.started_at else None, "ended_at": row.ended_at.isoformat() if row.ended_at else None, } for row in rows ] @app.post("/api/production-orders") def create_production_order(payload: ProductionOrderCreate, db: Session = Depends(get_db)) -> dict: order = ProductionOrder(**payload.model_dump(), status="planned") db.add(order) db.commit() db.refresh(order) hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_created"}) return {"id": order.id, "status": order.status} def _find_order_or_404(db: Session, order_id: int) -> ProductionOrder: order = db.get(ProductionOrder, order_id) if order is None: raise HTTPException(status_code=404, detail="Production order not found") return order @app.post("/api/production-orders/{order_id}/start") def start_production_order(order_id: int, db: Session = Depends(get_db)) -> dict: order = _find_order_or_404(db, order_id) running_order = get_running_order(db, order.machine_id) if running_order and running_order.id != order.id: raise HTTPException(status_code=400, detail="Another order is already running on this machine") order.status = "running" order.started_at = order.started_at or datetime.now(UTC) machine = db.get(Machine, order.machine_id) if machine: machine.status = "production" machine.status_since = datetime.now(UTC) db.commit() hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_started"}) return {"id": order.id, "status": order.status} @app.post("/api/production-orders/{order_id}/pause") def pause_production_order(order_id: int, db: Session = Depends(get_db)) -> dict: order = _find_order_or_404(db, order_id) order.status = "paused" machine = db.get(Machine, order.machine_id) if machine: machine.status = "hors_planning" machine.status_since = datetime.now(UTC) db.commit() hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_paused"}) return {"id": order.id, "status": order.status} @app.post("/api/production-orders/{order_id}/close") def close_production_order(order_id: int, db: Session = Depends(get_db)) -> dict: order = _find_order_or_404(db, order_id) order.status = "closed" order.ended_at = datetime.now(UTC) machine = db.get(Machine, order.machine_id) if machine: machine.status = "hors_planning" machine.status_since = datetime.now(UTC) open_downtime = get_open_downtime(db, order.machine_id) if open_downtime is not None: open_downtime.end_time = datetime.now(UTC) open_downtime.duration_sec = max(int((open_downtime.end_time - open_downtime.start_time).total_seconds()), 0) db.commit() hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_closed"}) return {"id": order.id, "status": order.status} @app.get("/api/downtimes/open") def get_open_downtimes(db: Session = Depends(get_db)) -> list[dict]: rows = db.execute(select(Downtime).where(Downtime.end_time.is_(None)).order_by(Downtime.start_time.asc())).scalars() return [ { "id": row.id, "machine_id": row.machine_id, "production_order_id": row.production_order_id, "start_time": row.start_time.isoformat(), "reason_code": row.reason_code, "comment": row.comment, "auto_detected": row.auto_detected, "qualified_by": row.qualified_by, } for row in rows ] @app.post("/api/downtimes/{downtime_id}/qualify") def qualify_downtime(downtime_id: int, payload: DowntimeQualify, db: Session = Depends(get_db)) -> dict: downtime = db.get(Downtime, downtime_id) if downtime is None: raise HTTPException(status_code=404, detail="Downtime not found") downtime.reason_code = payload.reason_code downtime.comment = payload.comment downtime.qualified_by = payload.qualified_by machine = db.get(Machine, downtime.machine_id) if machine and downtime.end_time is None: machine.status = "reglage" if payload.reason_code in {"reglage", "changement_moule"} else "arret_qualifie" machine.status_since = datetime.now(UTC) db.commit() hub.notify({"type": "refresh", "source": "api", "machine_id": downtime.machine_id, "event_type": "downtime_qualified"}) return {"id": downtime.id, "reason_code": downtime.reason_code} @app.post("/api/scraps") def create_scrap(payload: ScrapCreate, db: Session = Depends(get_db)) -> dict: machine = db.execute(select(Machine).where(Machine.code == payload.machine_id)).scalar_one_or_none() if machine is None: raise HTTPException(status_code=404, detail="Machine not found") scrap = Scrap( machine_id=machine.id, production_order_id=payload.production_order_id, quantity=payload.quantity, reason_code=payload.reason_code, comment=payload.comment, operator_name=payload.operator_name, timestamp=payload.timestamp or datetime.now(UTC), ) db.add(scrap) db.commit() hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "scrap_created"}) return {"id": scrap.id} @app.get("/api/scraps") def list_scraps(db: Session = Depends(get_db)) -> list[dict]: rows = db.execute(select(Scrap).order_by(Scrap.timestamp.desc()).limit(200)).scalars() return [ { "id": row.id, "machine_id": row.machine_id, "production_order_id": row.production_order_id, "quantity": row.quantity, "reason_code": row.reason_code, "comment": row.comment, "operator_name": row.operator_name, "timestamp": row.timestamp.isoformat(), } for row in rows ] @app.get("/api/oee/daily") def get_daily_oee(date_value: date = Query(alias="date"), db: Session = Depends(get_db)) -> dict: machine_ids = db.execute(select(Machine.id).order_by(Machine.code)).scalars().all() return compute_daily_oee(db, date_value, list(machine_ids)) @app.get("/api/oee/machines/{machine_id}") def get_machine_oee(machine_id: int, from_value: datetime = Query(alias="from"), to_value: datetime = Query(alias="to"), db: Session = Depends(get_db)) -> dict: if db.get(Machine, machine_id) is None: raise HTTPException(status_code=404, detail="Machine not found") return compute_machine_oee(db, machine_id, from_value, to_value) @app.websocket("/ws/dashboard") async def dashboard_websocket(websocket: WebSocket): await hub.connect(websocket) try: with SessionLocal() as session: await websocket.send_json({"type": "snapshot", "data": build_dashboard_snapshot(session)}) while True: await websocket.receive_text() except WebSocketDisconnect: hub.disconnect(websocket)