Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/ (skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion, AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
67
Produktion/AutoRecover/auto_recover.py
Normal file
67
Produktion/AutoRecover/auto_recover.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
AutoRecover - Drucker Fehler Handler
|
||||
Erkennt das "Error"-Fenster (Java AWT) und klickt automatisch "Recover" (max. 3x).
|
||||
"""
|
||||
|
||||
import win32gui
|
||||
import win32con
|
||||
import ctypes
|
||||
import time
|
||||
|
||||
MAX_VERSUCHE = 3
|
||||
PRUEF_INTERVAL = 2 # Sekunden zwischen den Checks
|
||||
|
||||
def click_recover_button(hwnd):
|
||||
"""
|
||||
Holt die Fensterposition und klickt auf den Recover-Button.
|
||||
Der Button ist zentriert am unteren Rand des Fensters.
|
||||
"""
|
||||
# Fenster in den Vordergrund holen
|
||||
win32gui.SetForegroundWindow(hwnd)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Fensterposition und -größe ermitteln
|
||||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
|
||||
# Recover-Button liegt ca. bei: horizontal zentriert, ~85% der Höhe
|
||||
click_x = left + width // 2
|
||||
click_y = top + int(height * 0.85)
|
||||
|
||||
# Mausklick simulieren
|
||||
ctypes.windll.user32.SetCursorPos(click_x, click_y)
|
||||
time.sleep(0.1)
|
||||
ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # linke Taste runter
|
||||
ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # linke Taste hoch
|
||||
|
||||
print(f" → Klick auf Position ({click_x}, {click_y})")
|
||||
|
||||
def main():
|
||||
versuche = 0
|
||||
print("AutoRecover läuft... (Strg+C zum Beenden)")
|
||||
print(f"Maximal {MAX_VERSUCHE} Versuche.\n")
|
||||
|
||||
while versuche < MAX_VERSUCHE:
|
||||
hwnd = win32gui.FindWindow(None, "Error")
|
||||
|
||||
if hwnd and win32gui.IsWindowVisible(hwnd):
|
||||
versuche += 1
|
||||
print(f"[Versuch {versuche}/{MAX_VERSUCHE}] Fehlerfenster gefunden!")
|
||||
|
||||
time.sleep(0.5)
|
||||
click_recover_button(hwnd)
|
||||
|
||||
if versuche >= MAX_VERSUCHE:
|
||||
print(f"\nMaximale Versuche ({MAX_VERSUCHE}) erreicht. Programm beendet.")
|
||||
break
|
||||
|
||||
time.sleep(3)
|
||||
else:
|
||||
time.sleep(PRUEF_INTERVAL)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nProgramm manuell beendet.")
|
||||
11
Produktion/AutoRecover/build_exe.bat
Normal file
11
Produktion/AutoRecover/build_exe.bat
Normal file
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
echo PyInstaller wird installiert falls noetig...
|
||||
pip install pyinstaller pywin32 --quiet
|
||||
|
||||
echo.
|
||||
echo Erstelle AutoRecover.exe ...
|
||||
python -m PyInstaller --onefile --noconsole --name AutoRecover auto_recover.py
|
||||
|
||||
echo.
|
||||
echo Fertig! Die EXE liegt in: dist\AutoRecover.exe
|
||||
pause
|
||||
27
Produktion/AutoRecover/debug_window.py
Normal file
27
Produktion/AutoRecover/debug_window.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Debug: Zeigt alle Elemente im Error-Fenster"""
|
||||
import win32gui
|
||||
|
||||
def dump_window(hwnd, indent=0):
|
||||
try:
|
||||
class_name = win32gui.GetClassName(hwnd)
|
||||
text = win32gui.GetWindowText(hwnd)
|
||||
print(" " * indent + f'[{class_name}] "{text}" (hwnd={hwnd})')
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
def callback(child_hwnd, _):
|
||||
dump_window(child_hwnd, indent + 4)
|
||||
win32gui.EnumChildWindows(hwnd, callback, None)
|
||||
except:
|
||||
pass
|
||||
|
||||
hwnd = win32gui.FindWindow(None, "Error")
|
||||
if hwnd:
|
||||
print(f"Fenster gefunden: hwnd={hwnd}\n")
|
||||
dump_window(hwnd)
|
||||
else:
|
||||
print("Kein Fenster mit Titel 'Error' gefunden!")
|
||||
print("Bitte Fehlerfenster öffnen und nochmal ausführen.")
|
||||
|
||||
input("\nEnter zum Beenden...")
|
||||
46
Produktion/AutoRecover/find_window_info.py
Normal file
46
Produktion/AutoRecover/find_window_info.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Schritt 1: Fenster-Erkennung
|
||||
Lass den Druckerfehler aufpoppen, dann starte dieses Skript.
|
||||
Es zeigt alle sichtbaren Fenster und deren Buttons an.
|
||||
"""
|
||||
|
||||
import win32gui
|
||||
import win32con
|
||||
import win32api
|
||||
|
||||
def get_child_controls(hwnd):
|
||||
controls = []
|
||||
def callback(child_hwnd, _):
|
||||
class_name = win32gui.GetClassName(child_hwnd)
|
||||
text = win32gui.GetWindowText(child_hwnd)
|
||||
if class_name == "Button" and text:
|
||||
controls.append(f' Button: "{text}"')
|
||||
elif text:
|
||||
controls.append(f' {class_name}: "{text}"')
|
||||
win32gui.EnumChildWindows(hwnd, callback, None)
|
||||
return controls
|
||||
|
||||
def list_windows():
|
||||
windows = []
|
||||
def callback(hwnd, _):
|
||||
if win32gui.IsWindowVisible(hwnd):
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if title:
|
||||
controls = get_child_controls(hwnd)
|
||||
windows.append((title, controls))
|
||||
win32gui.EnumWindows(callback, None)
|
||||
return windows
|
||||
|
||||
print("=" * 60)
|
||||
print("Gefundene Fenster mit Buttons:")
|
||||
print("=" * 60)
|
||||
|
||||
for title, controls in list_windows():
|
||||
if controls:
|
||||
print(f'\nFenster: "{title}"')
|
||||
for c in controls:
|
||||
print(c)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Kopiere den Fenstertitel und Button-Namen für AutoRecover.")
|
||||
input("\nEnter drücken zum Beenden...")
|
||||
67
Produktion/skrift-produktion/Einrichten.bat
Normal file
67
Produktion/skrift-produktion/Einrichten.bat
Normal file
@@ -0,0 +1,67 @@
|
||||
@echo off
|
||||
title Skrift FTP-Checker – Einrichtung
|
||||
color 0A
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Skrift FTP-Checker – Einrichtung
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
:: Python pruefen
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
color 0C
|
||||
echo FEHLER: Python wurde nicht gefunden!
|
||||
echo.
|
||||
echo Bitte Python von https://python.org herunterladen,
|
||||
echo installieren und dabei "Add Python to PATH" aktivieren.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
python --version
|
||||
echo Python gefunden.
|
||||
echo.
|
||||
|
||||
:: Virtuelle Umgebung erstellen falls nicht vorhanden
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
echo Virtuelle Umgebung bereits vorhanden.
|
||||
) else (
|
||||
echo Erstelle virtuelle Umgebung...
|
||||
python -m venv .venv
|
||||
if errorlevel 1 (
|
||||
color 0C
|
||||
echo FEHLER: Konnte .venv nicht erstellen!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Fertig.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Abhaengigkeiten installieren
|
||||
echo Installiere / aktualisiere Abhaengigkeiten...
|
||||
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
|
||||
if errorlevel 1 (
|
||||
color 0C
|
||||
echo.
|
||||
echo FEHLER: Installation fehlgeschlagen!
|
||||
echo Bitte Internetverbindung pruefen und erneut versuchen.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Fertig.
|
||||
echo.
|
||||
|
||||
color 0A
|
||||
echo ============================================
|
||||
echo Einrichtung abgeschlossen!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Doppelklick auf "Starten.vbs" zum Starten.
|
||||
echo.
|
||||
timeout /t 3 >nul
|
||||
23
Produktion/skrift-produktion/Starten (Debug).bat
Normal file
23
Produktion/skrift-produktion/Starten (Debug).bat
Normal file
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
title Skrift FTP-Checker – Debug
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Laufende Instanz auf Port 8765 beenden
|
||||
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
|
||||
taskkill /PID %%a /F >nul 2>&1
|
||||
)
|
||||
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
echo Starte mit .venv Python...
|
||||
.venv\Scripts\python.exe main.py
|
||||
) else (
|
||||
echo HINWEIS: .venv nicht gefunden. Bitte zuerst Einrichten.bat ausfuehren.
|
||||
echo Versuche System-Python...
|
||||
python main.py
|
||||
)
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Programm mit Fehler beendet. Siehe Meldung oben.
|
||||
pause
|
||||
)
|
||||
40
Produktion/skrift-produktion/Starten.bat
Normal file
40
Produktion/skrift-produktion/Starten.bat
Normal file
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
title Skrift FTP-Checker
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: ── venv pruefen / erstellen ─────────────────────────────────────────
|
||||
if exist ".venv\Scripts\python.exe" goto :deps
|
||||
|
||||
echo [Setup] Virtuelle Umgebung wird erstellt...
|
||||
python -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo FEHLER: Python nicht gefunden!
|
||||
echo Bitte Python von https://python.org installieren
|
||||
echo und dabei "Add Python to PATH" aktivieren.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Erledigt.
|
||||
|
||||
:deps
|
||||
:: ── Abhaengigkeiten installieren (immer) ─────────────────────────────
|
||||
echo [Setup] Pakete werden geprueft / installiert...
|
||||
.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo FEHLER: Pakete konnten nicht installiert werden.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:run
|
||||
:: ── Alte Instanz auf Port 8765 beenden ───────────────────────────────
|
||||
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do (
|
||||
taskkill /PID %%a /F >nul 2>&1
|
||||
)
|
||||
|
||||
:: ── App in eigenem Fenster starten (laeuft weiter wenn dieses Fenster zu) ──
|
||||
echo Skrift FTP-Checker wird gestartet...
|
||||
start "Skrift FTP-Checker" .venv\Scripts\python.exe main.py
|
||||
0
Produktion/skrift-produktion/app/__init__.py
Normal file
0
Produktion/skrift-produktion/app/__init__.py
Normal file
0
Produktion/skrift-produktion/app/api/__init__.py
Normal file
0
Produktion/skrift-produktion/app/api/__init__.py
Normal file
1238
Produktion/skrift-produktion/app/api/routes.py
Normal file
1238
Produktion/skrift-produktion/app/api/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
28
Produktion/skrift-produktion/app/constants.py
Normal file
28
Produktion/skrift-produktion/app/constants.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
NEW = "new"
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
ERROR = "error"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class MachineStatus(str, Enum):
|
||||
IDLE = "Idle"
|
||||
PRINTING = "Printing"
|
||||
ERROR = "Error"
|
||||
PAUSED = "Paused"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
MACHINE_ERROR_CODES = {
|
||||
0: "Erfolgreich",
|
||||
-1: "Parameterfehler",
|
||||
-2: "Keine Maschine verfügbar",
|
||||
-3: "Service beschäftigt",
|
||||
1: "Fehler beim Generieren der Schreibdatei",
|
||||
2: "Bitte erneut in der Software einloggen",
|
||||
}
|
||||
0
Produktion/skrift-produktion/app/core/__init__.py
Normal file
0
Produktion/skrift-produktion/app/core/__init__.py
Normal file
176
Produktion/skrift-produktion/app/core/config_manager.py
Normal file
176
Produktion/skrift-produktion/app/core/config_manager.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import os
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"format_mapping": {
|
||||
"letter": {
|
||||
"A4": "a4_single",
|
||||
"A6 Hochformat": "a6_v1",
|
||||
"A6 Querformat": "a6_v2",
|
||||
},
|
||||
"envelope": {
|
||||
"DIN Lang": "kuvert_a_v1",
|
||||
"C6": "kuvert_b_v1",
|
||||
},
|
||||
},
|
||||
"connection": {
|
||||
"protocol": "sftp",
|
||||
"host": "",
|
||||
"port": 22,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"private_key_path": "",
|
||||
"remote_path": "/orders/",
|
||||
"poll_interval_seconds": 30,
|
||||
},
|
||||
"machine": {
|
||||
"base_url": "http://192.168.2.32:90",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"mid": "",
|
||||
"poll_interval_seconds": 5,
|
||||
},
|
||||
"templates": {
|
||||
"a4_single": {
|
||||
"name": "A4 Brief – Einzeln",
|
||||
"width": 8.27,
|
||||
"height": 11.69,
|
||||
"xpos": 0.0,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
},
|
||||
"a6_v1": {
|
||||
"name": "A6 Brief – Variante 1",
|
||||
"width": 4.1,
|
||||
"height": 5.8,
|
||||
"xpos": -0.3,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
},
|
||||
"a6_v2": {
|
||||
"name": "A6 Brief – Variante 2",
|
||||
"width": 4.1,
|
||||
"height": 5.8,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 90,
|
||||
},
|
||||
"kuvert_a_v1": {
|
||||
"name": "Kuvert A – Variante 1",
|
||||
"width": 4.49,
|
||||
"height": 6.38,
|
||||
"xpos": 4.2,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90,
|
||||
},
|
||||
"kuvert_a_v2": {
|
||||
"name": "Kuvert A – Variante 2",
|
||||
"width": 4.33,
|
||||
"height": 8.27,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90,
|
||||
},
|
||||
"kuvert_b_v1": {
|
||||
"name": "Kuvert B – Variante 1",
|
||||
"width": 3.9,
|
||||
"height": 8.27,
|
||||
"xpos": 4.49,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90,
|
||||
},
|
||||
},
|
||||
"app": {
|
||||
"theme": "dark",
|
||||
"local_temp_dir": "",
|
||||
},
|
||||
"machines": [],
|
||||
}
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self, config_path: str):
|
||||
self._path = config_path
|
||||
self._data: dict = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(self._path):
|
||||
with open(self._path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), loaded)
|
||||
else:
|
||||
self._data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||
with open(self._path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _deep_merge(self, base: dict, override: dict) -> dict:
|
||||
result = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def get(self, key_path: str, default: Any = None) -> Any:
|
||||
"""Access nested keys with dot notation: 'connection.host'"""
|
||||
parts = key_path.split(".")
|
||||
current = self._data
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return default
|
||||
return current
|
||||
|
||||
def set(self, key_path: str, value: Any):
|
||||
"""Set nested key with dot notation and persist."""
|
||||
parts = key_path.split(".")
|
||||
current = self._data
|
||||
for part in parts[:-1]:
|
||||
current = current.setdefault(part, {})
|
||||
current[parts[-1]] = value
|
||||
self._save()
|
||||
|
||||
def get_section(self, section: str) -> dict:
|
||||
return copy.deepcopy(self._data.get(section, {}))
|
||||
|
||||
def set_section(self, section: str, data: dict):
|
||||
self._data[section] = data
|
||||
self._save()
|
||||
|
||||
def get_templates(self) -> dict:
|
||||
return copy.deepcopy(self._data.get("templates", {}))
|
||||
|
||||
def save_template(self, key: str, template: dict):
|
||||
if "templates" not in self._data:
|
||||
self._data["templates"] = {}
|
||||
self._data["templates"][key] = template
|
||||
self._save()
|
||||
|
||||
def delete_template(self, key: str):
|
||||
self._data.get("templates", {}).pop(key, None)
|
||||
self._save()
|
||||
|
||||
def get_local_temp_dir(self) -> str:
|
||||
path = self.get("app.local_temp_dir") or os.path.join(
|
||||
os.environ.get("TEMP", os.path.expanduser("~")), "ftp_checker"
|
||||
)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
def reload(self):
|
||||
self._load()
|
||||
219
Produktion/skrift-produktion/app/core/ftp_client.py
Normal file
219
Produktion/skrift-produktion/app/core/ftp_client.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import errno
|
||||
import ftplib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class FTPConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ManifestNotFound(Exception):
|
||||
"""Der Ordner enthält (noch) keine order-metadata.json – kein echter Fehler."""
|
||||
|
||||
|
||||
def _is_missing_file(exc: Exception) -> bool:
|
||||
"""Erkennt 'Datei existiert nicht' über SFTP (ENOENT) und FTP (550)."""
|
||||
if isinstance(exc, FileNotFoundError):
|
||||
return True
|
||||
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOENT:
|
||||
return True
|
||||
if isinstance(exc, ftplib.error_perm) and str(exc).startswith("550"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FTPClient:
|
||||
"""
|
||||
Protocol-agnostic FTP/SFTP client.
|
||||
Uses paramiko for SFTP and ftplib for FTP.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self._config = config
|
||||
self._sftp = None
|
||||
self._ssh = None
|
||||
self._ftp: Optional[ftplib.FTP] = None
|
||||
self._protocol: str = "sftp"
|
||||
|
||||
def connect(self):
|
||||
self._protocol = self._config.get("connection.protocol", "sftp").lower()
|
||||
host = self._config.get("connection.host", "")
|
||||
port = int(self._config.get("connection.port", 22))
|
||||
username = self._config.get("connection.username", "")
|
||||
password = self._config.get("connection.password", "")
|
||||
key_path = self._config.get("connection.private_key_path", "")
|
||||
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
import paramiko
|
||||
self._ssh = paramiko.SSHClient()
|
||||
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self._ssh.connect(
|
||||
hostname=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password or None,
|
||||
key_filename=key_path or None,
|
||||
timeout=10,
|
||||
)
|
||||
self._sftp = self._ssh.open_sftp()
|
||||
else:
|
||||
ftp_port = int(self._config.get("connection.port", 21))
|
||||
self._ftp = ftplib.FTP()
|
||||
self._ftp.connect(host, ftp_port, timeout=10)
|
||||
self._ftp.login(username, password)
|
||||
except Exception as e:
|
||||
raise FTPConnectionError(f"Verbindung fehlgeschlagen: {e}") from e
|
||||
|
||||
def disconnect(self):
|
||||
try:
|
||||
if self._sftp:
|
||||
self._sftp.close()
|
||||
if self._ssh:
|
||||
self._ssh.close()
|
||||
if self._ftp:
|
||||
self._ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._sftp = None
|
||||
self._ssh = None
|
||||
self._ftp = None
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
if self._sftp is None:
|
||||
return False
|
||||
self._sftp.listdir(".")
|
||||
return True
|
||||
else:
|
||||
if self._ftp is None:
|
||||
return False
|
||||
self._ftp.voidcmd("NOOP")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_order_folders(self) -> list[str]:
|
||||
remote_path = self._config.get("connection.remote_path", "/orders/")
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
entries = self._sftp.listdir_attr(remote_path)
|
||||
return [
|
||||
e.filename
|
||||
for e in entries
|
||||
if stat.S_ISDIR(e.st_mode)
|
||||
]
|
||||
else:
|
||||
original = self._ftp.pwd()
|
||||
self._ftp.cwd(remote_path)
|
||||
folders = []
|
||||
lines = []
|
||||
self._ftp.retrlines("LIST", lines.append)
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if parts and parts[0].startswith("d"):
|
||||
name = parts[-1]
|
||||
if name not in (".", ".."):
|
||||
folders.append(name)
|
||||
self._ftp.cwd(original)
|
||||
return folders
|
||||
except Exception as e:
|
||||
raise FTPConnectionError(f"Ordner auflisten fehlgeschlagen: {e}") from e
|
||||
|
||||
def list_folder_files(self, folder: str, recursive: bool = False) -> list[dict]:
|
||||
"""
|
||||
Listet Dateien im Auftragsordner auf. Mit ``recursive=True`` werden auch
|
||||
Dateien in Unterordnern (z.B. 'umschlaege/') aufgenommen; ``name`` enthält
|
||||
dann den relativen Pfad (z.B. 'umschlaege/kuvert_000.svg').
|
||||
"""
|
||||
remote_path = self._config.get("connection.remote_path", "/orders/")
|
||||
folder_path = f"{remote_path.rstrip('/')}/{folder}"
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
return self._list_sftp(folder_path, "", recursive)
|
||||
else:
|
||||
return self._list_ftp(folder_path, "", recursive)
|
||||
except Exception as e:
|
||||
raise FTPConnectionError(f"Dateiliste fehlgeschlagen ({folder}): {e}") from e
|
||||
|
||||
def _list_sftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
|
||||
out = []
|
||||
for e in self._sftp.listdir_attr(base_path):
|
||||
rel = f"{prefix}{e.filename}"
|
||||
if stat.S_ISDIR(e.st_mode):
|
||||
if recursive:
|
||||
out.extend(self._list_sftp(f"{base_path}/{e.filename}", rel + "/", True))
|
||||
else:
|
||||
out.append({"name": rel, "size": e.st_size or 0})
|
||||
return out
|
||||
|
||||
def _list_ftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]:
|
||||
original = self._ftp.pwd()
|
||||
self._ftp.cwd(base_path)
|
||||
lines = []
|
||||
self._ftp.retrlines("LIST", lines.append)
|
||||
self._ftp.cwd(original)
|
||||
out = []
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
name = parts[-1]
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
if parts[0].startswith("d"):
|
||||
if recursive:
|
||||
out.extend(self._list_ftp(f"{base_path}/{name}", f"{prefix}{name}/", True))
|
||||
else:
|
||||
try:
|
||||
size = int(parts[4])
|
||||
except (IndexError, ValueError):
|
||||
size = 0
|
||||
out.append({"name": f"{prefix}{name}", "size": size})
|
||||
return out
|
||||
|
||||
def read_order_manifest(self, folder: str) -> dict:
|
||||
remote_path = self._config.get("connection.remote_path", "/orders/")
|
||||
manifest_path = f"{remote_path.rstrip('/')}/{folder}/order-metadata.json"
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
with self._sftp.open(manifest_path, "r") as f:
|
||||
return json.loads(f.read())
|
||||
else:
|
||||
buf = io.BytesIO()
|
||||
self._ftp.retrbinary(f"RETR {manifest_path}", buf.write)
|
||||
return json.loads(buf.getvalue().decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Ungültiges JSON in {manifest_path}: {e}") from e
|
||||
except Exception as e:
|
||||
# Fehlende Datei ist der Normalfall (Ordner noch im Upload o.Ä.) –
|
||||
# gesondert melden, damit der Poller nicht jeden Zyklus warnt.
|
||||
if _is_missing_file(e):
|
||||
raise ManifestNotFound(folder) from e
|
||||
raise FTPConnectionError(f"Manifest lesen fehlgeschlagen: {e}") from e
|
||||
|
||||
def download_svg(self, folder: str, filename: str, local_dir: str) -> str:
|
||||
"""
|
||||
Lädt eine SVG-Datei herunter. filename kann einen Unterordnerpfad
|
||||
enthalten (z.B. 'umschlaege/kuvert_000.svg') — lokal wird nur der
|
||||
Dateiname (ohne Unterordner) gespeichert.
|
||||
"""
|
||||
remote_path = self._config.get("connection.remote_path", "/orders/")
|
||||
remote_file = f"{remote_path.rstrip('/')}/{folder}/{filename}"
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
local_path = os.path.join(local_dir, os.path.basename(filename))
|
||||
try:
|
||||
if self._protocol == "sftp":
|
||||
self._sftp.get(remote_file, local_path)
|
||||
else:
|
||||
with open(local_path, "wb") as f:
|
||||
self._ftp.retrbinary(f"RETR {remote_file}", f.write)
|
||||
return local_path
|
||||
except Exception as e:
|
||||
raise FTPConnectionError(f"Download fehlgeschlagen ({filename}): {e}") from e
|
||||
182
Produktion/skrift-produktion/app/core/machine_client.py
Normal file
182
Produktion/skrift-produktion/app/core/machine_client.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.constants import MACHINE_ERROR_CODES, MachineStatus
|
||||
|
||||
|
||||
class MachineAPIError(Exception):
|
||||
def __init__(self, code: int, message: str = ""):
|
||||
self.code = code
|
||||
self.message = message or MACHINE_ERROR_CODES.get(code, f"Unbekannter Fehler (Code {code})")
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class MachineAPIClient:
|
||||
def __init__(self, config, machine_override: dict = None):
|
||||
self._config = config
|
||||
self._override = machine_override or {}
|
||||
self._session = requests.Session()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
if self._override:
|
||||
return self._override.get("base_url", "").rstrip("/")
|
||||
return self._config.get("machine.base_url", "").rstrip("/")
|
||||
|
||||
def login(self) -> bool:
|
||||
if self._override:
|
||||
username = self._override.get("username", "")
|
||||
password = self._override.get("password", "")
|
||||
else:
|
||||
username = self._config.get("machine.username", "")
|
||||
password = self._config.get("machine.password", "")
|
||||
if not username:
|
||||
return True # No login required
|
||||
with self._lock:
|
||||
resp = self._session.get(
|
||||
f"{self._base_url}/login",
|
||||
params={"uin": username, "passwd": password},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("error", -1) == 0
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
"""Returns list of machine status dicts."""
|
||||
if self._override:
|
||||
mid = self._override.get("mid", "")
|
||||
else:
|
||||
mid = self._config.get("machine.mid", "")
|
||||
params = {}
|
||||
if mid:
|
||||
params["mids"] = mid
|
||||
with self._lock:
|
||||
resp = self._session.get(
|
||||
f"{self._base_url}/machine_status",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
def get_first_status(self) -> Optional[dict]:
|
||||
statuses = self.get_status()
|
||||
return statuses[0] if statuses else None
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
status = self.get_first_status()
|
||||
if status is None:
|
||||
return False
|
||||
return status.get("status") == MachineStatus.IDLE
|
||||
|
||||
def recover_write(self, mid: str = "") -> bool:
|
||||
"""Setzt einen bereits in der Maschine liegenden Schreib-Task nach einem
|
||||
Fehler fort ('Feed again' / recover_write).
|
||||
|
||||
WICHTIG: Es wird KEINE Datei neu hochgeladen – der vorhandene Task wird
|
||||
nur neu zugeführt/fortgesetzt. Gibt True zurück, wenn die Maschine den
|
||||
Recovery bestätigt ({"code":0}).
|
||||
"""
|
||||
if self._override:
|
||||
effective_mid = mid or self._override.get("mid", "")
|
||||
else:
|
||||
effective_mid = mid or self._config.get("machine.mid", "")
|
||||
params = {}
|
||||
if effective_mid:
|
||||
params["mid"] = effective_mid
|
||||
with self._lock:
|
||||
resp = self._session.get(
|
||||
f"{self._base_url}/recover_write",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
raise MachineAPIError(-1, f"Ungültige recover_write-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
|
||||
return data.get("code", -1) == 0
|
||||
|
||||
def send_svgs(
|
||||
self,
|
||||
svg_paths: list[str],
|
||||
width: float,
|
||||
height: float,
|
||||
xpos: float = 0,
|
||||
ypos: float = 0,
|
||||
rotation: float = 0,
|
||||
scale: float = 1.0,
|
||||
mid: str = "",
|
||||
clear: bool = True,
|
||||
start: bool = True,
|
||||
repeat: int = 1,
|
||||
) -> dict:
|
||||
"""
|
||||
Upload one or more SVG files in a single multipart POST.
|
||||
All files share the same width/height/xpos/ypos/rotation/scale.
|
||||
"""
|
||||
def _fmt(v: float) -> str:
|
||||
"""Format float without trailing .0 so the machine API gets '90' not '90.0'."""
|
||||
return str(int(v)) if float(v) == int(v) else str(v)
|
||||
|
||||
data = {
|
||||
"width": f"{_fmt(width)}in",
|
||||
"height": f"{_fmt(height)}in",
|
||||
"xpos": f"{_fmt(xpos)}in",
|
||||
"ypos": f"{_fmt(ypos)}in",
|
||||
"rotation": _fmt(rotation),
|
||||
"scale": _fmt(scale),
|
||||
"clear": "1" if clear else "0",
|
||||
"start": "1" if start else "0",
|
||||
"repeat": str(repeat),
|
||||
}
|
||||
if self._override:
|
||||
effective_mid = mid or self._override.get("mid", "")
|
||||
else:
|
||||
effective_mid = mid or self._config.get("machine.mid", "")
|
||||
if effective_mid:
|
||||
data["mid"] = effective_mid
|
||||
|
||||
import os, logging
|
||||
_log = logging.getLogger(__name__)
|
||||
last = len(svg_paths) - 1
|
||||
result = {}
|
||||
for i, path in enumerate(svg_paths):
|
||||
file_data = {
|
||||
**data,
|
||||
"clear": "1" if i == 0 else "0",
|
||||
"start": "1" if i == last else "0",
|
||||
}
|
||||
_log.info(
|
||||
"write_svg [%d/%d] %s | params: %s",
|
||||
i + 1, last + 1, os.path.basename(path),
|
||||
{k: v for k, v in file_data.items() if k != "file"},
|
||||
)
|
||||
with open(path, "rb") as h:
|
||||
files = [("file", (os.path.basename(path), h, "image/svg+xml"))]
|
||||
with self._lock:
|
||||
resp = self._session.post(
|
||||
f"{self._base_url}/write_svg",
|
||||
data=file_data,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
raise MachineAPIError(-1, f"Ungültige API-Antwort (HTTP {resp.status_code}): {resp.text[:200]}")
|
||||
_log.info("write_svg response [%d/%d]: %s", i + 1, last + 1, result)
|
||||
if result.get("error", -1) != 0:
|
||||
err_code = result["error"]
|
||||
raise MachineAPIError(
|
||||
err_code,
|
||||
f"Maschine meldet Fehler {err_code} bei Datei '{os.path.basename(path)}' "
|
||||
f"(params: width={data['width']} height={data['height']} "
|
||||
f"xpos={data['xpos']} ypos={data['ypos']} "
|
||||
f"rotation={data['rotation']} scale={data['scale']})"
|
||||
)
|
||||
return result
|
||||
390
Produktion/skrift-produktion/app/core/order_manager.py
Normal file
390
Produktion/skrift-produktion/app/core/order_manager.py
Normal file
@@ -0,0 +1,390 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.constants import OrderStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
order_number: str
|
||||
session_id: str
|
||||
finalized_at: str
|
||||
letter_count: int
|
||||
envelope_count: int
|
||||
letters: list
|
||||
envelopes: list
|
||||
letter_format: str
|
||||
envelope_format: str
|
||||
folder_name: str
|
||||
local_dir: str
|
||||
envelope_beschriftung: str = ""
|
||||
status: OrderStatus = OrderStatus.NEW
|
||||
error_message: Optional[str] = None
|
||||
letter_params: Optional[dict] = None
|
||||
envelope_params: Optional[dict] = None
|
||||
split_size: int = 0 # 0 = no split
|
||||
queue_send_letters: bool = True # in der Warteschlange: Briefe senden?
|
||||
queue_send_envelopes: bool = True # in der Warteschlange: Kuverts senden?
|
||||
queue_seq: float = 0.0 # Abarbeitungsreihenfolge der Warteschlange (kleiner = früher)
|
||||
letter_print_log: list = field(default_factory=list) # [{"from":1,"to":10,"at":"ISO"}]
|
||||
envelope_print_log: list = field(default_factory=list)
|
||||
comment: str = ""
|
||||
has_manifest: bool = True # False = Ordner ohne order-metadata.json (nur Dateien)
|
||||
|
||||
|
||||
class OrderManager:
|
||||
def __init__(self, processed_path: str, temp_base_dir: str, archive_path: str = None):
|
||||
self._processed_path = processed_path
|
||||
self._temp_base_dir = temp_base_dir
|
||||
self._archive_path = archive_path or os.path.join(
|
||||
os.path.dirname(processed_path), "archive.json"
|
||||
)
|
||||
self._order_data_path = os.path.join(
|
||||
os.path.dirname(processed_path), "order_data.json"
|
||||
)
|
||||
self._orders: dict[str, Order] = {}
|
||||
self._processed: set[str] = set()
|
||||
self._archived_orders: dict[str, dict] = {}
|
||||
self._order_data: dict[str, dict] = {} # persistent per-order data
|
||||
self._lock = threading.Lock()
|
||||
self._load_processed()
|
||||
self._load_archive()
|
||||
self._load_order_data()
|
||||
|
||||
def _load_processed(self):
|
||||
if os.path.exists(self._processed_path):
|
||||
try:
|
||||
with open(self._processed_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self._processed = set(data.get("processed", []))
|
||||
except Exception:
|
||||
self._processed = set()
|
||||
|
||||
def _save_processed(self):
|
||||
os.makedirs(os.path.dirname(self._processed_path), exist_ok=True)
|
||||
with open(self._processed_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"processed": sorted(self._processed),
|
||||
"last_updated": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def _load_order_data(self):
|
||||
if os.path.exists(self._order_data_path):
|
||||
try:
|
||||
with open(self._order_data_path, "r", encoding="utf-8") as f:
|
||||
self._order_data = json.load(f)
|
||||
except Exception:
|
||||
self._order_data = {}
|
||||
|
||||
def _save_order_data(self):
|
||||
# Merge current in-memory orders into persistent store
|
||||
for num, order in self._orders.items():
|
||||
self._order_data[num] = {
|
||||
"comment": order.comment,
|
||||
"letter_print_log": order.letter_print_log,
|
||||
"envelope_print_log": order.envelope_print_log,
|
||||
}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self._order_data_path), exist_ok=True)
|
||||
with open(self._order_data_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._order_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_order_data(self, order: "Order"):
|
||||
"""Merge persisted per-order data (comment, print log) into an Order object."""
|
||||
stored = self._order_data.get(order.order_number, {})
|
||||
if stored.get("comment"):
|
||||
order.comment = stored["comment"]
|
||||
if stored.get("letter_print_log"):
|
||||
order.letter_print_log = list(stored["letter_print_log"])
|
||||
if stored.get("envelope_print_log"):
|
||||
order.envelope_print_log = list(stored["envelope_print_log"])
|
||||
|
||||
def _load_archive(self):
|
||||
if os.path.exists(self._archive_path):
|
||||
try:
|
||||
with open(self._archive_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self._archived_orders = {
|
||||
entry["order_number"]: entry
|
||||
for entry in data.get("archived", [])
|
||||
if "order_number" in entry
|
||||
}
|
||||
except Exception:
|
||||
self._archived_orders = {}
|
||||
|
||||
def _save_archive(self):
|
||||
os.makedirs(os.path.dirname(self._archive_path), exist_ok=True)
|
||||
with open(self._archive_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"archived": list(self._archived_orders.values()),
|
||||
"last_updated": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def archive(self, order_number: str):
|
||||
"""Move an order to the archive. Saves minimal metadata to archive file."""
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if not order:
|
||||
return
|
||||
metadata = {
|
||||
"order_number": order.order_number,
|
||||
"folder_name": order.folder_name,
|
||||
"finalized_at": order.finalized_at,
|
||||
"letter_count": order.letter_count,
|
||||
"envelope_count": order.envelope_count,
|
||||
"letter_format": order.letter_format,
|
||||
"envelope_format": order.envelope_format,
|
||||
"envelope_beschriftung": order.envelope_beschriftung,
|
||||
"archived_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
self._archived_orders[order_number] = metadata
|
||||
del self._orders[order_number]
|
||||
self._save_archive()
|
||||
|
||||
def unarchive(self, order_number: str) -> bool:
|
||||
"""Restore an archived order back to active status (NEW)."""
|
||||
with self._lock:
|
||||
metadata = self._archived_orders.get(order_number)
|
||||
if not metadata:
|
||||
return False
|
||||
# Remove from archive + processed
|
||||
del self._archived_orders[order_number]
|
||||
self._processed.discard(order_number)
|
||||
# Re-create a minimal Order object so it shows immediately
|
||||
temp_dir = os.path.join(self._temp_base_dir, order_number)
|
||||
order = Order(
|
||||
order_number=metadata["order_number"],
|
||||
session_id="",
|
||||
finalized_at=metadata.get("finalized_at", ""),
|
||||
letter_count=metadata.get("letter_count", 0),
|
||||
envelope_count=metadata.get("envelope_count", 0),
|
||||
letters=[],
|
||||
envelopes=[],
|
||||
letter_format=metadata.get("letter_format", ""),
|
||||
envelope_format=metadata.get("envelope_format", ""),
|
||||
folder_name=metadata.get("folder_name", ""),
|
||||
local_dir=temp_dir,
|
||||
envelope_beschriftung=metadata.get("envelope_beschriftung", ""),
|
||||
status=OrderStatus.NEW,
|
||||
)
|
||||
self._apply_order_data(order)
|
||||
self._orders[order_number] = order
|
||||
self._save_archive()
|
||||
self._save_processed()
|
||||
return True
|
||||
|
||||
def get_archived(self) -> list[dict]:
|
||||
"""Return archived order metadata, sorted by finalized_at descending."""
|
||||
with self._lock:
|
||||
items = list(self._archived_orders.values())
|
||||
items.sort(key=lambda x: x.get("finalized_at", ""), reverse=True)
|
||||
return items
|
||||
|
||||
def _apply_manifest_fields(self, order: "Order", folder: str, manifest: dict):
|
||||
"""Populate/refresh an order's content fields from its manifest."""
|
||||
order.session_id = manifest.get("sessionId") or ""
|
||||
order.finalized_at = manifest.get("finalizedAt") or ""
|
||||
order.letter_count = manifest.get("letterCount") or 0
|
||||
order.envelope_count = manifest.get("envelopeCount") or 0
|
||||
order.letters = manifest.get("letters") or []
|
||||
order.envelopes = manifest.get("envelopes") or []
|
||||
order.letter_format = manifest.get("letterFormat") or ""
|
||||
order.envelope_format = manifest.get("envelopeFormat") or ""
|
||||
order.envelope_beschriftung = manifest.get("envelopeBeschriftung") or ""
|
||||
order.folder_name = folder
|
||||
order.has_manifest = True
|
||||
|
||||
def update_from_remote(
|
||||
self,
|
||||
folders: list[str],
|
||||
manifests: dict[str, dict],
|
||||
no_manifest_folders: set[str] = None,
|
||||
) -> list[Order]:
|
||||
"""
|
||||
Merge newly fetched folder data. Returns list of newly discovered orders.
|
||||
|
||||
Ordner mit Manifest werden wie gewohnt angelegt. Ordner in
|
||||
``no_manifest_folders`` (bestätigt ohne order-metadata.json) erscheinen
|
||||
mit dem Ordnernamen als Auftragsnummer und ohne SVG-Liste. Taucht später
|
||||
ein Manifest auf, wird der bestehende Auftrag aufgewertet.
|
||||
"""
|
||||
no_manifest_folders = no_manifest_folders or set()
|
||||
new_orders = []
|
||||
with self._lock:
|
||||
for folder in folders:
|
||||
manifest = manifests.get(folder)
|
||||
if manifest:
|
||||
order_number = manifest.get("orderNumber") or folder
|
||||
existing = self._orders.get(order_number)
|
||||
if existing:
|
||||
# Bereits bekannt – nur einen Manifest-losen Stub aufwerten.
|
||||
if not existing.has_manifest:
|
||||
self._apply_manifest_fields(existing, folder, manifest)
|
||||
continue
|
||||
if order_number in self._archived_orders:
|
||||
continue # Already archived
|
||||
local_dir = os.path.join(self._temp_base_dir, order_number)
|
||||
already_sent = order_number in self._processed
|
||||
order = Order(
|
||||
order_number=order_number,
|
||||
session_id="",
|
||||
finalized_at="",
|
||||
letter_count=0,
|
||||
envelope_count=0,
|
||||
letters=[],
|
||||
envelopes=[],
|
||||
letter_format="",
|
||||
envelope_format="",
|
||||
folder_name=folder,
|
||||
local_dir=local_dir,
|
||||
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
|
||||
)
|
||||
self._apply_manifest_fields(order, folder, manifest)
|
||||
self._apply_order_data(order)
|
||||
self._orders[order_number] = order
|
||||
if not already_sent:
|
||||
new_orders.append(order)
|
||||
elif folder in no_manifest_folders:
|
||||
# Ordner ohne Manifest: Ordnername = Auftragsnummer.
|
||||
order_number = folder
|
||||
if order_number in self._orders:
|
||||
continue
|
||||
if order_number in self._archived_orders:
|
||||
continue
|
||||
local_dir = os.path.join(self._temp_base_dir, order_number)
|
||||
already_sent = order_number in self._processed
|
||||
order = Order(
|
||||
order_number=order_number,
|
||||
session_id="",
|
||||
finalized_at="",
|
||||
letter_count=0,
|
||||
envelope_count=0,
|
||||
letters=[],
|
||||
envelopes=[],
|
||||
letter_format="",
|
||||
envelope_format="",
|
||||
folder_name=folder,
|
||||
local_dir=local_dir,
|
||||
status=OrderStatus.SENT if already_sent else OrderStatus.NEW,
|
||||
has_manifest=False,
|
||||
)
|
||||
self._apply_order_data(order)
|
||||
self._orders[order_number] = order
|
||||
if not already_sent:
|
||||
new_orders.append(order)
|
||||
return new_orders
|
||||
|
||||
def get_all(self) -> list[Order]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
self._orders.values(),
|
||||
key=lambda o: o.finalized_at,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def get_by_number(self, order_number: str) -> Optional[Order]:
|
||||
with self._lock:
|
||||
return self._orders.get(order_number)
|
||||
|
||||
def update_status(self, order_number: str, status: OrderStatus, error: str = None):
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if order:
|
||||
order.status = status
|
||||
order.error_message = error
|
||||
|
||||
def mark_processed(self, order_number: str):
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if order:
|
||||
order.status = OrderStatus.SENT
|
||||
order.error_message = None
|
||||
self._processed.add(order_number)
|
||||
self._save_processed()
|
||||
|
||||
def is_processed(self, order_number: str) -> bool:
|
||||
with self._lock:
|
||||
return order_number in self._processed
|
||||
|
||||
def get_queued(self) -> list[Order]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
[o for o in self._orders.values() if o.status == OrderStatus.QUEUED],
|
||||
key=lambda o: (o.queue_seq, o.finalized_at),
|
||||
)
|
||||
|
||||
def add_print_log(self, order_number: str, kind: str, from_idx: int, to_idx: int):
|
||||
"""Record a printed range (0-based, inclusive) for 'letter' or 'envelope'."""
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if not order:
|
||||
return
|
||||
entry = {"from": from_idx, "to": to_idx, "at": datetime.utcnow().isoformat() + "Z"}
|
||||
if kind == "letter":
|
||||
order.letter_print_log.append(entry)
|
||||
elif kind == "envelope":
|
||||
order.envelope_print_log.append(entry)
|
||||
self._save_order_data()
|
||||
|
||||
def add_print_indices(self, order_number: str, kind: str, indices: list):
|
||||
"""
|
||||
Record printed 0-based indices (Briefnummern) for 'letter' or 'envelope'.
|
||||
Aufeinanderfolgende Nummern werden zu {from,to}-Bereichen komprimiert.
|
||||
"""
|
||||
idx = sorted({int(i) for i in indices})
|
||||
if not idx:
|
||||
return
|
||||
ranges = []
|
||||
start = prev = idx[0]
|
||||
for i in idx[1:]:
|
||||
if i == prev + 1:
|
||||
prev = i
|
||||
else:
|
||||
ranges.append((start, prev))
|
||||
start = prev = i
|
||||
ranges.append((start, prev))
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if not order:
|
||||
return
|
||||
log = order.letter_print_log if kind == "letter" else order.envelope_print_log
|
||||
for a, b in ranges:
|
||||
log.append({"from": a, "to": b, "at": now})
|
||||
self._save_order_data()
|
||||
|
||||
def set_comment(self, order_number: str, comment: str):
|
||||
"""Save a comment for an order."""
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if order:
|
||||
order.comment = comment
|
||||
if order_number not in self._order_data:
|
||||
self._order_data[order_number] = {}
|
||||
self._order_data[order_number]["comment"] = comment
|
||||
self._save_order_data()
|
||||
|
||||
def set_order_params(self, order_number: str, **kwargs):
|
||||
"""Update per-order UI parameters (template key, overrides, split size)."""
|
||||
with self._lock:
|
||||
order = self._orders.get(order_number)
|
||||
if not order:
|
||||
return
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(order, k):
|
||||
setattr(order, k, v)
|
||||
2870
Produktion/skrift-produktion/app/static/index.html
Normal file
2870
Produktion/skrift-produktion/app/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
122
Produktion/skrift-produktion/app/workers/machine_poll_worker.py
Normal file
122
Produktion/skrift-produktion/app/workers/machine_poll_worker.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from app.constants import MachineStatus
|
||||
from app.core.machine_client import MachineAPIClient
|
||||
|
||||
|
||||
class MachinePollWorker(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
machine_client: MachineAPIClient,
|
||||
on_status_update: Callable,
|
||||
on_log: Callable,
|
||||
on_idle_trigger: Callable,
|
||||
):
|
||||
super().__init__(daemon=True)
|
||||
self._config = config
|
||||
self._machine = machine_client
|
||||
self._on_status_update = on_status_update # called with dict or None
|
||||
self._on_log = on_log
|
||||
self._on_idle_trigger = on_idle_trigger # called when machine becomes Idle
|
||||
self._stop_event = threading.Event()
|
||||
self._last_status: Optional[str] = None
|
||||
|
||||
# --- Auto-Recovery bei Fehler während des Drucks ---
|
||||
# Versucht max. N-mal recover_write ("Feed again"), OHNE den Auftrag
|
||||
# neu zu senden. Wird nur ausgelöst, wenn die Maschine aus dem Zustand
|
||||
# "Printing" heraus auf "Error" wechselt (= Fehler während des Drucks).
|
||||
self._max_retries = config.get("machine.error_max_retries", 3)
|
||||
self._retry_delay = config.get("machine.error_retry_delay_seconds", 5)
|
||||
self._error_active = False # läuft gerade eine Recovery-Episode?
|
||||
self._error_retries = 0 # bereits unternommene recover_write-Versuche
|
||||
self._last_retry_ts = 0.0 # Zeitstempel des letzten Versuchs (monotonic)
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def run(self):
|
||||
interval = self._config.get("machine.poll_interval_seconds", 5)
|
||||
while not self._stop_event.wait(timeout=interval):
|
||||
interval = self._config.get("machine.poll_interval_seconds", 5)
|
||||
self._poll()
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
status_list = self._machine.get_status()
|
||||
if status_list:
|
||||
status = status_list[0]
|
||||
self._on_status_update(status)
|
||||
current = status.get("status", MachineStatus.UNKNOWN)
|
||||
if self._last_status != MachineStatus.IDLE and current == MachineStatus.IDLE:
|
||||
self._on_idle_trigger()
|
||||
self._handle_error_recovery(self._last_status, current, status)
|
||||
self._last_status = current
|
||||
else:
|
||||
self._on_status_update(None)
|
||||
self._last_status = None
|
||||
except Exception as e:
|
||||
self._on_log(f"Maschinen-Status Fehler: {e}", "error")
|
||||
self._on_status_update(None)
|
||||
|
||||
def _handle_error_recovery(self, prev: Optional[str], current: str, status: dict):
|
||||
"""Automatischer recover_write-Retry bei Fehler während des Drucks.
|
||||
|
||||
Sendet NICHTS neu – ruft nur recover_write auf, um den bereits in der
|
||||
Maschine liegenden Task fortzusetzen. Max. self._max_retries Versuche
|
||||
mit je self._retry_delay Sekunden Abstand.
|
||||
"""
|
||||
if current == MachineStatus.ERROR:
|
||||
# Episode nur starten, wenn der Fehler aus laufendem Druck kam
|
||||
if not self._error_active and prev == MachineStatus.PRINTING:
|
||||
self._error_active = True
|
||||
self._error_retries = 0
|
||||
self._last_retry_ts = 0.0
|
||||
self._on_log(
|
||||
"Maschine meldet Fehler während des Drucks – starte automatische "
|
||||
f"Wiederaufnahme (max. {self._max_retries} Versuche, kein Neu-Senden).",
|
||||
"warning",
|
||||
)
|
||||
|
||||
if self._error_active and self._error_retries < self._max_retries:
|
||||
now = time.monotonic()
|
||||
if now - self._last_retry_ts >= self._retry_delay:
|
||||
self._error_retries += 1
|
||||
self._last_retry_ts = now
|
||||
mid = status.get("mid", "")
|
||||
self._on_log(
|
||||
f"Wiederaufnahme-Versuch {self._error_retries}/{self._max_retries} "
|
||||
f"(recover_write)…", "info",
|
||||
)
|
||||
try:
|
||||
ok = self._machine.recover_write(mid)
|
||||
if ok:
|
||||
self._on_log(
|
||||
f"recover_write bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
self._on_log(
|
||||
f"recover_write nicht bestätigt (Versuch {self._error_retries}/{self._max_retries}).",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
self._on_log(f"recover_write Fehler: {e}", "error")
|
||||
|
||||
elif self._error_active and self._error_retries >= self._max_retries:
|
||||
# Alle Versuche ausgeschöpft, Maschine immer noch im Fehler
|
||||
self._on_log(
|
||||
f"Automatische Wiederaufnahme nach {self._max_retries} Versuchen "
|
||||
"fehlgeschlagen – bitte Maschine manuell prüfen.", "error",
|
||||
)
|
||||
self._error_active = False # nicht weiter versuchen (re-armt erst bei erneutem Druck)
|
||||
else:
|
||||
# Maschine hat den Fehlerzustand verlassen
|
||||
if self._error_active and current in (MachineStatus.PRINTING, MachineStatus.IDLE):
|
||||
if self._error_retries > 0:
|
||||
self._on_log("Maschine hat sich erholt – Druck wird fortgesetzt.", "success")
|
||||
self._error_active = False
|
||||
self._error_retries = 0
|
||||
self._last_retry_ts = 0.0
|
||||
183
Produktion/skrift-produktion/app/workers/poll_worker.py
Normal file
183
Produktion/skrift-produktion/app/workers/poll_worker.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
from app.core.ftp_client import FTPClient, FTPConnectionError, ManifestNotFound
|
||||
from app.core.order_manager import OrderManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FTPPollWorker(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
order_manager: OrderManager,
|
||||
on_new_orders: Callable,
|
||||
on_log: Callable,
|
||||
on_connection_change: Callable,
|
||||
):
|
||||
super().__init__(daemon=True)
|
||||
self._config = config
|
||||
self._order_manager = order_manager
|
||||
self._on_new_orders = on_new_orders # called with list[Order]
|
||||
self._on_log = on_log # called with (message, level)
|
||||
self._on_connection_change = on_connection_change # called with bool
|
||||
self._stop_event = threading.Event()
|
||||
self._ftp = FTPClient(config)
|
||||
self._connected = False
|
||||
# Anti-Spam: pro Ordner die zuletzt gemeldete Hinweis-Signatur merken,
|
||||
# damit dieselbe Meldung nicht bei jedem Poll erneut im Log landet.
|
||||
self._folder_notices: dict[str, str] = {}
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def run(self):
|
||||
self._poll() # Immediate first poll
|
||||
interval = self._config.get("connection.poll_interval_seconds", 30)
|
||||
while not self._stop_event.wait(timeout=interval):
|
||||
interval = self._config.get("connection.poll_interval_seconds", 30)
|
||||
self._poll()
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
if not self._ftp.is_connected():
|
||||
self._ftp.disconnect()
|
||||
self._ftp.connect()
|
||||
if not self._connected:
|
||||
self._connected = True
|
||||
self._on_connection_change(True)
|
||||
self._on_log("FTP-Verbindung hergestellt.", "info")
|
||||
|
||||
folders = self._ftp.list_order_folders()
|
||||
|
||||
# Hinweise für verschwundene Ordner vergessen (damit sie bei
|
||||
# erneutem Auftauchen wieder einmalig gemeldet werden).
|
||||
present = set(folders)
|
||||
for gone in [f for f in self._folder_notices if f not in present]:
|
||||
self._folder_notices.pop(gone, None)
|
||||
|
||||
manifests = {}
|
||||
no_manifest_folders: set[str] = set()
|
||||
for folder in folders:
|
||||
try:
|
||||
manifest = self._ftp.read_order_manifest(folder)
|
||||
# Kuverts liegen im Unterordner 'umschlaege/' — Pfad voraussetzen
|
||||
manifest["envelopes"] = [
|
||||
f"umschlaege/{f}" for f in manifest.get("envelopes", [])
|
||||
]
|
||||
manifests[folder] = manifest
|
||||
self._folder_notices.pop(folder, None) # Ordner ok → Hinweis löschen
|
||||
except ManifestNotFound:
|
||||
# Ordner ohne order-metadata.json: trotzdem als Auftrag
|
||||
# anzeigen (Ordnername = Auftragsnummer, alle Dateien).
|
||||
no_manifest_folders.add(folder)
|
||||
if self._folder_notices.get(folder) != "missing":
|
||||
self._folder_notices[folder] = "missing"
|
||||
self._on_log(
|
||||
f"Ordner {folder}: kein order-metadata.json – "
|
||||
"wird ohne SVG-Zuordnung angezeigt.", "info"
|
||||
)
|
||||
except (ValueError, FTPConnectionError) as e:
|
||||
# Echte Fehler (kaputtes JSON, Verbindungsproblem) weiterhin
|
||||
# als Warnung – aber pro Ordner nur einmal je Meldung.
|
||||
# Nicht als "manifestlos" anzeigen, da das Manifest existiert.
|
||||
if self._folder_notices.get(folder) != str(e):
|
||||
self._folder_notices[folder] = str(e)
|
||||
self._on_log(f"Manifest übersprungen ({folder}): {e}", "warning")
|
||||
|
||||
new_orders = self._order_manager.update_from_remote(
|
||||
folders, manifests, no_manifest_folders
|
||||
)
|
||||
if new_orders:
|
||||
self._on_log(f"{len(new_orders)} neuer Auftrag/Aufträge gefunden.", "info")
|
||||
self._on_new_orders(new_orders)
|
||||
|
||||
except FTPConnectionError as e:
|
||||
self._connected = False
|
||||
self._on_connection_change(False)
|
||||
self._on_log(f"FTP-Fehler: {e}", "error")
|
||||
except Exception as e:
|
||||
self._on_log(f"Unbekannter Poll-Fehler: {e}", "error")
|
||||
|
||||
def reconnect(self):
|
||||
"""Force reconnect on next poll (e.g., after settings change)."""
|
||||
self._ftp.disconnect()
|
||||
self._connected = False
|
||||
|
||||
|
||||
def process_queue_tick():
|
||||
"""
|
||||
Startet den nächsten Warteschlangen-Auftrag, sobald die Maschine frei ist.
|
||||
|
||||
Läuft ohne manuelle Druckbestätigung: der Auftrag wird nach der Übertragung
|
||||
automatisch abgeschlossen (siehe _run_send_job), sodass der nächste Auftrag
|
||||
startet, sobald die Maschine wieder Idle meldet.
|
||||
|
||||
Wiederverwendbar: vom QueueWorker-Loop und vom /api/queue/kick-Endpoint.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency
|
||||
from app.api.routes import _state, _run_send_job
|
||||
|
||||
# Skip wenn schon ein Job läuft
|
||||
if _state.get("current_order"):
|
||||
return
|
||||
# Skip wenn Formatwechsel-Bestätigung aussteht (physischer Materialwechsel)
|
||||
if _state.get("format_confirm_event"):
|
||||
return
|
||||
|
||||
om = _state.get("om")
|
||||
machine = _state.get("machine")
|
||||
if not om or not machine:
|
||||
return
|
||||
|
||||
# Prüfen ob Maschine bereit ist
|
||||
if not machine.is_idle():
|
||||
return
|
||||
|
||||
# Nächsten Auftrag aus der Warteschlange holen
|
||||
queued = om.get_queued()
|
||||
if not queued:
|
||||
return
|
||||
|
||||
next_order = queued[0]
|
||||
|
||||
# Gespeicherte Druckparameter verwenden
|
||||
zero = {"width": 0, "height": 0, "xpos": 0, "ypos": 0, "scale": 1, "rotation": 0}
|
||||
letter_params = next_order.letter_params or dict(zero)
|
||||
envelope_params = next_order.envelope_params or dict(zero)
|
||||
|
||||
# Typ-Auswahl der Warteschlange respektieren (Briefe- oder Kuvert-Sammeldruck)
|
||||
send_letters = getattr(next_order, "queue_send_letters", True) and bool(next_order.letters)
|
||||
send_envelopes = getattr(next_order, "queue_send_envelopes", True) and bool(next_order.envelopes)
|
||||
|
||||
log.info(f"Queue-Worker: Starte Auftrag {next_order.order_number}")
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_send_job,
|
||||
args=(next_order, letter_params, envelope_params),
|
||||
kwargs={"send_letters": send_letters, "send_envelopes": send_envelopes},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
|
||||
class QueueWorker(threading.Thread):
|
||||
"""Verarbeitet automatisch warteschlange-Aufträge wenn die Maschine bereit ist."""
|
||||
|
||||
INTERVAL = 6 # Sekunden zwischen Prüfungen
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def run(self):
|
||||
while not self._stop_event.wait(timeout=self.INTERVAL):
|
||||
try:
|
||||
process_queue_tick()
|
||||
except Exception as e:
|
||||
log.warning(f"QueueWorker Fehler: {e}")
|
||||
117
Produktion/skrift-produktion/config.default.json
Normal file
117
Produktion/skrift-produktion/config.default.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"connection": {
|
||||
"protocol": "sftp",
|
||||
"host": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "skrift",
|
||||
"password": "",
|
||||
"private_key_path": "",
|
||||
"remote_path": "/orders/",
|
||||
"poll_interval_seconds": 30
|
||||
},
|
||||
"machine": {
|
||||
"base_url": "http://192.168.2.32:90",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"mid": "",
|
||||
"poll_interval_seconds": 5
|
||||
},
|
||||
"format_mapping": {
|
||||
"letter": {
|
||||
"A4": "a4_v1",
|
||||
"A6 Hochformat": "a6p_v1",
|
||||
"A6 Querformat": "a6l_v1"
|
||||
},
|
||||
"envelope": {
|
||||
"DIN Lang": "dinlang_v1",
|
||||
"C6": "c6_v1"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"a4_v1": {
|
||||
"name": "A4 – Variante 1",
|
||||
"width": 8.27,
|
||||
"height": 11.69,
|
||||
"xpos": 0.0,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 0
|
||||
},
|
||||
"a6p_v1": {
|
||||
"name": "A6 Hochformat – Variante 1",
|
||||
"width": 4.1,
|
||||
"height": 5.8,
|
||||
"xpos": -0.3,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 0
|
||||
},
|
||||
"a6p_v2": {
|
||||
"name": "A6 Hochformat – Variante 2",
|
||||
"width": 4.1,
|
||||
"height": 5.8,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
},
|
||||
"a6l_v1": {
|
||||
"name": "A6 Querformat – Variante 1",
|
||||
"width": 5.8,
|
||||
"height": 4.1,
|
||||
"xpos": -0.3,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 0
|
||||
},
|
||||
"a6l_v2": {
|
||||
"name": "A6 Querformat – Variante 2",
|
||||
"width": 5.8,
|
||||
"height": 4.1,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.3,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
},
|
||||
"dinlang_v1": {
|
||||
"name": "DIN Lang Kuvert – Variante 1",
|
||||
"width": 4.33,
|
||||
"height": 8.27,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
},
|
||||
"dinlang_v2": {
|
||||
"name": "DIN Lang Kuvert – Variante 2",
|
||||
"width": 3.9,
|
||||
"height": 8.27,
|
||||
"xpos": 4.49,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
},
|
||||
"c6_v1": {
|
||||
"name": "C6 Kuvert – Variante 1",
|
||||
"width": 4.49,
|
||||
"height": 6.38,
|
||||
"xpos": 4.2,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
},
|
||||
"c6_v2": {
|
||||
"name": "C6 Kuvert – Variante 2",
|
||||
"width": 4.33,
|
||||
"height": 6.38,
|
||||
"xpos": 4.0,
|
||||
"ypos": -0.2,
|
||||
"scale": 1.0,
|
||||
"rotation": 90
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"theme": "dark",
|
||||
"local_temp_dir": ""
|
||||
}
|
||||
}
|
||||
125
Produktion/skrift-produktion/main.py
Normal file
125
Produktion/skrift-produktion/main.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from app.core.config_manager import ConfigManager
|
||||
from app.core.machine_client import MachineAPIClient
|
||||
from app.core.order_manager import OrderManager
|
||||
from app.workers.poll_worker import FTPPollWorker, QueueWorker
|
||||
from app.workers.machine_poll_worker import MachinePollWorker
|
||||
from app.api import routes
|
||||
from app.api.routes import push_log, push_machine_status, push_new_orders, push_ftp_status
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
PROCESSED_PATH = os.path.join(os.path.dirname(__file__), "processed_orders.json")
|
||||
ARCHIVE_PATH = os.path.join(os.path.dirname(__file__), "archive.json")
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "app", "static")
|
||||
PORT = 8765
|
||||
|
||||
|
||||
def create_app():
|
||||
config = ConfigManager(CONFIG_PATH)
|
||||
order_manager = OrderManager(
|
||||
processed_path=PROCESSED_PATH,
|
||||
temp_base_dir=config.get_local_temp_dir(),
|
||||
archive_path=ARCHIVE_PATH,
|
||||
)
|
||||
machine_client = MachineAPIClient(config)
|
||||
|
||||
app = FastAPI(title="Skrift FTP-Checker")
|
||||
|
||||
# SSE event queue (created inside the async loop)
|
||||
event_queue: asyncio.Queue = None
|
||||
loop_holder = {}
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
nonlocal event_queue
|
||||
event_queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop_holder["loop"] = loop
|
||||
|
||||
ftp_worker = FTPPollWorker(
|
||||
config=config,
|
||||
order_manager=order_manager,
|
||||
on_new_orders=lambda orders: (push_new_orders(), None),
|
||||
on_log=push_log,
|
||||
on_connection_change=push_ftp_status,
|
||||
)
|
||||
machine_worker = MachinePollWorker(
|
||||
config=config,
|
||||
machine_client=machine_client,
|
||||
on_status_update=push_machine_status,
|
||||
on_log=push_log,
|
||||
on_idle_trigger=_on_machine_idle,
|
||||
)
|
||||
|
||||
routes.init(
|
||||
config=config,
|
||||
order_manager=order_manager,
|
||||
machine_client=machine_client,
|
||||
ftp_worker=ftp_worker,
|
||||
machine_worker=machine_worker,
|
||||
event_queue=event_queue,
|
||||
)
|
||||
routes._state["loop"] = loop
|
||||
routes._state["auto_queue"] = False
|
||||
routes._state["cancel_event"] = None
|
||||
routes._state["current_order"] = None
|
||||
|
||||
queue_worker = QueueWorker()
|
||||
|
||||
ftp_worker.start()
|
||||
machine_worker.start()
|
||||
queue_worker.start()
|
||||
|
||||
# Login attempt
|
||||
threading.Thread(target=_do_login, args=(machine_client,), daemon=True).start()
|
||||
|
||||
# Open browser after short delay
|
||||
threading.Timer(1.2, lambda: webbrowser.open(f"http://localhost:{PORT}")).start()
|
||||
|
||||
def _on_machine_idle():
|
||||
pass # Queue-Verarbeitung übernimmt QueueWorker
|
||||
|
||||
def _do_login(machine):
|
||||
try:
|
||||
ok = machine.login()
|
||||
push_log("Maschinen-Login erfolgreich." if ok else "Maschinen-Login fehlgeschlagen.", "success" if ok else "warning")
|
||||
except Exception as e:
|
||||
push_log(f"Maschinen-Login Fehler: {e}", "error")
|
||||
|
||||
# Routes
|
||||
app.include_router(routes.router)
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
# no-store, damit der Browser nach einem Update immer die neue
|
||||
# index.html (inkl. aktualisiertem JS) lädt und nicht die alte cacht.
|
||||
return FileResponse(
|
||||
os.path.join(STATIC_DIR, "index.html"),
|
||||
headers={"Cache-Control": "no-store, must-revalidate"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
handlers=[logging.StreamHandler()],
|
||||
)
|
||||
app = create_app()
|
||||
uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="warning")
|
||||
5
Produktion/skrift-produktion/requirements.txt
Normal file
5
Produktion/skrift-produktion/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn>=0.29.0
|
||||
paramiko>=3.4.0
|
||||
requests>=2.31.0
|
||||
python-multipart>=0.0.9
|
||||
Reference in New Issue
Block a user