commit 36180448e148082368a706075550a8172f2d86dc Author: Bryan Hoffmann Date: Thu Aug 20 12:24:38 2026 +0200 Initial commit diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..4c575a9 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,35 @@ +name: Build und Push Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Code auschecken + uses: actions/checkout@v4 + + - name: Bei der Registry anmelden + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ github.server_url }} -u ${{ github.actor }} --password-stdin + env: + # Bei Gitea/Forgejo zeigt github.server_url auf die eigene Instanz, + # z.B. https://git.meinefirma.de + DUMMY: "" + + - name: Image-Namen bestimmen + id: vars + run: | + REGISTRY_HOST=$(echo "${{ github.server_url }}" | sed -E 's#https?://##') + IMAGE="$REGISTRY_HOST/${{ github.repository }}" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + + - name: Image bauen + run: docker build -t ${{ steps.vars.outputs.image }}:latest -t ${{ steps.vars.outputs.image }}:${{ github.sha }} . + + - name: Image pushen + run: | + docker push ${{ steps.vars.outputs.image }}:latest + docker push ${{ steps.vars.outputs.image }}:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9204635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4beefe0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY build/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY build/app.py build/db.py ./ +COPY build/static ./static +COPY build/templates ./templates + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \ + CMD curl -fsS http://localhost:5000/api/health || exit 1 + +# gunicorn statt Flask-Devserver: mehrere Worker, robust für Dauerbetrieb +# mit ca. 40 gleichzeitig genutzten Maschinenseiten. +CMD ["gunicorn", "--workers", "4", "--threads", "2", "--timeout", "60", \ + "--bind", "0.0.0.0:5000", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4174d8b --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# WDM Performance – Produktionserfassung für ~40 Maschinen + +Flask-Backend mit **serverseitigem Rendering** (Jinja2-Templates, normale +HTML-Formulare, Post-Redirect-Get) – **kein Client-JavaScript**, genau wie +beim Essensportal-Projekt. Postgres-Datenbank. + +## Start + +```bash +cp .env.example .env +# .env anpassen (Passwort etc.) +docker compose up -d --build +``` + +Die Anwendung ist danach unter `http://:8090/` erreichbar (Port über +`APP_PORT` in `.env` änderbar). + +**Wichtig zur DB-Initialisierung:** `build/db/init.sql` (= dein hochgeladenes Schema, +ergänzt um ein paar Beispiel-Abweichungsgründe) wird **nur beim allerersten +Start** ausgeführt, wenn das Datenvolume noch leer ist. Hast du bereits eine +bestehende Postgres-Datenbank mit Daten, dann: + +- entweder das `db`-Volume vor dem ersten Start leer lassen und `init.sql` + anpassen/ersetzen, **oder** +- den `db`-Service in `docker-compose.yml` entfernen und stattdessen über + `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` in der `.env` + auf deine bestehende Datenbank zeigen. + +## Aufruf der einzelnen Seiten + +- Maschine N (1–40): `http://:8090/m` + z.B. `http://:8090/m1` +- Produktionsleiter: `http://:8090/start` + +**Alte/lange URL-Form:** `http://:8090/m_m_view_main` (z.B. +`.../m1_m_view_main`) funktioniert unverändert weiter und zeigt auf +dieselbe Seite. Sie bleibt bewusst zusätzlich zur kurzen Form erhalten, +damit bereits auf Tablets als Kiosk-Startseite/Lesezeichen hinterlegte +URLs nicht angepasst werden müssen. Für neu eingerichtete Geräte reicht +die kurze Form `/m`. + +**Hinweis zum ursprünglich gewünschten `#/...`-Format:** Ein Hash-Fragment +(`#/...`) wird nie an den Server geschickt und funktioniert nur mit +Client-JavaScript, das per Definition entfällt (kein JS). Deshalb sind die +Seiten stattdessen unter echten Server-Pfaden mit denselben Bezeichnern +erreichbar (ohne `#`). + +Die Maschinennummer `` entspricht der `id` in der Tabelle `Maschinen`. +Für Tablets an den Maschinen einfach jeweils die passende URL als +Startseite/Kiosk-Link einrichten. + +**Reverse Proxy:** Falls du die App hinter einem Unterpfad (z.B. +`/app/wdm-performance/`) betreiben willst, muss der Proxy diesen Präfix vor +Weiterleitung an den Container **entfernen** (strip prefix), da das +Backend mit wurzelrelativen Pfaden arbeitet. Beispiel nginx: + +```nginx +location /app/wdm-performance/ { + proxy_pass http://wdm-app:5000/; +} +``` + +## Funktionsumfang + +### Maschinenseite (`/m`, alte Form `/m_m_view_main` weiterhin gültig) +- Überschrift = Maschinenname aus der DB +- Kein aktiver Auftrag → **Start**-Button → Auftragsauswahl (offene/pausierte + Aufträge dieser Maschine, sortiert nach Priorität/`sid`) → Auswahl markiert + Auftrag als aktiv, schreibt `start`-Eintrag in `MDE` +- Aktiver Auftrag → Start-Button ausgeblendet, stattdessen: + - **Prod. erfassen**: Menge (numerisch, ≥0, ≥ bisherige Menge, ≤110% Soll) + + Pflicht-Dropdown Abweichungsgrund, Server validiert verbindlich vor + dem Schreiben in `MDE` + - **Auftrag pausieren**: gleiche Eingabemaske, setzt Auftrag auf + `status=pause`, `aktiv=false` + - **Auftrag beenden**: bei Menge < Soll erscheint eine große + Bestätigungsabfrage; nach Bestätigung wird die Sollmenge als produziert + in `MDE` (`status=beendet`) vermerkt, Auftrag auf `abgeschlossen` / + `aktiv=false` gesetzt + +### Produktionsleiter (`#/start`) +- **Auftrag anlegen**: Maschine (Dropdown), Auftragsnummer, optional Artikel, + Produktionsmenge, Stück/Stunde sowie optional eine **Priorität** (Position + in der Warteschlange dieser Maschine, 1 = als nächstes dran) – alles + serverseitig validiert. Wird eine Priorität angegeben, wird der Auftrag an + genau dieser Position eingereiht und alle bestehenden offenen/pausierten + Aufträge dieser Maschine rücken automatisch nach (dichte Nummerierung + 1..N, kein bloßes Setzen eines rohen Zahlenwerts). Ohne Angabe wird der + Auftrag ans Ende der Warteschlange gehängt. +- **Aufträge importieren** (`/start_import`): Aufträge aus einer Excel- + (.xlsx) oder CSV-Datei per Drag & Drop oder Dateiauswahl bulk-anlegen, + siehe eigener Abschnitt unten +- **Auftragsübersicht**: alle Aufträge (inkl. Artikel, Maschinenname), + standardmäßig nur offen/pausiert (umschaltbar auf "Alle"), optional nach + Maschine gefiltert, löschbar und (mit Einschränkungen) bearbeitbar: + - **Status und Priorität sind hier nicht editierbar** (nur informativ + angezeigt) – Status ändert sich ausschließlich über die eigentlichen + Maschinen-Aktionen (Start/Pause/Beenden), Priorität ausschließlich über + die Drag & Drop-Seite "Auftragspriorität". + - **Aktive Aufträge werden angezeigt, können aber nicht bearbeitet + werden** – ein Bearbeitungsversuch (auch ein direkter POST) wird mit + einer Fehlermeldung abgelehnt, solange der Auftrag gerade an der + Maschine läuft. + - Editierbar bleiben Maschine (inkl. automatischem Umhängen der kompletten + MDE-Buchungshistorie auf die neue Maschine), Auftragsnummer, Artikel, + Produktionsmenge und Stück/Stunde. + - **Mengen-Schutz**: Wurde an einem Auftrag bereits eine Menge gemeldet + (MDE-Historie vorhanden), darf die Produktionsmenge nicht mehr so weit + reduziert werden, dass die bereits gemeldete Menge außerhalb der + 110%-Toleranz läge (dieselbe Toleranzregel, die auch an der Maschine + beim Erfassen gilt) – die App zeigt dabei den konkret noch zulässigen + Mindestwert an. +- **Buchungskorrektur**: direkter Eingriff in die `MDE`-Tabelle (editieren/ + löschen), optional nach Maschine gefiltert +- **Auftragspriorität**: Reihenfolge der offenen/pausierten Aufträge je + Maschine per **Drag & Drop** ändern (Auftrag anfassen und an neue Position + ziehen) – wird automatisch gespeichert. Funktioniert am PC/Laptop mit + Maus; auf reinen Touch-Geräten ohne Maus ggf. eingeschränkt (Hinweis dazu + steht auf der Seite selbst). +- **Zu Maschine springen**: Direktlinks zu jeder Maschinenseite (zeigt an, + ob die Maschine gerade läuft) +- **Maschinenverwaltung** (`/start_maschinen_verwaltung`, passwortgeschützt): + Maschinen anlegen, umbenennen/Standort ändern, löschen (nur möglich wenn + keine Aufträge mehr an der Maschine hängen). Passwort über die + Umgebungsvariable `MASCHINEN_PASSWORT` in der `.env` konfigurierbar + (Standard `admin` – bitte unbedingt ändern!). Für die Login-Session wird + zusätzlich ein `SECRET_KEY` benötigt (ebenfalls in der `.env` setzen). + +### Aufträge importieren (`/start_import`) +Excel- (.xlsx) oder CSV-Datei mit (mindestens) diesen Spalten hochladen +(Reihenfolge egal, Erkennung anhand von Stichworten in der Kopfzeile). Die +Datei kann per Drag & Drop auf das gestrichelte Feld gezogen oder klassisch +über die Dateiauswahl ausgewählt werden. Bei CSV werden Zeichenkodierung +(UTF-8/BOM, sonst cp1252), Trennzeichen (Komma, Semikolon – wie bei +deutschem Excel-Export üblich – oder Tab) sowie deutsches/internationales +Zahlformat (`1.234,56` oder `1234.56`) automatisch erkannt. + +| Spalte | Verwendung | +|-------------------|---------------------------------------------------------| +| Maschine | muss exakt (Groß-/Kleinschreibung egal) einer bestehenden Maschine entsprechen, sonst wird die Zeile verworfen | +| man. Prio | wird direkt als Priorität (`sid`) übernommen (das Feld erlaubt auch Kommazahlen, z.B. `15.1`) | +| FA.-Nr. | → Auftragsnummer. Existiert diese FA.-Nr. bereits irgendwo im System, wird die Zeile unangetastet übersprungen | +| Rest-Menge | zusammen mit Ist-Menge → Produktionsmenge (Soll) = Rest + Ist | +| Ist-Menge | wird als bereits produzierte Menge übernommen (Start-Buchung in `MDE`), damit Fortschritt und Mengen-Validierung an der Maschine korrekt weiterlaufen | +| Artikel | wird 1:1 übernommen und angezeigt | +| Soll-pro-Stunde | → Stück/Stunde (Soll) | +| Bezeichnung, Auftrag, Lieferdatum, Bemerkung | werden ignoriert | + +Ist die Rest-Menge einer Zeile `0`, wird der Auftrag direkt mit Status +"Abgeschlossen" angelegt (inkl. passender Buchung in der MDE-Historie). +Nach dem Import zeigt die Seite eine Liste, was importiert, was wegen +unbekannter Maschine verworfen und was wegen bereits vorhandener FA.-Nr. +übersprungen wurde. + +### Artikel +Neues, optionales Textfeld je Auftrag (Spalte `artikel`). Kann beim +manuellen Anlegen eingetragen werden oder kommt aus dem Import; wird auf der +Maschinenseite, in der Auftragsauswahl, der Auftragsübersicht, der +Prioritätsliste und (sofern gerade ein Auftrag aktiv ist) auch in der +Maschinenübersicht ("Zu Maschine springen") mit angezeigt. + +### Logo / Icon +`build/static/logo.svg` wird als Favicon und (in doppelter Standardgröße, +104×104px) über dem Seitentitel angezeigt. Einfach durch eine eigene Datei +mit demselben Namen (`logo.svg`, idealerweise quadratisch) ersetzen, um das +Firmenlogo einzubinden – kein Code muss dafür angepasst werden. + +### JavaScript-Einsatz (Ausnahmen vom "kein Client-JS"-Grundsatz) +Zwei bewusste, punktuelle Ausnahmen von der sonst reinen Server-Side- +Rendering-Philosophie: +- Die Filter-Dropdowns (Maschine/Status) in Auftragsübersicht, Buchungs- + korrektur und Auftragspriorität lösen die Filterung direkt beim Auswählen + aus (`onchange`-Submit) – reiner Komfort, der "Filtern"-Button bleibt als + Fallback sichtbar und funktioniert auch ohne JavaScript. +- Die Drag-&-Drop-Sortierung auf der Auftragspriorität-Seite benötigt + zwingend JavaScript (ohne JS erscheint dort ein Hinweis, dass Sortieren + nicht möglich ist – alle anderen Seiten funktionieren weiterhin ohne JS). + +## Stabilität im Dauerbetrieb + +- Backend läuft unter `gunicorn` (mehrere Worker/Threads statt Flask-Devserver) +- Datenbank-Connection-Pool mit automatischem Retry beim Start (verträgt + verzögerten DB-Start ohne Absturz) +- Docker-Healthchecks für App und DB, `restart: unless-stopped` +- Reines Server-Side-Rendering ohne Build-Step und ohne Client-JavaScript – + jede Aktion ist ein klassischer Formular-Submit (Post-Redirect-Get), das + ist auf Tablets mit älteren/eingeschränkten Browsern besonders robust + +## Struktur + +``` +wdm-performance/ +├── Dockerfile Build-Anweisungen (Kontext = Projektwurzel) +├── docker-compose.yml +├── README.md +├── .env.example +└── build/ + ├── app.py Flask-Anwendung (alle Routen, Server-Side-Rendering) + ├── db.py DB-Pool mit Retry-Logik + ├── requirements.txt + ├── db/init.sql Schema (dein Dump + Beispiel-Abweichungsgründe) + ├── templates/ Jinja2-HTML-Templates (kein JavaScript) + └── static/ + └── style.css Großflächiges Touch-Design +``` + +Wichtig: `docker compose` muss aus dem Projektwurzel-Verzeichnis heraus +aufgerufen werden (dort, wo `docker-compose.yml` liegt), nicht aus `build/`. diff --git a/build/app.py b/build/app.py new file mode 100644 index 0000000..ceddd35 --- /dev/null +++ b/build/app.py @@ -0,0 +1,1126 @@ +""" +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) diff --git a/build/db.py b/build/db.py new file mode 100644 index 0000000..a13b439 --- /dev/null +++ b/build/db.py @@ -0,0 +1,91 @@ +""" +Datenbank-Anbindung für die WDM-Performance-Anwendung. + +Verwendet einen Thread-sicheren Connection-Pool. Beim Start wird mehrfach +versucht die Verbindung aufzubauen, da Postgres im Docker-Setup u.U. noch +nicht bereit ist, wenn der App-Container startet. +""" +import os +import time +import logging +from contextlib import contextmanager + +import psycopg2 +import psycopg2.extras +from psycopg2.pool import ThreadedConnectionPool + +log = logging.getLogger("wdm-performance") +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + +DB_HOST = os.environ.get("DB_HOST", "db") +DB_PORT = os.environ.get("DB_PORT", "5432") +DB_NAME = os.environ.get("DB_NAME", "wdm") +DB_USER = os.environ.get("DB_USER", "wdm") +DB_PASSWORD = os.environ.get("DB_PASSWORD", "wdm") + +_pool = None + + +def init_pool(max_versuche=30, wartezeit_sekunden=2): + """Baut den Connection-Pool auf. Versucht es mehrfach, falls die DB + beim Containerstart noch nicht erreichbar ist (kein harter Absturz).""" + global _pool + for versuch in range(1, max_versuche + 1): + try: + _pool = ThreadedConnectionPool( + minconn=1, + maxconn=10, + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + connect_timeout=5, + ) + log.info("Datenbankverbindung zu %s:%s/%s hergestellt.", DB_HOST, DB_PORT, DB_NAME) + return + except psycopg2.OperationalError as exc: + if versuch == max_versuche: + log.error("Konnte nach %d Versuchen keine DB-Verbindung herstellen: %s", max_versuche, exc) + raise + log.warning("DB noch nicht erreichbar (Versuch %d/%d), warte %ds ...", versuch, max_versuche, wartezeit_sekunden) + time.sleep(wartezeit_sekunden) + + +@contextmanager +def get_conn(): + """Liefert eine Connection aus dem Pool. Bei Fehlern wird die + Connection verworfen statt sie kaputt zurückzugeben (Stabilität bei + Langzeitbetrieb).""" + if _pool is None: + init_pool() + conn = _pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _pool.putconn(conn) + + +@contextmanager +def get_cursor(dict_cursor=True): + with get_conn() as conn: + cursor_factory = psycopg2.extras.RealDictCursor if dict_cursor else None + cur = conn.cursor(cursor_factory=cursor_factory) + try: + yield cur + finally: + cur.close() + + +def migrate(): + """Leichte, idempotente Schema-Migrationen für bereits laufende + Installationen. `init.sql` wird nur beim allerersten Start (leeres + Datenvolume) ausgeführt - bestehende Installationen bekommen neue Spalten + stattdessen hier beim App-Start nachgerüstet.""" + with get_cursor() as cur: + cur.execute('ALTER TABLE "Auftraege" ADD COLUMN IF NOT EXISTS "artikel" text') + log.info("Schema-Migration geprüft/angewendet.") diff --git a/build/db/init.sql b/build/db/init.sql new file mode 100644 index 0000000..e2b938c --- /dev/null +++ b/build/db/init.sql @@ -0,0 +1,98 @@ +-- Adminer 5.4.2 PostgreSQL 17.9 dump + +DROP TABLE IF EXISTS "Auftraege"; +DROP SEQUENCE IF EXISTS "public"."Auftraege_id_seq"; +CREATE SEQUENCE "public"."Auftraege_id_seq" INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."Auftraege" ( + "id" integer DEFAULT nextval('"Auftraege_id_seq"') NOT NULL, + "auftragsnummer" text, + "einheiten" real, + "soll_h" real, + "status" text, + "aktiv" boolean, + "start" bigint, + "ende" bigint, + "created_at" bigint, + "sid" real, + "maschine_id" real, + "artikel" text, + CONSTRAINT "Auftraege_pkey" PRIMARY KEY ("id") +) +WITH (oids = false); + + +DROP TABLE IF EXISTS "MDE"; +DROP SEQUENCE IF EXISTS "public"."MDE_id_seq1"; +CREATE SEQUENCE "public"."MDE_id_seq1" INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."MDE" ( + "id" integer DEFAULT nextval('"MDE_id_seq1"') NOT NULL, + "maschine_id" real, + "timestamp" bigint, + "order" text, + "produziert" real, + "status" text, + "h_date" timestamptz, + "h_avg" real, + "p_grund" text, + CONSTRAINT "MDE_pkey1" PRIMARY KEY ("id") +) +WITH (oids = false); + + +DROP TABLE IF EXISTS "Maschinen"; +DROP SEQUENCE IF EXISTS "public"."Maschinen_id_seq"; +CREATE SEQUENCE "public"."Maschinen_id_seq" INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."Maschinen" ( + "id" integer DEFAULT nextval('"Maschinen_id_seq"') NOT NULL, + "name" text, + "standort" text, + CONSTRAINT "Maschinen_pkey" PRIMARY KEY ("id") +) +WITH (oids = false); + + +DROP TABLE IF EXISTS "abweichung"; +DROP SEQUENCE IF EXISTS "public".abweichung_id_seq; +CREATE SEQUENCE "public".abweichung_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."abweichung" ( + "id" integer DEFAULT nextval('abweichung_id_seq') NOT NULL, + "sid" real, + "beschreibung" text, + CONSTRAINT "abweichung_pkey" PRIMARY KEY ("id") +) +WITH (oids = false); + + +-- Abweichungsgründe (aus abweichung.sql übernommen) +INSERT INTO "public"."abweichung" ("id", "sid", "beschreibung") VALUES +(1, 1, 'Andere Anlagenunterstüzung'), +(2, 2, 'Drahtführung / Bohrleiste defekt'), +(3, 3, 'Einwurfbefüllung'), +(4, 4, 'Elektr. / Mech. Störung'), +(5, 5, 'Elektrodenwechsel bzw. Verschieben / Magnete'), +(6, 6, 'Handabnahme ( Roboter defekt )'), +(7, 7, 'Haspel Bestückung'), +(8, 8, 'Kein Material'), +(9, 9, 'Kein Schichtstapler'), +(10, 10, 'Längsdrahteinwurf / Haspelablauf'), +(11, 11, 'Maschine Feineinstellung / Nachjustierung'), +(12, 12, 'Materialqualität'), +(13, 13, 'Mitarbeiter fehlt'), +(14, 14, 'Nacharbeit an Maschine ( z.B. ausklinken / beschneiden )'), +(16, 16, 'Querdrahteinwurf'), +(17, 17, 'Rosetten teilen'), +(18, 18, 'Schichtwechsel'), +(19, 19, 'Verpackung / Palettenwechsel'), +(20, 20, 'Vorgabezeit unrealistisch'), +(21, 21, 'Warten auf QS'), +(22, 22, 'Warten auf Schichtstapler'), +(23, 23, 'Warten auf Teamkoordinator'), +(15, 15, 'Pause'), +(24, 24, 'Mengenmeldung'); +SELECT setval('"public".abweichung_id_seq', 24); + +-- 2026-07-17 07:27:25 UTC diff --git a/build/requirements.txt b/build/requirements.txt new file mode 100644 index 0000000..46c3278 --- /dev/null +++ b/build/requirements.txt @@ -0,0 +1,4 @@ +flask==3.1.0 +psycopg2-binary==2.9.10 +gunicorn==23.0.0 +openpyxl==3.1.5 diff --git a/build/static/logo.svg b/build/static/logo.svg new file mode 100644 index 0000000..9039359 --- /dev/null +++ b/build/static/logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/static/style.css b/build/static/style.css new file mode 100644 index 0000000..f10b4b2 --- /dev/null +++ b/build/static/style.css @@ -0,0 +1,484 @@ +:root { + --bg: #12161c; + --panel: #1c222b; + --panel-2: #262e3a; + --border: #333d4c; + --text: #eef1f5; + --text-dim: #9aa4b2; + --primary: #2f7dff; + --primary-dark: #1f5fd6; + --green: #2fb872; + --amber: #e0a629; + --red: #e0433b; + --radius: 16px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", Roboto, Arial, sans-serif; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + padding: 28px 24px 12px; + text-align: center; +} + +.header-logo { + /* Logo ist von Haus aus breiter als hoch (kein Quadrat) - deshalb nur die + Höhe vorgeben und die Breite automatisch im echten Seitenverhältnis + mitskalieren lassen, statt es in ein Quadrat zu zwingen/zu verzerren. */ + height: 130px; + width: auto; + max-width: 90%; + display: block; + margin: 0 auto 10px; +} + +.header h1 { + margin: 0; + font-size: 2.4em; + letter-spacing: 0.5px; +} + +.header .subtitle { + color: var(--text-dim); + font-size: 1.1em; + margin-top: 6px; +} + +.content { + flex: 1; + padding: 16px 24px 40px; + max-width: 1000px; + margin: 0 auto; + width: 100%; +} +.content.wide { + max-width: 1400px; +} + +.btn-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-top: 24px; +} + +.big-btn { + background: var(--panel); + border: 2px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 32px 20px; + font-size: 1.4em; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + text-decoration: none; + font-family: inherit; + width: 100%; +} + +form.btn-form { display: contents; } +.order-form { display: block; width: 100%; } +.order-form .order-item { width: 100%; } + +.big-btn:active { transform: scale(0.97); } + +.big-btn.primary { background: var(--primary); border-color: var(--primary-dark); } +.big-btn.green { background: var(--green); border-color: #1f8f57; } +.big-btn.amber { background: var(--amber); border-color: #b98418; color: #23200f; } +.big-btn.red { background: var(--red); border-color: #a92e28; } +.big-btn.neutral { background: var(--panel-2); } +.big-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.big-btn.small { + min-height: 70px; + font-size: 1.1em; + padding: 16px; +} + +.back-row { + margin-top: 28px; +} +.back-row.top { + margin-top: 0; + margin-bottom: 22px; +} + +.machine-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 14px; + margin-top: 10px; +} +.machine-link { + background: var(--panel-2); + border: 2px solid var(--border); + border-radius: 12px; + padding: 18px 12px; + text-decoration: none; + color: var(--text); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + font-size: 1.1em; + font-weight: 600; + text-align: center; +} +.machine-link:active { transform: scale(0.97); } +.machine-link .machine-link-status { + font-size: 0.7em; + font-weight: 700; + text-transform: uppercase; + color: var(--text-dim); + background: var(--bg); + padding: 3px 10px; + border-radius: 20px; +} +.machine-link.aktiv { border-color: var(--green); } +.machine-link.aktiv .machine-link-status { color: var(--green); } +.machine-link .machine-link-artikel { + font-size: 0.65em; + font-weight: 500; + color: var(--text-dim); + text-transform: none; +} + +.card { + background: var(--panel); + border: 2px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin-top: 20px; +} +.card.compact { padding: 18px 20px; max-width: 480px; margin-left: auto; margin-right: auto; } + +.compact-form { max-width: 480px; margin: 0 auto; } +.compact-form .form-group { margin-top: 12px; } +.compact-form .form-group label { font-size: 0.95em; margin-bottom: 5px; } +.compact-form .form-group input, .compact-form .form-group select { + padding: 11px 12px; + font-size: 1.05em; +} +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.action-row.compact { max-width: 480px; margin: 20px auto 0; } +.action-row.compact .big-btn { flex: none; width: 100%; } + +.status-line { + display: flex; + justify-content: space-between; + font-size: 1.15em; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.status-line:last-child { border-bottom: none; } +.status-line .label { color: var(--text-dim); } + +.order-list { display: flex; flex-direction: column; gap: 14px; margin-top: 20px; } + +.order-item { + background: var(--panel-2); + border: 2px solid var(--border); + border-radius: 12px; + padding: 18px 22px; + cursor: pointer; + font-size: 1.2em; + display: flex; + justify-content: space-between; + align-items: center; + font-family: inherit; + color: var(--text); + text-align: left; + border-left: 6px solid var(--primary); +} +button.order-item:active { transform: scale(0.98); } +.order-item-main { + display: flex; + flex-direction: column; + gap: 4px; +} +.order-item-nummer { + font-size: 1.3em; + font-weight: 800; + letter-spacing: 0.3px; +} +.order-item-menge { + font-size: 0.75em; + color: var(--text-dim); + font-weight: 500; +} +.order-item .tag { + font-size: 0.7em; + padding: 4px 10px; + border-radius: 20px; + background: var(--amber); + color: #23200f; + font-weight: 700; + white-space: nowrap; + margin-left: 14px; +} + +.form-group { margin-top: 18px; } +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 1.1em; + color: var(--text-dim); +} +.form-group input, .form-group select { + width: 100%; + padding: 16px; + font-size: 1.3em; + border-radius: 10px; + border: 2px solid var(--border); + background: var(--bg); + color: var(--text); +} + +.error-box { + background: rgba(224, 67, 59, 0.15); + border: 2px solid var(--red); + color: #ffb3af; + padding: 14px 18px; + border-radius: 10px; + margin-top: 18px; + font-size: 1.05em; +} + +.info-box { + background: rgba(47, 125, 255, 0.12); + border: 2px solid var(--primary); + padding: 14px 18px; + border-radius: 10px; + margin-top: 18px; +} + +.action-row { + display: flex; + gap: 16px; + margin-top: 26px; + flex-wrap: wrap; +} +.action-row .big-btn { flex: 1; min-width: 160px; } + +table.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; + font-size: 0.95em; +} +table.data-table th, table.data-table td { + border-bottom: 1px solid var(--border); + padding: 9px 10px; + text-align: left; + white-space: nowrap; +} +table.data-table th { + color: var(--text-dim); + font-weight: 600; + position: sticky; + top: 0; + background: var(--bg); + z-index: 1; +} +table.data-table tr:hover { background: var(--panel-2); } +table.data-table td.col-actions { white-space: nowrap; } + +.table-wrap { + overflow: auto; + max-height: 68vh; + border: 2px solid var(--border); + border-radius: 12px; + margin-top: 16px; +} +.table-wrap table.data-table { margin-top: 0; } + +.list-meta { + color: var(--text-dim); + font-size: 0.95em; + margin-top: 6px; +} + +.row-actions { + display: flex; + gap: 10px; + flex-wrap: nowrap; +} + +.filter-row { display: flex; gap: 14px; align-items: center; margin-top: 10px; flex-wrap: wrap; } +.filter-row select { padding: 10px; font-size: 1em; border-radius: 8px; border: 2px solid var(--border); background: var(--bg); color: var(--text); } +.filter-row button { padding: 10px 18px; font-size: 1em; border-radius: 8px; border: 2px solid var(--border); background: var(--panel-2); color: var(--text); cursor: pointer; font-family: inherit; } + +.confirm-box { + background: var(--panel); + border: 3px solid var(--red); + border-radius: var(--radius); + padding: 32px; + text-align: center; + margin-top: 20px; +} +.confirm-box h2 { font-size: 1.6em; margin-top: 0; } + +.loading, .empty-state { + text-align: center; + color: var(--text-dim); + padding: 40px 20px; + font-size: 1.2em; +} + +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.7); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: 20px; +} +.modal-box { + background: var(--panel); + border: 3px solid var(--red); + border-radius: var(--radius); + padding: 32px; + max-width: 480px; + text-align: center; +} +.modal-box h2 { font-size: 1.6em; margin-top: 0; } +.modal-actions { display: flex; gap: 16px; margin-top: 26px; } +.modal-actions .big-btn { flex: 1; } + +.priority-list { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; } +.priority-item { + background: var(--panel-2); + border: 2px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + display: flex; + align-items: center; + gap: 14px; + font-size: 1.1em; + cursor: grab; +} +.priority-item.dragging { + opacity: 0.4; + border-style: dashed; +} +.priority-item .drag-handle { + font-size: 1.3em; + color: var(--text-dim); + line-height: 1; +} +.priority-item .priority-item-info { + display: flex; + flex-direction: column; + gap: 2px; +} +.priority-item .priority-item-artikel { + font-size: 0.75em; + color: var(--text-dim); + font-weight: 500; +} +.priority-item .tag { + margin-left: auto; + font-size: 0.75em; + padding: 4px 10px; + border-radius: 20px; + background: var(--amber); + color: #23200f; + font-weight: 700; + white-space: nowrap; +} +.priority-item .sid-badge { + background: var(--primary); + border-radius: 20px; + padding: 4px 12px; + font-size: 0.85em; + font-weight: 700; +} +.hint { + color: var(--text-dim); + font-size: 0.95em; + margin: 10px 0 16px; + line-height: 1.5; +} +.save-indicator { + margin-top: 12px; + color: var(--text-dim); + font-size: 0.9em; + min-height: 1.3em; +} +.card details { margin-top: 14px; } +.card summary { cursor: pointer; font-weight: 600; } +.card ul { margin: 8px 0 0; padding-left: 20px; } +.card li { margin-bottom: 5px; } + +.dropzone { + position: relative; + border: 2px dashed var(--border); + border-radius: 12px; + padding: 34px 16px; + text-align: center; + background: var(--bg); + transition: border-color 0.15s ease, background 0.15s ease; +} +.dropzone.dragover { + border-color: var(--primary); + background: rgba(47, 125, 255, 0.1); +} +.dropzone input[type="file"] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} +.dropzone-text { + color: var(--text-dim); + font-size: 1.02em; + pointer-events: none; + word-break: break-all; +} + +.icon-btn { + background: var(--panel-2); + border: 2px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 9px 16px; + cursor: pointer; + font-size: 0.95em; + font-family: inherit; + text-decoration: none; + display: inline-block; + min-width: 92px; + text-align: center; +} +.icon-btn.danger { border-color: var(--red); color: #ffb3af; } + +@media (min-width: 700px) { + .header h1 { font-size: 3em; } +} diff --git a/build/templates/base.html b/build/templates/base.html new file mode 100644 index 0000000..1780ef3 --- /dev/null +++ b/build/templates/base.html @@ -0,0 +1,24 @@ + + + + + +{{ title }} – WDM Performance + + + + +
+
+ +

