Zwei getrennte Knoepfe und app.py in Module aufgeteilt

Knoepfe: "Storno" und "Berichtigen" setzen den Weg schon beim Oeffnen fest,
das Popup fragt dann nur noch die Nummer. Vorher entschied ein zweiter Knopf
im Popup - der ist raus.

app.py war 1802 Zeilen, jetzt 672. Neu daneben:
  helfer.py        Zahlen, Datum, Namen, Pruefungen        (87)
  bestand.py       was im Rechnungsordner liegt, nur lesen (171)
  storno.py        den spiegelbildlichen Storno rechnen     (57)
  einzelinstanz.py nur ein Fenster zulassen                 (90)
  theme.py         Farben nach Windows hell/dunkel         (120)
  korrektur.py     Storno- und Berichtigungsweg            (306)
  einstellungen.py die Dialoge aus dem Menue               (358)

Die Oberflaeche bleibt eine Klasse - die Teile sind Mixins, damit kein
einziger self-Zugriff umgeschrieben werden musste. Kein Modul importiert
app.py zurueck, keine ungenutzten Importe mehr. Die Pruefstaende holen die
Funktionen jetzt aus dem Modul, dem sie gehoeren.

pruef_storno.py 100 gruen, pruef_nummern.py 7 gruen, Fenster nach der
Aufteilung gestartet und angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 19:41:00 +02:00
parent d3962ec8d1
commit 2277a12e0f
10 changed files with 1225 additions and 1147 deletions

1156
app.py

File diff suppressed because it is too large Load diff

171
bestand.py Normal file
View file

@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
"""Was im Rechnungsordner liegt - lesen, nicht schreiben.
Der Ordner ist die Buchhaltung: welche Nummern vergeben sind, was in einer alten
Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen.
"""
import datetime
import json
import os
import config
from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename
def vorhandene_rechnungen(ordner, nummer):
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
unter einem anderen Namen als zweite Datei durch.
"""
# Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben
# Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal
# vergeben.
koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer))
try:
namen = os.listdir(ordner)
except OSError:
return []
treffer = []
for name in namen:
if not name.lower().endswith(".pdf"):
continue
stamm = name[:-4]
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
if any(stamm == k or stamm.startswith(k + "_") for k in koepfe):
treffer.append(os.path.join(ordner, name))
return sorted(treffer)
def kenndaten_lesen(pfad):
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
spiegeln, was rausgegangen ist.
"""
try:
from pypdf import PdfReader
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
if not str(roh).strip().startswith("{"):
return None
daten = json.loads(str(roh))
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
return None
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
return None
daten["_pfad"] = pfad
return daten
def adresse_aus_pdf(pfad):
"""Adressblock aus dem Fliesstext einer Rechnung lesen.
Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren.
Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer.
"""
try:
from pypdf import PdfReader
zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines()
except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF
print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})")
return {}
start = next((i for i, z in enumerate(zeilen)
if z.strip().startswith("Rechnung an")), None)
if start is None:
return {}
block = []
for z in zeilen[start + 1:start + 8]:
t = z.strip()
if not t or t.startswith(("Rechnungsnummer", "Stornonummer")):
break
block.append(t)
if not block:
return {}
daten = {}
if block[0] in ANREDEN:
daten["anrede"] = block.pop(0)
# PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und
# noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn
# sie nicht selbst die Namenszeile ist.
for i in range(len(block) - 1, -1, -1):
teile = block[i].split()
if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5:
daten["plzort"] = block.pop(i)
if i - 1 >= 1:
daten["strasse"] = block.pop(i - 1)
break
if block:
daten["name"] = block.pop(0)
if block:
daten["zusatz"] = " ".join(block)
return daten
def kundendaten_ermitteln(meta):
"""Kunde einer alten Rechnung so vollstaendig wie moeglich.
Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext.
Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen
entstanden -, wird das letzte Wort zum Nachnamen.
Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur
aus dem Text kamen und deshalb geprueft gehoeren.
"""
kunde = dict(meta.get("kunde") or {})
woher = []
vorname = saeubern(kunde.get("vorname") or meta.get("vorname"))
nachname = saeubern(kunde.get("nachname") or meta.get("nachname"))
if not kunde.get("plzort") and meta.get("_pfad"):
aus_text = adresse_aus_pdf(meta["_pfad"])
for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"),
("strasse", "Straße"), ("plzort", "PLZ / Ort")):
if aus_text.get(feld) and not kunde.get(feld):
kunde[feld] = aus_text[feld]
woher.append(klartext)
if not (vorname or nachname) and aus_text.get("name"):
vorname = aus_text["name"]
woher.append("Name")
if not nachname:
# Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt
# wird am Leerzeichen: das letzte Wort ist der Nachname.
vorname, nachname = name_aufteilen(vorname)
if nachname:
woher.append("Nachname vom Vornamen abgetrennt")
kunde["vorname"], kunde["nachname"] = vorname, nachname
return kunde, woher
def rechnungen_im_ordner(ordner):
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
stornierbar ist, entscheidet der Aufrufer.
"""
try:
namen = sorted(os.listdir(ordner))
except OSError:
return []
gefunden = []
for name in namen:
# Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob
# eine PDF von uns stammt, sagen ohnehin erst die Kenndaten.
if not name.lower().endswith(".pdf"):
continue
daten = kenndaten_lesen(os.path.join(ordner, name))
if daten:
gefunden.append(daten)
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
return gefunden
def naechste_freie_nummer(ordner, cfg):
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
"""
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
z = cfg.get("rechnung_zaehler", 0)
for _ in range(1000):
z += 1
kandidat = config.format_rechnungsnummer(jahr, z)
if not vorhandene_rechnungen(ordner, kandidat):
return kandidat
return None

