645 lines
26 KiB
Python
645 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]:
|
|
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)
|