{{ title }}

+ {% if subtitle %}
{{ subtitle }}
{% endif %} +
+
+ {% if error %}
{{ error }}
{% endif %} + {% if info %}
{{ info }}
{% endif %} + {% block content %}{% endblock %} +
+
+ + diff --git a/build/templates/leitstand_anlegen.html b/build/templates/leitstand_anlegen.html new file mode 100644 index 0000000..85129da --- /dev/null +++ b/build/templates/leitstand_anlegen.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block content %} + +

+ Priorität = Position in der Warteschlange dieser Maschine (1 = als + nächstes dran). Bestehende offene/pausierte Aufträge dieser + Maschine rücken automatisch nach. Leer lassen, um den Auftrag ans + Ende einzureihen. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/build/templates/leitstand_home.html b/build/templates/leitstand_home.html new file mode 100644 index 0000000..39c7cfc --- /dev/null +++ b/build/templates/leitstand_home.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} + +{% endblock %} diff --git a/build/templates/leitstand_import.html b/build/templates/leitstand_import.html new file mode 100644 index 0000000..32ac489 --- /dev/null +++ b/build/templates/leitstand_import.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% block content %} + + +
+

+ Excel- (.xlsx) oder CSV-Datei mit den Spalten Maschine, man. Prio, + FA.-Nr., Rest-Menge, Ist-Menge, + Artikel und Soll-pro-Stunde hochladen (weitere Spalten + wie Bezeichnung/Auftrag/Lieferdatum/Bemerkung werden ignoriert). Bei CSV werden + Trennzeichen (Komma/Semikolon/Tab) und Zahlformat (1.234,56 oder 1234.56) + automatisch erkannt.
+ Aufträge für unbekannte Maschinen werden verworfen, bereits vorhandene + FA.-Nrn. bleiben unangetastet. Ist die Rest-Menge 0, wird der Auftrag direkt als + abgeschlossen angelegt. +

