Files
plast-track/backend/app/main.py
2026-06-17 02:21:47 +01:00

658 lines
26 KiB
Python

from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, select, text
from sqlalchemy.exc import OperationalError
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,
MachineEventIn,
ProductionOrderCreate,
ScrapCreate,
SimulatorEventCreate,
SimulatorScenarioCreate,
)
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, handle_machine_event
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)
async def wait_for_database() -> None:
last_error: OperationalError | None = None
for _ in range(settings.database_startup_retries):
try:
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
return
except OperationalError as error:
last_error = error
await asyncio.sleep(settings.database_startup_retry_delay_sec)
if last_error is not None:
raise last_error
@asynccontextmanager
async def lifespan(app: FastAPI):
await wait_for_database()
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.cors_allowed_origins,
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
]
def _get_machine_by_code_or_404(db: Session, machine_code: str) -> Machine:
machine = db.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
return machine
def _emit_simulator_event(db: Session, payload: SimulatorEventCreate) -> dict:
_get_machine_by_code_or_404(db, payload.machine_id)
incoming = MachineEventIn(
machine_id=payload.machine_id,
event_type=payload.event_type,
timestamp=payload.timestamp or datetime.now(UTC),
cycle_time_sec=payload.cycle_time_sec,
source="simulator-ui",
input_name=payload.input_name,
input_value=payload.input_value,
)
result = handle_machine_event(db, incoming)
if result["should_refresh"]:
hub.notify({"type": "refresh", "source": "simulator-ui", **result})
return result
@app.post("/api/simulator/events")
def create_simulator_event(payload: SimulatorEventCreate, db: Session = Depends(get_db)) -> dict:
return _emit_simulator_event(db, payload)
@app.post("/api/simulator/scenarios")
def run_simulator_scenario(payload: SimulatorScenarioCreate, db: Session = Depends(get_db)) -> dict:
machine = _get_machine_by_code_or_404(db, payload.machine_id)
now = datetime.now(UTC)
cycle_time_sec = float(payload.cycle_time_sec or 20)
events: list[SimulatorEventCreate] = []
if payload.scenario == "power_on":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_on", timestamp=now))
elif payload.scenario == "power_off":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_off", timestamp=now))
elif payload.scenario == "cycle_completed":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_burst":
first_event_time = now - timedelta(seconds=cycle_time_sec * max(payload.count - 1, 0))
for index in range(payload.count):
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=first_event_time + timedelta(seconds=cycle_time_sec * index),
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_signal_edge":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms + 100, 500)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "debounce_noise":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms - 50, 1)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "machine_stopped":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_stopped", timestamp=now))
elif payload.scenario == "recover_with_cycle":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "alarm_on":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=True,
)
)
elif payload.scenario == "alarm_off":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=False,
)
)
elif payload.scenario == "raw_input":
if not payload.input_name:
raise HTTPException(status_code=400, detail="input_name is required for raw_input")
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name=payload.input_name,
input_value=payload.input_value,
)
)
else:
raise HTTPException(status_code=400, detail="Unsupported simulator scenario")
results = [_emit_simulator_event(db, event) for event in events]
return {"scenario": payload.scenario, "machine_id": machine.code, "events": results}
@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]:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
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,
"machine_id": row.machine_id,
"machine_code": machine.code,
"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()
result = []
for row in rows:
machine = db.get(Machine, row.machine_id)
order = db.get(ProductionOrder, row.production_order_id) if row.production_order_id else None
result.append(
{
"id": row.id,
"machine_id": row.machine_id,
"machine_code": machine.code if machine else None,
"production_order_id": row.production_order_id,
"order_number": order.order_number if order else None,
"start_time": row.start_time.isoformat(),
"end_time": None,
"duration_sec": row.duration_sec,
"reason_code": row.reason_code,
"comment": row.comment,
"auto_detected": row.auto_detected,
"qualified_by": row.qualified_by,
}
)
return result
@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)