Die bisherigen Rechnungen tragen den ganzen Namen in der Vornamen-Zeile und oft gar keine Anschrift. Beim Stornieren stand dann nur "Andres Schaefer" da und niemand wusste, wer der Gast ist. - name_aufteilen(): am Leerzeichen trennen, das letzte Wort ist der Nachname. - adresse_aus_pdf(): der Adressblock steht im Fliesstext zwischen "Rechnung an:" und der Rechnungsnummer. Anrede wird an der Auswahlliste erkannt, PLZ/Ort an der 4- bis 5-stelligen Zahl am Zeilenanfang, die Zeile davor ist die Strasse - aber nur, wenn sie nicht die Namenszeile ist. - kundendaten_ermitteln(): Kenndaten zuerst, Text als Rueckfall; benutzt vom Storno (der geht an denselben Empfaenger) und vom Formular. - Der Hinweis nach dem Storno sagt jetzt, was aus dem Text gelesen wurde und was in der alten Rechnung schlicht nicht drinsteht. Es wird nichts erfunden: fehlt die Anschrift in der alten PDF, bleiben die Felder leer und die Pflichtpruefung greift. pruef_storno.py: 80 Pruefungen gruen, darunter eine echte Altrechnung im Muster "alles in der Vornamen-Zeile" - mit und ohne Anschrift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
1690 lines
76 KiB
Python
1690 lines
76 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter).
|
||
|
||
Bedienung:
|
||
1. Kundenadresse + An-/Abreise eingeben
|
||
2. bei den passenden Leistungen Anzahl (und ggf. Personen) eintragen
|
||
3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/
|
||
|
||
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import datetime
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, filedialog
|
||
|
||
import config
|
||
import berechnung
|
||
import pdf_renderer
|
||
|
||
|
||
def parse_num(s):
|
||
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
|
||
s = (s or "").strip().replace(",", ".")
|
||
if not s:
|
||
return 0.0
|
||
try:
|
||
return float(s)
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
|
||
def parse_date(s):
|
||
"""'28.01.2026' -> date; sonst None"""
|
||
s = (s or "").strip()
|
||
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
||
try:
|
||
return datetime.datetime.strptime(s, fmt).date()
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def safe_filename(s):
|
||
keep = "-_.() "
|
||
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
|
||
|
||
|
||
def vorhandene_rechnungen(ordner, nummer):
|
||
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
|
||
|
||
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
|
||
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
|
||
unter einem anderen Namen als zweite Datei durch.
|
||
"""
|
||
# Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben
|
||
# Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal
|
||
# vergeben.
|
||
koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer))
|
||
try:
|
||
namen = os.listdir(ordner)
|
||
except OSError:
|
||
return []
|
||
treffer = []
|
||
for name in namen:
|
||
if not name.lower().endswith(".pdf"):
|
||
continue
|
||
stamm = name[:-4]
|
||
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
|
||
if any(stamm == k or stamm.startswith(k + "_") for k in koepfe):
|
||
treffer.append(os.path.join(ordner, name))
|
||
return sorted(treffer)
|
||
|
||
|
||
def saeubern(s):
|
||
"""Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem.
|
||
|
||
Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss
|
||
genau das, was nachher auf dem Blatt steht.
|
||
"""
|
||
return " ".join(str(s or "").split())
|
||
|
||
|
||
def buchstaben(s):
|
||
return sum(1 for c in str(s) if c.isalpha())
|
||
|
||
|
||
def ziffern(s):
|
||
return sum(1 for c in str(s) if c.isdigit())
|
||
|
||
|
||
# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt
|
||
# tippbar, damit Sonderfaelle nicht am Programm scheitern.
|
||
ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"]
|
||
|
||
|
||
def nummer_normalisieren(s):
|
||
"""'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck.
|
||
|
||
Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen.
|
||
"""
|
||
s = str(s or "").strip()
|
||
teile = s.split("-")
|
||
if len(teile) == 2:
|
||
try:
|
||
return config.format_rechnungsnummer(int(teile[0]), int(teile[1]))
|
||
except ValueError:
|
||
pass
|
||
return s
|
||
|
||
|
||
def kenndaten_lesen(pfad):
|
||
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
|
||
|
||
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
|
||
spiegeln, was rausgegangen ist.
|
||
"""
|
||
try:
|
||
from pypdf import PdfReader
|
||
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
|
||
if not str(roh).strip().startswith("{"):
|
||
return None
|
||
daten = json.loads(str(roh))
|
||
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
|
||
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
|
||
return None
|
||
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
|
||
return None
|
||
daten["_pfad"] = pfad
|
||
return daten
|
||
|
||
|
||
def adresse_aus_pdf(pfad):
|
||
"""Adressblock aus dem Fliesstext einer Rechnung lesen.
|
||
|
||
Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren.
|
||
Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer.
|
||
"""
|
||
try:
|
||
from pypdf import PdfReader
|
||
zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines()
|
||
except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF
|
||
print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})")
|
||
return {}
|
||
|
||
start = next((i for i, z in enumerate(zeilen)
|
||
if z.strip().startswith("Rechnung an")), None)
|
||
if start is None:
|
||
return {}
|
||
block = []
|
||
for z in zeilen[start + 1:start + 8]:
|
||
t = z.strip()
|
||
if not t or t.startswith(("Rechnungsnummer", "Stornonummer")):
|
||
break
|
||
block.append(t)
|
||
if not block:
|
||
return {}
|
||
|
||
daten = {}
|
||
if block[0] in ANREDEN:
|
||
daten["anrede"] = block.pop(0)
|
||
# PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und
|
||
# noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn
|
||
# sie nicht selbst die Namenszeile ist.
|
||
for i in range(len(block) - 1, -1, -1):
|
||
teile = block[i].split()
|
||
if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5:
|
||
daten["plzort"] = block.pop(i)
|
||
if i - 1 >= 1:
|
||
daten["strasse"] = block.pop(i - 1)
|
||
break
|
||
if block:
|
||
daten["name"] = block.pop(0)
|
||
if block:
|
||
daten["zusatz"] = " ".join(block)
|
||
return daten
|
||
|
||
|
||
def name_aufteilen(ganz):
|
||
"""'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname."""
|
||
teile = str(ganz or "").split()
|
||
if len(teile) > 1:
|
||
return " ".join(teile[:-1]), teile[-1]
|
||
return "", (teile[0] if teile else "")
|
||
|
||
|
||
def kundendaten_ermitteln(meta):
|
||
"""Kunde einer alten Rechnung so vollstaendig wie moeglich.
|
||
|
||
Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext.
|
||
Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen
|
||
entstanden -, wird das letzte Wort zum Nachnamen.
|
||
|
||
Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur
|
||
aus dem Text kamen und deshalb geprueft gehoeren.
|
||
"""
|
||
kunde = dict(meta.get("kunde") or {})
|
||
woher = []
|
||
vorname = saeubern(kunde.get("vorname") or meta.get("vorname"))
|
||
nachname = saeubern(kunde.get("nachname") or meta.get("nachname"))
|
||
|
||
if not kunde.get("plzort") and meta.get("_pfad"):
|
||
aus_text = adresse_aus_pdf(meta["_pfad"])
|
||
for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"),
|
||
("strasse", "Straße"), ("plzort", "PLZ / Ort")):
|
||
if aus_text.get(feld) and not kunde.get(feld):
|
||
kunde[feld] = aus_text[feld]
|
||
woher.append(klartext)
|
||
if not (vorname or nachname) and aus_text.get("name"):
|
||
vorname = aus_text["name"]
|
||
woher.append("Name")
|
||
|
||
if not nachname:
|
||
# Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt
|
||
# wird am Leerzeichen: das letzte Wort ist der Nachname.
|
||
vorname, nachname = name_aufteilen(vorname)
|
||
if nachname:
|
||
woher.append("Nachname vom Vornamen abgetrennt")
|
||
kunde["vorname"], kunde["nachname"] = vorname, nachname
|
||
return kunde, woher
|
||
|
||
|
||
def rechnungen_im_ordner(ordner):
|
||
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
|
||
|
||
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
|
||
stornierbar ist, entscheidet der Aufrufer.
|
||
"""
|
||
try:
|
||
namen = sorted(os.listdir(ordner))
|
||
except OSError:
|
||
return []
|
||
gefunden = []
|
||
for name in namen:
|
||
# Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob
|
||
# eine PDF von uns stammt, sagen ohnehin erst die Kenndaten.
|
||
if not name.lower().endswith(".pdf"):
|
||
continue
|
||
daten = kenndaten_lesen(os.path.join(ordner, name))
|
||
if daten:
|
||
gefunden.append(daten)
|
||
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
|
||
return gefunden
|
||
|
||
|
||
def storno_rechnung_bauen(meta, cfg):
|
||
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
|
||
|
||
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
|
||
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
|
||
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
|
||
"""
|
||
r = berechnung.Rechnung()
|
||
nummer = meta.get("rechnungsnummer", "")
|
||
bloecke = meta.get("ust_bloecke") or []
|
||
if not bloecke:
|
||
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
|
||
# die USt unter den Tisch.
|
||
brutto = float(meta.get("zwischensumme") or 0)
|
||
satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7))
|
||
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
|
||
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
|
||
"brutto": brutto}]
|
||
|
||
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
|
||
for i, b in enumerate(bloecke, start=1):
|
||
satz = int(float(b.get("satz", 0) or 0))
|
||
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
|
||
netto = -abs(berechnung.runde(b.get("netto") or 0))
|
||
ust = -abs(berechnung.runde(b.get("ust") or 0))
|
||
r.zeilen.append(berechnung.PositionsZeile(
|
||
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
|
||
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
|
||
brutto=brutto, netto=netto))
|
||
info = saetze.get(satz, {})
|
||
r.bloecke.append(berechnung.Steuerblock(
|
||
satz=satz, netto=netto, ust=ust,
|
||
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
|
||
extra_blatt=bool(info.get("extra_blatt", False))))
|
||
|
||
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
|
||
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
|
||
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
|
||
|
||
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
|
||
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
|
||
# berechne() den Aufschlag baut.
|
||
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
|
||
if steuer:
|
||
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
|
||
r.aufschlaege.append(berechnung.Aufschlag(
|
||
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
|
||
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
|
||
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
|
||
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
|
||
return r
|
||
|
||
|
||
def naechste_freie_nummer(ordner, cfg):
|
||
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
|
||
|
||
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
|
||
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
|
||
"""
|
||
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
|
||
z = cfg.get("rechnung_zaehler", 0)
|
||
for _ in range(1000):
|
||
z += 1
|
||
kandidat = config.format_rechnungsnummer(jahr, z)
|
||
if not vorhandene_rechnungen(ordner, kandidat):
|
||
return kandidat
|
||
return None
|
||
|
||
|
||
def einzelinstanz_pruefen():
|
||
"""Sperrt das Programm gegen ein zweites Fenster.
|
||
|
||
Beide Fenster lesen beim Start denselben Zaehler aus der config und
|
||
schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt
|
||
vergeben. Darum: nur eine Instanz je Windows-Sitzung.
|
||
|
||
Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder
|
||
False, wenn schon eine Instanz laeuft.
|
||
"""
|
||
if not sys.platform.startswith("win"):
|
||
return None
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
ERROR_ALREADY_EXISTS = 183
|
||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||
k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
|
||
k32.CreateMutexW.restype = wintypes.HANDLE
|
||
handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl")
|
||
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
|
||
return False
|
||
return handle
|
||
|
||
|
||
|
||
# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json.
|
||
PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid")
|
||
|
||
|
||
def eigene_pid_merken():
|
||
"""Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet."""
|
||
try:
|
||
with open(PID_DATEI, "w", encoding="ascii") as f:
|
||
f.write(str(os.getpid()))
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def laufende_pid_lesen():
|
||
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""
|
||
try:
|
||
with open(PID_DATEI, "r", encoding="ascii") as f:
|
||
return int(f.read().strip())
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def fenster_nach_vorn(pid):
|
||
"""Holt das Fenster des Prozesses <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
|
||
|
||
|
||
def windows_dark_mode():
|
||
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
|
||
if not sys.platform.startswith("win"):
|
||
return False
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
|
||
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
|
||
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
class RechnungsApp(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.cfg = config.load_config()
|
||
self.title("Rechnungstool - " + self.cfg["firma"].get("name", "Campinghof"))
|
||
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
|
||
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
|
||
self.geometry(f"1060x{hoehe}")
|
||
self.minsize(900, 540)
|
||
|
||
self.anzahl_vars = {} # nr -> StringVar
|
||
self.personen_vars = {} # nr -> StringVar
|
||
self.gesamt_lbls = {} # nr -> Label
|
||
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
|
||
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
|
||
self.storno_stand = None
|
||
|
||
self._apply_theme() # Farben nach Windows hell/dunkel
|
||
self._build_menu()
|
||
self._build_kopf()
|
||
self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar
|
||
self._build_positionen() # Positionsliste fuellt den Rest und scrollt
|
||
|
||
self._neue_nummer_vorschlagen()
|
||
self.recalc()
|
||
|
||
# Titelleiste dunkel (nach dem Mappen des Fensters)
|
||
self.after(60, lambda: self._dark_titlebar(self))
|
||
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
|
||
self.after(250, self._frage_speicherort_falls_noetig)
|
||
|
||
# ----------------------------------------------------------------- Theme
|
||
def _apply_theme(self):
|
||
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
|
||
dark = windows_dark_mode()
|
||
self._dark_mode = dark
|
||
if dark:
|
||
self.col_bg = "#23272e" # Fenster-Hintergrund
|
||
self.col_fg = "#e6e6e6" # Schrift
|
||
self.col_field = "#2f343c" # Eingabefelder
|
||
self.col_sel = "#3a4a44" # aktiv/hover
|
||
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
|
||
self.col_line = "#3a3f47"
|
||
self.col_scroll = "#4a505a" # Scroll-Griff
|
||
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
|
||
else:
|
||
self.col_bg = "#f3f3f3"
|
||
self.col_fg = "#1a1a1a"
|
||
self.col_field = "#ffffff"
|
||
self.col_sel = "#cfe3d8"
|
||
self.col_accent = "#1f5c3d"
|
||
self.col_line = "#cccccc"
|
||
self.col_scroll = "#b8b8b8"
|
||
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
|
||
|
||
self.configure(bg=self.col_bg)
|
||
style = ttk.Style(self)
|
||
try:
|
||
style.theme_use("clam")
|
||
except tk.TclError:
|
||
pass
|
||
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
|
||
self.col_sel, self.col_accent)
|
||
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
|
||
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
|
||
style.configure("TFrame", background=bg)
|
||
style.configure("TLabel", background=bg, foreground=fg)
|
||
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
|
||
style.configure("TLabelframe.Label", background=bg, foreground=accent)
|
||
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
|
||
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
|
||
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
|
||
arrowcolor=bg, padding=(10, 3))
|
||
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
|
||
style.configure("TCheckbutton", background=bg, foreground=fg)
|
||
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
|
||
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
|
||
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
|
||
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
|
||
# unsichtbar im Feld.
|
||
style.configure("TCombobox", fieldbackground=field, foreground=fg,
|
||
background=field, arrowcolor=fg,
|
||
selectbackground=field, selectforeground=fg)
|
||
style.map("TCombobox", fieldbackground=[("readonly", field)],
|
||
foreground=[("readonly", fg)], background=[("readonly", field)],
|
||
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
|
||
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
|
||
self.option_add("*TCombobox*Listbox.background", field)
|
||
self.option_add("*TCombobox*Listbox.foreground", fg)
|
||
self.option_add("*TCombobox*Listbox.selectBackground", sel)
|
||
self.option_add("*TCombobox*Listbox.selectForeground", fg)
|
||
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
|
||
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
|
||
bordercolor=bg, lightcolor=bg, darkcolor=bg,
|
||
arrowcolor=fg, relief="flat", gripcount=0)
|
||
style.map(sbs, background=[("active", accent)],
|
||
troughcolor=[("disabled", bg)])
|
||
style.configure("TSeparator", background=self.col_line)
|
||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||
self.option_add("*background", bg)
|
||
self.option_add("*foreground", fg)
|
||
|
||
def _style_dialog(self, d):
|
||
"""Toplevel-Dialog an das Theme angleichen."""
|
||
d.configure(bg=self.col_bg)
|
||
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
||
d.after(20, lambda: self._dark_titlebar(d))
|
||
|
||
def _dark_titlebar(self, win):
|
||
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
|
||
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
|
||
return
|
||
try:
|
||
import ctypes
|
||
win.update_idletasks()
|
||
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
|
||
val = ctypes.c_int(1)
|
||
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
|
||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
|
||
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
|
||
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
|
||
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
|
||
except Exception as e: # noqa: BLE001
|
||
print("[theme] Titelleiste:", e)
|
||
|
||
# ---------------------------------------------------------------- Menue
|
||
def _build_menu(self):
|
||
# Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform
|
||
# (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben).
|
||
self._open_pop = None
|
||
self._menus = {
|
||
"Datei": [
|
||
("PDF erstellen", self.pdf_erstellen),
|
||
("Formular zurücksetzen", self.reset_formular),
|
||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||
("-", None),
|
||
("Beenden", self.destroy),
|
||
],
|
||
"Einstellungen": [
|
||
("Steuersätze…", self.dlg_steuern),
|
||
("Leistungskatalog / Preise…", self.dlg_katalog),
|
||
("Firmendaten, Logo & Texte…", self.dlg_firma),
|
||
("Rechnungsnummer…", self.dlg_nummer),
|
||
("Speicherort der Rechnungen…", self.dlg_speicherort),
|
||
],
|
||
"Hilfe": [("Info", self._info)],
|
||
}
|
||
bar = tk.Frame(self, bg=self.col_bg)
|
||
bar.pack(side="top", fill="x")
|
||
for text, items in self._menus.items():
|
||
lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3)
|
||
lbl.pack(side="left")
|
||
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.col_sel))
|
||
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
|
||
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
|
||
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
|
||
self.bind_all("<Control-p>", lambda e: self.pdf_erstellen())
|
||
|
||
def _menu_popup(self, anchor, items):
|
||
if self._open_pop: # offenes Menu zu -> Toggle
|
||
self._close_pop()
|
||
return
|
||
pop = tk.Toplevel(self)
|
||
pop.overrideredirect(True)
|
||
pop.configure(bg=self.col_line) # 1px Rahmen = col_line statt weiss
|
||
inner = tk.Frame(pop, bg=self.col_field)
|
||
inner.pack(padx=1, pady=1)
|
||
for label, cmd in items:
|
||
if label == "-":
|
||
tk.Frame(inner, bg=self.col_line, height=1).pack(fill="x", pady=3)
|
||
continue
|
||
it = tk.Label(inner, text=label, bg=self.col_field, fg=self.col_fg,
|
||
anchor="w", padx=16, pady=5)
|
||
it.pack(fill="x")
|
||
it.bind("<Enter>", lambda e, w=it: w.configure(bg=self.col_sel))
|
||
it.bind("<Leave>", lambda e, w=it: w.configure(bg=self.col_field))
|
||
it.bind("<Button-1>", lambda e, c=cmd: self._run_menu(c))
|
||
pop.update_idletasks()
|
||
pop.geometry(f"+{anchor.winfo_rootx()}+{anchor.winfo_rooty() + anchor.winfo_height()}")
|
||
self._open_pop = pop
|
||
pop.grab_set()
|
||
pop.bind("<Button-1>", self._maybe_close_pop, add="+")
|
||
pop.bind("<Escape>", lambda e: self._close_pop())
|
||
self._dark_titlebar(pop)
|
||
|
||
def _maybe_close_pop(self, event):
|
||
pop = self._open_pop
|
||
if not pop:
|
||
return
|
||
if (event.x_root < pop.winfo_rootx() or event.x_root > pop.winfo_rootx() + pop.winfo_width()
|
||
or event.y_root < pop.winfo_rooty() or event.y_root > pop.winfo_rooty() + pop.winfo_height()):
|
||
self._close_pop()
|
||
|
||
def _run_menu(self, cmd):
|
||
self._close_pop()
|
||
if cmd:
|
||
cmd()
|
||
|
||
def _close_pop(self):
|
||
if getattr(self, "_open_pop", None):
|
||
try:
|
||
self._open_pop.grab_release()
|
||
self._open_pop.destroy()
|
||
except tk.TclError:
|
||
pass
|
||
self._open_pop = None
|
||
|
||
# ---------------------------------------------------------------- Kopf
|
||
def _build_kopf(self):
|
||
top = ttk.Frame(self, padding=(12, 10, 12, 4))
|
||
top.pack(fill="x")
|
||
|
||
# Kunde (links)
|
||
kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8)
|
||
kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
||
self.k_anrede = tk.StringVar()
|
||
self.k_vorname = tk.StringVar()
|
||
self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal
|
||
self.k_zusatz = tk.StringVar()
|
||
self.k_strasse = tk.StringVar()
|
||
self.k_plzort = tk.StringVar()
|
||
felder = [
|
||
("Anrede", self.k_anrede),
|
||
("Vorname *", self.k_vorname),
|
||
("Nachname / Firma *", self.k_name),
|
||
("Zusatz (optional)", self.k_zusatz),
|
||
("Straße / Nr. *", self.k_strasse),
|
||
("PLZ / Ort *", self.k_plzort),
|
||
]
|
||
for i, (lbl, var) in enumerate(felder):
|
||
ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||
if var is self.k_anrede:
|
||
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
|
||
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
|
||
else:
|
||
w = ttk.Entry(kunde, textvariable=var, width=38)
|
||
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
|
||
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
|
||
foreground=self.col_muted, wraplength=330, justify="left").grid(
|
||
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||
kunde.columnconfigure(1, weight=1)
|
||
|
||
# Rechnungsinfos (rechts)
|
||
info = ttk.LabelFrame(top, text="Rechnungsdaten", padding=8)
|
||
info.grid(row=0, column=1, sticky="nsew")
|
||
self.r_nummer = tk.StringVar()
|
||
self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y"))
|
||
self.r_anreise = tk.StringVar()
|
||
self.r_abreise = tk.StringVar()
|
||
self.r_naechte = tk.StringVar(value="–")
|
||
rows = [
|
||
("Rechnungsnummer", self.r_nummer),
|
||
("Rechnungsdatum", self.r_datum),
|
||
("Anreise (TT.MM.JJJJ)", self.r_anreise),
|
||
("Abreise (TT.MM.JJJJ)", self.r_abreise),
|
||
]
|
||
date_vars = (self.r_datum, self.r_anreise, self.r_abreise)
|
||
for i, (lbl, var) in enumerate(rows):
|
||
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||
ent = ttk.Entry(info, textvariable=var, width=18)
|
||
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||
if var is self.r_nummer:
|
||
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
|
||
# vergibt dann das Programm, damit keine doppelt sein kann.
|
||
self.nummer_entry = ent
|
||
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
|
||
ent.bind("<KeyRelease>", self._auto_datum_evt)
|
||
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
|
||
ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid(
|
||
row=4, column=1, sticky="w", padx=(8, 0))
|
||
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
|
||
self.storno_hinweis = tk.StringVar(value="")
|
||
ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020",
|
||
font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid(
|
||
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||
info.columnconfigure(1, weight=1)
|
||
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
|
||
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
|
||
|
||
top.columnconfigure(0, weight=1)
|
||
top.columnconfigure(1, weight=0)
|
||
|
||
def _auto_datum_evt(self, event):
|
||
"""Punkte automatisch setzen, Cursor ans Ende. Nach Tag+Monat das aktuelle
|
||
Jahr ergänzen (1206 -> 12.06.2026, Jahr aus der Systemuhr). Backspace/Pfeile
|
||
lassen normales Editieren zu."""
|
||
if event.keysym in ("BackSpace", "Delete", "Left", "Right", "Home", "End", "Tab"):
|
||
return
|
||
e = event.widget
|
||
s = e.get()
|
||
d = "".join(c for c in s if c.isdigit())[:8]
|
||
if len(d) < 2:
|
||
out = d
|
||
elif len(d) == 2:
|
||
out = d + "." # Tag fertig -> Punkt
|
||
elif len(d) < 4:
|
||
out = f"{d[:2]}.{d[2:]}"
|
||
elif len(d) == 4:
|
||
out = f"{d[:2]}.{d[2:4]}.{datetime.date.today().year}" # Monat fertig -> Jahr automatisch
|
||
else:
|
||
out = f"{d[:2]}.{d[2:4]}.{d[4:]}" # eigenes Jahr getippt
|
||
if out != s:
|
||
e.delete(0, "end")
|
||
e.insert(0, out)
|
||
e.icursor("end")
|
||
if len(d) == 4: # Tag+Monat fertig, Jahr automatisch -> direkt ins nächste Feld
|
||
e.tk_focusNext().focus()
|
||
|
||
def _update_naechte(self):
|
||
a = parse_date(self.r_anreise.get())
|
||
b = parse_date(self.r_abreise.get())
|
||
if a and b and (b - a).days > 0:
|
||
self.r_naechte.set(str((b - a).days))
|
||
else:
|
||
self.r_naechte.set("–")
|
||
|
||
# ---------------------------------------------------------- Positionen
|
||
# Spaltendefinition – Reihenfolge wie in der Excel:
|
||
# (Titel, Ausrichtung, Mindestbreite px). col 1 (Leistung) bekommt das Gewicht.
|
||
COLS = [
|
||
("Pos.", "center", 38),
|
||
("Leistung", "w", 330),
|
||
("USt %", "center", 50),
|
||
("Anzahl\nTage/Menge", "center", 80),
|
||
("Pers.", "center", 56),
|
||
("E.-Preis", "e", 72),
|
||
("Gesamt", "e", 92),
|
||
]
|
||
|
||
def _build_positionen(self):
|
||
wrap = ttk.LabelFrame(self, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4))
|
||
wrap.pack(side="top", fill="both", expand=True, padx=12, pady=4)
|
||
|
||
canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_bg)
|
||
sb = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview)
|
||
self.pos_frame = ttk.Frame(canvas)
|
||
self._pos_win = canvas.create_window((0, 0), window=self.pos_frame, anchor="nw")
|
||
self.pos_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
# pos_frame auf Canvas-Breite ziehen (minus Luft rechts zum Scrollbalken)
|
||
canvas.bind("<Configure>", lambda e: canvas.itemconfig(self._pos_win, width=e.width - 18))
|
||
canvas.configure(yscrollcommand=sb.set)
|
||
canvas.pack(side="left", fill="both", expand=True, pady=(4, 0))
|
||
sb.pack(side="right", fill="y")
|
||
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(int(-e.delta / 120), "units"))
|
||
|
||
# Feste Spaltenbreiten -> Kopf und Daten teilen sich EIN Grid und fluchten
|
||
for i, (_, _, msize) in enumerate(self.COLS):
|
||
self.pos_frame.columnconfigure(i, minsize=msize, weight=(1 if i == 1 else 0))
|
||
|
||
self._render_positionszeilen()
|
||
|
||
def _render_positionszeilen(self):
|
||
for w in self.pos_frame.winfo_children():
|
||
w.destroy()
|
||
self.anzahl_vars.clear()
|
||
self.personen_vars.clear()
|
||
self.gesamt_lbls.clear()
|
||
|
||
# Kopfzeile (gleiches Grid wie die Daten)
|
||
for i, (title, anchor, _) in enumerate(self.COLS):
|
||
ttk.Label(self.pos_frame, text=title, anchor=anchor,
|
||
font=("Segoe UI", 9, "bold")).grid(row=0, column=i, padx=3, pady=(0, 3), sticky="ew")
|
||
ttk.Separator(self.pos_frame, orient="horizontal").grid(
|
||
row=1, column=0, columnspan=len(self.COLS), sticky="ew", pady=(0, 2))
|
||
|
||
for idx, pos in enumerate(self.cfg["katalog"]):
|
||
r = idx + 2
|
||
nr = pos["nr"]
|
||
av = tk.StringVar()
|
||
pv = tk.StringVar()
|
||
self.anzahl_vars[nr] = av
|
||
self.personen_vars[nr] = pv
|
||
av.trace_add("write", lambda *a: self.recalc())
|
||
pv.trace_add("write", lambda *a: self.recalc())
|
||
|
||
ttk.Label(self.pos_frame, text=str(nr), anchor="center").grid(row=r, column=0, padx=3, pady=2, sticky="ew")
|
||
ttk.Label(self.pos_frame, text=pos["leistung"], anchor="w", wraplength=320,
|
||
justify="left").grid(row=r, column=1, padx=3, pady=2, sticky="ew")
|
||
ttk.Label(self.pos_frame, text=str(pos["satz"]), anchor="center").grid(row=r, column=2, padx=3, pady=2, sticky="ew")
|
||
ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center").grid(row=r, column=3, padx=3, pady=2)
|
||
# Personen-Feld NUR bei "pro Person"-Positionen anzeigen
|
||
if pos["pro_person"]:
|
||
ttk.Entry(self.pos_frame, textvariable=pv, width=6, justify="center").grid(row=r, column=4, padx=3, pady=2)
|
||
ttk.Label(self.pos_frame, text=berechnung.eur_kurz(pos["preis"]), anchor="e").grid(row=r, column=5, padx=3, pady=2, sticky="ew")
|
||
g = ttk.Label(self.pos_frame, text="0,00", anchor="e")
|
||
g.grid(row=r, column=6, padx=3, pady=2, sticky="ew")
|
||
self.gesamt_lbls[nr] = g
|
||
|
||
# ------------------------------------------------------------- Summen
|
||
def _build_summen(self):
|
||
# Statuszeile ganz unten
|
||
status = ttk.Frame(self, padding=(12, 2))
|
||
status.pack(side="bottom", fill="x")
|
||
ttk.Label(status, text="powered by ravokk", foreground=self.col_muted,
|
||
font=("Segoe UI", 8)).pack(side="right")
|
||
|
||
f = ttk.Frame(self, padding=(12, 6, 12, 10))
|
||
f.pack(side="bottom", fill="x")
|
||
ttk.Separator(self, orient="horizontal").pack(side="bottom", fill="x")
|
||
|
||
# Summen werden in recalc() dynamisch befuellt (Netto -> USt je Satz -> Gesamt)
|
||
self.summen_box = ttk.Frame(f)
|
||
self.summen_box.pack(side="right")
|
||
|
||
links = ttk.Frame(f)
|
||
links.pack(side="left")
|
||
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
|
||
ttk.Button(links, text="Rechnung stornieren", command=self.dlg_storno, width=20).pack(side="left", padx=4)
|
||
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
|
||
|
||
# --------------------------------------------------------- Berechnung
|
||
def _eingaben(self):
|
||
e = {}
|
||
for nr, av in self.anzahl_vars.items():
|
||
e[nr] = {"anzahl": parse_num(av.get()), "personen": parse_num(self.personen_vars[nr].get())}
|
||
return e
|
||
|
||
def recalc(self):
|
||
r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg)
|
||
aktiv = {z.nr: z.brutto for z in r.zeilen}
|
||
for nr, lbl in self.gesamt_lbls.items():
|
||
lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0)))
|
||
|
||
# Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene
|
||
# USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag.
|
||
for w in self.summen_box.winfo_children():
|
||
w.destroy()
|
||
rows = [("Nettobetrag:", berechnung.eur(r.netto_gesamt), False)]
|
||
for b in r.bloecke:
|
||
name = b.bezeichnung or "USt"
|
||
rows.append((f"{name} {b.satz} %:", berechnung.eur(b.ust), False))
|
||
if r.aufschlaege:
|
||
rows.append(("Zwischensumme:", berechnung.eur(r.brutto), False))
|
||
for a in r.aufschlaege:
|
||
satz = berechnung.eur_kurz(a.satz).replace(",00", "")
|
||
rows.append((f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz} %):", berechnung.eur(a.betrag), False))
|
||
rows.append(("Gesamtbetrag:", berechnung.eur(r.gesamt), True))
|
||
|
||
for i, (lbl, val, fett) in enumerate(rows):
|
||
font = ("Segoe UI", 12, "bold") if fett else ("Segoe UI", 10)
|
||
pady = (5, 0) if fett else (1, 0)
|
||
ttk.Label(self.summen_box, text=lbl, font=font).grid(row=i, column=0, sticky="e", padx=6, pady=pady)
|
||
vkw = {"font": font, "width": 14, "anchor": "e"}
|
||
if fett:
|
||
vkw["foreground"] = self.col_accent
|
||
ttk.Label(self.summen_box, text=val, **vkw).grid(row=i, column=1, sticky="e", pady=pady)
|
||
|
||
self._letzte_rechnung = r
|
||
return r
|
||
|
||
# ----------------------------------------------------------- Aktionen
|
||
def _neue_nummer_vorschlagen(self):
|
||
jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year)
|
||
zaehler = self.cfg.get("rechnung_zaehler", 0)
|
||
heute_jahr = datetime.date.today().year
|
||
if heute_jahr != jahr:
|
||
jahr, zaehler = heute_jahr, 0
|
||
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
|
||
|
||
# ------------------------------------------------------------- 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")
|
||
else:
|
||
self.storno_hinweis.set("")
|
||
self.nummer_entry.configure(state="normal")
|
||
|
||
def _storno_verwerfen(self, grund=""):
|
||
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
|
||
|
||
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
|
||
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
|
||
"""
|
||
stand = self.storno_stand
|
||
if not stand:
|
||
return
|
||
try:
|
||
if os.path.exists(stand["pfad"]):
|
||
os.remove(stand["pfad"])
|
||
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
|
||
except OSError as e: # noqa: BLE001
|
||
messagebox.showwarning("Storno-PDF blieb liegen",
|
||
f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}")
|
||
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
|
||
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
|
||
config.save_config(self.cfg)
|
||
self.storno_stand = None
|
||
self._storno_anzeigen()
|
||
self._neue_nummer_vorschlagen()
|
||
if grund:
|
||
print(f"[storno] {grund}")
|
||
|
||
def dlg_storno(self):
|
||
"""Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen."""
|
||
if self.storno_stand:
|
||
messagebox.showinfo(
|
||
"Storno liegt schon bereit",
|
||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||
f"{self.storno_stand['zu']} ist noch offen.\n\n"
|
||
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
|
||
return
|
||
|
||
out_dir = config.ensure_output_dir(self.cfg)
|
||
alle = rechnungen_im_ordner(out_dir)
|
||
stornierte = {nummer_normalisieren(d.get("storno_zu"))
|
||
for d in alle if d.get("art") == "storno"}
|
||
offen = [d for d in alle
|
||
if d.get("art") != "storno"
|
||
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
|
||
if not offen:
|
||
messagebox.showwarning(
|
||
"Nichts zu stornieren",
|
||
f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.")
|
||
return
|
||
|
||
d = tk.Toplevel(self)
|
||
d.title("Rechnung stornieren")
|
||
d.transient(self)
|
||
d.grab_set()
|
||
self._style_dialog(d)
|
||
frm = ttk.Frame(d, padding=12)
|
||
frm.pack(fill="both", expand=True)
|
||
|
||
ttk.Label(frm, justify="left", text=
|
||
"Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert\n"
|
||
"noch gelöscht werden. Das Programm schreibt eine Stornorechnung mit\n"
|
||
"eigener neuer Nummer, die sich auf sie bezieht.").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), 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()
|
||
self._storno_anlegen(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", 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)
|
||
|
||
def kunde_pruefen(self):
|
||
"""Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung.
|
||
|
||
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
|
||
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
|
||
Anschrift. Die Felder werden dabei gesaeubert.
|
||
"""
|
||
for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||
self.k_strasse, self.k_plzort):
|
||
var.set(saeubern(var.get()))
|
||
|
||
maengel = []
|
||
for feld, var in (("Vorname", self.k_vorname),
|
||
("Nachname / Firma", self.k_name),
|
||
("Straße / Nr.", self.k_strasse)):
|
||
wert = var.get()
|
||
if not wert:
|
||
maengel.append(f"{feld}: ist leer")
|
||
elif buchstaben(wert) < 2:
|
||
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
|
||
|
||
ort = self.k_plzort.get()
|
||
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 pdf_erstellen(self):
|
||
r = self.recalc()
|
||
if not r.zeilen:
|
||
messagebox.showwarning("Keine Positionen",
|
||
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
|
||
return
|
||
nummer = self.r_nummer.get().strip()
|
||
if not nummer:
|
||
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
|
||
return
|
||
# Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung
|
||
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
|
||
maengel = self.kunde_pruefen()
|
||
if maengel:
|
||
messagebox.showwarning(
|
||
"Kundenangaben fehlen",
|
||
"So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) +
|
||
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
|
||
"Rechnung (§ 14 Abs. 4 Nr. 1 UStG).")
|
||
return
|
||
# Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne
|
||
# (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer.
|
||
if not ziffern(self.k_strasse.get()):
|
||
if not messagebox.askyesno(
|
||
"Hausnummer fehlt",
|
||
f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n"
|
||
"Trotzdem so auf die Rechnung schreiben?"):
|
||
return
|
||
|
||
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
|
||
# nicht aus dem Adressblock raten muss.
|
||
nachname = self.k_name.get().strip()
|
||
vorname = self.k_vorname.get().strip()
|
||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||
self.k_strasse.get(), self.k_plzort.get()]
|
||
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
||
stand = self.storno_stand
|
||
if stand:
|
||
if not messagebox.askyesno(
|
||
"Storno wirklich durchführen?",
|
||
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
|
||
f"und {nummer} wird die berichtigte Rechnung.\n\n"
|
||
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
|
||
"ihm zugegangen ist.\n\n"
|
||
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
|
||
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
|
||
icon="warning", default="yes"):
|
||
self._storno_verwerfen("vom Benutzer verworfen")
|
||
messagebox.showinfo(
|
||
"Storno verworfen",
|
||
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||
return
|
||
|
||
kopf = {
|
||
"rechnungsnummer": nummer,
|
||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||
"anreise": self.r_anreise.get().strip(),
|
||
"abreise": self.r_abreise.get().strip(),
|
||
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
|
||
"kunde_zeilen": kunde_zeilen,
|
||
"nachname": nachname,
|
||
"vorname": vorname,
|
||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||
"strasse": self.k_strasse.get().strip(),
|
||
"plzort": self.k_plzort.get().strip()},
|
||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||
for z in r.zeilen],
|
||
}
|
||
if stand:
|
||
kopf["art"] = "korrektur"
|
||
kopf["ersetzt"] = stand["zu"]
|
||
kopf["storno_nummer"] = stand["nummer"]
|
||
out_dir = config.ensure_output_dir(self.cfg)
|
||
|
||
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
||
# Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede
|
||
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
||
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
||
if vorhanden:
|
||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||
frei = naechste_freie_nummer(out_dir, self.cfg)
|
||
if not frei:
|
||
messagebox.showerror(
|
||
"Rechnungsnummer schon vergeben",
|
||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Rechnungsnummer schon vergeben",
|
||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
|
||
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
|
||
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
|
||
icon="warning", default="yes"):
|
||
return
|
||
nummer = frei
|
||
self.r_nummer.set(nummer)
|
||
kopf["rechnungsnummer"] = nummer
|
||
|
||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||
if self.k_name.get().strip():
|
||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||
pfad = os.path.join(out_dir, fname + ".pdf")
|
||
|
||
try:
|
||
# Ein PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt
|
||
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"PDF konnte nicht erstellt werden:\n{e}")
|
||
print("[pdf] FEHLER:", e)
|
||
return
|
||
|
||
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
|
||
self._zaehler_aus_nummer_speichern(nummer)
|
||
|
||
print(f"[pdf] erstellt -> {pfad}")
|
||
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
|
||
storno_pfad = None
|
||
if stand:
|
||
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
|
||
storno_pfad = stand["pfad"]
|
||
self.storno_stand = None
|
||
self._storno_anzeigen()
|
||
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
|
||
f"({os.path.basename(storno_pfad)}).\n"
|
||
"Beide Blätter gehören zum Gast.") + hinweis
|
||
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
|
||
self._oeffne_datei(pfad)
|
||
if storno_pfad:
|
||
self._oeffne_datei(storno_pfad)
|
||
|
||
def _zaehler_aus_nummer_speichern(self, nummer):
|
||
try:
|
||
jahr_s, z_s = nummer.split("-")
|
||
jahr, z = int(jahr_s), int(z_s)
|
||
except (ValueError, AttributeError):
|
||
return
|
||
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
|
||
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
|
||
# vergeben - genau der Fehler, den wir loswerden wollen.
|
||
alt_jahr = self.cfg.get("rechnung_jahr", 0)
|
||
alt_z = self.cfg.get("rechnung_zaehler", 0)
|
||
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
|
||
return
|
||
self.cfg["rechnung_jahr"] = jahr
|
||
self.cfg["rechnung_zaehler"] = z
|
||
config.save_config(self.cfg)
|
||
self._neue_nummer_vorschlagen()
|
||
|
||
def reset_formular(self):
|
||
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
|
||
v.set("")
|
||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||
for av in self.anzahl_vars.values():
|
||
av.set("")
|
||
for pv in self.personen_vars.values():
|
||
pv.set("")
|
||
self._neue_nummer_vorschlagen()
|
||
self.recalc()
|
||
|
||
def _ordner_oeffnen(self):
|
||
self._oeffne_datei(config.ensure_output_dir(self.cfg))
|
||
|
||
def _frage_speicherort_falls_noetig(self):
|
||
if (self.cfg.get("output_dir") or "").strip():
|
||
return
|
||
vorschlag = config.default_output_dir()
|
||
messagebox.showinfo(
|
||
"Speicherort der Rechnungen",
|
||
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
|
||
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
|
||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
|
||
initialdir=os.path.expanduser("~"))
|
||
self.cfg["output_dir"] = d if d else vorschlag
|
||
config.save_config(self.cfg)
|
||
config.ensure_output_dir(self.cfg)
|
||
|
||
def dlg_speicherort(self):
|
||
aktuell = config.ensure_output_dir(self.cfg)
|
||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
|
||
if d:
|
||
self.cfg["output_dir"] = d
|
||
config.save_config(self.cfg)
|
||
config.ensure_output_dir(self.cfg)
|
||
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
|
||
|
||
def _oeffne_datei(self, pfad):
|
||
try:
|
||
if sys.platform.startswith("win"):
|
||
os.startfile(pfad) # noqa: S606
|
||
elif sys.platform == "darwin":
|
||
os.system(f'open "{pfad}"')
|
||
else:
|
||
os.system(f'xdg-open "{pfad}"')
|
||
except Exception as e: # noqa: BLE001
|
||
messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
|
||
|
||
def _info(self):
|
||
d = tk.Toplevel(self)
|
||
d.title("Info")
|
||
d.transient(self)
|
||
d.resizable(False, False)
|
||
self._style_dialog(d)
|
||
frm = ttk.Frame(d, padding=18)
|
||
frm.pack(fill="both", expand=True)
|
||
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
|
||
foreground=self.col_accent).pack(anchor="w")
|
||
ttk.Label(frm, justify="left",
|
||
text="Daten eingeben, PDF erstellen, drucken.\n"
|
||
"Steuersätze, Preise und Firmendaten lassen sich\n"
|
||
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
|
||
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
|
||
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
|
||
d.bind("<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")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen.
|
||
_sperre = einzelinstanz_pruefen()
|
||
if _sperre is False:
|
||
# Laeuft schon: dessen Fenster nach vorn holen und still beenden.
|
||
if not fenster_nach_vorn(laufende_pid_lesen()):
|
||
_r = tk.Tk()
|
||
_r.withdraw()
|
||
messagebox.showerror(
|
||
"Rechnungstool läuft bereits",
|
||
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
|
||
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
|
||
|
||
Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""")
|
||
_r.destroy()
|
||
sys.exit(0)
|
||
|
||
eigene_pid_merken()
|
||
app = RechnungsApp()
|
||
app.mainloop()
|