+
+
+ +
+ +
Datei hierher ziehen oder klicken zum Auswählen
+
+
+
+ +
+
+ +
+ +{% if ergebnis %} +
+

Ergebnis

+

+ {{ ergebnis.importiert|length }} importiert, + {{ ergebnis.uebersprungen_vorhanden|length }} bereits vorhanden (übersprungen), + {{ ergebnis.verworfen_maschine|length }} wegen unbekannter Maschine verworfen{% if ergebnis.fehlerhafte_zeilen %}, + {{ ergebnis.fehlerhafte_zeilen|length }} fehlerhaft{% endif %}. +

+ + {% if ergebnis.importiert %} +
+ Importiert ({{ ergebnis.importiert|length }}) +
    {% for z in ergebnis.importiert %}
  • {{ z }}
  • {% endfor %}
+
+ {% endif %} + {% if ergebnis.uebersprungen_vorhanden %} +
+ Bereits vorhanden – übersprungen ({{ ergebnis.uebersprungen_vorhanden|length }}) +
    {% for z in ergebnis.uebersprungen_vorhanden %}
  • {{ z }}
  • {% endfor %}
+
+ {% endif %} + {% if ergebnis.verworfen_maschine %} +
+ Maschine unbekannt – verworfen ({{ ergebnis.verworfen_maschine|length }}) +
    {% for z in ergebnis.verworfen_maschine %}
  • {{ z }}
  • {% endfor %}
