diff --git a/.idea/misc.xml b/.idea/misc.xml index 060d2c5..db8786c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,7 @@ + + \ No newline at end of file diff --git a/zls_export_helper.py b/zls_export_helper.py index e69de29..c41c31b 100644 --- a/zls_export_helper.py +++ b/zls_export_helper.py @@ -0,0 +1,390 @@ +# -*- 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("", 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// 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()