diff --git a/app.py b/app.py index 8fc8ffe..eb796bb 100644 --- a/app.py +++ b/app.py @@ -8,442 +8,36 @@ Bedienung: 3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/ Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen". + +Aufgeteilt in: + helfer.py - Zahlen, Datum, Namen, Pruefungen + bestand.py - was im Rechnungsordner liegt (nur lesen) + storno.py - den spiegelbildlichen Storno rechnen + einzelinstanz.py - nur ein Fenster zulassen + theme.py - Farben nach Windows hell/dunkel + korrektur.py - Storno- und Berichtigungsweg + einstellungen.py - die Dialoge aus dem Menue "Einstellungen" """ -import json import os import sys import datetime import tkinter as tk -from tkinter import ttk, messagebox, filedialog +from tkinter import ttk, messagebox import config import berechnung import pdf_renderer +from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern +from bestand import naechste_freie_nummer, vorhandene_rechnungen +from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, + laufende_pid_lesen) +from theme import ThemeMixin +from korrektur import KorrekturMixin +from einstellungen import EinstellungenMixin -def parse_num(s): - """'12', '3,5' -> float; leer/ungueltig -> 0.0""" - s = (s or "").strip().replace(",", ".") - if not s: - return 0.0 - try: - return float(s) - except ValueError: - return 0.0 - - -def parse_date(s): - """'28.01.2026' -> date; sonst None""" - s = (s or "").strip() - for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): - try: - return datetime.datetime.strptime(s, fmt).date() - except ValueError: - continue - return None - - -def safe_filename(s): - keep = "-_.() " - return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" - - -def vorhandene_rechnungen(ordner, nummer): - """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. - - Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), - darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer - unter einem anderen Namen als zweite Datei durch. - """ - # Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben - # Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal - # vergeben. - koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer)) - try: - namen = os.listdir(ordner) - except OSError: - return [] - treffer = [] - for name in namen: - if not name.lower().endswith(".pdf"): - continue - stamm = name[:-4] - # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus - if any(stamm == k or stamm.startswith(k + "_") for k in koepfe): - treffer.append(os.path.join(ordner, name)) - return sorted(treffer) - - -def saeubern(s): - """Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem. - - Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss - genau das, was nachher auf dem Blatt steht. - """ - return " ".join(str(s or "").split()) - - -def buchstaben(s): - return sum(1 for c in str(s) if c.isalpha()) - - -def ziffern(s): - return sum(1 for c in str(s) if c.isdigit()) - - -# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt -# tippbar, damit Sonderfaelle nicht am Programm scheitern. -ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"] - - -def nummer_normalisieren(s): - """'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck. - - Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen. - """ - s = str(s or "").strip() - teile = s.split("-") - if len(teile) == 2: - try: - return config.format_rechnungsnummer(int(teile[0]), int(teile[1])) - except ValueError: - pass - return s - - -def kenndaten_lesen(pfad): - """Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine. - - Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das - spiegeln, was rausgegangen ist. - """ - try: - from pypdf import PdfReader - roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" - if not str(roh).strip().startswith("{"): - return None - daten = json.loads(str(roh)) - except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF - print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") - return None - if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): - return None - daten["_pfad"] = pfad - return daten - - -def adresse_aus_pdf(pfad): - """Adressblock aus dem Fliesstext einer Rechnung lesen. - - Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren. - Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer. - """ - try: - from pypdf import PdfReader - zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines() - except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF - print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})") - return {} - - start = next((i for i, z in enumerate(zeilen) - if z.strip().startswith("Rechnung an")), None) - if start is None: - return {} - block = [] - for z in zeilen[start + 1:start + 8]: - t = z.strip() - if not t or t.startswith(("Rechnungsnummer", "Stornonummer")): - break - block.append(t) - if not block: - return {} - - daten = {} - if block[0] in ANREDEN: - daten["anrede"] = block.pop(0) - # PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und - # noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn - # sie nicht selbst die Namenszeile ist. - for i in range(len(block) - 1, -1, -1): - teile = block[i].split() - if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5: - daten["plzort"] = block.pop(i) - if i - 1 >= 1: - daten["strasse"] = block.pop(i - 1) - break - if block: - daten["name"] = block.pop(0) - if block: - daten["zusatz"] = " ".join(block) - return daten - - -def kunde_maengel(kunde): - """Beanstandungen an Name und Anschrift; leere Liste = in Ordnung. - - Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, - Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine - Anschrift. Vier Ziffern reichen, damit auch oesterreichische und - niederlaendische Postleitzahlen durchgehen. - """ - maengel = [] - for feld, schluessel in (("Vorname", "vorname"), - ("Nachname / Firma", "nachname"), - ("Straße / Nr.", "strasse")): - wert = saeubern(kunde.get(schluessel)) - if not wert: - maengel.append(f"{feld}: ist leer") - elif buchstaben(wert) < 2: - maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") - - ort = saeubern(kunde.get("plzort")) - if not ort: - maengel.append("PLZ / Ort: ist leer") - elif ziffern(ort) < 4 or buchstaben(ort) < 2: - maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " - "z. B. 04416 Markkleeberg") - return maengel - - -def name_aufteilen(ganz): - """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" - teile = str(ganz or "").split() - if len(teile) > 1: - return " ".join(teile[:-1]), teile[-1] - return "", (teile[0] if teile else "") - - -def kundendaten_ermitteln(meta): - """Kunde einer alten Rechnung so vollstaendig wie moeglich. - - Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext. - Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen - entstanden -, wird das letzte Wort zum Nachnamen. - - Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur - aus dem Text kamen und deshalb geprueft gehoeren. - """ - kunde = dict(meta.get("kunde") or {}) - woher = [] - vorname = saeubern(kunde.get("vorname") or meta.get("vorname")) - nachname = saeubern(kunde.get("nachname") or meta.get("nachname")) - - if not kunde.get("plzort") and meta.get("_pfad"): - aus_text = adresse_aus_pdf(meta["_pfad"]) - for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"), - ("strasse", "Straße"), ("plzort", "PLZ / Ort")): - if aus_text.get(feld) and not kunde.get(feld): - kunde[feld] = aus_text[feld] - woher.append(klartext) - if not (vorname or nachname) and aus_text.get("name"): - vorname = aus_text["name"] - woher.append("Name") - - if not nachname: - # Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt - # wird am Leerzeichen: das letzte Wort ist der Nachname. - vorname, nachname = name_aufteilen(vorname) - if nachname: - woher.append("Nachname vom Vornamen abgetrennt") - kunde["vorname"], kunde["nachname"] = vorname, nachname - return kunde, woher - - -def rechnungen_im_ordner(ordner): - """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. - - Stornos und schon stornierte Rechnungen sind mit drin - welche davon - stornierbar ist, entscheidet der Aufrufer. - """ - try: - namen = sorted(os.listdir(ordner)) - except OSError: - return [] - gefunden = [] - for name in namen: - # Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob - # eine PDF von uns stammt, sagen ohnehin erst die Kenndaten. - if not name.lower().endswith(".pdf"): - continue - daten = kenndaten_lesen(os.path.join(ordner, name)) - if daten: - gefunden.append(daten) - gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) - return gefunden - - -def storno_rechnung_bauen(meta, cfg): - """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. - - Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der - alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, - und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. - """ - r = berechnung.Rechnung() - nummer = meta.get("rechnungsnummer", "") - bloecke = meta.get("ust_bloecke") or [] - if not bloecke: - # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt - # die USt unter den Tisch. - brutto = float(meta.get("zwischensumme") or 0) - satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) - netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto - bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), - "brutto": brutto}] - - saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} - for i, b in enumerate(bloecke, start=1): - satz = int(float(b.get("satz", 0) or 0)) - brutto = -abs(berechnung.runde(b.get("brutto") or 0)) - netto = -abs(berechnung.runde(b.get("netto") or 0)) - ust = -abs(berechnung.runde(b.get("ust") or 0)) - r.zeilen.append(berechnung.PositionsZeile( - nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", - satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, - brutto=brutto, netto=netto)) - info = saetze.get(satz, {}) - r.bloecke.append(berechnung.Steuerblock( - satz=satz, netto=netto, ust=ust, - bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), - extra_blatt=bool(info.get("extra_blatt", False)))) - - r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) - r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) - r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) - - # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. - # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem - # berechne() den Aufschlag baut. - steuer = berechnung.runde(meta.get("steuer_betrag") or 0) - if steuer: - extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) - r.aufschlaege.append(berechnung.Aufschlag( - satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), - bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), - basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) - r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) - return r - - -def naechste_freie_nummer(ordner, cfg): - """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. - - Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner - hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. - """ - jahr = cfg.get("rechnung_jahr", datetime.date.today().year) - z = cfg.get("rechnung_zaehler", 0) - for _ in range(1000): - z += 1 - kandidat = config.format_rechnungsnummer(jahr, z) - if not vorhandene_rechnungen(ordner, kandidat): - return kandidat - return None - - -def einzelinstanz_pruefen(): - """Sperrt das Programm gegen ein zweites Fenster. - - Beide Fenster lesen beim Start denselben Zaehler aus der config und - schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt - vergeben. Darum: nur eine Instanz je Windows-Sitzung. - - Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder - False, wenn schon eine Instanz laeuft. - """ - if not sys.platform.startswith("win"): - return None - import ctypes - from ctypes import wintypes - ERROR_ALREADY_EXISTS = 183 - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] - k32.CreateMutexW.restype = wintypes.HANDLE - handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") - if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: - return False - return handle - - - -# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. -PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") - - -def eigene_pid_merken(): - """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" - try: - with open(PID_DATEI, "w", encoding="ascii") as f: - f.write(str(os.getpid())) - except OSError: - pass - - -def laufende_pid_lesen(): - """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" - try: - with open(PID_DATEI, "r", encoding="ascii") as f: - return int(f.read().strip()) - except (OSError, ValueError): - return None - - -def fenster_nach_vorn(pid): - """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. - - Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel - haengt am Firmennamen aus der config und aendert sich mit ihm. - """ - if not pid or not sys.platform.startswith("win"): - return False - import ctypes - from ctypes import wintypes - SW_RESTORE = 9 - u32 = ctypes.WinDLL("user32", use_last_error=True) - u32.IsWindowVisible.argtypes = [wintypes.HWND] - u32.IsIconic.argtypes = [wintypes.HWND] - u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] - u32.SetForegroundWindow.argtypes = [wintypes.HWND] - u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] - u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] - - treffer = [] - - def besuch(hwnd, _lparam): - fremd = wintypes.DWORD() - u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) - # nur das echte Hauptfenster: sichtbar und mit Titel - if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): - treffer.append(hwnd) - return False - return True - - CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) - u32.EnumWindows(CB(besuch), 0) - if not treffer: - return False - hwnd = treffer[0] - if u32.IsIconic(hwnd): - u32.ShowWindow(hwnd, SW_RESTORE) - u32.SetForegroundWindow(hwnd) - return True - - -def windows_dark_mode(): - """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" - if not sys.platform.startswith("win"): - return False - try: - import winreg - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: - return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 - except OSError: - return False - - -class RechnungsApp(tk.Tk): +class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def __init__(self): super().__init__() self.cfg = config.load_config() @@ -477,100 +71,6 @@ class RechnungsApp(tk.Tk): # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) self.after(250, self._frage_speicherort_falls_noetig) - # ----------------------------------------------------------------- Theme - def _apply_theme(self): - """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" - dark = windows_dark_mode() - self._dark_mode = dark - if dark: - self.col_bg = "#23272e" # Fenster-Hintergrund - self.col_fg = "#e6e6e6" # Schrift - self.col_field = "#2f343c" # Eingabefelder - self.col_sel = "#3a4a44" # aktiv/hover - self.col_accent = "#5fb98f" # Akzent (Gruen, hell) - self.col_line = "#3a3f47" - self.col_scroll = "#4a505a" # Scroll-Griff - self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") - else: - self.col_bg = "#f3f3f3" - self.col_fg = "#1a1a1a" - self.col_field = "#ffffff" - self.col_sel = "#cfe3d8" - self.col_accent = "#1f5c3d" - self.col_line = "#cccccc" - self.col_scroll = "#b8b8b8" - self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") - - self.configure(bg=self.col_bg) - style = ttk.Style(self) - try: - style.theme_use("clam") - except tk.TclError: - pass - bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, - self.col_sel, self.col_accent) - style.configure(".", background=bg, foreground=fg, fieldbackground=field, - bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) - style.configure("TFrame", background=bg) - style.configure("TLabel", background=bg, foreground=fg) - style.configure("TLabelframe", background=bg, bordercolor=self.col_line) - style.configure("TLabelframe.Label", background=bg, foreground=accent) - style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) - style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) - style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", - arrowcolor=bg, padding=(10, 3)) - style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) - style.configure("TCheckbutton", background=bg, foreground=fg) - style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) - style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) - # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, - # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert - # unsichtbar im Feld. - style.configure("TCombobox", fieldbackground=field, foreground=fg, - background=field, arrowcolor=fg, - selectbackground=field, selectforeground=fg) - style.map("TCombobox", fieldbackground=[("readonly", field)], - foreground=[("readonly", fg)], background=[("readonly", field)], - selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) - # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget - self.option_add("*TCombobox*Listbox.background", field) - self.option_add("*TCombobox*Listbox.foreground", fg) - self.option_add("*TCombobox*Listbox.selectBackground", sel) - self.option_add("*TCombobox*Listbox.selectForeground", fg) - for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): - style.configure(sbs, troughcolor=bg, background=self.col_scroll, - bordercolor=bg, lightcolor=bg, darkcolor=bg, - arrowcolor=fg, relief="flat", gripcount=0) - style.map(sbs, background=[("active", accent)], - troughcolor=[("disabled", bg)]) - style.configure("TSeparator", background=self.col_line) - # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults - self.option_add("*background", bg) - self.option_add("*foreground", fg) - - def _style_dialog(self, d): - """Toplevel-Dialog an das Theme angleichen.""" - d.configure(bg=self.col_bg) - # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht - d.after(20, lambda: self._dark_titlebar(d)) - - def _dark_titlebar(self, win): - """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" - if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): - return - try: - import ctypes - win.update_idletasks() - hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) - val = ctypes.c_int(1) - for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) - ctypes.windll.dwmapi.DwmSetWindowAttribute( - hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) - # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) - SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED - ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) - except Exception as e: # noqa: BLE001 - print("[theme] Titelleiste:", e) # ---------------------------------------------------------------- Menue def _build_menu(self): @@ -855,8 +355,10 @@ class RechnungsApp(tk.Tk): links = ttk.Frame(f) links.pack(side="left") ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) - ttk.Button(links, text="Stornieren / berichtigen", command=self.dlg_storno, - width=22).pack(side="left", padx=4) + ttk.Button(links, text="Storno", command=self.dlg_storno, + width=12).pack(side="left", padx=4) + ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen, + width=14).pack(side="left", padx=4) ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) # --------------------------------------------------------- Berechnung @@ -908,274 +410,6 @@ class RechnungsApp(tk.Tk): jahr, zaehler = heute_jahr, 0 self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) - # ------------------------------------------------------------- Storno - def _storno_anzeigen(self): - """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" - stand = self.storno_stand - if stand: - self.storno_hinweis.set( - f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" - f"Beim Erstellen wird noch einmal gefragt.") - self.nummer_entry.configure(state="readonly") - elif self.berichtigung_stand: - self.storno_hinweis.set( - f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n" - "Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.") - self.nummer_entry.configure(state="readonly") - else: - self.storno_hinweis.set("") - self.nummer_entry.configure(state="normal") - - def _berichtigung_starten(self, meta, out_dir): - """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" - nummer = str(meta.get("rechnungsnummer", "")) - fehlend = self._formular_aus_meta(meta) - self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), - "meta": meta} - # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. - self.r_nummer.set(nummer) - self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) - self._storno_anzeigen() - hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" - messagebox.showinfo( - "Rechnung geladen", - f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" - "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " - "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " - f"bleibt liegen.{hinweis}") - - def _storno_verwerfen(self, grund=""): - """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. - - Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das - Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst. - """ - stand = self.storno_stand - if not stand: - return - try: - if os.path.exists(stand["pfad"]): - os.remove(stand["pfad"]) - print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") - except OSError as e: # noqa: BLE001 - messagebox.showwarning("Storno-PDF blieb liegen", - f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") - self.cfg["rechnung_jahr"] = stand["jahr_vorher"] - self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] - config.save_config(self.cfg) - self.storno_stand = None - self._storno_anzeigen() - self._neue_nummer_vorschlagen() - if grund: - print(f"[storno] {grund}") - - def dlg_storno(self): - """Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen.""" - if self.storno_stand: - messagebox.showinfo( - "Storno liegt schon bereit", - f"Der Storno {self.storno_stand['nummer']} zu Rechnung " - f"{self.storno_stand['zu']} ist noch offen.\n\n" - "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") - return - - out_dir = config.ensure_output_dir(self.cfg) - alle = rechnungen_im_ordner(out_dir) - stornierte = {nummer_normalisieren(d.get("storno_zu")) - for d in alle if d.get("art") == "storno"} - offen = [d for d in alle - if d.get("art") not in ("storno", "berichtigung") - and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] - if not offen: - messagebox.showwarning( - "Nichts zu stornieren", - f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.") - return - - d = tk.Toplevel(self) - d.title("Rechnung stornieren") - d.transient(self) - d.grab_set() - self._style_dialog(d) - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - ttk.Label(frm, justify="left", wraplength=470, text= - "Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert noch " - "gelöscht werden.\n\n" - "Storno erstellen: falsche Beträge oder Leistungen. Es entsteht eine " - "Stornorechnung mit eigener neuer Nummer und danach die berichtigte Rechnung.\n" - "Nur berichtigen: es stimmen nur die Angaben zum Kunden nicht. Die Rechnung " - "behält Nummer und Beträge, es wird nur ein berichtigtes Blatt nachgereicht " - "(§ 31 Abs. 5 UStDV).").grid( - row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) - - ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) - # Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen - # Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht - # dann die Tcl-Variable und das Feld steht leer da. - ent = ttk.Entry(frm, width=20) - ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0)) - ent.focus_set() - - v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.") - ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid( - row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)) - - def suche(): - """(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum.""" - nr = nummer_normalisieren(ent.get()) - if not nr: - return None, "Nummer eintragen – das Programm sucht die Rechnung." - treffer = next((x for x in alle - if nummer_normalisieren(x.get("rechnungsnummer")) == nr - and x.get("art") != "berichtigung"), None) - if not treffer: - return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." - if treffer.get("art") == "storno": - return None, f"{nr} ist selbst eine Stornorechnung." - if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte: - return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht." - gesamt = float(treffer.get("gesamt") or 0) - return treffer, ( - f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}" - f" {treffer.get('nachname','')}\n" - f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, " - f"Gesamt {berechnung.eur(gesamt)}\n" - f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.") - - def vorschau(*_a): - v_info.set(suche()[1]) - ent.bind("", vorschau) - - def anlegen(*_a): - meta, meldung = suche() - if not meta: - v_info.set(meldung) - return - d.destroy() - self._storno_anlegen(meta, out_dir) - ent.bind("", anlegen) - - def berichtigen(): - meta, meldung = suche() - if not meta: - v_info.set(meldung) - return - d.destroy() - self._berichtigung_starten(meta, out_dir) - - btns = ttk.Frame(frm) - btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") - ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4) - ttk.Button(btns, text="Nur berichtigen", command=berichtigen).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - frm.columnconfigure(1, weight=1) - - def _storno_anlegen(self, meta, out_dir): - """Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung.""" - alt_nummer = str(meta.get("rechnungsnummer", "")) - nummer = naechste_freie_nummer(out_dir, self.cfg) - if not nummer: - messagebox.showerror("Keine freie Nummer", - "Es wurde keine freie Rechnungsnummer gefunden.") - return - - r = storno_rechnung_bauen(meta, self.cfg) - heute = datetime.date.today().strftime("%d.%m.%Y") - # Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in - # deren Kenndaten fehlt, wird aus ihrem Adressblock geholt. - kunde, woher = kundendaten_ermitteln(meta) - namenszeile = " ".join(t for t in (kunde.get("vorname", ""), - kunde.get("nachname", "")) if t) - kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), - kunde.get("strasse", ""), kunde.get("plzort", "")] - kopf = { - "rechnungsnummer": nummer, - "datum": heute, - "anreise": "", "abreise": "", "naechte": "", - "kunde_zeilen": kunde_zeilen, - "nachname": kunde.get("nachname", ""), - "vorname": kunde.get("vorname", ""), - "art": "storno", - "storno_zu": alt_nummer, - "storno_datum": str(meta.get("datum") or ""), - "kunde": kunde, - } - name = f"Storno_{safe_filename(nummer)}" - if kopf["nachname"]: - name += "_" + safe_filename(kopf["nachname"]) - pfad = os.path.join(out_dir, name + ".pdf") - try: - pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) - except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - messagebox.showerror("Fehler beim Erstellen", - f"Storno-PDF konnte nicht erstellt werden:\n{e}") - print("[storno] FEHLER:", e) - return - - self.storno_stand = { - "pfad": pfad, "nummer": nummer, "zu": alt_nummer, - "zu_datum": str(meta.get("datum") or ""), - "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), - "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), - } - self._zaehler_aus_nummer_speichern(nummer) - print(f"[storno] erstellt -> {pfad}") - - fehlend = self._formular_aus_meta(meta) - self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") - self._storno_anzeigen() - - hinweis = "" - if fehlend: - hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " - "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") - messagebox.showinfo( - "Storno erstellt", - f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" - f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " - f"\"PDF erstellen\" drücken.{hinweis}") - - def _formular_aus_meta(self, meta): - """Formular mit den Daten der alten Rechnung füllen. - - Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) - tragen weder Anschrift noch Positionen in den Kenndaten. - """ - kunde, woher = kundendaten_ermitteln(meta) - self.k_vorname.set(kunde.get("vorname", "")) - self.k_name.set(kunde.get("nachname", "")) - self.k_anrede.set(str(kunde.get("anrede", "") or "")) - self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) - self.k_strasse.set(str(kunde.get("strasse", "") or "")) - self.k_plzort.set(str(kunde.get("plzort", "") or "")) - self.r_anreise.set(str(meta.get("anreise", "") or "")) - self.r_abreise.set(str(meta.get("abreise", "") or "")) - self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) - - for nr, var in self.anzahl_vars.items(): - var.set("") - self.personen_vars[nr].set("") - for p in meta.get("positionen") or []: - nr = p.get("nr") - if nr in self.anzahl_vars: - self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) - self.personen_vars[nr].set(str(p.get("personen", "") or "")) - self.recalc() - - hinweise = [] - if woher: - hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher)) - fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()), - ("PLZ / Ort", self.k_plzort.get()), - ("Nachname", self.k_name.get())) - if not wert] - if fehlt: - hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt)) - if not (meta.get("positionen") or []): - hinweise.append("Leistungen konnten nicht übernommen werden") - return "; ".join(hinweise) def kunde_pruefen(self): """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. @@ -1403,28 +637,6 @@ class RechnungsApp(tk.Tk): def _ordner_oeffnen(self): self._oeffne_datei(config.ensure_output_dir(self.cfg)) - def _frage_speicherort_falls_noetig(self): - if (self.cfg.get("output_dir") or "").strip(): - return - vorschlag = config.default_output_dir() - messagebox.showinfo( - "Speicherort der Rechnungen", - "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" - f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") - d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", - initialdir=os.path.expanduser("~")) - self.cfg["output_dir"] = d if d else vorschlag - config.save_config(self.cfg) - config.ensure_output_dir(self.cfg) - - def dlg_speicherort(self): - aktuell = config.ensure_output_dir(self.cfg) - d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) - if d: - self.cfg["output_dir"] = d - config.save_config(self.cfg) - config.ensure_output_dir(self.cfg) - messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") def _oeffne_datei(self, pfad): try: @@ -1437,330 +649,6 @@ class RechnungsApp(tk.Tk): except Exception as e: # noqa: BLE001 messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") - def _info(self): - d = tk.Toplevel(self) - d.title("Info") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - frm = ttk.Frame(d, padding=18) - frm.pack(fill="both", expand=True) - ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), - foreground=self.col_accent).pack(anchor="w") - ttk.Label(frm, justify="left", - text="Daten eingeben, PDF erstellen, drucken.\n" - "Steuersätze, Preise und Firmendaten lassen sich\n" - "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) - ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") - ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) - d.bind("", lambda e: d.destroy()) - d.bind("", lambda e: d.destroy()) - d.grab_set() - - # ====================================================== Einstellungen - def dlg_steuern(self): - d = tk.Toplevel(self) - d.title("Steuersätze") - d.transient(self) - d.grab_set() - self._style_dialog(d) - d.geometry("620x470") - - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), - text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " - "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " - "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " - "Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8)) - - liste = ttk.Frame(frm) - liste.pack(fill="x") - arbeit = [] - for s in self.cfg.get("steuersaetze", []): - arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))), - "bez": tk.StringVar(value=s.get("bezeichnung", "")), - "extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))}) - - def render_saetze(): - for w in liste.winfo_children(): - w.destroy() - for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): - ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( - row=0, column=c, padx=4, pady=(0, 3), sticky="w") - for i, row in enumerate(arbeit, start=1): - ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) - ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w") - ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2) - ttk.Button(liste, text="✕", width=3, - command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4) - - def satz_hinzufuegen(): - arbeit.append({"satz": tk.StringVar(value="0"), - "bez": tk.StringVar(value="Mehrwertsteuer"), - "extra": tk.BooleanVar(value=False)}) - render_saetze() - - render_saetze() - ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0)) - - def speichern(): - neu = [] - for row in arbeit: - s = row["satz"].get().strip() - if s == "": - continue - neu.append({"satz": int(parse_num(s)), - "bezeichnung": row["bez"].get().strip() or "USt", - "extra_blatt": bool(row["extra"].get())}) - if not neu: - messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) - return - self.cfg["steuersaetze"] = neu - config.save_config(self.cfg) - self._render_positionszeilen() - self.recalc() - d.destroy() - - btns = ttk.Frame(frm) - btns.pack(side="bottom", fill="x", pady=(12, 0)) - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - - def dlg_katalog(self): - d = tk.Toplevel(self) - d.title("Leistungskatalog / Preise") - d.transient(self) - d.grab_set() - self._style_dialog(d) - - info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left", - text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob " - "pro Person gerechnet wird. Weitere Steuersätze legst du unter " - "Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X " - "rechts eine Leistung entfernen.") - info_lbl.pack(side="top", fill="x") - - # alle definierten Steuersaetze sind je Position waehlbar - saetze_cfg = self.cfg.get("steuersaetze", []) - - def satz_label(satz): - for s in saetze_cfg: - if int(s.get("satz", -1)) == int(satz or 0): - return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") - return f"{satz} %" - - def combo_werte(aktuell): - opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg] - al = satz_label(aktuell) - if al not in opts: - opts.append(al) - return opts - - def satz_aus_label(lbl): - try: - return int(parse_num(str(lbl).split("%")[0])) - except (ValueError, IndexError): - return 0 - - # Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen) - arbeit = [] - for pos in self.cfg["katalog"]: - arbeit.append({ - "leistung": tk.StringVar(value=pos["leistung"]), - "satz": tk.StringVar(value=str(pos["satz"])), - "preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])), - "pro_person": tk.BooleanVar(value=bool(pos["pro_person"])), - }) - - def render(): - for w in inner.winfo_children(): - w.destroy() - for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): - ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( - row=0, column=c, padx=4, pady=(0, 4), sticky="w") - for i, row in enumerate(arbeit, start=1): - ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) - ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we") - cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get())) - cb.set(satz_label(row["satz"].get())) - cb.bind("<>", - lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get())))) - cb.grid(row=i, column=2, padx=4, pady=1, sticky="w") - ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1) - ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1) - ttk.Button(inner, text="✕", width=3, - command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1) - - def hinzufuegen(): - arbeit.append({ - "leistung": tk.StringVar(value="Neue Leistung"), - "satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"), - "preis": tk.StringVar(value="0,00"), - "pro_person": tk.BooleanVar(value=False), - }) - render() - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - canvas.yview_moveto(1.0) - - def speichern(): - neu = [] - for i, row in enumerate(arbeit, start=1): - name = row["leistung"].get().strip() - if not name: - continue # leere Zeilen verwerfen - neu.append({ - "nr": i, - "leistung": name, - "satz": int(parse_num(row["satz"].get())), - "preis": parse_num(row["preis"].get()), - "pro_person": bool(row["pro_person"].get()), - }) - if not neu: - messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) - return - self.cfg["katalog"] = neu - config.save_config(self.cfg) - self._render_positionszeilen() - self.recalc() - d.destroy() - - # Buttonleiste unten zuerst -> bleibt immer sichtbar - btns = ttk.Frame(d, padding=8) - btns.pack(side="bottom", fill="x") - ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4) - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) - - # Scrollbereich - body = ttk.Frame(d) - body.pack(side="top", fill="both", expand=True) - canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg) - sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview) - inner = ttk.Frame(canvas, padding=8) - inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=inner, anchor="nw") - canvas.configure(yscrollcommand=sb.set) - canvas.pack(side="left", fill="both", expand=True) - sb.pack(side="right", fill="y") - - render() - - # Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile) - d.update_idletasks() - inhalt_w = inner.winfo_reqwidth() - canvas.configure(width=inhalt_w) - win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80) - win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90, - d.winfo_screenheight() - 120) - d.geometry(f"{win_w}x{int(win_h)}") - d.minsize(min(win_w, 820), 420) - info_lbl.configure(wraplength=win_w - 24) - - def dlg_firma(self): - d = tk.Toplevel(self) - d.title("Firmendaten & Texte") - d.transient(self) - d.grab_set() - self._style_dialog(d) - d.geometry("640x640") - - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - felder = [ - ("name", "Firmenname"), ("untertitel", "Untertitel"), - ("absender", "Absenderzeile"), ("inhaber", "Inhaber"), - ("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"), - ("steuernummer", "Steuernummer"), ("homepage", "Homepage"), - ("email", "E-Mail"), ("telefon", "Telefon"), - ] - vars_ = {} - for i, (key, lbl) in enumerate(felder): - ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) - v = tk.StringVar(value=self.cfg["firma"].get(key, "")) - ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) - vars_[key] = v - frm.columnconfigure(1, weight=1) - base = len(felder) - - # Logo (Bilddatei) mit Durchsuchen / Entfernen - ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) - logo_row = ttk.Frame(frm) - logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) - v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) - ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) - - def logo_waehlen(): - p = filedialog.askopenfilename( - title="Logo auswählen", - filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) - if p: - v_logo.set(p) - - ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) - ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) - - ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) - t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) - t1.insert("1.0", self.cfg["texte"].get("dank1", "")) - t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) - t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) - t2.insert("1.0", self.cfg["texte"].get("dank2", "")) - t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) - vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) - ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) - - def speichern(): - for key, v in vars_.items(): - self.cfg["firma"][key] = v.get() - self.cfg["firma"]["logo"] = v_logo.get().strip() - self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() - self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() - self.cfg["texte"]["hinweis"] = vh.get().strip() - config.save_config(self.cfg) - self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) - d.destroy() - - btns = ttk.Frame(frm) - btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - - def dlg_nummer(self): - d = tk.Toplevel(self) - d.title("Rechnungsnummer") - d.transient(self) - d.grab_set() - self._style_dialog(d) - frm = ttk.Frame(d, padding=12) - frm.pack(fill="both", expand=True) - - v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year))) - v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0))) - ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" - "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", - justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) - ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) - ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) - ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) - ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) - - def speichern(): - self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) - self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) - config.save_config(self.cfg) - self._neue_nummer_vorschlagen() - d.destroy() - - btns = ttk.Frame(frm) - btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e") - ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) - ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - if __name__ == "__main__": # Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen. diff --git a/bestand.py b/bestand.py new file mode 100644 index 0000000..c687ba8 --- /dev/null +++ b/bestand.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""Was im Rechnungsordner liegt - lesen, nicht schreiben. + +Der Ordner ist die Buchhaltung: welche Nummern vergeben sind, was in einer alten +Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen. +""" + +import datetime +import json +import os + +import config +from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename + + +def vorhandene_rechnungen(ordner, nummer): + """Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren. + + Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"), + darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer + unter einem anderen Namen als zweite Datei durch. + """ + # Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben + # Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal + # vergeben. + koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer)) + try: + namen = os.listdir(ordner) + except OSError: + return [] + treffer = [] + for name in namen: + if not name.lower().endswith(".pdf"): + continue + stamm = name[:-4] + # exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus + if any(stamm == k or stamm.startswith(k + "_") for k in koepfe): + treffer.append(os.path.join(ordner, name)) + return sorted(treffer) +def kenndaten_lesen(pfad): + """Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine. + + Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das + spiegeln, was rausgegangen ist. + """ + try: + from pypdf import PdfReader + roh = (PdfReader(pfad).metadata or {}).get("/Subject") or "" + if not str(roh).strip().startswith("{"): + return None + daten = json.loads(str(roh)) + except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF + print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})") + return None + if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"): + return None + daten["_pfad"] = pfad + return daten +def adresse_aus_pdf(pfad): + """Adressblock aus dem Fliesstext einer Rechnung lesen. + + Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren. + Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer. + """ + try: + from pypdf import PdfReader + zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines() + except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF + print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})") + return {} + + start = next((i for i, z in enumerate(zeilen) + if z.strip().startswith("Rechnung an")), None) + if start is None: + return {} + block = [] + for z in zeilen[start + 1:start + 8]: + t = z.strip() + if not t or t.startswith(("Rechnungsnummer", "Stornonummer")): + break + block.append(t) + if not block: + return {} + + daten = {} + if block[0] in ANREDEN: + daten["anrede"] = block.pop(0) + # PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und + # noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn + # sie nicht selbst die Namenszeile ist. + for i in range(len(block) - 1, -1, -1): + teile = block[i].split() + if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5: + daten["plzort"] = block.pop(i) + if i - 1 >= 1: + daten["strasse"] = block.pop(i - 1) + break + if block: + daten["name"] = block.pop(0) + if block: + daten["zusatz"] = " ".join(block) + return daten +def kundendaten_ermitteln(meta): + """Kunde einer alten Rechnung so vollstaendig wie moeglich. + + Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext. + Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen + entstanden -, wird das letzte Wort zum Nachnamen. + + Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur + aus dem Text kamen und deshalb geprueft gehoeren. + """ + kunde = dict(meta.get("kunde") or {}) + woher = [] + vorname = saeubern(kunde.get("vorname") or meta.get("vorname")) + nachname = saeubern(kunde.get("nachname") or meta.get("nachname")) + + if not kunde.get("plzort") and meta.get("_pfad"): + aus_text = adresse_aus_pdf(meta["_pfad"]) + for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"), + ("strasse", "Straße"), ("plzort", "PLZ / Ort")): + if aus_text.get(feld) and not kunde.get(feld): + kunde[feld] = aus_text[feld] + woher.append(klartext) + if not (vorname or nachname) and aus_text.get("name"): + vorname = aus_text["name"] + woher.append("Name") + + if not nachname: + # Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt + # wird am Leerzeichen: das letzte Wort ist der Nachname. + vorname, nachname = name_aufteilen(vorname) + if nachname: + woher.append("Nachname vom Vornamen abgetrennt") + kunde["vorname"], kunde["nachname"] = vorname, nachname + return kunde, woher +def rechnungen_im_ordner(ordner): + """Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst. + + Stornos und schon stornierte Rechnungen sind mit drin - welche davon + stornierbar ist, entscheidet der Aufrufer. + """ + try: + namen = sorted(os.listdir(ordner)) + except OSError: + return [] + gefunden = [] + for name in namen: + # Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob + # eine PDF von uns stammt, sagen ohnehin erst die Kenndaten. + if not name.lower().endswith(".pdf"): + continue + daten = kenndaten_lesen(os.path.join(ordner, name)) + if daten: + gefunden.append(daten) + gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) + return gefunden +def naechste_freie_nummer(ordner, cfg): + """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. + + Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner + hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. + """ + jahr = cfg.get("rechnung_jahr", datetime.date.today().year) + z = cfg.get("rechnung_zaehler", 0) + for _ in range(1000): + z += 1 + kandidat = config.format_rechnungsnummer(jahr, z) + if not vorhandene_rechnungen(ordner, kandidat): + return kandidat + return None diff --git a/einstellungen.py b/einstellungen.py new file mode 100644 index 0000000..23a92ad --- /dev/null +++ b/einstellungen.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""Die Dialoge aus dem Menue "Einstellungen" - Steuersaetze, Katalog, Firma, Nummer. + +Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht +hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox, filedialog + +import config +from helfer import parse_num + + +class EinstellungenMixin: + """Einstellungsdialoge. Erwartet die Felder der RechnungsApp.""" + + def _frage_speicherort_falls_noetig(self): + if (self.cfg.get("output_dir") or "").strip(): + return + vorschlag = config.default_output_dir() + messagebox.showinfo( + "Speicherort der Rechnungen", + "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" + f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") + d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", + initialdir=os.path.expanduser("~")) + self.cfg["output_dir"] = d if d else vorschlag + config.save_config(self.cfg) + config.ensure_output_dir(self.cfg) + def dlg_speicherort(self): + aktuell = config.ensure_output_dir(self.cfg) + d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell) + if d: + self.cfg["output_dir"] = d + config.save_config(self.cfg) + config.ensure_output_dir(self.cfg) + messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") + def _info(self): + d = tk.Toplevel(self) + d.title("Info") + d.transient(self) + d.resizable(False, False) + self._style_dialog(d) + frm = ttk.Frame(d, padding=18) + frm.pack(fill="both", expand=True) + ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), + foreground=self.col_accent).pack(anchor="w") + ttk.Label(frm, justify="left", + text="Daten eingeben, PDF erstellen, drucken.\n" + "Steuersätze, Preise und Firmendaten lassen sich\n" + "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) + ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") + ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) + d.bind("", lambda e: d.destroy()) + d.bind("", lambda e: d.destroy()) + d.grab_set() + # ====================================================== Einstellungen + def dlg_steuern(self): + d = tk.Toplevel(self) + d.title("Steuersätze") + d.transient(self) + d.grab_set() + self._style_dialog(d) + d.geometry("620x470") + + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), + text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " + "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " + "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " + "Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8)) + + liste = ttk.Frame(frm) + liste.pack(fill="x") + arbeit = [] + for s in self.cfg.get("steuersaetze", []): + arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))), + "bez": tk.StringVar(value=s.get("bezeichnung", "")), + "extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))}) + + def render_saetze(): + for w in liste.winfo_children(): + w.destroy() + for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): + ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( + row=0, column=c, padx=4, pady=(0, 3), sticky="w") + for i, row in enumerate(arbeit, start=1): + ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) + ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w") + ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2) + ttk.Button(liste, text="✕", width=3, + command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4) + + def satz_hinzufuegen(): + arbeit.append({"satz": tk.StringVar(value="0"), + "bez": tk.StringVar(value="Mehrwertsteuer"), + "extra": tk.BooleanVar(value=False)}) + render_saetze() + + render_saetze() + ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0)) + + def speichern(): + neu = [] + for row in arbeit: + s = row["satz"].get().strip() + if s == "": + continue + neu.append({"satz": int(parse_num(s)), + "bezeichnung": row["bez"].get().strip() or "USt", + "extra_blatt": bool(row["extra"].get())}) + if not neu: + messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) + return + self.cfg["steuersaetze"] = neu + config.save_config(self.cfg) + self._render_positionszeilen() + self.recalc() + d.destroy() + + btns = ttk.Frame(frm) + btns.pack(side="bottom", fill="x", pady=(12, 0)) + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_katalog(self): + d = tk.Toplevel(self) + d.title("Leistungskatalog / Preise") + d.transient(self) + d.grab_set() + self._style_dialog(d) + + info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left", + text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob " + "pro Person gerechnet wird. Weitere Steuersätze legst du unter " + "Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X " + "rechts eine Leistung entfernen.") + info_lbl.pack(side="top", fill="x") + + # alle definierten Steuersaetze sind je Position waehlbar + saetze_cfg = self.cfg.get("steuersaetze", []) + + def satz_label(satz): + for s in saetze_cfg: + if int(s.get("satz", -1)) == int(satz or 0): + return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") + return f"{satz} %" + + def combo_werte(aktuell): + opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg] + al = satz_label(aktuell) + if al not in opts: + opts.append(al) + return opts + + def satz_aus_label(lbl): + try: + return int(parse_num(str(lbl).split("%")[0])) + except (ValueError, IndexError): + return 0 + + # Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen) + arbeit = [] + for pos in self.cfg["katalog"]: + arbeit.append({ + "leistung": tk.StringVar(value=pos["leistung"]), + "satz": tk.StringVar(value=str(pos["satz"])), + "preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])), + "pro_person": tk.BooleanVar(value=bool(pos["pro_person"])), + }) + + def render(): + for w in inner.winfo_children(): + w.destroy() + for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): + ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( + row=0, column=c, padx=4, pady=(0, 4), sticky="w") + for i, row in enumerate(arbeit, start=1): + ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) + ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we") + cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get())) + cb.set(satz_label(row["satz"].get())) + cb.bind("<>", + lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get())))) + cb.grid(row=i, column=2, padx=4, pady=1, sticky="w") + ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1) + ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1) + ttk.Button(inner, text="✕", width=3, + command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1) + + def hinzufuegen(): + arbeit.append({ + "leistung": tk.StringVar(value="Neue Leistung"), + "satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"), + "preis": tk.StringVar(value="0,00"), + "pro_person": tk.BooleanVar(value=False), + }) + render() + canvas.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all")) + canvas.yview_moveto(1.0) + + def speichern(): + neu = [] + for i, row in enumerate(arbeit, start=1): + name = row["leistung"].get().strip() + if not name: + continue # leere Zeilen verwerfen + neu.append({ + "nr": i, + "leistung": name, + "satz": int(parse_num(row["satz"].get())), + "preis": parse_num(row["preis"].get()), + "pro_person": bool(row["pro_person"].get()), + }) + if not neu: + messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) + return + self.cfg["katalog"] = neu + config.save_config(self.cfg) + self._render_positionszeilen() + self.recalc() + d.destroy() + + # Buttonleiste unten zuerst -> bleibt immer sichtbar + btns = ttk.Frame(d, padding=8) + btns.pack(side="bottom", fill="x") + ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4) + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) + + # Scrollbereich + body = ttk.Frame(d) + body.pack(side="top", fill="both", expand=True) + canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg) + sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview) + inner = ttk.Frame(canvas, padding=8) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=sb.set) + canvas.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + + render() + + # Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile) + d.update_idletasks() + inhalt_w = inner.winfo_reqwidth() + canvas.configure(width=inhalt_w) + win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80) + win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90, + d.winfo_screenheight() - 120) + d.geometry(f"{win_w}x{int(win_h)}") + d.minsize(min(win_w, 820), 420) + info_lbl.configure(wraplength=win_w - 24) + def dlg_firma(self): + d = tk.Toplevel(self) + d.title("Firmendaten & Texte") + d.transient(self) + d.grab_set() + self._style_dialog(d) + d.geometry("640x640") + + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + felder = [ + ("name", "Firmenname"), ("untertitel", "Untertitel"), + ("absender", "Absenderzeile"), ("inhaber", "Inhaber"), + ("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"), + ("steuernummer", "Steuernummer"), ("homepage", "Homepage"), + ("email", "E-Mail"), ("telefon", "Telefon"), + ] + vars_ = {} + for i, (key, lbl) in enumerate(felder): + ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) + v = tk.StringVar(value=self.cfg["firma"].get(key, "")) + ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + vars_[key] = v + frm.columnconfigure(1, weight=1) + base = len(felder) + + # Logo (Bilddatei) mit Durchsuchen / Entfernen + ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) + logo_row = ttk.Frame(frm) + logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) + v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) + ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) + + def logo_waehlen(): + p = filedialog.askopenfilename( + title="Logo auswählen", + filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) + if p: + v_logo.set(p) + + ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) + ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) + + ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) + t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) + t1.insert("1.0", self.cfg["texte"].get("dank1", "")) + t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) + t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) + t2.insert("1.0", self.cfg["texte"].get("dank2", "")) + t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) + vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) + ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) + + def speichern(): + for key, v in vars_.items(): + self.cfg["firma"][key] = v.get() + self.cfg["firma"]["logo"] = v_logo.get().strip() + self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() + self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() + self.cfg["texte"]["hinweis"] = vh.get().strip() + config.save_config(self.cfg) + self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) + d.destroy() + + btns = ttk.Frame(frm) + btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + def dlg_nummer(self): + d = tk.Toplevel(self) + d.title("Rechnungsnummer") + d.transient(self) + d.grab_set() + self._style_dialog(d) + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year))) + v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0))) + ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" + "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", + justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) + ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) + ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) + ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) + ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) + + def speichern(): + self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) + self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) + config.save_config(self.cfg) + self._neue_nummer_vorschlagen() + d.destroy() + + btns = ttk.Frame(frm) + btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e") + ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") diff --git a/einzelinstanz.py b/einzelinstanz.py new file mode 100644 index 0000000..cdd10cc --- /dev/null +++ b/einzelinstanz.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""Nur ein Fenster zulassen und das offene nach vorn holen. + +Zwei Fenster lesen beim Start denselben Zähler und schlagen dieselbe +Rechnungsnummer vor - die Nummer wird dann doppelt vergeben. +""" + +import os +import sys + +import config + + +def einzelinstanz_pruefen(): + """Sperrt das Programm gegen ein zweites Fenster. + + Beide Fenster lesen beim Start denselben Zaehler aus der config und + schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt + vergeben. Darum: nur eine Instanz je Windows-Sitzung. + + Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder + False, wenn schon eine Instanz laeuft. + """ + if not sys.platform.startswith("win"): + return None + import ctypes + from ctypes import wintypes + ERROR_ALREADY_EXISTS = 183 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR] + k32.CreateMutexW.restype = wintypes.HANDLE + handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl") + if ctypes.get_last_error() == ERROR_ALREADY_EXISTS: + return False + return handle +# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json. +PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid") +def eigene_pid_merken(): + """Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet.""" + try: + with open(PID_DATEI, "w", encoding="ascii") as f: + f.write(str(os.getpid())) + except OSError: + pass +def laufende_pid_lesen(): + """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" + try: + with open(PID_DATEI, "r", encoding="ascii") as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None +def fenster_nach_vorn(pid): + """Holt das Fenster des Prozesses nach vorn. True bei Erfolg. + + Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel + haengt am Firmennamen aus der config und aendert sich mit ihm. + """ + if not pid or not sys.platform.startswith("win"): + return False + import ctypes + from ctypes import wintypes + SW_RESTORE = 9 + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.IsWindowVisible.argtypes = [wintypes.HWND] + u32.IsIconic.argtypes = [wintypes.HWND] + u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] + u32.SetForegroundWindow.argtypes = [wintypes.HWND] + u32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + + treffer = [] + + def besuch(hwnd, _lparam): + fremd = wintypes.DWORD() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd)) + # nur das echte Hauptfenster: sichtbar und mit Titel + if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd): + treffer.append(hwnd) + return False + return True + + CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + u32.EnumWindows(CB(besuch), 0) + if not treffer: + return False + hwnd = treffer[0] + if u32.IsIconic(hwnd): + u32.ShowWindow(hwnd, SW_RESTORE) + u32.SetForegroundWindow(hwnd) + return True diff --git a/helfer.py b/helfer.py new file mode 100644 index 0000000..1f6acd1 --- /dev/null +++ b/helfer.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""Kleine Helfer, die überall gebraucht werden: Zahlen, Datum, Namen, Prüfungen.""" + +import datetime + +import config + + +def parse_num(s): + """'12', '3,5' -> float; leer/ungueltig -> 0.0""" + s = (s or "").strip().replace(",", ".") + if not s: + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 +def parse_date(s): + """'28.01.2026' -> date; sonst None""" + s = (s or "").strip() + for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): + try: + return datetime.datetime.strptime(s, fmt).date() + except ValueError: + continue + return None +def safe_filename(s): + keep = "-_.() " + return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung" +def saeubern(s): + """Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem. + + Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss + genau das, was nachher auf dem Blatt steht. + """ + return " ".join(str(s or "").split()) +def buchstaben(s): + return sum(1 for c in str(s) if c.isalpha()) +def ziffern(s): + return sum(1 for c in str(s) if c.isdigit()) +# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt +# tippbar, damit Sonderfaelle nicht am Programm scheitern. +ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"] +def nummer_normalisieren(s): + """'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck. + + Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen. + """ + s = str(s or "").strip() + teile = s.split("-") + if len(teile) == 2: + try: + return config.format_rechnungsnummer(int(teile[0]), int(teile[1])) + except ValueError: + pass + return s +def kunde_maengel(kunde): + """Beanstandungen an Name und Anschrift; leere Liste = in Ordnung. + + Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen, + Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine + Anschrift. Vier Ziffern reichen, damit auch oesterreichische und + niederlaendische Postleitzahlen durchgehen. + """ + maengel = [] + for feld, schluessel in (("Vorname", "vorname"), + ("Nachname / Firma", "nachname"), + ("Straße / Nr.", "strasse")): + wert = saeubern(kunde.get(schluessel)) + if not wert: + maengel.append(f"{feld}: ist leer") + elif buchstaben(wert) < 2: + maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen") + + ort = saeubern(kunde.get("plzort")) + if not ort: + maengel.append("PLZ / Ort: ist leer") + elif ziffern(ort) < 4 or buchstaben(ort) < 2: + maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, " + "z. B. 04416 Markkleeberg") + return maengel +def name_aufteilen(ganz): + """'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname.""" + teile = str(ganz or "").split() + if len(teile) > 1: + return " ".join(teile[:-1]), teile[-1] + return "", (teile[0] if teile else "") diff --git a/korrektur.py b/korrektur.py new file mode 100644 index 0000000..a49cd1c --- /dev/null +++ b/korrektur.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +"""Storno- und Berichtigungsweg. + +Zwei Wege, ein Popup-Bauplan: + Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer, + danach die berichtigte Rechnung. Zwei Nummern verbraucht. + Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt + Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur + ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht. + +Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO). +""" + +import datetime +import os +import tkinter as tk +from tkinter import ttk, messagebox + +import berechnung +import config +import pdf_renderer +from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner +from helfer import nummer_normalisieren, safe_filename +from storno import storno_rechnung_bauen + + +class KorrekturMixin: + """Storno und Berichtigung. Erwartet die Felder der RechnungsApp.""" + + # ------------------------------------------------------------- Storno + def _storno_anzeigen(self): + """Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen.""" + stand = self.storno_stand + if stand: + self.storno_hinweis.set( + f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n" + f"Beim Erstellen wird noch einmal gefragt.") + self.nummer_entry.configure(state="readonly") + elif self.berichtigung_stand: + self.storno_hinweis.set( + f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n" + "Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.") + self.nummer_entry.configure(state="readonly") + else: + self.storno_hinweis.set("") + self.nummer_entry.configure(state="normal") + def _berichtigung_starten(self, meta, out_dir): + """Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt.""" + nummer = str(meta.get("rechnungsnummer", "")) + fehlend = self._formular_aus_meta(meta) + self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""), + "meta": meta} + # Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno. + self.r_nummer.set(nummer) + self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) + self._storno_anzeigen() + hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" + messagebox.showinfo( + "Rechnung geladen", + f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" + "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " + "es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung " + f"bleibt liegen.{hinweis}") + def _storno_verwerfen(self, grund=""): + """Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren. + + Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das + Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst. + """ + stand = self.storno_stand + if not stand: + return + try: + if os.path.exists(stand["pfad"]): + os.remove(stand["pfad"]) + print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}") + except OSError as e: # noqa: BLE001 + messagebox.showwarning("Storno-PDF blieb liegen", + f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}") + self.cfg["rechnung_jahr"] = stand["jahr_vorher"] + self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] + config.save_config(self.cfg) + self.storno_stand = None + self._storno_anzeigen() + self._neue_nummer_vorschlagen() + if grund: + print(f"[storno] {grund}") + def dlg_storno(self): + """Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup.""" + self._dlg_korrektur("storno") + def dlg_berichtigen(self): + """Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup.""" + self._dlg_korrektur("berichtigung") + def _dlg_korrektur(self, modus): + """Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest. + + "storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung + "berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer + """ + storno = modus == "storno" + if self.storno_stand: + messagebox.showinfo( + "Storno liegt schon bereit", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} ist noch offen.\n\n" + "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") + return + if self.berichtigung_stand: + messagebox.showinfo( + "Berichtigung läuft schon", + f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen " + "im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.") + return + + out_dir = config.ensure_output_dir(self.cfg) + alle = rechnungen_im_ordner(out_dir) + stornierte = {nummer_normalisieren(d.get("storno_zu")) + for d in alle if d.get("art") == "storno"} + offen = [d for d in alle + if d.get("art") not in ("storno", "berichtigung") + and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] + if not offen: + messagebox.showwarning( + "Nichts zu korrigieren", + f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.") + return + + d = tk.Toplevel(self) + d.title("Rechnung stornieren" if storno else "Rechnung berichtigen") + d.transient(self) + d.grab_set() + self._style_dialog(d) + frm = ttk.Frame(d, padding=12) + frm.pack(fill="both", expand=True) + + ttk.Label(frm, justify="left", wraplength=470, text=( + "Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert " + "liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine " + "Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach " + "die berichtigte Rechnung." + if storno else + "Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, " + "Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt " + "nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, " + "dafür gibt es den Storno.")).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) + + ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3) + # Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen + # Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht + # dann die Tcl-Variable und das Feld steht leer da. + ent = ttk.Entry(frm, width=20) + ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0)) + ent.focus_set() + + v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.") + ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid( + row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)) + + def suche(): + """(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum.""" + nr = nummer_normalisieren(ent.get()) + if not nr: + return None, "Nummer eintragen – das Programm sucht die Rechnung." + treffer = next((x for x in alle + if nummer_normalisieren(x.get("rechnungsnummer")) == nr + and x.get("art") != "berichtigung"), None) + if not treffer: + return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." + if treffer.get("art") == "storno": + return None, f"{nr} ist selbst eine Stornorechnung." + if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte: + return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht." + gesamt = float(treffer.get("gesamt") or 0) + return treffer, ( + f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}" + f" {treffer.get('nachname','')}\n" + f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, " + f"Gesamt {berechnung.eur(gesamt)}\n" + f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.") + + def vorschau(*_a): + v_info.set(suche()[1]) + ent.bind("", vorschau) + + def anlegen(*_a): + meta, meldung = suche() + if not meta: + v_info.set(meldung) + return + d.destroy() + if storno: + self._storno_anlegen(meta, out_dir) + else: + self._berichtigung_starten(meta, out_dir) + ent.bind("", anlegen) + + btns = ttk.Frame(frm) + btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e") + ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden", + command=anlegen).pack(side="right", padx=4) + ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") + frm.columnconfigure(1, weight=1) + def _storno_anlegen(self, meta, out_dir): + """Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung.""" + alt_nummer = str(meta.get("rechnungsnummer", "")) + nummer = naechste_freie_nummer(out_dir, self.cfg) + if not nummer: + messagebox.showerror("Keine freie Nummer", + "Es wurde keine freie Rechnungsnummer gefunden.") + return + + r = storno_rechnung_bauen(meta, self.cfg) + heute = datetime.date.today().strftime("%d.%m.%Y") + # Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in + # deren Kenndaten fehlt, wird aus ihrem Adressblock geholt. + kunde, woher = kundendaten_ermitteln(meta) + namenszeile = " ".join(t for t in (kunde.get("vorname", ""), + kunde.get("nachname", "")) if t) + kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""), + kunde.get("strasse", ""), kunde.get("plzort", "")] + kopf = { + "rechnungsnummer": nummer, + "datum": heute, + "anreise": "", "abreise": "", "naechte": "", + "kunde_zeilen": kunde_zeilen, + "nachname": kunde.get("nachname", ""), + "vorname": kunde.get("vorname", ""), + "art": "storno", + "storno_zu": alt_nummer, + "storno_datum": str(meta.get("datum") or ""), + "kunde": kunde, + } + name = f"Storno_{safe_filename(nummer)}" + if kopf["nachname"]: + name += "_" + safe_filename(kopf["nachname"]) + pfad = os.path.join(out_dir, name + ".pdf") + try: + pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) + except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen + messagebox.showerror("Fehler beim Erstellen", + f"Storno-PDF konnte nicht erstellt werden:\n{e}") + print("[storno] FEHLER:", e) + return + + self.storno_stand = { + "pfad": pfad, "nummer": nummer, "zu": alt_nummer, + "zu_datum": str(meta.get("datum") or ""), + "jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year), + "zaehler_vorher": self.cfg.get("rechnung_zaehler", 0), + } + self._zaehler_aus_nummer_speichern(nummer) + print(f"[storno] erstellt -> {pfad}") + + fehlend = self._formular_aus_meta(meta) + self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "") + self._storno_anzeigen() + + hinweis = "" + if fehlend: + hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " + "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") + messagebox.showinfo( + "Storno erstellt", + f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" + f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " + f"\"PDF erstellen\" drücken.{hinweis}") + def _formular_aus_meta(self, meta): + """Formular mit den Daten der alten Rechnung füllen. + + Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2) + tragen weder Anschrift noch Positionen in den Kenndaten. + """ + kunde, woher = kundendaten_ermitteln(meta) + self.k_vorname.set(kunde.get("vorname", "")) + self.k_name.set(kunde.get("nachname", "")) + self.k_anrede.set(str(kunde.get("anrede", "") or "")) + self.k_zusatz.set(str(kunde.get("zusatz", "") or "")) + self.k_strasse.set(str(kunde.get("strasse", "") or "")) + self.k_plzort.set(str(kunde.get("plzort", "") or "")) + self.r_anreise.set(str(meta.get("anreise", "") or "")) + self.r_abreise.set(str(meta.get("abreise", "") or "")) + self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) + + for nr, var in self.anzahl_vars.items(): + var.set("") + self.personen_vars[nr].set("") + for p in meta.get("positionen") or []: + nr = p.get("nr") + if nr in self.anzahl_vars: + self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) + self.personen_vars[nr].set(str(p.get("personen", "") or "")) + self.recalc() + + hinweise = [] + if woher: + hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher)) + fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()), + ("PLZ / Ort", self.k_plzort.get()), + ("Nachname", self.k_name.get())) + if not wert] + if fehlt: + hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt)) + if not (meta.get("positionen") or []): + hinweise.append("Leistungen konnten nicht übernommen werden") + return "; ".join(hinweise) diff --git a/pruef_nummern.py b/pruef_nummern.py index bb916c1..596b918 100644 --- a/pruef_nummern.py +++ b/pruef_nummern.py @@ -9,7 +9,7 @@ import sys import tempfile sys.path.insert(0, r"C:\claude\rechnungstool") -from app import vorhandene_rechnungen # noqa: E402 +from bestand import vorhandene_rechnungen # noqa: E402 ordner = tempfile.mkdtemp(prefix="rechnungspruef_") dateien = [ diff --git a/pruef_storno.py b/pruef_storno.py index eef1327..c17d132 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -33,6 +33,7 @@ roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) import app as A # noqa: E402 +import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 fehler = [] @@ -81,7 +82,7 @@ f.pdf_erstellen() erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf") pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben") -meta1 = A.kenndaten_lesen(erste_pdf) +meta1 = BE.kenndaten_lesen(erste_pdf) pruefe(bool(meta1), "Kenndaten der Rechnung lesbar") pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})") pruefe(meta1.get("art") == "rechnung", "art = rechnung") @@ -92,14 +93,14 @@ gesamt1 = float(meta1["gesamt"]) print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}") # ---- 2. stornieren ------------------------------------------------------ -offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] +offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden") f._storno_anlegen(offen[0], ordner) pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher") storno_pdf = f.storno_stand["pfad"] if f.storno_stand else "" pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})") -meta2 = A.kenndaten_lesen(storno_pdf) +meta2 = BE.kenndaten_lesen(storno_pdf) pruefe(meta2.get("art") == "storno", "art = storno") pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001") pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin") @@ -121,7 +122,7 @@ dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf") pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben") pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer") pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei") -meta3 = A.kenndaten_lesen(dritte) +meta3 = BE.kenndaten_lesen(dritte) pruefe(meta3.get("art") == "korrektur", "art = korrektur") pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001") pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002") @@ -146,7 +147,7 @@ print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}") # ---- 5. Rueckweg: Storno anlegen und verwerfen -------------------------- antworten["Storno wirklich"] = False -offen = [d for d in A.rechnungen_im_ordner(ordner) +offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") == "korrektur"] f._storno_anlegen(offen[0], ordner) storno2 = f.storno_stand["pfad"] @@ -303,9 +304,9 @@ def alt_rechnung(name, dateiname, kunde_zeilen): alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf", ["Herr", "Andres Schäfer", "c/o Stellplatz 12", "Bornaer Chaussee 36", "04416 Markkleeberg"]) -meta_alt = A.kenndaten_lesen(alt) +meta_alt = BE.kenndaten_lesen(alt) pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten") -kunde, woher = A.kundendaten_ermitteln(meta_alt) +kunde, woher = BE.kundendaten_ermitteln(meta_alt) pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})") pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})") pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})") @@ -315,16 +316,16 @@ pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde # b) sein echter Fall: NUR der Name, keine Anschrift alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""]) -kunde2, _ = A.kundendaten_ermitteln(A.kenndaten_lesen(alt2)) +kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2)) pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt") pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden") # c) Formular fuellen: was fehlt, wird benannt -meldung = f._formular_aus_meta(A.kenndaten_lesen(alt2)) +meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2)) pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer", "Formular bekommt Vor- und Nachnamen getrennt") pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})") -meldung = f._formular_aus_meta(A.kenndaten_lesen(alt)) +meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt)) pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular") pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})") for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): @@ -332,7 +333,7 @@ for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): # ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf ------------------- alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""]) -meta3 = A.kenndaten_lesen(alt3) +meta3 = BE.kenndaten_lesen(alt3) vorher = sorted(os.listdir(ordner)) zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"] @@ -367,7 +368,7 @@ pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung, f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer " f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})") -meta_b = A.kenndaten_lesen(ziel) +meta_b = BE.kenndaten_lesen(ziel) pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung") pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung") pruefe(float(meta_b.get("gesamt") or 0) == 0.0, diff --git a/storno.py b/storno.py new file mode 100644 index 0000000..e772d33 --- /dev/null +++ b/storno.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Den spiegelbildlichen Storno zu einer Rechnung rechnen.""" + +import berechnung + + +def storno_rechnung_bauen(meta, cfg): + """Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno. + + Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der + alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes, + und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten. + """ + r = berechnung.Rechnung() + nummer = meta.get("rechnungsnummer", "") + bloecke = meta.get("ust_bloecke") or [] + if not bloecke: + # v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt + # die USt unter den Tisch. + brutto = float(meta.get("zwischensumme") or 0) + satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7)) + netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto + bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto), + "brutto": brutto}] + + saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])} + for i, b in enumerate(bloecke, start=1): + satz = int(float(b.get("satz", 0) or 0)) + brutto = -abs(berechnung.runde(b.get("brutto") or 0)) + netto = -abs(berechnung.runde(b.get("netto") or 0)) + ust = -abs(berechnung.runde(b.get("ust") or 0)) + r.zeilen.append(berechnung.PositionsZeile( + nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)", + satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False, + brutto=brutto, netto=netto)) + info = saetze.get(satz, {}) + r.bloecke.append(berechnung.Steuerblock( + satz=satz, netto=netto, ust=ust, + bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"), + extra_blatt=bool(info.get("extra_blatt", False)))) + + r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke)) + r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke)) + r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt) + + # Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht. + # Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem + # berechne() den Aufschlag baut. + steuer = berechnung.runde(meta.get("steuer_betrag") or 0) + if steuer: + extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {}) + r.aufschlaege.append(berechnung.Aufschlag( + satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0), + bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"), + basis=r.brutto, betrag=-abs(steuer), extra_blatt=True)) + r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege)) + return r diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..23aba1d --- /dev/null +++ b/theme.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste. + +Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum +baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben. +""" + +import sys +import tkinter as tk +from tkinter import ttk + + +def windows_dark_mode(): + """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" + if not sys.platform.startswith("win"): + return False + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: + return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 + except OSError: + return False + + +class ThemeMixin: + """Farben setzen. Erwartet ein tk.Tk als Basis.""" + + # ----------------------------------------------------------------- Theme + def _apply_theme(self): + """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" + dark = windows_dark_mode() + self._dark_mode = dark + if dark: + self.col_bg = "#23272e" # Fenster-Hintergrund + self.col_fg = "#e6e6e6" # Schrift + self.col_field = "#2f343c" # Eingabefelder + self.col_sel = "#3a4a44" # aktiv/hover + self.col_accent = "#5fb98f" # Akzent (Gruen, hell) + self.col_line = "#3a3f47" + self.col_scroll = "#4a505a" # Scroll-Griff + self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") + else: + self.col_bg = "#f3f3f3" + self.col_fg = "#1a1a1a" + self.col_field = "#ffffff" + self.col_sel = "#cfe3d8" + self.col_accent = "#1f5c3d" + self.col_line = "#cccccc" + self.col_scroll = "#b8b8b8" + self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") + + self.configure(bg=self.col_bg) + style = ttk.Style(self) + try: + style.theme_use("clam") + except tk.TclError: + pass + bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, + self.col_sel, self.col_accent) + style.configure(".", background=bg, foreground=fg, fieldbackground=field, + bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) + style.configure("TFrame", background=bg) + style.configure("TLabel", background=bg, foreground=fg) + style.configure("TLabelframe", background=bg, bordercolor=self.col_line) + style.configure("TLabelframe.Label", background=bg, foreground=accent) + style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) + style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) + style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", + arrowcolor=bg, padding=(10, 3)) + style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) + style.configure("TCheckbutton", background=bg, foreground=fg) + style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) + style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) + # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, + # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert + # unsichtbar im Feld. + style.configure("TCombobox", fieldbackground=field, foreground=fg, + background=field, arrowcolor=fg, + selectbackground=field, selectforeground=fg) + style.map("TCombobox", fieldbackground=[("readonly", field)], + foreground=[("readonly", fg)], background=[("readonly", field)], + selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) + # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget + self.option_add("*TCombobox*Listbox.background", field) + self.option_add("*TCombobox*Listbox.foreground", fg) + self.option_add("*TCombobox*Listbox.selectBackground", sel) + self.option_add("*TCombobox*Listbox.selectForeground", fg) + for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): + style.configure(sbs, troughcolor=bg, background=self.col_scroll, + bordercolor=bg, lightcolor=bg, darkcolor=bg, + arrowcolor=fg, relief="flat", gripcount=0) + style.map(sbs, background=[("active", accent)], + troughcolor=[("disabled", bg)]) + style.configure("TSeparator", background=self.col_line) + # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults + self.option_add("*background", bg) + self.option_add("*foreground", fg) + def _style_dialog(self, d): + """Toplevel-Dialog an das Theme angleichen.""" + d.configure(bg=self.col_bg) + # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht + d.after(20, lambda: self._dark_titlebar(d)) + def _dark_titlebar(self, win): + """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" + if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): + return + try: + import ctypes + win.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) + val = ctypes.c_int(1) + for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) + # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) + SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED + ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) + except Exception as e: # noqa: BLE001 + print("[theme] Titelleiste:", e)