""" 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 -> Maschinenseite (kurze Form, z.B. auf Tablets als Startseite hinterlegt) /m_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")] # 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") @app.route("/m_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_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_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_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_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_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_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//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//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": bedingungen.append("status IN ('offen', 'pause')") 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//bearbeiten", methods=["GET", "POST"]) def leitstand_uebersicht_bearbeiten(auftrag_id): """Auftrag bearbeiten. Status und Priorität (sid) sind hier bewusst NICHT editierbar - Status ändert sich nur über die eigentlichen Maschinen- Aktionen (Start/Pause/Beenden), Priorität nur über 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"]), 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") 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." 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"] return render_template( "leitstand_uebersicht_bearbeiten.html", title="Auftrag bearbeiten", auftrag=auftrag, maschinen=maschinen, status_anzeige=STATUS_LABEL.get(alt["status"], alt["status"]), filter_maschine_id=filter_maschine_id, filter_status=filter_status, error=error, ) cur.execute( 'UPDATE "Auftraege" SET auftragsnummer = %s, einheiten = %s, soll_h = %s, ' 'artikel = %s, maschine_id = %s WHERE id = %s', (auftragsnummer, einheiten_f, soll_h_f, artikel, neue_maschine_id, 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"]), filter_maschine_id=filter_maschine_id, filter_status=filter_status, ) @app.route("/start_uebersicht//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: cur.execute('DELETE FROM "Auftraege" WHERE id = %s', (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//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//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)