391 lines
13 KiB
Python
391 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
ZLS-Export Leitfaden für Loco-Soft
|
||
GUI mit Schritt-Screenshots je Workflow, CSV-Header-Prüfung via JSON,
|
||
Umbenennen und Kopieren auf den Desktop, danach zurück zum Start.
|
||
|
||
Voraussetzungen (installieren in der venv):
|
||
pip install customtkinter pillow
|
||
|
||
Optional für EXE:
|
||
pip install pyinstaller
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import glob
|
||
import shutil
|
||
import datetime
|
||
import traceback
|
||
import tkinter.filedialog as fd
|
||
import tkinter.messagebox as mb
|
||
|
||
import customtkinter as ctk
|
||
from PIL import Image, ImageTk
|
||
|
||
|
||
APP_TITLE = "Das ist ein Leitfaden zu Exportieren der ZLS Liste an Loco-Soft"
|
||
|
||
# Namen der Workflows -> Ordnernamen unter ./assets
|
||
WORKFLOWS = [
|
||
("Kundenadressen", "kundenadressen"),
|
||
("Fahrzeugdaten", "fahrzeugdaten"),
|
||
("Bestandsfahrzeuge", "bestandsfahrzeuge"),
|
||
("Reifenlager", "reifenlager"),
|
||
("Rechnungsablage", "rechnungsablage"),
|
||
]
|
||
|
||
# Standard-Schema (falls assets/schema.json fehlt)
|
||
# Du kannst das hier anpassen oder eine eigene schema.json in ./assets ablegen.
|
||
DEFAULT_SCHEMA = {
|
||
# Prüf-Logik: erstes zutreffendes Pattern gewinnt
|
||
# "pattern": kann ein Teil des Dateinamens sein (case-insensitive)
|
||
# "header": entweder String (exakte 1. Zeile) ODER Liste der Spalten (wird zu CSV-Header zusammengefügt)
|
||
"rules": [
|
||
{"pattern": "kunden", "header": ["KundenNr", "Name", "Straße", "PLZ", "Ort", "Telefon", "E-Mail"]},
|
||
{"pattern": "fahrzeug", "header": ["FahrzeugID", "Marke", "Modell", "FIN", "EZ", "KM"]},
|
||
{"pattern": "bestand", "header": ["BestandID", "Marke", "Modell", "Status", "Standort"]},
|
||
{"pattern": "reifen", "header": ["KundenNr", "SatzNr", "Dimension", "Profil", "Lagerplatz"]},
|
||
{"pattern": "rechnung", "header": ["RechnungsNr", "Datum", "KundenNr", "Betrag", "Steuer", "Gesamt"]},
|
||
],
|
||
# Wenn True: Header-Vergleich ist case-insensitive und trimmt Leerzeichen
|
||
"normalize": True,
|
||
# CSV-Delimiter-Erwartung für Header-Zeile (nur für den Vergleich)
|
||
"delimiter": ","
|
||
}
|
||
|
||
|
||
def resource_path(*relative):
|
||
"""Findet Pfade auch im PyInstaller-Bundle."""
|
||
if hasattr(sys, "_MEIPASS"):
|
||
base = sys._MEIPASS # PyInstaller temp folder
|
||
else:
|
||
base = os.path.abspath(os.path.dirname(__file__))
|
||
return os.path.join(base, *relative)
|
||
|
||
|
||
def load_schema():
|
||
path = resource_path("assets", "schema.json")
|
||
if os.path.isfile(path):
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
print("WARN: Konnte assets/schema.json nicht laden, verwende DEFAULT_SCHEMA.", file=sys.stderr)
|
||
return DEFAULT_SCHEMA
|
||
|
||
|
||
def list_screenshots(workflow_folder):
|
||
# akzeptierte Bildformate
|
||
exts = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
|
||
files = []
|
||
for e in exts:
|
||
files.extend(glob.glob(os.path.join(workflow_folder, e)))
|
||
files.sort()
|
||
return files
|
||
|
||
|
||
def format_timestamp(dt=None):
|
||
dt = dt or datetime.datetime.now()
|
||
# ddmmyyyy_hhmm
|
||
return dt.strftime("%d%m%Y_%H%M")
|
||
|
||
|
||
def normalize_header(text):
|
||
# Entfernt BOM, trims, vereinheitlicht Leerzeichen
|
||
t = text.replace("\ufeff", "").strip()
|
||
# Einheitliche Kommata
|
||
t = ",".join([p.strip() for p in t.split(",")])
|
||
return t
|
||
|
||
|
||
def header_from_list(cols, delimiter=","):
|
||
return delimiter.join([str(c).strip() for c in cols])
|
||
|
||
|
||
def match_rule(filename, rules):
|
||
low = filename.lower()
|
||
for rule in rules:
|
||
if str(rule.get("pattern", "")).lower() in low:
|
||
return rule
|
||
return None
|
||
|
||
|
||
def validate_csv_first_line(file_path, schema):
|
||
"""
|
||
Prüft die erste Zeile gegen das Schema.
|
||
Rückgabe: (ok: bool, message: str)
|
||
"""
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||
first_line = f.readline().rstrip("\n\r")
|
||
except Exception as e:
|
||
return False, f"Fehler beim Lesen: {e}"
|
||
|
||
rules = schema.get("rules", [])
|
||
delimiter = schema.get("delimiter", ",")
|
||
normalize = bool(schema.get("normalize", True))
|
||
|
||
rule = match_rule(os.path.basename(file_path), rules)
|
||
if not rule:
|
||
return False, "Keine passende Regel (pattern) im Schema gefunden."
|
||
|
||
expected = rule.get("header")
|
||
if isinstance(expected, list):
|
||
expected = header_from_list(expected, delimiter=delimiter)
|
||
expected = expected or ""
|
||
|
||
actual = first_line
|
||
if normalize:
|
||
expected = normalize_header(expected)
|
||
actual = normalize_header(actual)
|
||
|
||
ok = (actual == expected)
|
||
msg = "Header OK" if ok else f"Header NICHT OK\nErwartet: {expected}\nGefunden: {actual}"
|
||
return ok, msg
|
||
|
||
|
||
def ensure_desktop_target():
|
||
desktop = os.path.join(os.path.expanduser("~"), "Desktop")
|
||
target = os.path.join(desktop, "ZLS_Export")
|
||
os.makedirs(target, exist_ok=True)
|
||
return target
|
||
|
||
|
||
class WorkflowViewer(ctk.CTkFrame):
|
||
def __init__(self, master, title, folder, on_finished, *args, **kwargs):
|
||
super().__init__(master, *args, **kwargs)
|
||
self.master = master
|
||
self.title = title
|
||
self.folder = folder
|
||
self.on_finished = on_finished
|
||
|
||
self.schema = load_schema()
|
||
self.images = []
|
||
self.image_paths = list_screenshots(self.folder)
|
||
self.idx = 0
|
||
|
||
# UI
|
||
self.grid_columnconfigure(0, weight=1)
|
||
self.grid_rowconfigure(1, weight=1)
|
||
|
||
self.lbl_title = ctk.CTkLabel(self, text=title, font=ctk.CTkFont(size=22, weight="bold"))
|
||
self.lbl_title.grid(row=0, column=0, pady=(8, 4), padx=12)
|
||
|
||
self.canvas = ctk.CTkLabel(self, text="", fg_color="transparent")
|
||
self.canvas.grid(row=1, column=0, sticky="nsew", padx=12, pady=8)
|
||
|
||
# Navigationsleiste
|
||
nav = ctk.CTkFrame(self)
|
||
nav.grid(row=2, column=0, sticky="ew", padx=12, pady=(0, 8))
|
||
nav.grid_columnconfigure((0, 1, 2, 3), weight=1)
|
||
|
||
self.btn_prev = ctk.CTkButton(nav, text="◀ Zurück", command=self.prev_step)
|
||
self.btn_prev.grid(row=0, column=0, padx=6, pady=6, sticky="w")
|
||
|
||
self.lbl_pos = ctk.CTkLabel(nav, text="–/–")
|
||
self.lbl_pos.grid(row=0, column=1, padx=6, pady=6)
|
||
|
||
self.btn_next = ctk.CTkButton(nav, text="Weiter ▶", command=self.next_step)
|
||
self.btn_next.grid(row=0, column=2, padx=6, pady=6, sticky="e")
|
||
|
||
self.btn_abort = ctk.CTkButton(nav, text="Zum Start", fg_color="grey30", command=self.back_to_home)
|
||
self.btn_abort.grid(row=0, column=3, padx=6, pady=6, sticky="e")
|
||
|
||
# Bild-Klick (für "letzter Screenshot geklickt")
|
||
self.canvas.bind("<Button-1>", self.on_image_click)
|
||
|
||
self.load_images()
|
||
self.update_view()
|
||
|
||
def load_images(self):
|
||
self.images = []
|
||
max_w = 1100
|
||
max_h = 650
|
||
|
||
if not self.image_paths:
|
||
# Platzhalter
|
||
placeholder = Image.new("RGB", (900, 500), color=(245, 245, 245))
|
||
self.images.append(ImageTk.PhotoImage(placeholder))
|
||
self.image_paths = ["(Kein Screenshot gefunden)"]
|
||
else:
|
||
for p in self.image_paths:
|
||
try:
|
||
img = Image.open(p)
|
||
img.thumbnail((max_w, max_h), Image.LANCZOS)
|
||
self.images.append(ImageTk.PhotoImage(img))
|
||
except Exception:
|
||
# Platzhalter bei Fehler
|
||
ph = Image.new("RGB", (900, 500), color=(245, 245, 245))
|
||
self.images.append(ImageTk.PhotoImage(ph))
|
||
|
||
def update_view(self):
|
||
self.canvas.configure(image=self.images[self.idx])
|
||
pos = f"Schritt {self.idx+1} von {len(self.images)}"
|
||
# Bildunterschrift als Tooltip-Ersatz (Dateiname)
|
||
caption = os.path.basename(self.image_paths[self.idx])
|
||
self.lbl_pos.configure(text=f"{pos} — {caption}")
|
||
self.btn_prev.configure(state=("normal" if self.idx > 0 else "disabled"))
|
||
self.btn_next.configure(state=("normal" if self.idx < len(self.images)-1 else "disabled"))
|
||
|
||
def prev_step(self):
|
||
if self.idx > 0:
|
||
self.idx -= 1
|
||
self.update_view()
|
||
|
||
def next_step(self):
|
||
if self.idx < len(self.images) - 1:
|
||
self.idx += 1
|
||
self.update_view()
|
||
|
||
def on_image_click(self, _event):
|
||
# Wenn letzter Screenshot geklickt wird → Exportfrage
|
||
if self.idx == len(self.images) - 1:
|
||
go = mb.askyesno("Export starten?", "Möchtest du jetzt den Export prüfen, umbenennen und kopieren?")
|
||
if go:
|
||
self.run_export_flow()
|
||
|
||
def run_export_flow(self):
|
||
try:
|
||
source_dir = fd.askdirectory(title="Ordner mit exportierten CSV-Dateien wählen")
|
||
if not source_dir:
|
||
return
|
||
|
||
csv_files = sorted(glob.glob(os.path.join(source_dir, "*.csv")))
|
||
if not csv_files:
|
||
mb.showerror("Keine CSVs gefunden", "Im gewählten Ordner wurden keine .csv-Dateien gefunden.")
|
||
return
|
||
|
||
schema = self.schema
|
||
results = []
|
||
all_ok = True
|
||
|
||
# Prüfung & Umbenennen
|
||
timestamp = format_timestamp()
|
||
renamed_paths = []
|
||
for f in csv_files:
|
||
ok, msg = validate_csv_first_line(f, schema)
|
||
results.append((os.path.basename(f), ok, msg))
|
||
if not ok:
|
||
all_ok = False
|
||
|
||
# Meldung zu den Prüfergebnissen
|
||
summary_lines = []
|
||
for name, ok, msg in results:
|
||
status = "✅ OK" if ok else "❌ FEHLER"
|
||
summary_lines.append(f"{status} {name}\n{msg}")
|
||
mb.showinfo("Prüfergebnis", "\n\n".join(summary_lines))
|
||
|
||
if not all_ok:
|
||
proceed = mb.askyesno("Trotzdem fortfahren?",
|
||
"Nicht alle Dateien sind OK. Möchtest du trotzdem mit Umbenennen und Kopieren fortfahren?")
|
||
if not proceed:
|
||
return
|
||
|
||
# Umbenennen (im Quellordner) & Sammeln
|
||
temp_paths = []
|
||
for f in csv_files:
|
||
base = os.path.basename(f)
|
||
new_name = f"{timestamp}_{base}"
|
||
new_path = os.path.join(os.path.dirname(f), new_name)
|
||
# Umbenennen nur, wenn noch nicht bereits geprefixt
|
||
if not base.startswith(timestamp + "_"):
|
||
os.rename(f, new_path)
|
||
temp_paths.append(new_path)
|
||
else:
|
||
temp_paths.append(f) # war schon umbenannt
|
||
renamed_paths = temp_paths
|
||
|
||
# Zielordner auf dem Desktop
|
||
target_dir = ensure_desktop_target()
|
||
for p in renamed_paths:
|
||
shutil.copy2(p, target_dir)
|
||
|
||
mb.showinfo("Fertig", f"Dateien wurden nach:\n{target_dir}\nkopiert.")
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
mb.showerror("Fehler", f"Es ist ein Fehler aufgetreten:\n{e}")
|
||
finally:
|
||
# Zurück zum Start
|
||
self.on_finished()
|
||
|
||
def back_to_home(self):
|
||
self.on_finished()
|
||
|
||
|
||
class App(ctk.CTk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
ctk.set_appearance_mode("system") # "light" / "dark" / "system"
|
||
ctk.set_default_color_theme("blue")
|
||
|
||
self.title("ZLS → Loco-Soft Leitfaden")
|
||
self.geometry("1200x800")
|
||
self.minsize(1000, 700)
|
||
|
||
self.container = ctk.CTkFrame(self)
|
||
self.container.pack(fill="both", expand=True)
|
||
|
||
self.show_home()
|
||
|
||
def clear(self):
|
||
for w in self.container.winfo_children():
|
||
w.destroy()
|
||
|
||
def show_home(self):
|
||
self.clear()
|
||
frame = ctk.CTkFrame(self.container)
|
||
frame.pack(fill="both", expand=True, padx=16, pady=16)
|
||
frame.grid_columnconfigure((0, 1, 2), weight=1)
|
||
|
||
lbl = ctk.CTkLabel(frame, text=APP_TITLE, font=ctk.CTkFont(size=24, weight="bold"))
|
||
lbl.grid(row=0, column=0, columnspan=3, pady=(8, 16))
|
||
|
||
sub = ctk.CTkLabel(frame, text="Bitte einen Bereich wählen, um den Schritt-für-Schritt-Leitfaden zu öffnen.")
|
||
sub.grid(row=1, column=0, columnspan=3, pady=(0, 16))
|
||
|
||
# Buttons in einem Grid
|
||
r, c = 2, 0
|
||
for display_name, folder in WORKFLOWS:
|
||
btn = ctk.CTkButton(
|
||
frame,
|
||
height=64,
|
||
text=display_name,
|
||
command=lambda d=display_name, f=folder: self.open_workflow(d, f),
|
||
)
|
||
btn.grid(row=r, column=c, padx=10, pady=10, sticky="ew")
|
||
|
||
c += 1
|
||
if c > 2:
|
||
c = 0
|
||
r += 1
|
||
|
||
info = ctk.CTkLabel(
|
||
frame,
|
||
text=("Hinweis:\n"
|
||
"Lege deine Schritt-Screenshots in Unterordnern unter ./assets/<workflow>/ ab.\n"
|
||
"Optionale CSV-Header-Vorgaben in ./assets/schema.json."),
|
||
justify="left",
|
||
)
|
||
info.grid(row=r+1, column=0, columnspan=3, pady=(20, 0))
|
||
|
||
def open_workflow(self, display_name, folder_name):
|
||
abs_folder = resource_path("assets", folder_name)
|
||
if not os.path.isdir(abs_folder):
|
||
os.makedirs(abs_folder, exist_ok=True)
|
||
|
||
self.clear()
|
||
viewer = WorkflowViewer(
|
||
master=self.container,
|
||
title=display_name,
|
||
folder=abs_folder,
|
||
on_finished=self.show_home
|
||
)
|
||
viewer.pack(fill="both", expand=True, padx=8, pady=8)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = App()
|
||
app.mainloop()
|