358
einstellungen.py Normal file
View file

@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""Die Dialoge aus dem Menue "Einstellungen" - Steuersaetze, Katalog, Firma, Nummer.
Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht
hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
"""
import os
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import config
from helfer import parse_num
class EinstellungenMixin:
"""Einstellungsdialoge. Erwartet die Felder der RechnungsApp."""
def _frage_speicherort_falls_noetig(self):
if (self.cfg.get("output_dir") or "").strip():
return
vorschlag = config.default_output_dir()
messagebox.showinfo(
"Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
initialdir=os.path.expanduser("~"))
self.cfg["output_dir"] = d if d else vorschlag
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
def dlg_speicherort(self):
aktuell = config.ensure_output_dir(self.cfg)
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
if d:
self.cfg["output_dir"] = d
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
def _info(self):
d = tk.Toplevel(self)
d.title("Info")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
frm = ttk.Frame(d, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(frm, justify="left",
text="Daten eingeben, PDF erstellen, drucken.\n"
"Steuersätze, Preise und Firmendaten lassen sich\n"
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
d.bind("<Escape>", lambda e: d.destroy())
d.bind("<Return>", 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("<<ComboboxSelected>>",
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("<Configure>", 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")

90
einzelinstanz.py Normal file
View file

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""Nur ein Fenster zulassen und das offene nach vorn holen.
Zwei Fenster lesen beim Start denselben Zähler und schlagen dieselbe
Rechnungsnummer vor - die Nummer wird dann doppelt vergeben.
"""
import os
import sys
import config
def einzelinstanz_pruefen():
"""Sperrt das Programm gegen ein zweites Fenster.
Beide Fenster lesen beim Start denselben Zaehler aus der config und
schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt
vergeben. Darum: nur eine Instanz je Windows-Sitzung.
Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder
False, wenn schon eine Instanz laeuft.
"""
if not sys.platform.startswith("win"):
return None
import ctypes
from ctypes import wintypes
ERROR_ALREADY_EXISTS = 183
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
k32.CreateMutexW.restype = wintypes.HANDLE
handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
return False
return handle
# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json.
PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid")
def eigene_pid_merken():
"""Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet."""
try:
with open(PID_DATEI, "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""
try:
with open(PID_DATEI, "r", encoding="ascii") as f:
return int(f.read().strip())
except (OSError, ValueError):
return None
def fenster_nach_vorn(pid):
"""Holt das Fenster des Prozesses <pid> 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

87
helfer.py Normal file
View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Kleine Helfer, die überall gebraucht werden: Zahlen, Datum, Namen, Prüfungen."""
import datetime
import config
def parse_num(s):
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
s = (s or "").strip().replace(",", ".")
if not s:
return 0.0
try:
return float(s)
except ValueError:
return 0.0
def parse_date(s):
"""'28.01.2026' -> date; sonst None"""
s = (s or "").strip()
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def safe_filename(s):
keep = "-_.() "
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
def saeubern(s):
"""Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem.
Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss
genau das, was nachher auf dem Blatt steht.
"""
return " ".join(str(s or "").split())
def buchstaben(s):
return sum(1 for c in str(s) if c.isalpha())
def ziffern(s):
return sum(1 for c in str(s) if c.isdigit())
# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt
# tippbar, damit Sonderfaelle nicht am Programm scheitern.
ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"]
def nummer_normalisieren(s):
"""'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck.
Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen.
"""
s = str(s or "").strip()
teile = s.split("-")
if len(teile) == 2:
try:
return config.format_rechnungsnummer(int(teile[0]), int(teile[1]))
except ValueError:
pass
return s
def kunde_maengel(kunde):
"""Beanstandungen an Name und Anschrift; leere Liste = in Ordnung.
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
Anschrift. Vier Ziffern reichen, damit auch oesterreichische und
niederlaendische Postleitzahlen durchgehen.
"""
maengel = []
for feld, schluessel in (("Vorname", "vorname"),
("Nachname / Firma", "nachname"),
("Straße / Nr.", "strasse")):
wert = saeubern(kunde.get(schluessel))
if not wert:
maengel.append(f"{feld}: ist leer")
elif buchstaben(wert) < 2:
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
ort = saeubern(kunde.get("plzort"))
if not ort:
maengel.append("PLZ / Ort: ist leer")
elif ziffern(ort) < 4 or buchstaben(ort) < 2:
maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, "
"z. B. 04416 Markkleeberg")
return maengel
def name_aufteilen(ganz):
"""'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname."""
teile = str(ganz or "").split()
if len(teile) > 1:
return " ".join(teile[:-1]), teile[-1]
return "", (teile[0] if teile else "")

