diff --git a/.gitignore b/.gitignore index 3833f58..c0d17af 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ config.json _tmp/ *.7z.* release/ -rechnungstool.pid diff --git a/app.py b/app.py index 8b05b7f..905f65f 100644 --- a/app.py +++ b/app.py @@ -8,36 +8,60 @@ 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 +from tkinter import ttk, messagebox, filedialog 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 -class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): +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): def __init__(self): super().__init__() self.cfg = config.load_config() @@ -50,12 +74,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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() @@ -66,15 +84,95 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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): @@ -87,7 +185,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), ("-", None), - ("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist + ("Beenden", self.destroy), ], "Einstellungen": [ ("Steuersätze…", self.dlg_steuern), @@ -107,9 +205,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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") - # 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()) + self.bind_all("", lambda e: self.pdf_erstellen()) def _menu_popup(self, anchor, items): if self._open_pop: # offenes Menu zu -> Toggle @@ -175,25 +271,16 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.k_strasse = tk.StringVar() self.k_plzort = tk.StringVar() felder = [ - ("Anrede", self.k_anrede), - ("Vorname *", self.k_vorname), - ("Nachname / Firma *", self.k_name), + ("Anrede (optional)", self.k_anrede), + ("Vorname (optional)", 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) - 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)) + ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) kunde.columnconfigure(1, weight=1) # Rechnungsinfos (rechts) @@ -215,20 +302,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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()) @@ -361,10 +439,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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 @@ -409,30 +483,12 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # ----------------------------------------------------------- Aktionen def _neue_nummer_vorschlagen(self): - """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()}) + 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)) def pdf_erstellen(self): r = self.recalc() @@ -444,45 +500,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): if not nummer: messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.") return - # 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"): + if not self.k_name.get().strip(): + if not messagebox.askyesno("Kein Kundenname", + "Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"): return # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; @@ -493,26 +513,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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,92 +522,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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()) @@ -626,19 +542,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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: @@ -646,58 +551,12 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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 - 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.") + config.save_config(self.cfg) 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("") @@ -712,6 +571,28 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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: @@ -724,24 +605,331 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, 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 deleted file mode 100644 index baac192..0000000 --- a/bestand.py +++ /dev/null @@ -1,208 +0,0 @@ -# -*- 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 218280d..83b54fb 100644 --- a/config.py +++ b/config.py @@ -16,59 +16,11 @@ import sys # - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar), # gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS if getattr(sys, "frozen", False): - EXE_DIR = os.path.dirname(sys.executable) - RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR) + BASE_DIR = os.path.dirname(sys.executable) + RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR) else: - 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() + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + RESOURCE_DIR = BASE_DIR 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 deleted file mode 100644 index 200aa7c..0000000 --- a/einstellungen.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- 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 deleted file mode 100644 index cdd10cc..0000000 --- a/einzelinstanz.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- 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 deleted file mode 100644 index 1f6acd1..0000000 --- a/helfer.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- 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 deleted file mode 100644 index 77038df..0000000 --- a/korrektur.py +++ /dev/null @@ -1,429 +0,0 @@ -# -*- 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 bfc69ff..c554322 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -208,30 +208,10 @@ 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 = [ - ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", - kopf.get("rechnungsnummer", "")), + ("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"): @@ -259,11 +239,7 @@ 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. - # 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 "" + titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "") story.append(Paragraph(titel, st["h"])) # Positionstabelle @@ -366,64 +342,22 @@ 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)) - 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(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)) - # ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- + # ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- if not gesamtrechnung_betont: texte = cfg.get("texte", {}) block = [] - 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"])) + 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)) @@ -434,44 +368,11 @@ 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": 3, + "version": 1, "rechnungsnummer": kopf.get("rechnungsnummer", ""), "datum": kopf.get("datum", ""), "nachname": kopf.get("nachname", ""), @@ -481,31 +382,6 @@ 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) @@ -529,8 +405,7 @@ def render_rechnung(pfad, cfg, rechnung, kopf): ziel, pagesize=A4, leftMargin=MARGIN_L, rightMargin=MARGIN_R, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung") - + f" {kopf.get('rechnungsnummer','')}", + title=f"Rechnung {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 deleted file mode 100644 index 284dfb0..0000000 --- a/pruef_dialoge.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- 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 deleted file mode 100644 index 723a4d1..0000000 --- a/pruef_kernregeln.py +++ /dev/null @@ -1,399 +0,0 @@ -# -*- 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