Files
bryan.hoffmann 455f513f6e
Build und Push Docker Image / build-and-push (push) Successful in 11s
Auftragsübersicht anpassen
2026-08-27 11:52:24 +02:00

1157 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
WDM-Performance Produktionserfassung für ca. 40 Maschinen.
Reines Server-Side-Rendering (Flask + Jinja2, normale HTML-Formulare,
Post-Redirect-Get). Kein Client-seitiges JavaScript.
URL-Schema (statt der ursprünglich vorgesehenen Hash-Fragmente, die
technisch nur mit JavaScript funktionieren, als echte Server-Pfade):
/m<N> -> Maschinenseite (kurze Form, z.B. auf Tablets als
Startseite hinterlegt)
/m<N>_m_view_main -> dieselbe Maschinenseite, alte/lange URL-Form -
bleibt aus Kompatibilitätsgründen für bereits
eingerichtete Geräte/Lesezeichen weiter erreichbar
/start -> Produktionsleiter
"""
import os
import csv
import io
import time
import logging
import functools
import openpyxl
from flask import Flask, request, redirect, url_for, render_template, abort, session
import db
log = logging.getLogger("wdm-performance")
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "bitte-in-der-.env-aendern")
# Upload-Größe für den Auftragsimport begrenzen (5 MB reichen für Excel-Listen locker aus).
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
MASCHINEN_PASSWORT = os.environ.get("MASCHINEN_PASSWORT", "admin")
try:
db.init_pool()
db.migrate()
except Exception:
log.error("Initialer DB-Verbindungsaufbau oder Migration fehlgeschlagen, wird bei erstem Request erneut versucht.")
def require_maschinen_auth(view):
"""Schützt die Maschinenverwaltung mit einem einfachen Passwort (Session-basiert)."""
@functools.wraps(view)
def wrapper(*args, **kwargs):
if not session.get("maschinen_auth"):
return redirect(url_for("maschinenverwaltung_login", next=request.path))
return view(*args, **kwargs)
return wrapper
STATUS_LABEL = {
"offen": "Offen",
"aktiv": "Aktiv",
"pause": "Pausiert",
"abgeschlossen": "Abgeschlossen",
}
STATUS_OPTIONEN = [("offen", "Offen"), ("aktiv", "Aktiv"), ("pause", "Pausiert"), ("abgeschlossen", "Abgeschlossen")]
# In der Auftragsübersicht darf der Status manuell gesetzt werden (z.B. um einen
# Auftrag versehentlich abgeschlossenen wieder zu öffnen) - "aktiv" ist hier
# aber bewusst ausgeschlossen, das passiert ausschließlich über die
# Start-Aktion an der Maschine selbst.
STATUS_OPTIONEN_BEARBEITBAR = [(wert, label) for wert, label in STATUS_OPTIONEN if wert != "aktiv"]
# Anzahl Zeilen, auf die MDE-/Auftragslisten ausgelegt sind (kein hartes Limit,
# aber Referenzgröße für Layout/Performance-Überlegungen).
LISTEN_ERWARTETE_GROESSE = 100
MDE_LISTEN_LIMIT = 500
def find_abweichung_id_by_beschreibung(cur, text):
cur.execute('SELECT id FROM "abweichung" WHERE beschreibung = %s LIMIT 1', (text,))
row = cur.fetchone()
return str(row["id"]) if row else ""
def now_ts():
return int(time.time())
def fmt_num(v):
if v is None:
return ""
try:
f = float(v)
except (TypeError, ValueError):
return str(v)
return str(int(f)) if f == int(f) else f"{f:.2f}"
def fmt_ts(ts):
if not ts:
return "-"
try:
return time.strftime("%d.%m.%Y %H:%M", time.localtime(int(ts)))
except (TypeError, ValueError):
return "-"
# ---------------------------------------------------------------------
# DB-Hilfsfunktionen
# ---------------------------------------------------------------------
def get_maschine(cur, maschine_id):
cur.execute('SELECT * FROM "Maschinen" WHERE id = %s', (maschine_id,))
return cur.fetchone()
def get_aktiver_auftrag(cur, maschine_id):
cur.execute(
'SELECT * FROM "Auftraege" WHERE maschine_id = %s AND aktiv = true '
'ORDER BY id DESC LIMIT 1',
(maschine_id,),
)
return cur.fetchone()
def get_letzte_produktion(cur, maschine_id, auftragsnummer):
cur.execute(
'SELECT produziert FROM "MDE" WHERE maschine_id = %s AND "order" = %s '
'ORDER BY timestamp DESC, id DESC LIMIT 1',
(maschine_id, auftragsnummer),
)
row = cur.fetchone()
return row["produziert"] if row and row["produziert"] is not None else 0
def get_start_timestamp(cur, maschine_id, auftragsnummer):
cur.execute(
'SELECT timestamp FROM "MDE" WHERE maschine_id = %s AND "order" = %s '
'AND status = \'start\' ORDER BY timestamp ASC LIMIT 1',
(maschine_id, auftragsnummer),
)
row = cur.fetchone()
return row["timestamp"] if row else None
def berechne_h_avg(cur, maschine_id, auftragsnummer, produziert):
start_ts = get_start_timestamp(cur, maschine_id, auftragsnummer)
if not start_ts:
return 0
stunden = max((now_ts() - start_ts) / 3600.0, 1 / 60.0)
try:
return round(float(produziert) / stunden, 2)
except (TypeError, ZeroDivisionError):
return 0
def insert_mde(cur, maschine_id, auftragsnummer, produziert, status, p_grund=None):
h_avg = berechne_h_avg(cur, maschine_id, auftragsnummer, produziert)
cur.execute(
'INSERT INTO "MDE" (maschine_id, "timestamp", "order", produziert, status, h_date, h_avg, p_grund) '
"VALUES (%s, %s, %s, %s, %s, now(), %s, %s)",
(maschine_id, now_ts(), auftragsnummer, produziert, status, h_avg, p_grund),
)
def insert_mde_ohne_rate(cur, maschine_id, auftragsnummer, produziert, status, p_grund=None):
"""Wie insert_mde(), aber ohne Stück/Std-Berechnung - für nachträglich per
Import erzeugte Buchungen, für die es keinen echten Zeitverlauf gibt
(würde sonst einen irreführenden Momentan-Ausreißer bei h_avg erzeugen)."""
cur.execute(
'INSERT INTO "MDE" (maschine_id, "timestamp", "order", produziert, status, h_date, h_avg, p_grund) '
"VALUES (%s, %s, %s, %s, %s, now(), 0, %s)",
(maschine_id, now_ts(), auftragsnummer, produziert, status, p_grund),
)
# ---------------------------------------------------------------------
# Auftragsimport (Excel)
# ---------------------------------------------------------------------
def _normalisiere_header(text):
return (str(text) if text is not None else "").strip().lower()
def _spalten_zuordnen(header_zeile):
"""Ordnet die Spalten der hochgeladenen Excel-Datei anhand von
Stichworten in der Kopfzeile zu (statt exakter Namen), damit z.B.
Zusatzhinweise wie "Artikel Übernehmen + Anzeigen" trotzdem erkannt
werden. Bezeichnung/Auftrag/Lieferdatum/Bemerkung werden bewusst nicht
zugeordnet (=ignoriert)."""
zuordnung = {}
for idx, wert in enumerate(header_zeile):
h = _normalisiere_header(wert)
if not h:
continue
if "maschine" in h:
zuordnung.setdefault("maschine", idx)
elif "prio" in h:
zuordnung.setdefault("prio", idx)
elif "fa" in h and "nr" in h:
zuordnung.setdefault("auftragsnummer", idx)
elif "rest" in h and "menge" in h:
zuordnung.setdefault("rest_menge", idx)
elif "ist" in h and "menge" in h:
zuordnung.setdefault("ist_menge", idx)
elif "artikel" in h:
zuordnung.setdefault("artikel", idx)
elif "soll" in h and ("stunde" in h or "/h" in h or "-h" in h or h.endswith("h")):
zuordnung.setdefault("soll_h", idx)
return zuordnung
def _zellwert_als_text(wert):
if wert is None:
return ""
if isinstance(wert, float) and wert.is_integer():
return str(int(wert))
return str(wert).strip()
def _zellwert_als_float(wert, default=0.0):
if wert is None or wert == "":
return default
if isinstance(wert, (int, float)):
return float(wert)
text = str(wert).strip()
if not text:
return default
# Deutsche Zahlformate aus CSV-Dateien abfangen, z.B. "1.234,56" oder "15,1".
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
elif "," in text:
text = text.replace(",", ".")
try:
return float(text)
except (TypeError, ValueError):
return default
def _lese_tabellen_zeilen(datei):
"""Liest eine hochgeladene .xlsx- oder .csv-Datei ein und liefert eine
Liste von Zeilen (jeweils eine Liste von Zellwerten). Bei CSV werden
Zeichenkodierung (utf-8/BOM, sonst cp1252) und Trennzeichen (Komma,
Semikolon - wie bei deutschem Excel-Export üblich - oder Tab) automatisch
erkannt."""
name = (datei.filename or "").lower()
if name.endswith(".xlsx"):
wb = openpyxl.load_workbook(datei.stream, data_only=True, read_only=True)
ws = wb.worksheets[0]
return [list(zeile) for zeile in ws.iter_rows(values_only=True)]
if name.endswith(".csv"):
rohdaten = datei.stream.read()
text = None
for encoding in ("utf-8-sig", "cp1252", "latin-1"):
try:
text = rohdaten.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
text = rohdaten.decode("utf-8", errors="replace")
zeilen_text = text.splitlines()
erste_zeile = zeilen_text[0] if zeilen_text else ""
try:
dialekt = csv.Sniffer().sniff(erste_zeile, delimiters=";,\t")
reader = csv.reader(io.StringIO(text), dialect=dialekt)
except csv.Error:
delimiter = ";" if erste_zeile.count(";") >= erste_zeile.count(",") else ","
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
return [zeile for zeile in reader]
raise ValueError("Nicht unterstütztes Dateiformat.")
def validiere_menge(wert, bisherige_menge, soll):
if wert is None or wert == "":
return "Wert fehlt."
try:
wert = float(wert)
except (TypeError, ValueError):
return "Wert muss numerisch sein."
if wert < 0:
return "Wert darf nicht negativ sein."
if wert < float(bisherige_menge or 0):
return f"Wert darf nicht unter der bisher gemeldeten Menge ({fmt_num(bisherige_menge)}) liegen."
if soll and wert > float(soll) * 1.10:
return f"Wert darf maximal 10% über dem Sollwert ({fmt_num(soll)}) liegen."
return None
# ---------------------------------------------------------------------
# Maschinenseite
# ---------------------------------------------------------------------
@app.route("/m<int:maschine_id>")
@app.route("/m<int:maschine_id>_m_view_main", endpoint="machine_main_legacy")
def machine_main(maschine_id):
with db.get_cursor() as cur:
maschine = get_maschine(cur, maschine_id)
if not maschine:
abort(404)
auftrag = get_aktiver_auftrag(cur, maschine_id)
bisherige = 0
unter_soll = False
if auftrag:
bisherige = get_letzte_produktion(cur, maschine_id, auftrag["auftragsnummer"])
unter_soll = float(bisherige or 0) < float(auftrag["einheiten"] or 0)
soll = auftrag["einheiten"] if auftrag else 0
fortschritt = 0
if auftrag and soll:
fortschritt = min(100, round(float(bisherige) / float(soll) * 100))
return render_template(
"machine_main.html",
title=maschine["name"] or f"Maschine {maschine_id}",
subtitle=maschine["standort"] or "",
maschine=maschine,
auftrag=auftrag,
status_label=STATUS_LABEL.get(auftrag["status"], auftrag["status"]) if auftrag else "",
soll_fmt=fmt_num(soll),
rate_fmt=fmt_num(auftrag["soll_h"]) if auftrag else "",
bisherige_fmt=fmt_num(bisherige),
fortschritt=fortschritt,
unter_soll=unter_soll,
)
@app.route("/m<int:maschine_id>_auswahl")
def machine_auswahl(maschine_id):
with db.get_cursor() as cur:
maschine = get_maschine(cur, maschine_id)
if not maschine:
abort(404)
cur.execute(
'SELECT * FROM "Auftraege" WHERE maschine_id = %s AND status IN (\'offen\', \'pause\') '
'ORDER BY sid ASC NULLS LAST, id ASC',
(maschine_id,),
)
auftraege = cur.fetchall()
for a in auftraege:
a["einheiten_fmt"] = fmt_num(a["einheiten"])
a["status_label"] = STATUS_LABEL.get(a["status"], a["status"])
return render_template(
"machine_auswahl.html",
title="Auftragsauswahl",
subtitle=maschine["name"] or "",
maschine=maschine,
auftraege=auftraege,
)
@app.route("/m<int:maschine_id>_auswahl/waehlen", methods=["POST"])
def machine_auswahl_waehlen(maschine_id):
auftrag_id = request.form.get("auftrag_id")
if not auftrag_id:
abort(400)
with db.get_cursor() as cur:
cur.execute('SELECT * FROM "Auftraege" WHERE id = %s AND maschine_id = %s', (auftrag_id, maschine_id))
auftrag = cur.fetchone()
if not auftrag or auftrag["status"] not in ("offen", "pause"):
return redirect(url_for("machine_auswahl", maschine_id=maschine_id))
bestehender = get_aktiver_auftrag(cur, maschine_id)
if bestehender:
return redirect(url_for("machine_main", maschine_id=maschine_id))
ts = now_ts()
cur.execute(
'UPDATE "Auftraege" SET aktiv = true, status = \'aktiv\', '
'start = COALESCE(start, %s) WHERE id = %s',
(ts, auftrag_id),
)
bisherige = get_letzte_produktion(cur, maschine_id, auftrag["auftragsnummer"])
insert_mde(cur, maschine_id, auftrag["auftragsnummer"], bisherige, "start")
return redirect(url_for("machine_main", maschine_id=maschine_id))
def _meldung_view(maschine_id, ziel_status, eingabe_einheiten=None, eingabe_abweichung_id=None, error=None):
with db.get_cursor() as cur:
maschine = get_maschine(cur, maschine_id)
if not maschine:
abort(404)
auftrag = get_aktiver_auftrag(cur, maschine_id)
if not auftrag:
return render_template(
"machine_main.html", title=maschine["name"] or f"Maschine {maschine_id}",
subtitle=maschine["standort"] or "", maschine=maschine, auftrag=None,
error="Kein aktiver Auftrag.",
)
bisherige = get_letzte_produktion(cur, maschine_id, auftrag["auftragsnummer"])
cur.execute('SELECT * FROM "abweichung" ORDER BY beschreibung ASC')
abweichungen = cur.fetchall()
titel = "Auftrag pausieren" if ziel_status == "pause" else "Produktion erfassen"
form_action = url_for("machine_pause" if ziel_status == "pause" else "machine_erfassen", maschine_id=maschine_id)
if eingabe_abweichung_id is None:
default_text = "Pause" if ziel_status == "pause" else "Mengenmeldung"
eingabe_abweichung_id = find_abweichung_id_by_beschreibung(cur, default_text)
return render_template(
"machine_meldung.html",
title=titel,
subtitle=maschine["name"] or "",
maschine=maschine,
bisherige_fmt=fmt_num(bisherige),
soll_fmt=fmt_num(auftrag["einheiten"]),
abweichungen=abweichungen,
eingabe_einheiten=eingabe_einheiten if eingabe_einheiten is not None else "",
eingabe_abweichung_id=eingabe_abweichung_id or "",
form_action=form_action,
error=error,
)
def _meldung_speichern(maschine_id, ziel_status):
einheiten = request.form.get("einheiten")
abweichung_id = request.form.get("abweichung_id")
with db.get_cursor() as cur:
auftrag = get_aktiver_auftrag(cur, maschine_id)
if not auftrag:
return redirect(url_for("machine_main", maschine_id=maschine_id))
if not abweichung_id:
return _meldung_view(maschine_id, ziel_status, einheiten, abweichung_id, "Abweichungsgrund muss ausgewählt werden.")
cur.execute('SELECT * FROM "abweichung" WHERE id = %s', (abweichung_id,))
abweichung = cur.fetchone()
if not abweichung:
return _meldung_view(maschine_id, ziel_status, einheiten, abweichung_id, "Ungültiger Abweichungsgrund.")
bisherige = get_letzte_produktion(cur, maschine_id, auftrag["auftragsnummer"])
fehler = validiere_menge(einheiten, bisherige, auftrag["einheiten"])
if fehler:
return _meldung_view(maschine_id, ziel_status, einheiten, abweichung_id, fehler)
einheiten_f = float(einheiten)
insert_mde(cur, maschine_id, auftrag["auftragsnummer"], einheiten_f, ziel_status, abweichung["beschreibung"])
if ziel_status == "pause":
cur.execute(
'UPDATE "Auftraege" SET status = \'pause\', aktiv = false WHERE id = %s',
(auftrag["id"],),
)
return redirect(url_for("machine_main", maschine_id=maschine_id))
@app.route("/m<int:maschine_id>_erfassen", methods=["GET", "POST"])
def machine_erfassen(maschine_id):
if request.method == "POST":
return _meldung_speichern(maschine_id, "produziert")
return _meldung_view(maschine_id, "produziert")
@app.route("/m<int:maschine_id>_pause", methods=["GET", "POST"])
def machine_pause(maschine_id):
if request.method == "POST":
return _meldung_speichern(maschine_id, "pause")
return _meldung_view(maschine_id, "pause")
@app.route("/m<int:maschine_id>_beenden_confirm")
def machine_beenden_confirm(maschine_id):
with db.get_cursor() as cur:
maschine = get_maschine(cur, maschine_id)
if not maschine:
abort(404)
auftrag = get_aktiver_auftrag(cur, maschine_id)
if not auftrag:
return redirect(url_for("machine_main", maschine_id=maschine_id))
bisherige = get_letzte_produktion(cur, maschine_id, auftrag["auftragsnummer"])
return render_template(
"machine_beenden_confirm.html",
title="Auftrag beenden",
subtitle=maschine["name"] or "",
maschine=maschine,
soll_fmt=fmt_num(auftrag["einheiten"]),
bisherige_fmt=fmt_num(bisherige),
)
@app.route("/m<int:maschine_id>_beenden", methods=["POST"])
def machine_beenden(maschine_id):
with db.get_cursor() as cur:
auftrag = get_aktiver_auftrag(cur, maschine_id)
if not auftrag:
return redirect(url_for("machine_main", maschine_id=maschine_id))
soll = auftrag["einheiten"] or 0
insert_mde(cur, maschine_id, auftrag["auftragsnummer"], soll, "beendet")
cur.execute(
'UPDATE "Auftraege" SET status = \'abgeschlossen\', aktiv = false, ende = %s WHERE id = %s',
(now_ts(), auftrag["id"]),
)
return redirect(url_for("machine_main", maschine_id=maschine_id))
# ---------------------------------------------------------------------
# Produktionsleiter
# ---------------------------------------------------------------------
@app.route("/start")
def leitstand_home():
return render_template("leitstand_home.html", title="Produktionsleiter", subtitle="Übersicht & Steuerung")
@app.route("/start_maschinen")
def leitstand_maschinen():
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
for m in maschinen:
aktiver_auftrag = get_aktiver_auftrag(cur, m["id"])
m["hat_aktiven_auftrag"] = aktiver_auftrag is not None
m["aktiver_artikel"] = aktiver_auftrag["artikel"] if aktiver_auftrag else None
return render_template(
"leitstand_maschinen.html", title="Maschinenübersicht", subtitle="Direkt zu einer Maschinenseite springen",
maschinen=maschinen,
)
@app.route("/start_maschinen_verwaltung/login", methods=["GET", "POST"])
def maschinenverwaltung_login():
next_url = request.args.get("next") or request.form.get("next") or url_for("maschinenverwaltung")
if request.method == "POST":
passwort = request.form.get("passwort") or ""
if passwort == MASCHINEN_PASSWORT:
session["maschinen_auth"] = True
return redirect(next_url)
return render_template("maschinenverwaltung_login.html", title="Maschinenverwaltung", next=next_url, error="Falsches Passwort.")
return render_template("maschinenverwaltung_login.html", title="Maschinenverwaltung", next=next_url)
@app.route("/start_maschinen_verwaltung/logout", methods=["POST"])
def maschinenverwaltung_logout():
session.pop("maschinen_auth", None)
return redirect(url_for("leitstand_home"))
@app.route("/start_maschinen_verwaltung")
@require_maschinen_auth
def maschinenverwaltung():
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
return render_template(
"maschinenverwaltung.html", title="Maschinenverwaltung", subtitle="Maschinen anlegen, bearbeiten, löschen",
maschinen=maschinen,
)
@app.route("/start_maschinen_verwaltung/neu", methods=["GET", "POST"])
@require_maschinen_auth
def maschinenverwaltung_neu():
if request.method == "POST":
name = (request.form.get("name") or "").strip()
standort = (request.form.get("standort") or "").strip()
if not name:
return render_template(
"maschinenverwaltung_bearbeiten.html", title="Maschine anlegen",
maschine={"id": None, "name": name, "standort": standort}, neu=True,
error="Name darf nicht leer sein.",
)
with db.get_cursor() as cur:
cur.execute('INSERT INTO "Maschinen" (name, standort) VALUES (%s, %s)', (name, standort or None))
return redirect(url_for("maschinenverwaltung"))
return render_template("maschinenverwaltung_bearbeiten.html", title="Maschine anlegen", maschine={"id": None, "name": "", "standort": ""}, neu=True)
@app.route("/start_maschinen_verwaltung/<int:maschine_id>/bearbeiten", methods=["GET", "POST"])
@require_maschinen_auth
def maschinenverwaltung_bearbeiten(maschine_id):
with db.get_cursor() as cur:
if request.method == "POST":
name = (request.form.get("name") or "").strip()
standort = (request.form.get("standort") or "").strip()
if not name:
cur.execute('SELECT * FROM "Maschinen" WHERE id = %s', (maschine_id,))
maschine = cur.fetchone()
return render_template(
"maschinenverwaltung_bearbeiten.html", title="Maschine bearbeiten",
maschine=maschine, neu=False, error="Name darf nicht leer sein.",
)
cur.execute('UPDATE "Maschinen" SET name = %s, standort = %s WHERE id = %s', (name, standort or None, maschine_id))
return redirect(url_for("maschinenverwaltung"))
cur.execute('SELECT * FROM "Maschinen" WHERE id = %s', (maschine_id,))
maschine = cur.fetchone()
if not maschine:
abort(404)
return render_template("maschinenverwaltung_bearbeiten.html", title="Maschine bearbeiten", maschine=maschine, neu=False)
@app.route("/start_maschinen_verwaltung/<int:maschine_id>/loeschen", methods=["POST"])
@require_maschinen_auth
def maschinenverwaltung_loeschen(maschine_id):
with db.get_cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM "Auftraege" WHERE maschine_id = %s', (maschine_id,))
anzahl_auftraege = cur.fetchone()["n"]
if anzahl_auftraege:
maschinen = get_maschinen_liste(cur)
return render_template(
"maschinenverwaltung.html", title="Maschinenverwaltung", subtitle="Maschinen anlegen, bearbeiten, löschen",
maschinen=maschinen,
error=f"Maschine kann nicht gelöscht werden: es existieren noch {int(anzahl_auftraege)} zugehörige Aufträge.",
)
cur.execute('DELETE FROM "Maschinen" WHERE id = %s', (maschine_id,))
return redirect(url_for("maschinenverwaltung"))
def get_maschinen_liste(cur):
cur.execute('SELECT * FROM "Maschinen" ORDER BY id ASC')
return cur.fetchall()
@app.route("/start_anlegen", methods=["GET", "POST"])
def leitstand_anlegen():
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
if request.method == "GET":
return render_template("leitstand_anlegen.html", title="Auftrag anlegen", maschinen=maschinen, eingabe={})
eingabe = {
"maschine_id": request.form.get("maschine_id"),
"auftragsnummer": (request.form.get("auftragsnummer") or "").strip(),
"einheiten": request.form.get("einheiten"),
"soll_h": request.form.get("soll_h"),
"artikel": (request.form.get("artikel") or "").strip(),
"prioritaet": (request.form.get("prioritaet") or "").strip(),
}
maschine_id = eingabe["maschine_id"]
auftragsnummer = eingabe["auftragsnummer"]
error = None
einheiten_f = soll_h_f = None
position = None
if not maschine_id:
error = "Maschine muss ausgewählt werden."
elif not auftragsnummer:
error = "Auftragsnummer darf nicht leer sein."
else:
try:
einheiten_f = float(eingabe["einheiten"])
soll_h_f = float(eingabe["soll_h"])
except (TypeError, ValueError):
error = "Produktionsmenge und Stück/Stunde müssen numerisch sein."
else:
if einheiten_f <= 0 or soll_h_f <= 0:
error = "Produktionsmenge und Stück/Stunde müssen positiv sein."
if not error and eingabe["prioritaet"]:
try:
position = int(eingabe["prioritaet"])
except ValueError:
error = "Priorität muss eine ganze Zahl sein."
else:
if position < 1:
error = "Priorität muss mindestens 1 sein."
if error:
return render_template("leitstand_anlegen.html", title="Auftrag anlegen", maschinen=maschinen, eingabe=eingabe, error=error)
maschine = get_maschine(cur, maschine_id)
if not maschine:
return render_template("leitstand_anlegen.html", title="Auftrag anlegen", maschinen=maschinen, eingabe=eingabe, error="Maschine nicht gefunden.")
# Aktuelle Warteschlange (offen/pause) dieser Maschine ermitteln, um die
# gewünschte Priorität sauber einzureihen statt nur einen rohen sid-Wert
# zu setzen - bestehende Aufträge rücken dabei automatisch nach.
cur.execute(
'SELECT id FROM "Auftraege" WHERE maschine_id = %s AND status IN (\'offen\', \'pause\') '
'ORDER BY sid ASC NULLS LAST, id ASC',
(maschine_id,),
)
warteschlange = [row["id"] for row in cur.fetchall()]
anzahl = len(warteschlange)
position = min(position, anzahl + 1) if position else anzahl + 1
cur.execute(
'INSERT INTO "Auftraege" (auftragsnummer, einheiten, soll_h, status, aktiv, created_at, sid, maschine_id, artikel) '
"VALUES (%s, %s, %s, 'offen', false, %s, %s, %s, %s) RETURNING id",
(auftragsnummer, einheiten_f, soll_h_f, now_ts(), position, maschine_id, eingabe["artikel"] or None),
)
neuer_id = cur.fetchone()["id"]
warteschlange.insert(position - 1, neuer_id)
for idx, a_id in enumerate(warteschlange, start=1):
cur.execute('UPDATE "Auftraege" SET sid = %s WHERE id = %s', (idx, a_id))
# Maschinenauswahl bewusst erhalten (nicht zurücksetzen) - beim Anlegen
# mehrerer Aufträge nacheinander für dieselbe Maschine soll das
# Dropdown nicht bei jedem Speichern auf die erste Maschine der Liste
# zurückspringen.
return render_template(
"leitstand_anlegen.html", title="Auftrag anlegen", maschinen=maschinen,
eingabe={"maschine_id": maschine_id},
info=f"Auftrag {auftragsnummer} wurde an Position {position} angelegt.",
)
@app.route("/start_uebersicht")
def leitstand_uebersicht():
filter_maschine_id = request.args.get("maschine_id") or ""
filter_status = request.args.get("status", "offen_pause")
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
bedingungen = []
params = []
if filter_maschine_id:
bedingungen.append('maschine_id = %s')
params.append(filter_maschine_id)
if filter_status == "offen_pause":
# "aktiv" wird hier bewusst mit angezeigt (nur informativ, nicht
# bearbeitbar/löschbar - siehe leitstand_uebersicht_bearbeiten
# bzw. leitstand_uebersicht_loeschen), damit laufende Aufträge in
# der Übersicht nicht verschwinden.
bedingungen.append("status IN ('offen', 'pause', 'aktiv')")
where_sql = (" WHERE " + " AND ".join(bedingungen)) if bedingungen else ""
cur.execute(
f'SELECT * FROM "Auftraege"{where_sql} ORDER BY maschine_id ASC, sid ASC NULLS LAST, id DESC '
f'LIMIT {MDE_LISTEN_LIMIT}',
params,
)
auftraege = cur.fetchall()
maschine_name_by_id = {m["id"]: (m["name"] or f"Maschine {m['id']}") for m in maschinen}
for a in auftraege:
a["einheiten_fmt"] = fmt_num(a["einheiten"])
a["soll_h_fmt"] = fmt_num(a["soll_h"])
a["sid_fmt"] = fmt_num(a["sid"])
a["status_label"] = STATUS_LABEL.get(a["status"], a["status"])
a["maschine_name"] = maschine_name_by_id.get(int(a["maschine_id"])) if a["maschine_id"] is not None else ""
return render_template(
"leitstand_uebersicht.html",
title="Auftragsübersicht",
content_class="wide",
maschinen=maschinen,
auftraege=auftraege,
filter_maschine_id=filter_maschine_id,
filter_status=filter_status,
)
@app.route("/start_uebersicht/<int:auftrag_id>/bearbeiten", methods=["GET", "POST"])
def leitstand_uebersicht_bearbeiten(auftrag_id):
"""Auftrag bearbeiten. Der Status ist hier manuell änderbar (z.B. um einen
versehentlich abgeschlossenen Auftrag wieder auf "offen" zu setzen) -
"aktiv" ist als Zielwert bewusst ausgeschlossen, das passiert
ausschließlich über die eigentlichen Maschinen-Aktionen (Start/Pause/
Beenden). Priorität (sid) ist weiterhin NICHT editierbar, dafür gibt es
die Drag&Drop-Seite "Auftragspriorität". Aktive Aufträge werden nur
angezeigt, eine Bearbeitung wird komplett verweigert (Fehlermeldung), da
sie gerade an der Maschine läuft."""
filter_maschine_id = request.args.get("maschine_id") or request.form.get("maschine_id_filter") or ""
filter_status = request.args.get("status") or request.form.get("status_filter") or "offen_pause"
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
cur.execute('SELECT * FROM "Auftraege" WHERE id = %s', (auftrag_id,))
alt = cur.fetchone()
if not alt:
abort(404)
if alt["status"] == "aktiv":
alt["einheiten_fmt"] = fmt_num(alt["einheiten"])
alt["soll_h_fmt"] = fmt_num(alt["soll_h"])
alt["sid_fmt"] = fmt_num(alt["sid"])
return render_template(
"leitstand_uebersicht_bearbeiten.html", title="Auftrag bearbeiten",
auftrag=alt, maschinen=maschinen, gesperrt=True,
status_anzeige=STATUS_LABEL.get(alt["status"], alt["status"]),
status_optionen=STATUS_OPTIONEN_BEARBEITBAR,
filter_maschine_id=filter_maschine_id, filter_status=filter_status,
error="Dieser Auftrag ist aktuell aktiv und kann deshalb nicht bearbeitet werden. "
"Bitte an der Maschine erst pausieren oder beenden.",
)
if request.method == "POST":
auftragsnummer = (request.form.get("auftragsnummer") or "").strip()
einheiten = request.form.get("einheiten")
soll_h = request.form.get("soll_h")
artikel = (request.form.get("artikel") or "").strip() or None
neue_maschine_id = request.form.get("maschine_id")
neuer_status = request.form.get("status")
error = None
einheiten_f = soll_h_f = None
try:
einheiten_f = float(einheiten)
soll_h_f = float(soll_h)
except (TypeError, ValueError):
error = "Menge und Stück/h müssen numerisch sein."
if not error and not auftragsnummer:
error = "Auftragsnummer darf nicht leer sein."
gueltige_status = {wert for wert, _ in STATUS_OPTIONEN_BEARBEITBAR}
if not error and neuer_status not in gueltige_status:
error = "Ungültiger Status."
if not error:
if not neue_maschine_id:
error = "Maschine muss ausgewählt werden."
elif not get_maschine(cur, neue_maschine_id):
error = "Maschine nicht gefunden."
bisherige = 0
if not error:
bisherige = get_letzte_produktion(cur, alt["maschine_id"], alt["auftragsnummer"]) or 0
if bisherige and einheiten_f * 1.10 < float(bisherige):
mindest = float(bisherige) / 1.10
error = (
f"An diesem Auftrag wurde bereits gearbeitet ({fmt_num(bisherige)} St. gemeldet). "
f"Die Produktionsmenge darf nicht unter {fmt_num(mindest)} gesetzt werden, "
f"da die bereits gemeldete Menge sonst außerhalb der 110%-Toleranz läge."
)
if error:
auftrag = dict(alt)
auftrag["einheiten_fmt"] = einheiten
auftrag["soll_h_fmt"] = soll_h
auftrag["sid_fmt"] = fmt_num(alt["sid"])
auftrag["artikel"] = artikel
auftrag["maschine_id"] = neue_maschine_id or alt["maschine_id"]
auftrag["status"] = neuer_status if neuer_status in gueltige_status else alt["status"]
return render_template(
"leitstand_uebersicht_bearbeiten.html", title="Auftrag bearbeiten",
auftrag=auftrag, maschinen=maschinen,
status_anzeige=STATUS_LABEL.get(alt["status"], alt["status"]),
status_optionen=STATUS_OPTIONEN_BEARBEITBAR,
filter_maschine_id=filter_maschine_id, filter_status=filter_status, error=error,
)
# Wurde der Status auf "abgeschlossen" geändert (oder war er das
# schon und bleibt es), Endzeitpunkt entsprechend setzen bzw.
# beibehalten - beim (Wieder-)Öffnen wird er zurückgesetzt.
if neuer_status == "abgeschlossen":
ende_wert = alt["ende"] if alt["status"] == "abgeschlossen" else now_ts()
else:
ende_wert = None
cur.execute(
'UPDATE "Auftraege" SET auftragsnummer = %s, einheiten = %s, soll_h = %s, '
'artikel = %s, maschine_id = %s, status = %s, aktiv = false, ende = %s WHERE id = %s',
(auftragsnummer, einheiten_f, soll_h_f, artikel, neue_maschine_id, neuer_status, ende_wert, auftrag_id),
)
# Maschine und/oder Auftragsnummer geändert? Dann muss die MDE-Historie
# (dort per maschine_id + Auftragsnummer verknüpft) mitgezogen werden.
if str(neue_maschine_id) != str(alt["maschine_id"]) or auftragsnummer != alt["auftragsnummer"]:
cur.execute(
'UPDATE "MDE" SET maschine_id = %s, "order" = %s WHERE maschine_id = %s AND "order" = %s',
(neue_maschine_id, auftragsnummer, alt["maschine_id"], alt["auftragsnummer"]),
)
return redirect(url_for("leitstand_uebersicht", maschine_id=filter_maschine_id, status=filter_status))
alt["einheiten_fmt"] = fmt_num(alt["einheiten"])
alt["soll_h_fmt"] = fmt_num(alt["soll_h"])
alt["sid_fmt"] = fmt_num(alt["sid"])
return render_template(
"leitstand_uebersicht_bearbeiten.html", title="Auftrag bearbeiten",
auftrag=alt, maschinen=maschinen,
status_anzeige=STATUS_LABEL.get(alt["status"], alt["status"]),
status_optionen=STATUS_OPTIONEN_BEARBEITBAR,
filter_maschine_id=filter_maschine_id, filter_status=filter_status,
)
@app.route("/start_uebersicht/<int:auftrag_id>/loeschen", methods=["POST"])
def leitstand_uebersicht_loeschen(auftrag_id):
filter_maschine_id = request.form.get("maschine_id") or ""
filter_status = request.form.get("status") or "offen_pause"
with db.get_cursor() as cur:
# Aktive Aufträge (gerade an der Maschine laufend) dürfen hier nicht
# gelöscht werden können, unabhängig davon, was im UI angeboten wird
# (analog zur Bearbeiten-Sperre für aktive Aufträge).
cur.execute('DELETE FROM "Auftraege" WHERE id = %s AND status <> \'aktiv\'', (auftrag_id,))
return redirect(url_for("leitstand_uebersicht", maschine_id=filter_maschine_id, status=filter_status))
@app.route("/start_korrektur")
def leitstand_korrektur():
filter_maschine_id = request.args.get("maschine_id") or ""
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
if filter_maschine_id:
cur.execute(f'SELECT * FROM "MDE" WHERE maschine_id = %s ORDER BY "timestamp" DESC, id DESC LIMIT {MDE_LISTEN_LIMIT}', (filter_maschine_id,))
else:
cur.execute(f'SELECT * FROM "MDE" ORDER BY "timestamp" DESC, id DESC LIMIT {MDE_LISTEN_LIMIT}')
eintraege = cur.fetchall()
for m in eintraege:
m["zeit_fmt"] = fmt_ts(m["timestamp"])
m["produziert_fmt"] = fmt_num(m["produziert"])
m["h_avg_fmt"] = fmt_num(m["h_avg"])
return render_template(
"leitstand_korrektur.html", title="Buchungskorrektur", subtitle="Direkter Eingriff in die MDE-Tabelle",
content_class="wide",
maschinen=maschinen, eintraege=eintraege, filter_maschine_id=filter_maschine_id,
)
@app.route("/start_korrektur/<int:mde_id>/bearbeiten", methods=["GET", "POST"])
def leitstand_korrektur_bearbeiten(mde_id):
filter_maschine_id = request.args.get("maschine_id") or request.form.get("maschine_id_filter") or ""
with db.get_cursor() as cur:
if request.method == "POST":
order = request.form.get("order")
produziert = request.form.get("produziert")
status = request.form.get("status")
h_avg = request.form.get("h_avg")
p_grund = request.form.get("p_grund") or None
try:
produziert_f = float(produziert) if produziert not in (None, "") else None
h_avg_f = float(h_avg) if h_avg not in (None, "") else None
except (TypeError, ValueError):
cur.execute('SELECT * FROM "MDE" WHERE id = %s', (mde_id,))
eintrag = cur.fetchone()
eintrag["produziert_fmt"] = produziert
eintrag["h_avg_fmt"] = h_avg
return render_template(
"leitstand_korrektur_bearbeiten.html", title="Buchung bearbeiten",
eintrag=eintrag, filter_maschine_id=filter_maschine_id,
error="Produziert und Ø/h müssen numerisch sein.",
)
cur.execute(
'UPDATE "MDE" SET "order" = %s, produziert = %s, status = %s, h_avg = %s, p_grund = %s WHERE id = %s',
(order, produziert_f, status, h_avg_f, p_grund, mde_id),
)
return redirect(url_for("leitstand_korrektur", maschine_id=filter_maschine_id))
cur.execute('SELECT * FROM "MDE" WHERE id = %s', (mde_id,))
eintrag = cur.fetchone()
if not eintrag:
abort(404)
eintrag["produziert_fmt"] = fmt_num(eintrag["produziert"])
eintrag["h_avg_fmt"] = fmt_num(eintrag["h_avg"])
return render_template(
"leitstand_korrektur_bearbeiten.html", title="Buchung bearbeiten",
eintrag=eintrag, filter_maschine_id=filter_maschine_id,
)
@app.route("/start_korrektur/<int:mde_id>/loeschen", methods=["POST"])
def leitstand_korrektur_loeschen(mde_id):
filter_maschine_id = request.form.get("maschine_id") or ""
with db.get_cursor() as cur:
cur.execute('DELETE FROM "MDE" WHERE id = %s', (mde_id,))
return redirect(url_for("leitstand_korrektur", maschine_id=filter_maschine_id))
@app.route("/start_prioritaet")
def leitstand_prioritaet():
filter_maschine_id = request.args.get("maschine_id") or ""
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
if not filter_maschine_id and maschinen:
filter_maschine_id = str(maschinen[0]["id"])
auftraege = []
if filter_maschine_id:
cur.execute(
'SELECT * FROM "Auftraege" WHERE maschine_id = %s AND status IN (\'offen\', \'pause\') '
'ORDER BY sid ASC NULLS LAST, id ASC',
(filter_maschine_id,),
)
auftraege = cur.fetchall()
for a in auftraege:
a["status_label"] = STATUS_LABEL.get(a["status"], a["status"])
return render_template(
"leitstand_prioritaet.html", title="Auftragspriorität", subtitle="Reihenfolge je Maschine (sid)",
maschinen=maschinen, auftraege=auftraege, filter_maschine_id=filter_maschine_id,
)
@app.route("/start_prioritaet/reihenfolge", methods=["POST"])
def leitstand_prioritaet_reihenfolge():
"""Speichert eine per Drag & Drop auf der Prioritätsseite neu
festgelegte Reihenfolge. Erwartet JSON: {"maschine_id": ..., "ids": [...]}
(Auftrags-IDs in der gewünschten neuen Reihenfolge)."""
data = request.get_json(silent=True) or {}
maschine_id = data.get("maschine_id")
ids = data.get("ids") or []
if not maschine_id or not ids:
return {"status": "error", "message": "Ungültige Anfrage."}, 400
with db.get_cursor() as cur:
cur.execute(
'SELECT id FROM "Auftraege" WHERE maschine_id = %s AND status IN (\'offen\', \'pause\')',
(maschine_id,),
)
gueltige_ids = {row["id"] for row in cur.fetchall()}
try:
angefragte_ids = [int(i) for i in ids]
except (TypeError, ValueError):
return {"status": "error", "message": "Ungültige Auftrags-IDs."}, 400
neue_reihenfolge = [i for i in angefragte_ids if i in gueltige_ids]
for position, auftrag_id in enumerate(neue_reihenfolge, start=1):
cur.execute('UPDATE "Auftraege" SET sid = %s WHERE id = %s', (position, auftrag_id))
return {"status": "ok"}
@app.route("/start_import", methods=["GET", "POST"])
def leitstand_import():
if request.method == "GET":
return render_template("leitstand_import.html", title="Aufträge importieren")
datei = request.files.get("datei")
if not datei or not datei.filename:
return render_template("leitstand_import.html", title="Aufträge importieren",
error="Bitte eine Excel- oder CSV-Datei auswählen.")
if not datei.filename.lower().endswith((".xlsx", ".csv")):
return render_template("leitstand_import.html", title="Aufträge importieren",
error="Nur .xlsx- oder .csv-Dateien werden unterstützt.")
try:
zeilen = _lese_tabellen_zeilen(datei)
except Exception as exc:
log.error("Import: Datei konnte nicht gelesen werden: %s", exc)
return render_template("leitstand_import.html", title="Aufträge importieren",
error="Datei konnte nicht gelesen werden. Ist es eine gültige .xlsx- oder .csv-Datei?")
if not zeilen:
return render_template("leitstand_import.html", title="Aufträge importieren",
error="Die Datei enthält keine Daten.")
spalten = _spalten_zuordnen(zeilen[0])
if "maschine" not in spalten or "auftragsnummer" not in spalten:
return render_template(
"leitstand_import.html", title="Aufträge importieren",
error='Spalten "Maschine" und "FA.-Nr." konnten in der Kopfzeile nicht gefunden werden.',
)
importiert = []
uebersprungen_vorhanden = []
verworfen_maschine = []
fehlerhafte_zeilen = []
with db.get_cursor() as cur:
maschinen = get_maschinen_liste(cur)
maschinen_by_name = {(m["name"] or "").strip().lower(): m for m in maschinen}
for zeilennummer, zeile in enumerate(zeilen[1:], start=2):
def wert(feld):
idx = spalten.get(feld)
return zeile[idx] if idx is not None and idx < len(zeile) else None
maschine_name = _zellwert_als_text(wert("maschine"))
auftragsnummer = _zellwert_als_text(wert("auftragsnummer"))
if not maschine_name and not auftragsnummer:
continue # leere Zeile überspringen
if not auftragsnummer:
fehlerhafte_zeilen.append(f"Zeile {zeilennummer}: FA.-Nr. fehlt.")
continue
maschine = maschinen_by_name.get(maschine_name.lower())
if not maschine:
verworfen_maschine.append(
f"Zeile {zeilennummer}: Maschine \"{maschine_name}\" nicht gefunden (FA.-Nr. {auftragsnummer})."
)
continue
try:
with db.get_cursor() as cur:
cur.execute('SELECT 1 FROM "Auftraege" WHERE auftragsnummer = %s LIMIT 1', (auftragsnummer,))
if cur.fetchone():
uebersprungen_vorhanden.append(f"Zeile {zeilennummer}: FA.-Nr. {auftragsnummer} existiert bereits.")
continue
rest = _zellwert_als_float(wert("rest_menge"), 0.0)
ist = _zellwert_als_float(wert("ist_menge"), 0.0)
soll_h = _zellwert_als_float(wert("soll_h"), 0.0)
artikel = _zellwert_als_text(wert("artikel")) or None
prio_wert = wert("prio")
einheiten = rest + ist
abgeschlossen = rest <= 0
sid_wert = None
if prio_wert not in (None, ""):
try:
sid_wert = float(prio_wert)
except (TypeError, ValueError):
sid_wert = None
if sid_wert is None:
cur.execute(
'SELECT COALESCE(MAX(sid), 0) + 1 AS next_sid FROM "Auftraege" WHERE maschine_id = %s',
(maschine["id"],),
)
sid_wert = cur.fetchone()["next_sid"]
status = "abgeschlossen" if abgeschlossen else "offen"
ts = now_ts()
cur.execute(
'INSERT INTO "Auftraege" '
'(auftragsnummer, einheiten, soll_h, status, aktiv, created_at, sid, maschine_id, artikel, start, ende) '
"VALUES (%s, %s, %s, %s, false, %s, %s, %s, %s, %s, %s)",
(
auftragsnummer, einheiten, soll_h, status, ts, sid_wert, maschine["id"], artikel,
ts if abgeschlossen else None, ts if abgeschlossen else None,
),
)
if ist > 0:
insert_mde_ohne_rate(cur, maschine["id"], auftragsnummer, ist, "start", "Import")
if abgeschlossen:
insert_mde_ohne_rate(cur, maschine["id"], auftragsnummer, einheiten, "beendet", "Import")
zusatz = " (abgeschlossen)" if abgeschlossen else ""
importiert.append(
f"Zeile {zeilennummer}: {auftragsnummer}{maschine['name']} "
f"({fmt_num(einheiten)} Stk.{zusatz})"
)
except Exception as exc:
log.error("Import: Zeile %d fehlgeschlagen: %s", zeilennummer, exc)
fehlerhafte_zeilen.append(f"Zeile {zeilennummer}: technischer Fehler beim Speichern, wurde übersprungen.")
return render_template(
"leitstand_import.html", title="Aufträge importieren",
ergebnis={
"importiert": importiert,
"uebersprungen_vorhanden": uebersprungen_vorhanden,
"verworfen_maschine": verworfen_maschine,
"fehlerhafte_zeilen": fehlerhafte_zeilen,
},
)
# ---------------------------------------------------------------------
# Startseite / Healthcheck
# ---------------------------------------------------------------------
@app.route("/")
def index():
return redirect(url_for("leitstand_home"))
@app.route("/api/health")
def health():
try:
with db.get_cursor() as cur:
cur.execute("SELECT 1")
return {"status": "ok"}
except Exception as exc:
log.error("Healthcheck fehlgeschlagen: %s", exc)
return {"status": "error"}, 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)