Vscode Upload
parent
d5d56edd5c
commit
04eeea5aef
|
|
@ -1,4 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
|
|
@ -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("<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()
|
||||
Loading…
Reference in New Issue