306
korrektur.py Normal file
View file

@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""Storno- und Berichtigungsweg.
Zwei Wege, ein Popup-Bauplan:
Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer,
danach die berichtigte Rechnung. Zwei Nummern verbraucht.
Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
Die alte Rechnung wird in beiden Faellen NIE angefasst (§ 146 Abs. 4 AO).
"""
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox
import berechnung
import config
import pdf_renderer
from bestand import kundendaten_ermitteln, naechste_freie_nummer, rechnungen_im_ordner
from helfer import nummer_normalisieren, safe_filename
from storno import storno_rechnung_bauen
class KorrekturMixin:
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
# ------------------------------------------------------------- Storno
def _storno_anzeigen(self):
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
stand = self.storno_stand
if stand:
self.storno_hinweis.set(
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
f"Beim Erstellen wird noch einmal gefragt.")
self.nummer_entry.configure(state="readonly")
elif self.berichtigung_stand:
self.storno_hinweis.set(
f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n"
"Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.")
self.nummer_entry.configure(state="readonly")
else:
self.storno_hinweis.set("")
self.nummer_entry.configure(state="normal")
def _berichtigung_starten(self, meta, out_dir):
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
nummer = str(meta.get("rechnungsnummer", ""))
fehlend = self._formular_aus_meta(meta)
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
"meta": meta}
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
self.r_nummer.set(nummer)
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo(
"Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
f"bleibt liegen.{hinweis}")
def _storno_verwerfen(self, grund=""):
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
"""
stand = self.storno_stand
if not stand:
return
try:
if os.path.exists(stand["pfad"]):
os.remove(stand["pfad"])
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
except OSError as e: # noqa: BLE001
messagebox.showwarning("Storno-PDF blieb liegen",
f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}")
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
config.save_config(self.cfg)
self.storno_stand = None
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if grund:
print(f"[storno] {grund}")
def dlg_storno(self):
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
self._dlg_korrektur("storno")
def dlg_berichtigen(self):
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
self._dlg_korrektur("berichtigung")
def _dlg_korrektur(self, modus):
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
"storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung
"berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer
"""
storno = modus == "storno"
if self.storno_stand:
messagebox.showinfo(
"Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return
if self.berichtigung_stand:
messagebox.showinfo(
"Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
return
out_dir = config.ensure_output_dir(self.cfg)
alle = rechnungen_im_ordner(out_dir)
stornierte = {nummer_normalisieren(d.get("storno_zu"))
for d in alle if d.get("art") == "storno"}
offen = [d for d in alle
if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
"Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return
d = tk.Toplevel(self)
d.title("Rechnung stornieren" if storno else "Rechnung berichtigen")
d.transient(self)
d.grab_set()
self._style_dialog(d)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, justify="left", wraplength=470, text=(
"Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert "
"liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine "
"Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach "
"die berichtigte Rechnung."
if storno else
"Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, "
"Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt "
"nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, "
"dafür gibt es den Storno.")).grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3)
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
# dann die Tcl-Variable und das Feld steht leer da.
ent = ttk.Entry(frm, width=20)
ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0))
ent.focus_set()
v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.")
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
if not nr:
return None, "Nummer eintragen – das Programm sucht die Rechnung."
treffer = next((x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"), None)
if not treffer:
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
if treffer.get("art") == "storno":
return None, f"{nr} ist selbst eine Stornorechnung."
if nummer_normalisieren(treffer.get("rechnungsnummer")) in stornierte:
return None, f"{nr} wurde bereits storniert – ein zweites Mal geht nicht."
gesamt = float(treffer.get("gesamt") or 0)
return treffer, (
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}\n"
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(gesamt)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
def vorschau(*_a):
v_info.set(suche()[1])
ent.bind("<KeyRelease>", 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("<Return>", anlegen)
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden",
command=anlegen).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
frm.columnconfigure(1, weight=1)
def _storno_anlegen(self, meta, out_dir):
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
alt_nummer = str(meta.get("rechnungsnummer", ""))
nummer = naechste_freie_nummer(out_dir, self.cfg)
if not nummer:
messagebox.showerror("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
return
r = storno_rechnung_bauen(meta, self.cfg)
heute = datetime.date.today().strftime("%d.%m.%Y")
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
kunde, woher = kundendaten_ermitteln(meta)
namenszeile = " ".join(t for t in (kunde.get("vorname", ""),
kunde.get("nachname", "")) if t)
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
kunde.get("strasse", ""), kunde.get("plzort", "")]
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "", "naechte": "",
"kunde_zeilen": kunde_zeilen,
"nachname": kunde.get("nachname", ""),
"vorname": kunde.get("vorname", ""),
"art": "storno",
"storno_zu": alt_nummer,
"storno_datum": str(meta.get("datum") or ""),
"kunde": kunde,
}
name = f"Storno_{safe_filename(nummer)}"
if kopf["nachname"]:
name += "_" + safe_filename(kopf["nachname"])
pfad = os.path.join(out_dir, name + ".pdf")
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
self.storno_stand = {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"zu_datum": str(meta.get("datum") or ""),
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
}
self._zaehler_aus_nummer_speichern(nummer)
print(f"[storno] erstellt -> {pfad}")
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "")
self._storno_anzeigen()
hinweis = ""
if fehlend:
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
messagebox.showinfo(
"Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
f"\"PDF erstellen\" drücken.{hinweis}")
def _formular_aus_meta(self, meta):
"""Formular mit den Daten der alten Rechnung füllen.
Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2)
tragen weder Anschrift noch Positionen in den Kenndaten.
"""
kunde, woher = kundendaten_ermitteln(meta)
self.k_vorname.set(kunde.get("vorname", ""))
self.k_name.set(kunde.get("nachname", ""))
self.k_anrede.set(str(kunde.get("anrede", "") or ""))
self.k_zusatz.set(str(kunde.get("zusatz", "") or ""))
self.k_strasse.set(str(kunde.get("strasse", "") or ""))
self.k_plzort.set(str(kunde.get("plzort", "") or ""))
self.r_anreise.set(str(meta.get("anreise", "") or ""))
self.r_abreise.set(str(meta.get("abreise", "") or ""))
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
for nr, var in self.anzahl_vars.items():
var.set("")
self.personen_vars[nr].set("")
for p in meta.get("positionen") or []:
nr = p.get("nr")
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
self.recalc()
hinweise = []
if woher:
hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher))
fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()),
("PLZ / Ort", self.k_plzort.get()),
("Nachname", self.k_name.get()))
if not wert]
if fehlt:
hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt))
if not (meta.get("positionen") or []):
hinweise.append("Leistungen konnten nicht übernommen werden")
return "; ".join(hinweise)

View file

@ -9,7 +9,7 @@ import sys
import tempfile import tempfile
sys.path.insert(0, r"C:\claude\rechnungstool") sys.path.insert(0, r"C:\claude\rechnungstool")
from app import vorhandene_rechnungen # noqa: E402 from bestand import vorhandene_rechnungen # noqa: E402
ordner = tempfile.mkdtemp(prefix="rechnungspruef_") ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
dateien = [ dateien = [

View file

@ -33,6 +33,7 @@ roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402 import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402 from tkinter import messagebox # noqa: E402
fehler = [] fehler = []
@ -81,7 +82,7 @@ f.pdf_erstellen()
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf") erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben") pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
meta1 = A.kenndaten_lesen(erste_pdf) meta1 = BE.kenndaten_lesen(erste_pdf)
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar") pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})") pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
pruefe(meta1.get("art") == "rechnung", "art = rechnung") pruefe(meta1.get("art") == "rechnung", "art = rechnung")
@ -92,14 +93,14 @@ gesamt1 = float(meta1["gesamt"])
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}") print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
# ---- 2. stornieren ------------------------------------------------------ # ---- 2. stornieren ------------------------------------------------------
offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden") pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
f._storno_anlegen(offen[0], ordner) f._storno_anlegen(offen[0], ordner)
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher") pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else "" storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})") pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
meta2 = A.kenndaten_lesen(storno_pdf) meta2 = BE.kenndaten_lesen(storno_pdf)
pruefe(meta2.get("art") == "storno", "art = storno") pruefe(meta2.get("art") == "storno", "art = storno")
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001") pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin") pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin")
@ -121,7 +122,7 @@ dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben") pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer") pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei") pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
meta3 = A.kenndaten_lesen(dritte) meta3 = BE.kenndaten_lesen(dritte)
pruefe(meta3.get("art") == "korrektur", "art = korrektur") pruefe(meta3.get("art") == "korrektur", "art = korrektur")
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001") pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002") pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
@ -146,7 +147,7 @@ print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
# ---- 5. Rueckweg: Storno anlegen und verwerfen -------------------------- # ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
antworten["Storno wirklich"] = False antworten["Storno wirklich"] = False
offen = [d for d in A.rechnungen_im_ordner(ordner) offen = [d for d in BE.rechnungen_im_ordner(ordner)
if d.get("art") == "korrektur"] if d.get("art") == "korrektur"]
f._storno_anlegen(offen[0], ordner) f._storno_anlegen(offen[0], ordner)
storno2 = f.storno_stand["pfad"] storno2 = f.storno_stand["pfad"]
@ -303,9 +304,9 @@ def alt_rechnung(name, dateiname, kunde_zeilen):
alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf", alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf",
["Herr", "Andres Schäfer", "c/o Stellplatz 12", ["Herr", "Andres Schäfer", "c/o Stellplatz 12",
"Bornaer Chaussee 36", "04416 Markkleeberg"]) "Bornaer Chaussee 36", "04416 Markkleeberg"])
meta_alt = A.kenndaten_lesen(alt) meta_alt = BE.kenndaten_lesen(alt)
pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten") pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten")
kunde, woher = A.kundendaten_ermitteln(meta_alt) kunde, woher = BE.kundendaten_ermitteln(meta_alt)
pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})") pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})")
pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})") pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})")
pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})") pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})")
@ -315,16 +316,16 @@ pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde
# b) sein echter Fall: NUR der Name, keine Anschrift # b) sein echter Fall: NUR der Name, keine Anschrift
alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""]) alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""])
kunde2, _ = A.kundendaten_ermitteln(A.kenndaten_lesen(alt2)) kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2))
pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt") pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt")
pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden") pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden")
# c) Formular fuellen: was fehlt, wird benannt # c) Formular fuellen: was fehlt, wird benannt
meldung = f._formular_aus_meta(A.kenndaten_lesen(alt2)) meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2))
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer", pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Formular bekommt Vor- und Nachnamen getrennt") "Formular bekommt Vor- und Nachnamen getrennt")
pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})") pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})")
meldung = f._formular_aus_meta(A.kenndaten_lesen(alt)) meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt))
pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular") pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular")
pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})") pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})")
for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"): for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
@ -332,7 +333,7 @@ for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf ------------------- # ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf -------------------
alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""]) alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""])
meta3 = A.kenndaten_lesen(alt3) meta3 = BE.kenndaten_lesen(alt3)
vorher = sorted(os.listdir(ordner)) vorher = sorted(os.listdir(ordner))
zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"] zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"]
@ -367,7 +368,7 @@ pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung,
f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer " f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer "
f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})") f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})")
meta_b = A.kenndaten_lesen(ziel) meta_b = BE.kenndaten_lesen(ziel)
pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung") pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung")
pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung") pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung")
pruefe(float(meta_b.get("gesamt") or 0) == 0.0, pruefe(float(meta_b.get("gesamt") or 0) == 0.0,

57
storno.py Normal file
View file

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""Den spiegelbildlichen Storno zu einer Rechnung rechnen."""
import berechnung
def storno_rechnung_bauen(meta, cfg):
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
"""
r = berechnung.Rechnung()
nummer = meta.get("rechnungsnummer", "")
bloecke = meta.get("ust_bloecke") or []
if not bloecke:
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
# die USt unter den Tisch.
brutto = float(meta.get("zwischensumme") or 0)
satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7))
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
"brutto": brutto}]
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
for i, b in enumerate(bloecke, start=1):
satz = int(float(b.get("satz", 0) or 0))
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
netto = -abs(berechnung.runde(b.get("netto") or 0))
ust = -abs(berechnung.runde(b.get("ust") or 0))
r.zeilen.append(berechnung.PositionsZeile(
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
brutto=brutto, netto=netto))
info = saetze.get(satz, {})
r.bloecke.append(berechnung.Steuerblock(
satz=satz, netto=netto, ust=ust,
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
extra_blatt=bool(info.get("extra_blatt", False))))
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
# berechne() den Aufschlag baut.
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
if steuer:
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
r.aufschlaege.append(berechnung.Aufschlag(
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
return r

120
theme.py Normal file
View file

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste.
Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum
baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben.
"""
import sys
import tkinter as tk
from tkinter import ttk
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
if not sys.platform.startswith("win"):
return False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
except OSError:
return False
class ThemeMixin:
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
# ----------------------------------------------------------------- Theme
def _apply_theme(self):
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
dark = windows_dark_mode()
self._dark_mode = dark
if dark:
self.col_bg = "#23272e" # Fenster-Hintergrund
self.col_fg = "#e6e6e6" # Schrift
self.col_field = "#2f343c" # Eingabefelder
self.col_sel = "#3a4a44" # aktiv/hover
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
self.col_line = "#3a3f47"
self.col_scroll = "#4a505a" # Scroll-Griff
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
else:
self.col_bg = "#f3f3f3"
self.col_fg = "#1a1a1a"
self.col_field = "#ffffff"
self.col_sel = "#cfe3d8"
self.col_accent = "#1f5c3d"
self.col_line = "#cccccc"
self.col_scroll = "#b8b8b8"
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
self.configure(bg=self.col_bg)
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
self.col_sel, self.col_accent)
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg)
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
style.configure("TLabelframe.Label", background=bg, foreground=accent)
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
arrowcolor=bg, padding=(10, 3))
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
# unsichtbar im Feld.
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg,
selectbackground=field, selectforeground=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)],
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
self.option_add("*TCombobox*Listbox.background", field)
self.option_add("*TCombobox*Listbox.foreground", fg)
self.option_add("*TCombobox*Listbox.selectBackground", sel)
self.option_add("*TCombobox*Listbox.selectForeground", fg)
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
style.configure("TSeparator", background=self.col_line)
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen."""
d.configure(bg=self.col_bg)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)