+
+ {% endif %} + {% if ergebnis.fehlerhafte_zeilen %} +
+ Fehlerhafte Zeilen ({{ ergebnis.fehlerhafte_zeilen|length }}) +
    {% for z in ergebnis.fehlerhafte_zeilen %}
  • {{ z }}
  • {% endfor %}
+
+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/build/templates/leitstand_korrektur.html b/build/templates/leitstand_korrektur.html new file mode 100644 index 0000000..2943193 --- /dev/null +++ b/build/templates/leitstand_korrektur.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block content %} + + +
+ Maschine filtern: + + +
+ +{% if not eintraege %} +
Keine Einträge gefunden.
+{% else %} +
{{ eintraege|length }} Buchungen (neueste zuerst)
+
+ + + + + + {% for m in eintraege %} + + + + + + + + + + + + {% endfor %} + +
IDMaschineZeitAuftragProduziertStatusØ/hGrund
{{ m.id }}{{ m.maschine_id|int }}{{ m.zeit_fmt }}{{ m.order }}{{ m.produziert_fmt }}{{ m.status }}{{ m.h_avg_fmt }}{{ m.p_grund or '' }} +
+ Bearbeiten +
+ + +
+
+
+
+{% endif %} +{% endblock %} diff --git a/build/templates/leitstand_korrektur_bearbeiten.html b/build/templates/leitstand_korrektur_bearbeiten.html new file mode 100644 index 0000000..31f73b1 --- /dev/null +++ b/build/templates/leitstand_korrektur_bearbeiten.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block content %} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/build/templates/leitstand_maschinen.html b/build/templates/leitstand_maschinen.html new file mode 100644 index 0000000..91b5484 --- /dev/null +++ b/build/templates/leitstand_maschinen.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} + + +{% endblock %} diff --git a/build/templates/leitstand_prioritaet.html b/build/templates/leitstand_prioritaet.html new file mode 100644 index 0000000..16ca799 --- /dev/null +++ b/build/templates/leitstand_prioritaet.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% block content %} + +
+ Maschine: + + +
+ +{% if filter_maschine_id and not auftraege %} +
Keine offenen/pausierten Aufträge für diese Maschine.
+{% elif auftraege %} +

