diff --git a/.gitignore b/.gitignore index c0d17af..3833f58 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ config.json _tmp/ *.7z.* release/ +rechnungstool.pid diff --git a/app.py b/app.py index 905f65f..8b05b7f 100644 --- a/app.py +++ b/app.py @@ -8,60 +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 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 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() @@ -74,6 +50,12 @@ class RechnungsApp(tk.Tk): self.anzahl_vars = {} # nr -> StringVar self.personen_vars = {} # nr -> StringVar self.gesamt_lbls = {} # nr -> Label + # Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss. + # Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette. + self.storno_stand = None + # Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV): + # die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung. + self.berichtigung_stand = None self._apply_theme() # Farben nach Windows hell/dunkel self._build_menu() @@ -84,95 +66,15 @@ class RechnungsApp(tk.Tk): self._neue_nummer_vorschlagen() self.recalc() + # Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt + # es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum. + self.protocol("WM_DELETE_WINDOW", self._beenden) + # Titelleiste dunkel (nach dem Mappen des Fensters) self.after(60, lambda: self._dark_titlebar(self)) # 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) - style.configure("TCombobox", fieldbackground=field, foreground=fg, - background=field, arrowcolor=fg) - style.map("TCombobox", fieldbackground=[("readonly", field)], - foreground=[("readonly", fg)], background=[("readonly", field)]) - 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): @@ -185,7 +87,7 @@ class RechnungsApp(tk.Tk): ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), ("-", None), - ("Beenden", self.destroy), + ("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist ], "Einstellungen": [ ("Steuersätze…", self.dlg_steuern), @@ -205,7 +107,9 @@ class RechnungsApp(tk.Tk): lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_bg)) lbl.bind("", lambda e, b=lbl, it=items: self._menu_popup(b, it)) ttk.Separator(self, orient="horizontal").pack(side="top", fill="x") - self.bind_all("", lambda e: self.pdf_erstellen()) + # bind statt bind_all: sonst feuert Strg+P auch aus einem offenen + # Dialog heraus und schreibt im Formular dahinter eine Rechnung. + self.bind("", lambda e: self.pdf_erstellen()) def _menu_popup(self, anchor, items): if self._open_pop: # offenes Menu zu -> Toggle @@ -271,16 +175,25 @@ class RechnungsApp(tk.Tk): self.k_strasse = tk.StringVar() self.k_plzort = tk.StringVar() felder = [ - ("Anrede (optional)", self.k_anrede), - ("Vorname (optional)", self.k_vorname), - ("Nachname / Firma", self.k_name), + ("Anrede", self.k_anrede), + ("Vorname *", self.k_vorname), + ("Nachname / Firma *", self.k_name), ("Zusatz (optional)", self.k_zusatz), - ("Straße / Nr.", self.k_strasse), - ("PLZ / Ort", self.k_plzort), + ("Straße / Nr. *", self.k_strasse), + ("PLZ / Ort *", self.k_plzort), ] for i, (lbl, var) in enumerate(felder): ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2) - ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + if var is self.k_anrede: + # Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen. + w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN) + else: + w = ttk.Entry(kunde, textvariable=var, width=38) + w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören " + "auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).", + foreground=self.col_muted, wraplength=330, justify="left").grid( + row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0)) kunde.columnconfigure(1, weight=1) # Rechnungsinfos (rechts) @@ -302,11 +215,20 @@ class RechnungsApp(tk.Tk): ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2) ent = ttk.Entry(info, textvariable=var, width=18) ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + if var is self.r_nummer: + # Waehrend eines Stornos wird das Feld gesperrt - die Nummern + # vergibt dann das Programm, damit keine doppelt sein kann. + self.nummer_entry = ent if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende ent.bind("", self._auto_datum_evt) ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2) ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid( row=4, column=1, sticky="w", padx=(8, 0)) + # Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt. + self.storno_hinweis = tk.StringVar(value="") + ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020", + font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid( + row=5, column=0, columnspan=2, sticky="w", pady=(6, 0)) info.columnconfigure(1, weight=1) self.r_anreise.trace_add("write", lambda *a: self._update_naechte()) self.r_abreise.trace_add("write", lambda *a: self._update_naechte()) @@ -439,6 +361,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="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 @@ -483,12 +409,30 @@ class RechnungsApp(tk.Tk): # ----------------------------------------------------------- Aktionen def _neue_nummer_vorschlagen(self): - jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year) - zaehler = self.cfg.get("rechnung_zaehler", 0) - heute_jahr = datetime.date.today().year - if heute_jahr != jahr: - jahr, zaehler = heute_jahr, 0 - self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1)) + """Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander. + + Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld + zu diesem Vorgang und wird nicht angefasst. + """ + if self.storno_stand or self.berichtigung_stand: + return + frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg) + if frei: + self.r_nummer.set(frei) + + + def kunde_pruefen(self): + """Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung. + + Saeubert die Felder und prueft sie mit kunde_maengel(). + """ + for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, + self.k_strasse, self.k_plzort): + var.set(saeubern(var.get())) + return kunde_maengel({"vorname": self.k_vorname.get(), + "nachname": self.k_name.get(), + "strasse": self.k_strasse.get(), + "plzort": self.k_plzort.get()}) def pdf_erstellen(self): r = self.recalc() @@ -500,9 +444,45 @@ class RechnungsApp(tk.Tk): if not nummer: messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.") return - if not self.k_name.get().strip(): - if not messagebox.askyesno("Kein Kundenname", - "Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"): + # Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung + # (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben. + maengel = self.kunde_pruefen() + if maengel: + messagebox.showwarning( + "Kundenangaben fehlen", + "So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) + + "\n\nName und vollständige Anschrift des Kunden gehören auf jede " + "Rechnung (§ 14 Abs. 4 Nr. 1 UStG).") + return + # Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne + # (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer. + if not ziffern(self.k_strasse.get()): + if not messagebox.askyesno( + "Hausnummer fehlt", + f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n" + "Trotzdem so auf die Rechnung schreiben?"): + return + + # Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht + # mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder + # eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum + # Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin + # schon, darum hier nur bei einer ganz normalen Rechnung. + if not self.storno_stand and not self.berichtigung_stand: + wer = " ".join(t for t in (self.k_vorname.get().strip(), + self.k_name.get().strip()) if t) + if not messagebox.askyesno( + "Rechnung endgültig erstellen?", + f"Rechnung {nummer} für {wer or 'ohne Namen'} über " + f"{berechnung.eur(r.gesamt)}.\n\n" + "Sind alle Angaben richtig? Danach lässt sich die Rechnung " + "nicht mehr ändern.\n\n" + "Eine ausgestellte Rechnung darf weder überschrieben noch " + "gelöscht werden. Korrigiert wird sie nur noch über " + "„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche " + "Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt " + "es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).", + icon="question", default="yes"): return # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; @@ -513,6 +493,26 @@ class RechnungsApp(tk.Tk): namenszeile = " ".join(t for t in (vorname, nachname) if t) kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(), self.k_strasse.get(), self.k_plzort.get()] + # Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung. + # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder + # geloescht und es bleibt allein die urspruengliche Rechnung stehen. + stand = self.storno_stand + if stand: + if not messagebox.askyesno( + "Storno wirklich durchführen?", + f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, " + f"und {nummer} wird die berichtigte Rechnung.\n\n" + "Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie " + "ihm zugegangen ist.\n\n" + "Ja = Storno behalten und berichtigte Rechnung erstellen\n" + f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}", + icon="warning", default="yes"): + self._storno_verwerfen("vom Benutzer verworfen") + messagebox.showinfo( + "Storno verworfen", + f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.") + return + kopf = { "rechnungsnummer": nummer, "datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"), @@ -522,8 +522,92 @@ class RechnungsApp(tk.Tk): "kunde_zeilen": kunde_zeilen, "nachname": nachname, "vorname": vorname, + # Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter + # wieder ins Formular geladen werden kann statt abgetippt zu werden. + "kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname, + "nachname": nachname, "zusatz": self.k_zusatz.get().strip(), + "strasse": self.k_strasse.get().strip(), + "plzort": self.k_plzort.get().strip()}, + "positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen} + for z in r.zeilen], } + if stand: + kopf["art"] = "korrektur" + kopf["vorgang"] = stand.get("vorgang") or stand["zu"] + kopf["ersetzt"] = stand["zu"] + kopf["storno_nummer"] = stand["nummer"] out_dir = config.ensure_output_dir(self.cfg) + + # ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ---- + ber = self.berichtigung_stand + if ber: + kopf["art"] = "berichtigung" + kopf["vorgang"] = ber.get("vorgang") or ber["zu"] + kopf["berichtigt_zu"] = ber["zu"] + kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y") + if not messagebox.askyesno( + "Berichtigte Rechnung erstellen?", + f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes " + "Blatt geschrieben.\n\n" + "Nummer, Leistungen und Beträge bleiben unverändert – die alte " + "Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es " + "ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?", + default="yes"): + return + # Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das + # Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere + # Berichtigungen zur selben Rechnung auseinander (sortierbar). + pfad = os.path.join( + out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}" + f"_{safe_filename(ber['zu'])}.pdf") + if os.path.exists(pfad) and not messagebox.askyesno( + "Berichtigung von heute ersetzen?", + f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n" + f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"): + return + 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"Berichtigung konnte nicht erstellt werden:\n{e}") + print("[berichtigung] FEHLER:", e) + return + print(f"[berichtigung] erstellt -> {pfad}") + self.berichtigung_stand = None + self._storno_anzeigen() + if messagebox.askyesno("Fertig", + f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n" + "Jetzt öffnen (zum Drucken)?"): + self._oeffne_datei(pfad) + return + + # Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte + # Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen + # der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter + # Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede + # Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer. + vorhanden = vorhandene_rechnungen(out_dir, nummer) + if vorhanden: + liste = "\n".join("- " + os.path.basename(x) for x in vorhanden) + frei = naechste_freie_nummer(out_dir, self.cfg) + if not frei: + messagebox.showerror( + "Rechnungsnummer schon vergeben", + f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" + "Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.") + return + if not messagebox.askyesno( + "Rechnungsnummer schon vergeben", + f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" + "Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben " + "noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n" + f"Stattdessen die nächste freie Nummer {frei} verwenden?", + icon="warning", default="yes"): + return + nummer = frei + self.r_nummer.set(nummer) + kopf["rechnungsnummer"] = nummer + fname = f"Rechnung_{safe_filename(nummer)}" if self.k_name.get().strip(): fname += "_" + safe_filename(self.k_name.get().strip()) @@ -542,8 +626,19 @@ class RechnungsApp(tk.Tk): print(f"[pdf] erstellt -> {pfad}") hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else "" + storno_pfad = None + if stand: + # Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen. + storno_pfad = stand["pfad"] + self.storno_stand = None + self._storno_anzeigen() + hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} " + f"({os.path.basename(storno_pfad)}).\n" + "Beide Blätter gehören zum Gast.") + hinweis if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) + if storno_pfad: + self._oeffne_datei(storno_pfad) def _zaehler_aus_nummer_speichern(self, nummer): try: @@ -551,12 +646,58 @@ class RechnungsApp(tk.Tk): jahr, z = int(jahr_s), int(z_s) except (ValueError, AttributeError): return + # Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben, + # wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal + # vergeben - genau der Fehler, den wir loswerden wollen. + alt_jahr = self.cfg.get("rechnung_jahr", 0) + alt_z = self.cfg.get("rechnung_zaehler", 0) + if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z): + return self.cfg["rechnung_jahr"] = jahr self.cfg["rechnung_zaehler"] = z - config.save_config(self.cfg) + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben + # Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben + # ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt. + messagebox.showwarning( + "Rechnungsnummer nicht gespeichert", + f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n" + f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, " + "sonst wird die nächste Nummer noch einmal vergeben.") self._neue_nummer_vorschlagen() + def _beenden(self): + """Schliessen - mit Rückfrage, wenn ein Storno noch offen ist.""" + if self.storno_stand: + if not messagebox.askyesno( + "Storno ist noch offen", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte " + "Rechnung fehlt noch.\n\n" + "Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, " + "warum.\n\nTrotzdem beenden?", icon="warning", default="no"): + return + self.destroy() + def reset_formular(self): + # Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die + # neu eingetippten Daten plötzlich zum Storno der fremden Rechnung. + if self.storno_stand: + if not messagebox.askyesno( + "Storno verwerfen?", + f"Der Storno {self.storno_stand['nummer']} zu Rechnung " + f"{self.storno_stand['zu']} liegt noch bereit.\n\n" + "Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und " + f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n" + "Wirklich zurücksetzen?", icon="warning", default="no"): + return + self._storno_verwerfen("beim Zurücksetzen verworfen") + elif self.berichtigung_stand: + # Da ist noch nichts geschrieben - einfach fallenlassen. + self.berichtigung_stand = None + self._storno_anzeigen() + for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise): v.set("") @@ -571,28 +712,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: @@ -605,331 +724,24 @@ 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. + _sperre = einzelinstanz_pruefen() + if _sperre is False: + # Laeuft schon: dessen Fenster nach vorn holen und still beenden. + if not fenster_nach_vorn(laufende_pid_lesen()): + _r = tk.Tk() + _r.withdraw() + messagebox.showerror( + "Rechnungstool läuft bereits", + """Das Rechnungstool ist schon geöffnet, das Fenster ließ sich +aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln. + +Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""") + _r.destroy() + sys.exit(0) + + eigene_pid_merken() app = RechnungsApp() app.mainloop() diff --git a/bestand.py b/bestand.py new file mode 100644 index 0000000..baac192 --- /dev/null +++ b/bestand.py @@ -0,0 +1,208 @@ +# -*- 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 [] + # Windows haengt beim Kopieren " - Kopie" an oder setzt "Kopie von " davor. + VORSAETZE = ("kopie von ", "kopie (2) von ", "copy of ") + treffer = [] + for name in namen: + if not name.lower().endswith(".pdf"): + continue + stamm = name[:-4] + for vorsatz in VORSAETZE: + if stamm.lower().startswith(vorsatz): + stamm = stamm[len(vorsatz):] + break + # Nach der Nummer darf alles kommen, nur keine weitere Ziffer. Damit + # zaehlen auch "Rechnung_2026-013 (1).pdf" und "... - Kopie.pdf" mit - + # genau so sind die alten Dubletten entstanden. "2026-0135" faellt raus, + # weil dort eine Ziffer folgt. + klein = stamm.lower() + for k in koepfe: + if klein.startswith(k.lower()) and ( + len(stamm) == len(k) or not stamm[len(k)].isdigit()): + treffer.append(os.path.join(ordner, name)) + break + 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_nach(ordner, cfg, vergeben): + """Erste freie Nummer NACH - fuer die Rechnung, die auf ein Storno folgt. + + Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon + mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch + einmal. + """ + kandidat = naechste_freie_nummer(ordner, cfg) + if kandidat and kandidat != vergeben: + return kandidat + ersatz = dict(cfg) + try: + jahr, z = str(vergeben).split("-") + ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z) + except (ValueError, AttributeError): + return kandidat + return naechste_freie_nummer(ordner, ersatz) + + +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) + # Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends + # sonst - sonst schlaegt das Formular 2026-001 vor und der Storno nimmt + # gleichzeitig noch 2025-088. + if datetime.date.today().year != jahr: + jahr, z = datetime.date.today().year, 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/config.py b/config.py index 83b54fb..218280d 100644 --- a/config.py +++ b/config.py @@ -16,11 +16,59 @@ import sys # - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar), # gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS if getattr(sys, "frozen", False): - BASE_DIR = os.path.dirname(sys.executable) - RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR) + EXE_DIR = os.path.dirname(sys.executable) + RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR) else: - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - RESOURCE_DIR = BASE_DIR + EXE_DIR = os.path.dirname(os.path.abspath(__file__)) + RESOURCE_DIR = EXE_DIR + + +def _beschreibbar(ordner): + """Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.""" + try: + os.makedirs(ordner, exist_ok=True) + probe = os.path.join(ordner, ".schreibprobe") + with open(probe, "w") as f: + f.write("x") + os.remove(probe) + return True + except OSError: + return False + + +def _daten_ordner(): + """Der Ordner, den das Programm sich beim ersten Start selbst anlegt. + + Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf + einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern. + + 1. neben der EXE ein Unterordner "Daten" + 2. geht das nicht (EXE liegt z.B. unter "Programme"), dann + der Ordner Rechnungstool unter LOCALAPPDATA + + Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien + noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt + dass das Programm ohne Firmendaten und Zaehler neu anfaengt. + """ + if not getattr(sys, "frozen", False): + return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist + kandidat = os.path.join(EXE_DIR, "Daten") + if not _beschreibbar(kandidat): + kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool") + if not _beschreibbar(kandidat): + return EXE_DIR # letzter Ausweg: wie frueher + for name in ("config.json",): + alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name) + if os.path.exists(alt) and not os.path.exists(neu_): + try: + os.replace(alt, neu_) + print(f"[config] {name} in den Datenordner übernommen") + except OSError: + pass + return kandidat + + +BASE_DIR = _daten_ordner() CONFIG_PATH = os.path.join(BASE_DIR, "config.json") # Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl). diff --git a/einstellungen.py b/einstellungen.py new file mode 100644 index 0000000..200aa7c --- /dev/null +++ b/einstellungen.py @@ -0,0 +1,366 @@ +# -*- 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 datetime +import os +import tkinter as tk +from tkinter import ttk, messagebox, filedialog + +import berechnung +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..77038df --- /dev/null +++ b/korrektur.py @@ -0,0 +1,429 @@ +# -*- 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 verbietet +genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr +feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses +Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im +Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes +Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG, +§ 31 Abs. 5 UStDV). +""" + +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, + naechste_freie_nummer_nach, rechnungen_im_ordner) +from helfer import kunde_maengel, 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 ""), + "vorgang": str(meta.get("vorgang") or nummer), + "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 + # Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner, + # der Zähler ist zurückgedreht und die Rechnung gilt als storniert, + # obwohl der Nutzer sie behalten wollte. + messagebox.showerror( + "Storno konnte nicht gelöscht werden", + f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n" + "Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen " + "und noch einmal versuchen. Es wurde nichts verändert.") + return + self.cfg["rechnung_jahr"] = stand["jahr_vorher"] + self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"] + try: + config.save_config(self.cfg) + except OSError as e: # noqa: BLE001 + messagebox.showwarning( + "Zähler nicht gespeichert", + f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" + "Bitte im Menü unter „Rechnungsnummer…“ prüfen.") + 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) + stornos = {nummer_normalisieren(d.get("storno_zu")): d + for d in alle if d.get("art") == "storno"} + stornierte = set(stornos) + # Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung? + ersetzte = {nummer_normalisieren(d.get("ersetzt")) + for d in alle if d.get("art") == "korrektur"} + 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)) + + # Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das + # ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten, + # wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit + # "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist. + self._auswahl = tk.Listbox(frm, height=4, exportselection=False, + bg=self.col_field, fg=self.col_fg, + selectbackground=self.col_sel, selectforeground=self.col_fg, + relief="flat", font=("Consolas", 9)) + self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + self._auswahl.grid_remove() + self._kandidaten = [] + + def kandidaten_zur_nummer(nr): + return [x for x in alle + if nummer_normalisieren(x.get("rechnungsnummer")) == nr + and x.get("art") != "berichtigung"] + + def suche(): + """(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum.""" + nr = nummer_normalisieren(ent.get()) + if not nr: + self._kandidaten = [] + self._auswahl.grid_remove() + return None, "Nummer eintragen – das Programm sucht die Rechnung." + gefunden = kandidaten_zur_nummer(nr) + if not gefunden: + self._kandidaten = [] + self._auswahl.grid_remove() + return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung." + + # Mehrere Belege unter derselben Nummer -> auswählen lassen. + if len(gefunden) > 1: + if gefunden != self._kandidaten: + self._kandidaten = gefunden + self._auswahl.delete(0, "end") + for x in gefunden: + self._auswahl.insert("end", + f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} " + f"{berechnung.eur(x.get('gesamt') or 0):>11s} " + f"{os.path.basename(x.get('_pfad',''))}") + self._auswahl.grid() + self._auswahl.selection_clear(0, "end") + auswahl = self._auswahl.curselection() + if not auswahl: + return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – " + "das darf es nicht geben.\nBitte unten auswählen, " + "welcher Beleg gemeint ist.") + treffer = gefunden[auswahl[0]] + else: + self._kandidaten = [] + self._auswahl.grid_remove() + treffer = gefunden[0] + + if treffer.get("art") == "storno": + return None, f"{nr} ist selbst eine Stornorechnung." + nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer")) + if nr_norm in stornierte: + st = stornos.get(nr_norm) or {} + folgt = st.get("korrektur_nummer") or "" + if nr_norm in ersetzte or not folgt: + return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} " + "storniert – ein zweites Mal geht nicht.") + # Auf dem Storno steht, welche Nummer die berichtigte Rechnung + # bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie + # noch, wird sie einfach unter dieser Nummer geschrieben. + return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. " + f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – " + "die steht auch auf dem Storno-Blatt.") + gesamt = float(treffer.get("gesamt") or 0) + mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n" + if len(gefunden) > 1 else "") + return treffer, ( + mehrfach + + f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}" + f" {treffer.get('nachname','')}" + + (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\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) + self._auswahl.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=4, 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") + # Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest - + # die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit + # die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt + # werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt. + folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer) + # 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", "")] + + # Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast + # und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts, + # kann das Programm nichts erfinden; dann muss der Nutzer entscheiden. + maengel = kunde_maengel(kunde) + if maengel: + if not messagebox.askyesno( + "Angaben zum Kunden unvollständig", + f"In Rechnung {alt_nummer} steht nicht alles:\n\n- " + + "\n- ".join(maengel) + + "\n\nDas Storno geht an den Gast und trägt dieselben Angaben. " + "Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?", + icon="warning", default="no"): + return + + kopf = { + "rechnungsnummer": nummer, + "datum": heute, + "anreise": "", "abreise": "", "naechte": "", + "kunde_zeilen": kunde_zeilen, + "nachname": kunde.get("nachname", ""), + "vorname": kunde.get("vorname", ""), + "art": "storno", + # Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine + # schon berichtigte Rechnung noch einmal storniert, bleibt die ganze + # Kette unter EINEM Schluessel. + "vorgang": str(meta.get("vorgang") or alt_nummer), + "storno_zu": alt_nummer, + # alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette + # aus dem Storno allein lesbar ist. + "korrektur_nummer": folge_nummer or "", + "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, + "vorgang": str(meta.get("vorgang") or 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(folge_nummer 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/pdf_renderer.py b/pdf_renderer.py index c554322..bfc69ff 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -208,10 +208,30 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): if len(kunde_flow) == 1: kunde_flow.append(Paragraph(" ", st["addr"])) + art = kopf.get("art", "rechnung") info_rows = [ - ("Rechnungsnummer:", kopf.get("rechnungsnummer", "")), + ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", + kopf.get("rechnungsnummer", "")), ("Rechnungsdatum:", kopf.get("datum", "")), ] + # Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum. + if art == "storno" and kopf.get("storno_zu"): + # Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen + # Spalte um und der Bezug sieht aus wie zwei halbe Angaben. + info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", ""))) + if kopf.get("storno_datum"): + info_rows.append(("Rechnung vom:", kopf["storno_datum"])) + # Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die + # ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer. + if kopf.get("korrektur_nummer"): + info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"])) + elif art == "korrektur" and kopf.get("ersetzt"): + info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) + elif art == "berichtigung": + # Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der + # Berichtigung, damit man die Reihenfolge sieht. + if kopf.get("berichtigt_am"): + info_rows.append(("Berichtigt am:", kopf["berichtigt_am"])) if kopf.get("anreise"): info_rows.append(("Anreise:", kopf.get("anreise", ""))) if kopf.get("abreise"): @@ -239,7 +259,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # Ueberschrift (auf dem Amt-Blatt mit Zusatz) # Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht # in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist. - titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "") + # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg + # eindeutig als Aufhebung erkennbar ist. + titel = {"storno": "Stornorechnung", + "berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht") + titel += " (für das Amt)" if gesamtrechnung_betont else "" story.append(Paragraph(titel, st["h"])) # Positionstabelle @@ -342,22 +366,64 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): continue satz_txt = eur_kurz(a.satz).replace(",00", "") story.append(Spacer(1, 1.5 * mm)) - story.append(Paragraph( - f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " - f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) + if art == "storno": + # Auf dem Storno wird nichts erhoben, sondern zurueckgenommen. + story.append(Paragraph( + f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} " + f"({satz_txt} %) wird mit aufgehoben.", st["dank"])) + else: + story.append(Paragraph( + f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " + f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) story.append(Spacer(1, 8 * mm)) - # ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- + # ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- if not gesamtrechnung_betont: texte = cfg.get("texte", {}) block = [] - if texte.get("dank1"): - block.append(Paragraph(texte["dank1"], st["dank"])) - if texte.get("dank2"): - block.append(Paragraph(texte["dank2"], st["dank"])) - if texte.get("hinweis"): - block.append(Paragraph(texte["hinweis"], st["hinweis"])) + if art == "storno": + # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird + # nichts geschuldet, sondern aufgehoben. + bezug = kopf.get("storno_zu", "") + if kopf.get("storno_datum"): + bezug += f" vom {kopf['storno_datum']}" + folgt = kopf.get("korrektur_nummer") or "" + kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} " + f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " + f"→ {folgt}).") if folgt else "" + block.append(Paragraph( + f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " + "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " + "Rechnung verrechnet." + kette, st["dank"])) + elif art == "berichtigung": + # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung + # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 + # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht + # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und + # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim + # Aussteller. Also bleibt der Satz drauf. + block.append(Paragraph( + f"Dieses Blatt berichtigt die Angaben zur Rechnung " + f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " + "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind " + "unverändert – es handelt sich nicht um eine zusätzliche " + "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält " + "ihre Nummer.", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) + else: + if art == "korrektur" and kopf.get("ersetzt"): + hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" + if kopf.get("storno_nummer"): + hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde" + block.append(Paragraph(hinweis + ".", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) + if texte.get("dank2"): + block.append(Paragraph(texte["dank2"], st["dank"])) + if texte.get("hinweis"): + block.append(Paragraph(texte["hinweis"], st["hinweis"])) story.append(KeepTogether(block)) @@ -368,11 +434,44 @@ def _kenndaten(rechnung, kopf): Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus, statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck aendert sich nichts - Metadaten stehen nicht auf dem Blatt. + + Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm, + den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 % + stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben + unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt + der Import des Steuerjournals, das darf nicht brechen. + + Version 3 kommt mit dem Storno dazu: + "art" - "rechnung", "storno" oder "korrektur" + "storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung + "ersetzt" - bei der Korrektur die Nummer der falschen Rechnung + "kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand, + damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt + sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht. + Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der + Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None + if kopf.get("art") == "berichtigung": + # Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber + # KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art" + # nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal. + return json.dumps({ + "quelle": "rechnungstool", "version": 3, "art": "berichtigung", + "vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""), + "rechnungsnummer": kopf.get("berichtigt_zu", ""), + "berichtigt_zu": kopf.get("berichtigt_zu", ""), + "berichtigt_am": kopf.get("berichtigt_am", ""), + "datum": kopf.get("datum", ""), + "nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""), + "zwischensumme": 0.0, "gesamt": 0.0, + "steuer_satz": 0.0, "steuer_betrag": 0.0, + "netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [], + "kunde": kopf.get("kunde", {}), + }, ensure_ascii=False) daten = { "quelle": "rechnungstool", - "version": 1, + "version": 3, "rechnungsnummer": kopf.get("rechnungsnummer", ""), "datum": kopf.get("datum", ""), "nachname": kopf.get("nachname", ""), @@ -382,6 +481,31 @@ def _kenndaten(rechnung, kopf): "steuer_satz": float(aufschlag.satz) if aufschlag else 0.0, "steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0, "gesamt": runde(rechnung.gesamt), + # neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto) + "netto_gesamt": runde(rechnung.netto_gesamt), + "ust_gesamt": runde(rechnung.ust_gesamt), + "ust_bloecke": [ + {"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust), + "brutto": runde(b.brutto)} + for b in rechnung.bloecke + ], + # neu in Version 3 + "art": kopf.get("art", "rechnung"), + # Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte + # Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der + # allerersten Rechnung. Ein anderes Programm gruppiert danach in einem + # Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen. + # Eine normale Rechnung ist ihr eigener Vorgang. + "vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""), + "storno_zu": kopf.get("storno_zu", ""), + "korrektur_nummer": kopf.get("korrektur_nummer", ""), + "storno_datum": kopf.get("storno_datum", ""), + "ersetzt": kopf.get("ersetzt", ""), + "storno_nummer": kopf.get("storno_nummer", ""), + "anreise": kopf.get("anreise", ""), + "abreise": kopf.get("abreise", ""), + "kunde": kopf.get("kunde", {}), + "positionen": kopf.get("positionen", []), } return json.dumps(daten, ensure_ascii=False) @@ -405,7 +529,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf): ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=f"Rechnung {kopf.get('rechnungsnummer','')}", + title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung") + + f" {kopf.get('rechnungsnummer','')}", author=cfg["firma"].get("name", ""), subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal ) diff --git a/pruef_dialoge.py b/pruef_dialoge.py new file mode 100644 index 0000000..284dfb0 --- /dev/null +++ b/pruef_dialoge.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: geht jeder Dialog ueberhaupt auf? + +Klingt banal, ist es nicht: beim Aufteilen von app.py in Module sind zwei +Dialoge stumm kaputtgegangen (fehlende Importe), und kein anderer Pruefstand +hat es gemerkt, weil keiner sie oeffnet. Dieser hier oeffnet ALLE. + +Geprueft wird ausserdem, dass jeder Dialog auch wieder zugeht - ein modales +Fenster, das haengenbleibt, sperrt das Programm. +""" +import json +import os +import shutil +import sys +import tempfile +import tkinter as tk + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="dialog_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +config.CONFIG_PATH = os.path.join(tmp, "config.json") +roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) +roh["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) + +import app as A # noqa: E402 +from tkinter import messagebox # noqa: E402 + +messagebox.showinfo = lambda *a, **k: None +messagebox.showwarning = lambda *a, **k: None +messagebox.showerror = lambda *a, **k: None +messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen + + +# --- Reissleine --------------------------------------------------------- +# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil +# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten +# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt. +import threading as _th + + +def _reissleine(): + import os as _os + print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, " + "der nicht beantwortet wird") + _os._exit(2) + + +_wecker = _th.Timer(120, _reissleine) +_wecker.daemon = True +_wecker.start() + +fehler = 0 +f = A.RechnungsApp() +f.update() + +# Damit "Storno"/"Berichtigen" nicht an "nichts zu korrigieren" scheitern: +# eine echte Rechnung in den Wegwerf-Ordner legen. +f.k_vorname.set("Anna") +f.k_name.set("Beispiel") +f.k_strasse.set("Musterweg 1") +f.k_plzort.set("12345 Musterstadt") +f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2") +f.update() +f.pdf_erstellen() + +DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer", + "dlg_storno", "dlg_berichtigen"] + +for name in DIALOGE: + offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)] + try: + getattr(f, name)() + f.update() + except Exception as e: # noqa: BLE001 - genau das wollen wir sehen + print(f"ABSTURZ | {name}: {type(e).__name__}: {e}") + fehler += 1 + continue + neu = [w for w in f.winfo_children() + if isinstance(w, tk.Toplevel) and w not in offen_vorher] + if not neu: + print(f"FEHLER | {name}: kein Fenster aufgegangen") + fehler += 1 + continue + titel = neu[-1].title() + for w in neu: + w.grab_release() + w.destroy() + f.update() + print(f" OK | {name} -> „{titel}“") + +# Der Speicherort-Dialog oeffnet einen Systemdialog - nur die Vorstufe pruefen +try: + f._frage_speicherort_falls_noetig() + f.update() + print(" OK | _frage_speicherort_falls_noetig") +except Exception as e: # noqa: BLE001 + print(f"ABSTURZ | _frage_speicherort_falls_noetig: {type(e).__name__}: {e}") + fehler += 1 + +f.destroy() + +# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis +# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem. +if not fehler: # fehler ist hier ein Zaehler + import shutil as _sh + _sh.rmtree(tmp, ignore_errors=True) +print("---") +print("alles gruen" if not fehler else f"{fehler} Fehler") +sys.exit(1 if fehler else 0) diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py new file mode 100644 index 0000000..723a4d1 --- /dev/null +++ b/pruef_kernregeln.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht: + + 1. Eine Rechnungsnummer wird nie zweimal vergeben. + 2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht. + +Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte +trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit +sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE +Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird. + +Darum hier: + - Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists. + Eine Datei, die noch da ist, kann trotzdem veraendert worden sein. + - Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut. + - Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen. +""" +import hashlib +import json +import os +import shutil +import sys +import tempfile + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +tmp = tempfile.mkdtemp(prefix="kern_pruef_") +ordner = os.path.join(tmp, "rechnungen") +os.makedirs(ordner) + +import config # noqa: E402 +shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) +config.CONFIG_PATH = os.path.join(tmp, "config.json") +roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) +roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 +roh["output_dir"] = ordner +json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) + +import app as A # noqa: E402 +import bestand as BE # noqa: E402 +from tkinter import messagebox # noqa: E402 + + +# --- Reissleine --------------------------------------------------------- +# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil +# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten +# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt. +import threading as _th + + +def _reissleine(): + import os as _os + print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, " + "der nicht beantwortet wird") + _os._exit(2) + + +_wecker = _th.Timer(120, _reissleine) +_wecker.daemon = True +_wecker.start() + +fehler = [] + + +def pruefe(bedingung, text): + # Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf + # nicht mit einem UnicodeEncodeError abbrechen. + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kodierung = sys.stdout.encoding or "utf-8" + print(zeile.encode(kodierung, "replace").decode(kodierung)) + if not bedingung: + fehler.append(text) + + +# ---- Dialoge: jede Frage muss angemeldet sein -------------------------------- +antworten = {} +gefragt = [] +meldungen = [] + + +def askyesno(titel, text="", **kw): + gefragt.append(titel) + for schluessel, wert in antworten.items(): + if schluessel in titel: + return wert + # Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen + # Ablauf faelschlich fuer ein Ergebnis. + raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}") + + +messagebox.askyesno = askyesno +messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else "")) +messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else "")) +messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else "")) + + +def bestand_lesen(): + """Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt.""" + stand = {} + for name in sorted(os.listdir(ordner)): + if name.lower().endswith(".pdf"): + with open(os.path.join(ordner, name), "rb") as f: + stand[name] = hashlib.sha256(f.read()).hexdigest() + return stand + + +def unveraendert(vorher, ausser=()): + """Alle Dateien aus sind byteweise unveraendert (bis auf ).""" + jetzt = bestand_lesen() + for name, summe in vorher.items(): + if name in ausser: + continue + if jetzt.get(name) != summe: + return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert")) + return True, "" + + +def kunde_setzen(vor, nach, tage="2"): + f.k_vorname.set(vor) + f.k_name.set(nach) + f.k_strasse.set("Musterweg 1") + f.k_plzort.set("12345 Musterstadt") + f.anzahl_vars[erste].set(tage) + f.update() + + +f = A.RechnungsApp() +f.update() +f.cfg["output_dir"] = ordner +erste = sorted(f.anzahl_vars)[0] +antworten["Fertig"] = False # "jetzt oeffnen?" immer nein +antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen + +# ---- Die Sicherheitsfrage muss auch wirklich abbrechen ---------------------- +# "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt +# der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe. +antworten["endgültig erstellen"] = False +kunde_setzen("Test", "Abbruch", "1") +f.pdf_erstellen() +pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")], + "bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF") +antworten["endgültig erstellen"] = True + +# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ +kunde_setzen("Anna", "Beispiel", "4") +f.pdf_erstellen() +kunde_setzen("Bernd", "Zweit", "3") +f.pdf_erstellen() +pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf", + "Rechnung_2026-002_Zweit.pdf"], + f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})") + +# ============================================================================= +# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt +# ============================================================================= +print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---") +vorher = bestand_lesen() +gefragt.clear() +antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?" +kunde_setzen("Carla", "Dritt", "1") +f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand +f.update() +f.pdf_erstellen() + +pruefe(any("schon vergeben" in t for t in gefragt), + f"Programm meldet die vergebene Nummer ({gefragt})") +ok, warum = unveraendert(vorher) +pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})") +pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")), + "die neue Rechnung bekam die nächste FREIE Nummer 2026-003") +pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})") + +print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---") +vorher = bestand_lesen() +antworten["Rechnungsnummer schon vergeben"] = False +kunde_setzen("Doris", "Vier", "1") +f.r_nummer.set("2026-002") +f.update() +f.pdf_erstellen() +ok, warum = unveraendert(vorher) +pruefe(ok and bestand_lesen() == vorher, + f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})") + +print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---") +f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus +f.berichtigung_stand = None +f.storno_stand = None +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-004", + f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})") + +print("--- Regel 1: der Zähler darf nur vorwärts ---") +# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine +# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst +# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor. +kunde_setzen("Emil", "Hoch", "1") +f.r_nummer.set("2026-009") +f.update() +f.pdf_erstellen() +pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})") +kunde_setzen("Frida", "Tief", "1") +f.r_nummer.set("2026-007") +f.update() +f.pdf_erstellen() +pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")), + "die niedrigere freie Nummer 2026-007 wurde geschrieben") +pruefe(f.cfg["rechnung_zaehler"] == 9, + f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})") +f._neue_nummer_vorschlagen() +pruefe(f.r_nummer.get() == "2026-010", + f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})") + +print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---") +f.cfg["rechnung_jahr"] = 2025 +f.cfg["rechnung_zaehler"] = 87 +f._neue_nummer_vorschlagen() +vom_formular = f.r_nummer.get() +vom_storno = BE.naechste_freie_nummer(ordner, f.cfg) +pruefe(vom_formular == vom_storno, + f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})") +pruefe(vom_formular.startswith("2026-"), + f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})") +f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3 +config.save_config(f.cfg) + +# ============================================================================= +# REGEL 2: ausgestellte Rechnung nie anfassen +# ============================================================================= +print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---") +vorher = bestand_lesen() +gefragt.clear() +f.dlg_storno() +f.update() +import tkinter as tk # noqa: E402 +from tkinter import ttk # noqa: E402 +dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1] +feld = knopf = None +for rahmen in dlg.winfo_children(): + for w in rahmen.winfo_children(): + if isinstance(w, ttk.Entry): + feld = w + elif isinstance(w, ttk.Frame): + for b in w.winfo_children(): + if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")): + knopf = b +pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf") +feld.delete(0, "end") +feld.insert(0, "2026-001") +knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen() +f.update() + +pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001", + f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})") +storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else "" +ok, warum = unveraendert(vorher) +pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})") + +print("--- Regel 2: was steht auf dem Storno-Blatt? ---") +from pypdf import PdfReader # noqa: E402 +text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text() +pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf") +pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf") +# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also +# NACH ihrer Beschriftung, nicht daneben. +zeilen = text.splitlines() + + +def betraege_nach(label, wieviele=6): + """Die Zahlenwerte, die auf die Zeile mit