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