+ Reihenfolge per Drag & Drop ändern: Auftrag mit der Maus anfassen (Symbol + ☰) und an die gewünschte Position ziehen. Die neue Reihenfolge wird + automatisch gespeichert. Hinweis: funktioniert am PC/Laptop mit Maus; auf + reinen Touch-Geräten ohne Maus ist Drag & Drop ggf. eingeschränkt. +

+ +
+ {% for a in auftraege %} +
+ + #{{ loop.index }} + + {{ a.auftragsnummer }} + {% if a.artikel %}{{ a.artikel }}{% endif %} + + {{ a.status_label }} +
+ {% endfor %} +
+
+ +{% endif %} +{% endblock %} diff --git a/build/templates/leitstand_uebersicht.html b/build/templates/leitstand_uebersicht.html new file mode 100644 index 0000000..1254a8c --- /dev/null +++ b/build/templates/leitstand_uebersicht.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block content %} + + +
+ Maschine: + + Status: + + +
+ +{% if not auftraege %} +
Keine Aufträge gefunden.
+{% else %} +
{{ auftraege|length }} Aufträge
+
+ + + + + + {% for a in auftraege %} + + + + + + + + + + + + + {% endfor %} + +
IDAuftragsnr.ArtikelMaschineMengeStk/hStatusAktivSID
{{ a.id }}{{ a.auftragsnummer }}{{ a.artikel or "-" }}{{ a.maschine_name }}{{ a.einheiten_fmt }}{{ a.soll_h_fmt }}{{ a.status_label }}{{ "Ja" if a.aktiv else "Nein" }}{{ a.sid_fmt }} +
+ Bearbeiten +
+ + + +
+
+
+
+{% endif %} +{% endblock %} diff --git a/build/templates/leitstand_uebersicht_bearbeiten.html b/build/templates/leitstand_uebersicht_bearbeiten.html new file mode 100644 index 0000000..e3b2f90 --- /dev/null +++ b/build/templates/leitstand_uebersicht_bearbeiten.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block content %} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Status{{ status_anzeige }}
+
+ Priorität + {{ auftrag.sid_fmt }} (änderbar unter "Auftragspriorität") +
+
+ {% if not gesperrt %} +
+ +
+ {% endif %} +
+{% endblock %} diff --git a/build/templates/machine_auswahl.html b/build/templates/machine_auswahl.html new file mode 100644 index 0000000..b592d85 --- /dev/null +++ b/build/templates/machine_auswahl.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} + +{% if not auftraege %} +
Keine offenen oder pausierten Aufträge für diese Maschine.
+{% else %} +
+ {% for a in auftraege %} +
+ + +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/build/templates/machine_beenden_confirm.html b/build/templates/machine_beenden_confirm.html new file mode 100644 index 0000000..0f56985 --- /dev/null +++ b/build/templates/machine_beenden_confirm.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} +
+

