Initial Plast Track MVP
This commit is contained in:
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Plast Track MVP backend package."""
|
||||
21
backend/app/config.py
Normal file
21
backend/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "Plast Track MVP"
|
||||
database_url: str = "postgresql+psycopg://plasttrack:plasttrack@database:5432/plasttrack"
|
||||
mqtt_host: str = "mqtt"
|
||||
mqtt_port: int = 1883
|
||||
mqtt_topic: str = "plasttrack/machines/+/events"
|
||||
mqtt_client_id: str = "plast-track-backend"
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
downtime_check_interval_sec: int = 5
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
23
backend/app/database.py
Normal file
23
backend/app/database.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
466
backend/app/main.py
Normal file
466
backend/app/main.py
Normal file
@@ -0,0 +1,466 @@
|
||||
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)
|
||||
126
backend/app/models.py
Normal file
126
backend/app/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Machine(Base):
|
||||
__tablename__ = "machines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
brand: Mapped[str | None] = mapped_column(String(120))
|
||||
model: Mapped[str | None] = mapped_column(String(120))
|
||||
status: Mapped[str] = mapped_column(String(50), default="hors_planning")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
powered_on: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
cycle_signal_edge: Mapped[str] = mapped_column(String(20), default="rising")
|
||||
debounce_ms: Mapped[int] = mapped_column(Integer, default=300)
|
||||
min_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=5)
|
||||
max_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=300)
|
||||
stop_detection_delay_sec: Mapped[int] = mapped_column(Integer, default=180)
|
||||
last_cycle_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ProductionOrder(Base):
|
||||
__tablename__ = "production_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
order_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||
article_ref: Mapped[str] = mapped_column(String(80))
|
||||
article_name: Mapped[str] = mapped_column(String(160))
|
||||
mold_ref: Mapped[str | None] = mapped_column(String(80))
|
||||
material_ref: Mapped[str | None] = mapped_column(String(80))
|
||||
planned_qty: Mapped[int] = mapped_column(Integer)
|
||||
cavities: Mapped[int] = mapped_column(Integer, default=1)
|
||||
theoretical_cycle_time_sec: Mapped[float] = mapped_column(Float)
|
||||
status: Mapped[str] = mapped_column(String(40), default="planned")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MachineEvent(Base):
|
||||
__tablename__ = "machine_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Cycle(Base):
|
||||
__tablename__ = "cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
cycle_time_sec: Mapped[float] = mapped_column(Float)
|
||||
cavities: Mapped[int] = mapped_column(Integer, default=1)
|
||||
produced_qty: Mapped[int] = mapped_column(Integer, default=1)
|
||||
is_valid: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Downtime(Base):
|
||||
__tablename__ = "downtimes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
duration_sec: Mapped[int | None] = mapped_column(Integer)
|
||||
reason_code: Mapped[str | None] = mapped_column(String(80), ForeignKey("downtime_reasons.code"))
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
auto_detected: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
qualified_by: Mapped[str | None] = mapped_column(String(120))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Scrap(Base):
|
||||
__tablename__ = "scraps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer)
|
||||
reason_code: Mapped[str] = mapped_column(String(80), ForeignKey("scrap_reasons.code"))
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
operator_name: Mapped[str] = mapped_column(String(120))
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DowntimeReason(Base):
|
||||
__tablename__ = "downtime_reasons"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(160))
|
||||
|
||||
|
||||
class ScrapReason(Base):
|
||||
__tablename__ = "scrap_reasons"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(160))
|
||||
|
||||
|
||||
class Operator(Base):
|
||||
__tablename__ = "operators"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
59
backend/app/schemas.py
Normal file
59
backend/app/schemas.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MachineEventIn(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
machine_id: str
|
||||
event_type: str
|
||||
timestamp: datetime
|
||||
cycle_time_sec: float | None = None
|
||||
source: str | None = "digital_input"
|
||||
input_name: str | None = None
|
||||
input_value: bool | int | str | None = None
|
||||
|
||||
|
||||
class ProductionOrderCreate(BaseModel):
|
||||
order_number: str
|
||||
machine_id: int
|
||||
article_ref: str
|
||||
article_name: str
|
||||
mold_ref: str | None = None
|
||||
material_ref: str | None = None
|
||||
planned_qty: int = Field(gt=0)
|
||||
cavities: int = Field(default=1, gt=0)
|
||||
theoretical_cycle_time_sec: float = Field(gt=0)
|
||||
|
||||
|
||||
class DowntimeQualify(BaseModel):
|
||||
reason_code: str
|
||||
comment: str | None = None
|
||||
qualified_by: str
|
||||
|
||||
|
||||
class ScrapCreate(BaseModel):
|
||||
machine_id: str
|
||||
production_order_id: int | None = None
|
||||
quantity: int = Field(gt=0)
|
||||
reason_code: str
|
||||
comment: str | None = None
|
||||
operator_name: str
|
||||
timestamp: datetime | None = None
|
||||
|
||||
|
||||
class DailyOeeQuery(BaseModel):
|
||||
date: date
|
||||
|
||||
|
||||
class MachineConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
brand: str | None = None
|
||||
model: str | None = None
|
||||
is_active: bool | None = None
|
||||
cycle_signal_edge: str | None = None
|
||||
debounce_ms: int | None = Field(default=None, ge=0)
|
||||
min_cycle_time_sec: int | None = Field(default=None, ge=1)
|
||||
max_cycle_time_sec: int | None = Field(default=None, ge=1)
|
||||
stop_detection_delay_sec: int | None = Field(default=None, ge=1)
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend service modules."""
|
||||
118
backend/app/services/dashboard.py
Normal file
118
backend/app/services/dashboard.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Cycle, Downtime, Machine, ProductionOrder, Scrap
|
||||
from .events import get_open_downtime, get_running_order
|
||||
from .oee import compute_machine_oee
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def _today_bounds() -> tuple[datetime, datetime]:
|
||||
start = datetime.combine(datetime.now(UTC).date(), time.min, tzinfo=UTC)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def serialize_machine_config(machine: Machine) -> dict:
|
||||
return {
|
||||
"name": machine.name,
|
||||
"brand": machine.brand,
|
||||
"model": machine.model,
|
||||
"is_active": machine.is_active,
|
||||
"cycle_signal_edge": machine.cycle_signal_edge,
|
||||
"debounce_ms": machine.debounce_ms,
|
||||
"min_cycle_time_sec": machine.min_cycle_time_sec,
|
||||
"max_cycle_time_sec": machine.max_cycle_time_sec,
|
||||
"stop_detection_delay_sec": machine.stop_detection_delay_sec,
|
||||
}
|
||||
|
||||
|
||||
def serialize_machine_card(session: Session, machine: Machine) -> dict:
|
||||
running_order = get_running_order(session, machine.id)
|
||||
open_downtime = get_open_downtime(session, machine.id)
|
||||
today_start, today_end = _today_bounds()
|
||||
|
||||
recent_cycles = session.execute(
|
||||
select(Cycle.cycle_time_sec).where(Cycle.machine_id == machine.id).order_by(Cycle.timestamp.desc()).limit(20)
|
||||
).scalars().all()
|
||||
avg_cycle = sum(recent_cycles) / len(recent_cycles) if recent_cycles else 0
|
||||
|
||||
order_produced_qty = 0
|
||||
order_scrap_qty = 0
|
||||
progress = 0
|
||||
if running_order is not None:
|
||||
order_produced_qty = session.execute(
|
||||
select(func.coalesce(func.sum(Cycle.produced_qty), 0)).where(
|
||||
Cycle.production_order_id == running_order.id,
|
||||
Cycle.is_valid.is_(True),
|
||||
)
|
||||
).scalar_one()
|
||||
order_scrap_qty = session.execute(
|
||||
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(Scrap.production_order_id == running_order.id)
|
||||
).scalar_one()
|
||||
progress = min(order_produced_qty / running_order.planned_qty, 1) if running_order.planned_qty else 0
|
||||
|
||||
daily_oee = compute_machine_oee(session, machine.id, today_start, today_end)
|
||||
latest_downtime = session.execute(
|
||||
select(Downtime).where(Downtime.machine_id == machine.id).order_by(Downtime.start_time.desc()).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"id": machine.id,
|
||||
"code": machine.code,
|
||||
"name": machine.name,
|
||||
"brand": machine.brand,
|
||||
"model": machine.model,
|
||||
"status": machine.status,
|
||||
"status_since": machine.status_since.isoformat() if machine.status_since else None,
|
||||
"powered_on": machine.powered_on,
|
||||
"config": serialize_machine_config(machine),
|
||||
"active_order": None
|
||||
if running_order is None
|
||||
else {
|
||||
"id": running_order.id,
|
||||
"order_number": running_order.order_number,
|
||||
"article_ref": running_order.article_ref,
|
||||
"article_name": running_order.article_name,
|
||||
"mold_ref": running_order.mold_ref,
|
||||
"material_ref": running_order.material_ref,
|
||||
"planned_qty": running_order.planned_qty,
|
||||
"cavities": running_order.cavities,
|
||||
"theoretical_cycle_time_sec": running_order.theoretical_cycle_time_sec,
|
||||
"status": running_order.status,
|
||||
},
|
||||
"cycle_real_avg_sec": round(avg_cycle, 2),
|
||||
"produced_qty": int(order_produced_qty),
|
||||
"scrap_qty": int(order_scrap_qty),
|
||||
"order_progress": round(progress, 4),
|
||||
"today_oee": daily_oee,
|
||||
"open_downtime": None
|
||||
if open_downtime is None
|
||||
else {
|
||||
"id": open_downtime.id,
|
||||
"reason_code": open_downtime.reason_code,
|
||||
"start_time": open_downtime.start_time.isoformat(),
|
||||
"comment": open_downtime.comment,
|
||||
},
|
||||
"last_downtime": None
|
||||
if latest_downtime is None
|
||||
else {
|
||||
"id": latest_downtime.id,
|
||||
"reason_code": latest_downtime.reason_code,
|
||||
"start_time": latest_downtime.start_time.isoformat(),
|
||||
"end_time": latest_downtime.end_time.isoformat() if latest_downtime.end_time else None,
|
||||
"duration_sec": latest_downtime.duration_sec,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_dashboard_snapshot(session: Session) -> dict:
|
||||
machines = session.execute(select(Machine).where(Machine.is_active.is_(True)).order_by(Machine.code)).scalars().all()
|
||||
return {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"machines": [serialize_machine_card(session, machine) for machine in machines],
|
||||
}
|
||||
295
backend/app/services/events.py
Normal file
295
backend/app/services/events.py
Normal file
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
|
||||
from ..schemas import MachineEventIn
|
||||
|
||||
|
||||
REGALAGE_REASON_CODES = {"reglage", "changement_moule"}
|
||||
SIGNAL_EVENT_TYPE = "digital_input_changed"
|
||||
POWER_INPUT_NAME = "machine_power_on"
|
||||
CYCLE_INPUT_NAME = "cycle_signal"
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def _ensure_utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
|
||||
|
||||
def parse_machine_event(payload: str | bytes) -> MachineEventIn:
|
||||
raw = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
||||
data = json.loads(raw)
|
||||
return MachineEventIn.model_validate(data)
|
||||
|
||||
|
||||
def normalize_signal_value(value: bool | int | str | None) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "on", "high"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "off", "low"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _get_or_create_machine(session: Session, machine_code: str) -> Machine:
|
||||
machine = session.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
|
||||
if machine is None:
|
||||
machine = Machine(code=machine_code, name=machine_code, status="hors_planning")
|
||||
session.add(machine)
|
||||
session.flush()
|
||||
return machine
|
||||
|
||||
|
||||
def get_running_order(session: Session, machine_id: int) -> ProductionOrder | None:
|
||||
return session.execute(
|
||||
select(ProductionOrder).where(
|
||||
ProductionOrder.machine_id == machine_id,
|
||||
ProductionOrder.status == "running",
|
||||
).order_by(ProductionOrder.started_at.desc())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_open_downtime(session: Session, machine_id: int) -> Downtime | None:
|
||||
return session.execute(
|
||||
select(Downtime).where(
|
||||
Downtime.machine_id == machine_id,
|
||||
Downtime.end_time.is_(None),
|
||||
).order_by(Downtime.start_time.desc())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_last_input_event(session: Session, machine_id: int, input_name: str) -> MachineEvent | None:
|
||||
rows = session.execute(
|
||||
select(MachineEvent).where(
|
||||
MachineEvent.machine_id == machine_id,
|
||||
MachineEvent.event_type == SIGNAL_EVENT_TYPE,
|
||||
).order_by(MachineEvent.timestamp.desc()).limit(50)
|
||||
).scalars()
|
||||
for row in rows:
|
||||
if row.payload.get("input_name") == input_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def derive_machine_status(machine: Machine, open_downtime: Downtime | None, running_order: ProductionOrder | None) -> str:
|
||||
if not machine.powered_on or running_order is None:
|
||||
return "hors_planning"
|
||||
if open_downtime is None:
|
||||
return "production"
|
||||
if open_downtime.reason_code in REGALAGE_REASON_CODES:
|
||||
return "reglage"
|
||||
if open_downtime.reason_code:
|
||||
return "arret_qualifie"
|
||||
return "arret_non_qualifie"
|
||||
|
||||
|
||||
def open_downtime_if_needed(session: Session, machine: Machine, event_time: datetime, auto_detected: bool = True) -> Downtime | None:
|
||||
running_order = get_running_order(session, machine.id)
|
||||
if running_order is None or not machine.powered_on:
|
||||
machine.status = "hors_planning"
|
||||
machine.status_since = event_time
|
||||
return None
|
||||
|
||||
open_downtime = get_open_downtime(session, machine.id)
|
||||
if open_downtime is not None:
|
||||
return open_downtime
|
||||
|
||||
downtime = Downtime(
|
||||
machine_id=machine.id,
|
||||
production_order_id=running_order.id,
|
||||
start_time=event_time,
|
||||
auto_detected=auto_detected,
|
||||
)
|
||||
session.add(downtime)
|
||||
machine.status = "arret_non_qualifie"
|
||||
machine.status_since = event_time
|
||||
session.flush()
|
||||
return downtime
|
||||
|
||||
|
||||
def close_open_downtime(session: Session, machine: Machine, event_time: datetime) -> Downtime | None:
|
||||
downtime = get_open_downtime(session, machine.id)
|
||||
if downtime is None:
|
||||
return None
|
||||
downtime.end_time = event_time
|
||||
downtime.duration_sec = max(int((event_time - downtime.start_time).total_seconds()), 0)
|
||||
machine.status_since = event_time
|
||||
session.flush()
|
||||
return downtime
|
||||
|
||||
|
||||
def _is_matching_edge(previous_value: bool | None, current_value: bool | None, configured_edge: str) -> bool:
|
||||
if previous_value is None or current_value is None:
|
||||
return False
|
||||
if configured_edge == "falling":
|
||||
return previous_value is True and current_value is False
|
||||
return previous_value is False and current_value is True
|
||||
|
||||
|
||||
def _persist_cycle(
|
||||
session: Session,
|
||||
machine: Machine,
|
||||
running_order: ProductionOrder | None,
|
||||
event_time: datetime,
|
||||
cycle_time_sec: float,
|
||||
) -> None:
|
||||
cavities = running_order.cavities if running_order else 1
|
||||
is_valid = machine.min_cycle_time_sec <= cycle_time_sec <= machine.max_cycle_time_sec
|
||||
session.add(
|
||||
Cycle(
|
||||
machine_id=machine.id,
|
||||
production_order_id=running_order.id if running_order else None,
|
||||
timestamp=event_time,
|
||||
cycle_time_sec=cycle_time_sec,
|
||||
cavities=cavities,
|
||||
produced_qty=cavities if is_valid else 0,
|
||||
is_valid=is_valid,
|
||||
)
|
||||
)
|
||||
if cycle_time_sec >= machine.min_cycle_time_sec:
|
||||
machine.last_cycle_at = event_time
|
||||
machine.status_since = event_time
|
||||
|
||||
|
||||
def _handle_cycle_signal_event(
|
||||
session: Session,
|
||||
machine: Machine,
|
||||
running_order: ProductionOrder | None,
|
||||
incoming_event: MachineEventIn,
|
||||
event_time: datetime,
|
||||
previous_signal_event: MachineEvent | None,
|
||||
) -> None:
|
||||
input_value = normalize_signal_value(incoming_event.input_value)
|
||||
previous_value = None
|
||||
previous_signal_time = None
|
||||
if previous_signal_event is not None:
|
||||
previous_value = normalize_signal_value(previous_signal_event.payload.get("input_value"))
|
||||
previous_signal_time = _ensure_utc(previous_signal_event.timestamp)
|
||||
|
||||
if input_value is None or not _is_matching_edge(previous_value, input_value, machine.cycle_signal_edge):
|
||||
return
|
||||
|
||||
if previous_signal_time is not None:
|
||||
elapsed_ms = (event_time - previous_signal_time).total_seconds() * 1000
|
||||
if elapsed_ms < machine.debounce_ms:
|
||||
return
|
||||
|
||||
close_open_downtime(session, machine, event_time)
|
||||
|
||||
if machine.last_cycle_at is None:
|
||||
fallback_cycle_time = incoming_event.cycle_time_sec
|
||||
if fallback_cycle_time and fallback_cycle_time >= machine.min_cycle_time_sec:
|
||||
_persist_cycle(session, machine, running_order, event_time, float(fallback_cycle_time))
|
||||
else:
|
||||
machine.last_cycle_at = event_time
|
||||
machine.status_since = event_time
|
||||
return
|
||||
|
||||
cycle_time_sec = (event_time - _ensure_utc(machine.last_cycle_at)).total_seconds()
|
||||
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
|
||||
|
||||
|
||||
def handle_machine_event(session: Session, incoming_event: MachineEventIn) -> dict:
|
||||
event_time = _ensure_utc(incoming_event.timestamp)
|
||||
machine = _get_or_create_machine(session, incoming_event.machine_id)
|
||||
previous_signal_event = None
|
||||
should_refresh = False
|
||||
if incoming_event.event_type == SIGNAL_EVENT_TYPE and incoming_event.input_name:
|
||||
previous_signal_event = get_last_input_event(session, machine.id, incoming_event.input_name)
|
||||
|
||||
session.add(
|
||||
MachineEvent(
|
||||
machine_id=machine.id,
|
||||
event_type=incoming_event.event_type,
|
||||
timestamp=event_time,
|
||||
payload=incoming_event.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
|
||||
running_order = get_running_order(session, machine.id)
|
||||
|
||||
if incoming_event.event_type == "machine_power_on":
|
||||
machine.powered_on = True
|
||||
should_refresh = True
|
||||
elif incoming_event.event_type == "machine_power_off":
|
||||
machine.powered_on = False
|
||||
close_open_downtime(session, machine, event_time)
|
||||
should_refresh = True
|
||||
elif incoming_event.event_type == "machine_stopped":
|
||||
open_downtime_if_needed(session, machine, event_time, auto_detected=True)
|
||||
should_refresh = True
|
||||
elif incoming_event.event_type == "cycle_completed":
|
||||
close_open_downtime(session, machine, event_time)
|
||||
cycle_time_sec = float(incoming_event.cycle_time_sec or 0)
|
||||
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
|
||||
should_refresh = True
|
||||
elif incoming_event.event_type == SIGNAL_EVENT_TYPE:
|
||||
input_name = incoming_event.input_name
|
||||
input_value = normalize_signal_value(incoming_event.input_value)
|
||||
if input_name == POWER_INPUT_NAME and input_value is not None:
|
||||
machine.powered_on = input_value
|
||||
if not input_value:
|
||||
close_open_downtime(session, machine, event_time)
|
||||
should_refresh = True
|
||||
elif input_name == CYCLE_INPUT_NAME:
|
||||
previous_cycle_at = machine.last_cycle_at
|
||||
_handle_cycle_signal_event(session, machine, running_order, incoming_event, event_time, previous_signal_event)
|
||||
should_refresh = machine.last_cycle_at != previous_cycle_at
|
||||
elif input_name == "general_alarm" and input_value:
|
||||
should_refresh = True
|
||||
|
||||
machine.status = derive_machine_status(machine, get_open_downtime(session, machine.id), running_order)
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"machine_code": machine.code,
|
||||
"machine_id": machine.id,
|
||||
"event_type": incoming_event.event_type,
|
||||
"timestamp": event_time.isoformat(),
|
||||
"status": machine.status,
|
||||
"should_refresh": should_refresh,
|
||||
}
|
||||
|
||||
|
||||
def detect_missing_cycles(session: Session, now: datetime | None = None) -> list[dict]:
|
||||
current_time = _ensure_utc(now) if now else datetime.now(UTC)
|
||||
machines = session.execute(select(Machine).where(Machine.is_active.is_(True))).scalars().all()
|
||||
changes: list[dict] = []
|
||||
|
||||
for machine in machines:
|
||||
running_order = get_running_order(session, machine.id)
|
||||
if running_order is None or not machine.powered_on or machine.last_cycle_at is None:
|
||||
continue
|
||||
|
||||
delay_sec = max(machine.stop_detection_delay_sec, 1)
|
||||
last_cycle_at = _ensure_utc(machine.last_cycle_at)
|
||||
elapsed = (current_time - last_cycle_at).total_seconds()
|
||||
if elapsed < delay_sec:
|
||||
continue
|
||||
|
||||
downtime = get_open_downtime(session, machine.id)
|
||||
if downtime is not None:
|
||||
continue
|
||||
|
||||
downtime_start = last_cycle_at + timedelta(seconds=delay_sec)
|
||||
open_downtime_if_needed(session, machine, downtime_start, auto_detected=True)
|
||||
machine.status = "arret_non_qualifie"
|
||||
machine.status_since = current_time
|
||||
changes.append({"machine_code": machine.code, "event_type": "machine_stopped", "status": machine.status})
|
||||
|
||||
if changes:
|
||||
session.commit()
|
||||
else:
|
||||
session.rollback()
|
||||
return changes
|
||||
50
backend/app/services/mqtt_listener.py
Normal file
50
backend/app/services/mqtt_listener.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import SessionLocal
|
||||
from .events import handle_machine_event, parse_machine_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MqttListener:
|
||||
def __init__(self, notify_callback) -> None:
|
||||
self.settings = get_settings()
|
||||
self.notify_callback = notify_callback
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.settings.mqtt_client_id)
|
||||
self.client.on_connect = self.on_connect
|
||||
self.client.on_message = self.on_message
|
||||
self.thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
try:
|
||||
self.client.disconnect()
|
||||
except Exception:
|
||||
logger.exception("MQTT disconnect failed")
|
||||
|
||||
def _run(self) -> None:
|
||||
self.client.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60)
|
||||
self.client.loop_forever()
|
||||
|
||||
def on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||
logger.info("Connected to MQTT with code %s", reason_code)
|
||||
client.subscribe(self.settings.mqtt_topic)
|
||||
|
||||
def on_message(self, client, userdata, message) -> None:
|
||||
try:
|
||||
incoming_event = parse_machine_event(message.payload)
|
||||
with SessionLocal() as session:
|
||||
result = handle_machine_event(session, incoming_event)
|
||||
if result.pop("should_refresh", False):
|
||||
self.notify_callback({"type": "refresh", "source": "mqtt", **result})
|
||||
except Exception:
|
||||
logger.exception("Failed to handle MQTT message on topic %s", message.topic)
|
||||
202
backend/app/services/oee.py
Normal file
202
backend/app/services/oee.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Cycle, Downtime, ProductionOrder, Scrap
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
@dataclass
|
||||
class OeeTotals:
|
||||
planned_time_sec: float = 0
|
||||
downtime_sec: float = 0
|
||||
operating_time_sec: float = 0
|
||||
total_cycles: int = 0
|
||||
theoretical_cycle_time_sec: float = 0
|
||||
performance_time_sec: float = 0
|
||||
total_produced_qty: int = 0
|
||||
good_qty: int = 0
|
||||
scrap_qty: int = 0
|
||||
availability: float = 0
|
||||
performance: float = 0
|
||||
quality: float = 0
|
||||
oee: float = 0
|
||||
|
||||
def as_dict(self) -> dict[str, float | int]:
|
||||
return {
|
||||
"planned_time_sec": round(self.planned_time_sec, 2),
|
||||
"downtime_sec": round(self.downtime_sec, 2),
|
||||
"operating_time_sec": round(self.operating_time_sec, 2),
|
||||
"total_cycles": self.total_cycles,
|
||||
"theoretical_cycle_time_sec": round(self.theoretical_cycle_time_sec, 2),
|
||||
"total_produced_qty": self.total_produced_qty,
|
||||
"good_qty": self.good_qty,
|
||||
"scrap_qty": self.scrap_qty,
|
||||
"availability": round(self.availability, 4),
|
||||
"performance": round(self.performance, 4),
|
||||
"quality": round(self.quality, 4),
|
||||
"oee": round(self.oee, 4),
|
||||
}
|
||||
|
||||
|
||||
def calculate_oee_metrics(
|
||||
*,
|
||||
planned_time_sec: float,
|
||||
downtime_sec: float,
|
||||
total_cycles: int,
|
||||
performance_time_sec: float,
|
||||
theoretical_cycle_time_sec: float,
|
||||
total_produced_qty: int,
|
||||
scrap_qty: int,
|
||||
) -> OeeTotals:
|
||||
operating_time_sec = max(planned_time_sec - downtime_sec, 0)
|
||||
good_qty = max(total_produced_qty - scrap_qty, 0)
|
||||
|
||||
availability = operating_time_sec / planned_time_sec if planned_time_sec > 0 else 0
|
||||
performance = performance_time_sec / operating_time_sec if operating_time_sec > 0 else 0
|
||||
performance = min(max(performance, 0), 1)
|
||||
quality = good_qty / total_produced_qty if total_produced_qty > 0 else 0
|
||||
oee = availability * performance * quality
|
||||
|
||||
return OeeTotals(
|
||||
planned_time_sec=planned_time_sec,
|
||||
downtime_sec=downtime_sec,
|
||||
operating_time_sec=operating_time_sec,
|
||||
total_cycles=total_cycles,
|
||||
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
|
||||
performance_time_sec=performance_time_sec,
|
||||
total_produced_qty=total_produced_qty,
|
||||
good_qty=good_qty,
|
||||
scrap_qty=scrap_qty,
|
||||
availability=availability,
|
||||
performance=performance,
|
||||
quality=quality,
|
||||
oee=oee,
|
||||
)
|
||||
|
||||
|
||||
def overlap_seconds(window_start: datetime, window_end: datetime, start: datetime, end: datetime | None) -> float:
|
||||
actual_end = end or window_end
|
||||
overlap_start = max(window_start, start)
|
||||
overlap_end = min(window_end, actual_end)
|
||||
if overlap_end <= overlap_start:
|
||||
return 0
|
||||
return (overlap_end - overlap_start).total_seconds()
|
||||
|
||||
|
||||
def compute_machine_oee(session: Session, machine_id: int, window_start: datetime, window_end: datetime) -> dict:
|
||||
current_time = datetime.now(UTC)
|
||||
effective_window_end = min(window_end, current_time)
|
||||
if effective_window_end <= window_start:
|
||||
effective_window_end = window_end
|
||||
|
||||
order_rows = session.execute(
|
||||
select(ProductionOrder).where(
|
||||
ProductionOrder.machine_id == machine_id,
|
||||
ProductionOrder.started_at.is_not(None),
|
||||
ProductionOrder.started_at < effective_window_end,
|
||||
func.coalesce(ProductionOrder.ended_at, effective_window_end) > window_start,
|
||||
)
|
||||
).scalars()
|
||||
|
||||
planned_time_sec = 0.0
|
||||
for order in order_rows:
|
||||
effective_order_end = min(order.ended_at, effective_window_end) if order.ended_at else effective_window_end
|
||||
planned_time_sec += overlap_seconds(window_start, effective_window_end, order.started_at, effective_order_end)
|
||||
|
||||
cycle_rows = session.execute(
|
||||
select(
|
||||
Cycle.production_order_id,
|
||||
func.count(Cycle.id),
|
||||
func.coalesce(func.sum(Cycle.produced_qty), 0),
|
||||
).where(
|
||||
Cycle.machine_id == machine_id,
|
||||
Cycle.timestamp >= window_start,
|
||||
Cycle.timestamp <= effective_window_end,
|
||||
Cycle.is_valid.is_(True),
|
||||
).group_by(Cycle.production_order_id)
|
||||
).all()
|
||||
|
||||
total_cycles = sum(int(row[1]) for row in cycle_rows)
|
||||
total_produced_qty = sum(int(row[2]) for row in cycle_rows)
|
||||
|
||||
performance_time_sec = 0.0
|
||||
weighted_theoretical = 0.0
|
||||
counted_cycles = 0
|
||||
for production_order_id, cycle_count, _ in cycle_rows:
|
||||
if production_order_id is None:
|
||||
continue
|
||||
order = session.get(ProductionOrder, production_order_id)
|
||||
if order is None:
|
||||
continue
|
||||
performance_time_sec += order.theoretical_cycle_time_sec * int(cycle_count)
|
||||
weighted_theoretical += order.theoretical_cycle_time_sec * int(cycle_count)
|
||||
counted_cycles += int(cycle_count)
|
||||
|
||||
theoretical_cycle_time_sec = weighted_theoretical / counted_cycles if counted_cycles > 0 else 0
|
||||
|
||||
downtime_rows = session.execute(
|
||||
select(Downtime).where(
|
||||
Downtime.machine_id == machine_id,
|
||||
Downtime.start_time < effective_window_end,
|
||||
func.coalesce(Downtime.end_time, effective_window_end) > window_start,
|
||||
)
|
||||
).scalars()
|
||||
downtime_sec = sum(
|
||||
overlap_seconds(window_start, effective_window_end, row.start_time, min(row.end_time, effective_window_end) if row.end_time else effective_window_end)
|
||||
for row in downtime_rows
|
||||
)
|
||||
|
||||
scrap_qty = session.execute(
|
||||
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(
|
||||
Scrap.machine_id == machine_id,
|
||||
Scrap.timestamp >= window_start,
|
||||
Scrap.timestamp <= effective_window_end,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if planned_time_sec == 0 and (total_cycles > 0 or downtime_sec > 0):
|
||||
planned_time_sec = (effective_window_end - window_start).total_seconds()
|
||||
|
||||
totals = calculate_oee_metrics(
|
||||
planned_time_sec=planned_time_sec,
|
||||
downtime_sec=downtime_sec,
|
||||
total_cycles=total_cycles,
|
||||
performance_time_sec=performance_time_sec,
|
||||
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
|
||||
total_produced_qty=total_produced_qty,
|
||||
scrap_qty=int(scrap_qty),
|
||||
)
|
||||
return {
|
||||
"machine_id": machine_id,
|
||||
"from": window_start.isoformat(),
|
||||
"to": effective_window_end.isoformat(),
|
||||
**totals.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def compute_daily_oee(session: Session, target_date: date, machine_ids: list[int]) -> dict:
|
||||
window_start = datetime.combine(target_date, time.min, tzinfo=UTC)
|
||||
window_end = window_start + timedelta(days=1)
|
||||
machine_metrics = [compute_machine_oee(session, machine_id, window_start, window_end) for machine_id in machine_ids]
|
||||
|
||||
overall = calculate_oee_metrics(
|
||||
planned_time_sec=sum(item["planned_time_sec"] for item in machine_metrics),
|
||||
downtime_sec=sum(item["downtime_sec"] for item in machine_metrics),
|
||||
total_cycles=sum(item["total_cycles"] for item in machine_metrics),
|
||||
performance_time_sec=sum(item["theoretical_cycle_time_sec"] * item["total_cycles"] for item in machine_metrics),
|
||||
theoretical_cycle_time_sec=0,
|
||||
total_produced_qty=sum(item["total_produced_qty"] for item in machine_metrics),
|
||||
scrap_qty=sum(item["scrap_qty"] for item in machine_metrics),
|
||||
)
|
||||
|
||||
return {
|
||||
"date": target_date.isoformat(),
|
||||
"machines": machine_metrics,
|
||||
"overall": overall.as_dict(),
|
||||
}
|
||||
40
backend/app/services/realtime.py
Normal file
40
backend/app/services/realtime.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class RealtimeHub:
|
||||
def __init__(self) -> None:
|
||||
self._connections: set[WebSocket] = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
self._connections.add(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
self._connections.discard(websocket)
|
||||
|
||||
async def broadcast(self, payload: dict) -> None:
|
||||
stale: list[WebSocket] = []
|
||||
for websocket in self._connections:
|
||||
try:
|
||||
await websocket.send_json(payload)
|
||||
except Exception:
|
||||
stale.append(websocket)
|
||||
for websocket in stale:
|
||||
self._connections.discard(websocket)
|
||||
|
||||
def notify(self, payload: dict) -> None:
|
||||
if self._loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(self.broadcast(payload), self._loop)
|
||||
|
||||
|
||||
hub = RealtimeHub()
|
||||
2
backend/pytest.ini
Normal file
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
sqlalchemy==2.0.41
|
||||
psycopg[binary]==3.2.9
|
||||
pydantic-settings==2.9.1
|
||||
paho-mqtt==2.1.0
|
||||
python-dateutil==2.9.0.post0
|
||||
pytest==8.3.5
|
||||
159
backend/tests/test_events.py
Normal file
159
backend/tests/test_events.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.database import Base
|
||||
from app.main import start_production_order
|
||||
from app.models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
|
||||
from app.schemas import MachineEventIn
|
||||
from app.services.events import _is_matching_edge, detect_missing_cycles, handle_machine_event, normalize_signal_value, parse_machine_event
|
||||
|
||||
|
||||
def test_parse_machine_event() -> None:
|
||||
payload = """
|
||||
{
|
||||
"machine_id": "INJ-01",
|
||||
"event_type": "digital_input_changed",
|
||||
"timestamp": "2026-06-14T10:32:15Z",
|
||||
"input_name": "cycle_signal",
|
||||
"input_value": 1
|
||||
}
|
||||
"""
|
||||
|
||||
event = parse_machine_event(payload)
|
||||
|
||||
assert event.machine_id == "INJ-01"
|
||||
assert event.event_type == "digital_input_changed"
|
||||
assert event.input_name == "cycle_signal"
|
||||
assert event.input_value == 1
|
||||
|
||||
|
||||
def test_normalize_signal_value() -> None:
|
||||
assert normalize_signal_value(True) is True
|
||||
assert normalize_signal_value(0) is False
|
||||
assert normalize_signal_value("high") is True
|
||||
assert normalize_signal_value("OFF") is False
|
||||
assert normalize_signal_value("unknown") is None
|
||||
|
||||
|
||||
def test_edge_matching() -> None:
|
||||
assert _is_matching_edge(False, True, "rising") is True
|
||||
assert _is_matching_edge(True, False, "falling") is True
|
||||
assert _is_matching_edge(False, False, "rising") is False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSessionLocal = sessionmaker(bind=engine)
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
def seed_running_machine(session: Session) -> Machine:
|
||||
machine = Machine(
|
||||
code="INJ-T1",
|
||||
name="INJ-T1",
|
||||
brand="Test",
|
||||
model="Press",
|
||||
powered_on=True,
|
||||
cycle_signal_edge="rising",
|
||||
debounce_ms=300,
|
||||
min_cycle_time_sec=5,
|
||||
max_cycle_time_sec=300,
|
||||
stop_detection_delay_sec=180,
|
||||
)
|
||||
session.add(machine)
|
||||
session.flush()
|
||||
session.add(
|
||||
ProductionOrder(
|
||||
order_number="OF-T1",
|
||||
machine_id=machine.id,
|
||||
article_ref="ART-T1",
|
||||
article_name="Test Part",
|
||||
planned_qty=100,
|
||||
cavities=1,
|
||||
theoretical_cycle_time_sec=10,
|
||||
status="running",
|
||||
started_at=datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return machine
|
||||
|
||||
|
||||
def test_cycle_signal_debounce_suppresses_noisy_edge(db_session: Session) -> None:
|
||||
machine = seed_running_machine(db_session)
|
||||
first_edge = datetime(2026, 6, 14, 8, 0, 0, tzinfo=timezone.utc)
|
||||
noisy_edge = first_edge + timedelta(milliseconds=100)
|
||||
db_session.add(
|
||||
MachineEvent(
|
||||
machine_id=machine.id,
|
||||
event_type="digital_input_changed",
|
||||
timestamp=first_edge,
|
||||
payload={"input_name": "cycle_signal", "input_value": False},
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
result = handle_machine_event(
|
||||
db_session,
|
||||
MachineEventIn(
|
||||
machine_id=machine.code,
|
||||
event_type="digital_input_changed",
|
||||
timestamp=noisy_edge,
|
||||
input_name="cycle_signal",
|
||||
input_value=True,
|
||||
),
|
||||
)
|
||||
|
||||
cycle_count = db_session.execute(select(Cycle)).scalars().all()
|
||||
db_session.refresh(machine)
|
||||
assert cycle_count == []
|
||||
assert machine.last_cycle_at is None
|
||||
assert result["should_refresh"] is False
|
||||
|
||||
|
||||
def test_detect_missing_cycles_opens_unqualified_downtime(db_session: Session) -> None:
|
||||
machine = seed_running_machine(db_session)
|
||||
machine.last_cycle_at = datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc)
|
||||
db_session.commit()
|
||||
|
||||
changes = detect_missing_cycles(db_session, now=datetime(2026, 6, 14, 8, 4, tzinfo=timezone.utc))
|
||||
|
||||
downtime = db_session.execute(select(Downtime).where(Downtime.machine_id == machine.id)).scalar_one()
|
||||
db_session.refresh(machine)
|
||||
assert changes == [{"machine_code": machine.code, "event_type": "machine_stopped", "status": "arret_non_qualifie"}]
|
||||
assert downtime.reason_code is None
|
||||
assert downtime.start_time.replace(tzinfo=timezone.utc) == datetime(2026, 6, 14, 8, 3, tzinfo=timezone.utc)
|
||||
assert machine.status == "arret_non_qualifie"
|
||||
|
||||
|
||||
def test_start_order_rejects_second_running_order_on_machine(db_session: Session) -> None:
|
||||
machine = seed_running_machine(db_session)
|
||||
planned_order = ProductionOrder(
|
||||
order_number="OF-T2",
|
||||
machine_id=machine.id,
|
||||
article_ref="ART-T2",
|
||||
article_name="Second Part",
|
||||
planned_qty=50,
|
||||
cavities=1,
|
||||
theoretical_cycle_time_sec=12,
|
||||
status="planned",
|
||||
)
|
||||
db_session.add(planned_order)
|
||||
db_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
start_production_order(planned_order.id, db_session)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Another order is already running on this machine"
|
||||
38
backend/tests/test_oee.py
Normal file
38
backend/tests/test_oee.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.services.oee import calculate_oee_metrics
|
||||
|
||||
|
||||
def test_calculate_oee_metrics() -> None:
|
||||
metrics = calculate_oee_metrics(
|
||||
planned_time_sec=3600,
|
||||
downtime_sec=600,
|
||||
total_cycles=100,
|
||||
performance_time_sec=2500,
|
||||
theoretical_cycle_time_sec=25,
|
||||
total_produced_qty=200,
|
||||
scrap_qty=10,
|
||||
)
|
||||
|
||||
assert round(metrics.availability, 4) == 0.8333
|
||||
assert round(metrics.performance, 4) == 0.8333
|
||||
assert round(metrics.quality, 4) == 0.95
|
||||
assert round(metrics.oee, 4) == 0.6597
|
||||
|
||||
|
||||
def test_future_window_uses_current_time_bound(monkeypatch) -> None:
|
||||
from app.services import oee
|
||||
|
||||
fixed_now = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
class FixedDateTime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fixed_now if tz else fixed_now.replace(tzinfo=None)
|
||||
|
||||
monkeypatch.setattr(oee, "datetime", FixedDateTime)
|
||||
|
||||
start = datetime(2026, 6, 14, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 15, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
assert oee.overlap_seconds(start, fixed_now, start, end) == timedelta(hours=12).total_seconds()
|
||||
Reference in New Issue
Block a user