Wirklich beenden?

+

Die Sollmenge ({{ soll_fmt }}) wurde noch nicht erreicht.
Bisher gemeldet: {{ bisherige_fmt }}.

+ +
+{% endblock %} diff --git a/build/templates/machine_main.html b/build/templates/machine_main.html new file mode 100644 index 0000000..ec20743 --- /dev/null +++ b/build/templates/machine_main.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block content %} +{% if not auftrag %} +
+
Kein aktiver Auftrag.
+
+
+ ▶ Start +
+{% else %} +
+
Auftragsnummer{{ auftrag.auftragsnummer }}
+ {% if auftrag.artikel %}
Artikel{{ auftrag.artikel }}
{% endif %} +
Status{{ status_label }}
+
Soll-Menge{{ soll_fmt }}
+
Stück/Stunde (Soll){{ rate_fmt }}
+
Bisher gemeldet{{ bisherige_fmt }} ({{ fortschritt }}%)
+
+
+ 📝 Prod. erfassen + ⏸ Auftrag pausieren + {% if unter_soll %} + ⏹ Auftrag beenden + {% else %} +
+ +
+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/build/templates/machine_meldung.html b/build/templates/machine_meldung.html new file mode 100644 index 0000000..9733e80 --- /dev/null +++ b/build/templates/machine_meldung.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} + +
Bisher gemeldet: {{ bisherige_fmt }} / Soll: {{ soll_fmt }}
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/build/templates/maschinenverwaltung.html b/build/templates/maschinenverwaltung.html new file mode 100644 index 0000000..f38ace1 --- /dev/null +++ b/build/templates/maschinenverwaltung.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +
+ ← Zurück +
+ +
+
+ + + +{% if not maschinen %} +
Keine Maschinen vorhanden.
+{% else %} +
{{ maschinen|length }} Maschinen
+
+ + + + + + {% for m in maschinen %} + + + + + + + {% endfor %} + +
IDNameStandort
{{ m.id }}{{ m.name or '' }}{{ m.standort or '' }} +
+ Bearbeiten +
+ +
+
+
+
+{% endif %} +{% endblock %} diff --git a/build/templates/maschinenverwaltung_bearbeiten.html b/build/templates/maschinenverwaltung_bearbeiten.html new file mode 100644 index 0000000..ac9d11b --- /dev/null +++ b/build/templates/maschinenverwaltung_bearbeiten.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/build/templates/maschinenverwaltung_login.html b/build/templates/maschinenverwaltung_login.html new file mode 100644 index 0000000..4496820 --- /dev/null +++ b/build/templates/maschinenverwaltung_login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} + +
+ +
+
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..4c25257 --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,61 @@ +# Für ANDERE Docker-Instanzen: zieht nur das fertig gebaute Image, +# baut nicht selbst aus dem Quellcode. Diese Datei (+ .env) reicht dort aus, +# der Rest des Repos wird nicht benötigt. +# +# Vor dem ersten Start einmalig einloggen: +# docker login git.meinefirma.de -u +# +# Start: +# docker compose -f docker-compose.deploy.yml up -d +# +# Update auf neue Version: +# docker compose -f docker-compose.deploy.yml pull +# docker compose -f docker-compose.deploy.yml up -d + +services: + ens-db: + image: postgres:17 + container_name: ens-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-wdm} + POSTGRES_USER: ${DB_USER:-wdm} + POSTGRES_PASSWORD: ${DB_PASSWORD:-wdm} + ports: + - "5432:5432" + volumes: + - /var/lib/docker/volumes/postgresql_ens_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-wdm} -d ${DB_NAME:-wdm}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + oom_score_adj: -200 + + ens-app: + # Statt "build: ." wird hier das fertige Image aus der Gitea/Forgejo- + # Registry gezogen. Owner/Repo-Name muss klein geschrieben sein. + image: git.meinefirma.de/owner/ens:latest + container_name: ens-app + restart: unless-stopped + depends_on: + ens-db: + condition: service_healthy + environment: + DB_HOST: ens-db + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-wdm} + DB_USER: ${DB_USER:-wdm} + DB_PASSWORD: ${DB_PASSWORD:-wdm} + SECRET_KEY: ${SECRET_KEY:-bitte-in-der-.env-aendern} + MASCHINEN_PASSWORT: ${MASCHINEN_PASSWORT:-admin} + ports: + - "${APP_PORT:-8090}:5000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:5000/api/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + oom_score_adj: 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..645dc3c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + ens-db: + image: postgres:17 + container_name: ens-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-wdm} + POSTGRES_USER: ${DB_USER:-wdm} + POSTGRES_PASSWORD: ${DB_PASSWORD:-wdm} + ports: + - "5432:5432" + volumes: + - /var/lib/docker/volumes/postgresql_ens_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-wdm} -d ${DB_NAME:-wdm}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + oom_score_adj: -200 + + ens-app: + build: . + container_name: ens-app + restart: unless-stopped + depends_on: + ens-db: + condition: service_healthy + environment: + DB_HOST: ens-db + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-wdm} + DB_USER: ${DB_USER:-wdm} + DB_PASSWORD: ${DB_PASSWORD:-wdm} + SECRET_KEY: ${SECRET_KEY:-bitte-in-der-.env-aendern} + MASCHINEN_PASSWORT: ${MASCHINEN_PASSWORT:-admin} + ports: + - "${APP_PORT:-8090}:5000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:5000/api/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + oom_score_adj: 0