Rechnungstool Campinghof - Rechnungen als PDF statt Excel-Vorlage

Desktop-Programm (Python/Tkinter): Kundendaten + Leistungen eingeben,
druckfertige PDF-Rechnung im Briefkopf des Betriebs. Optionale
Beherbergungssteuer wird eigen ausgewiesen und bekommt ein zweites
Blatt fuers Amt.

Firmendaten, Preise und Steuersaetze stehen in der config.json neben
dem Programm (nicht im Repo) und werden im Menue "Einstellungen"
gepflegt. Im Code stehen nur Platzhalter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TheMockTv 2026-07-12 19:18:38 +02:00
commit dc170a9d9c
8 changed files with 1692 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
__pycache__/
*.pyc
build/
dist/
*.spec
rechnungen/
config.json
_tmp/
*.7z.*
release/

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# Rechnungstool Campinghof Bartl
Desktop-Programm (Python/Tkinter), das die alte Excel-Rechnungsvorlage ersetzt:
Kundendaten + Anzahl eingeben → druckfertige **PDF**-Rechnung im Campinghof-Layout.
## Starten
- **Doppelklick** auf `Rechnung starten.bat`
- oder im Terminal: `python app.py`
## Bedienung
1. **Kunde** (Name/Anschrift) und **An-/Abreise** eintragen die Nächte werden automatisch berechnet.
2. Bei den passenden **Leistungen** die **Anzahl** (Tage/Menge) eingeben; bei Personengebühren zusätzlich die **Personen**.
3. Summen (inkl. Steuer-Aufschlüsselung) erscheinen live unten.
4. **PDF erstellen** → die Rechnung landet im Ordner `rechnungen/` und öffnet sich zum Drucken.
Die Rechnungsnummer (`JJJJ-NNN`) zählt nach jedem Erstellen automatisch hoch.
## Einstellungen (Menü „Einstellungen")
- **Steuersätze & Bettensteuer** ermäßigter/regulärer USt-Satz; Beherbergungssteuer
per Checkbox an/aus + Prozentsatz. Bei aktiver Bettensteuer wird sie auf das Netto der
Beherbergungs-Positionen aufgeschlagen und eigen ausgewiesen.
- **Leistungskatalog / Preise** Bezeichnung, USt-Satz und Brutto-Preis je Position.
- **Firmendaten & Texte** Kopf, Bankverbindung, Fußzeile, Danktexte.
- **Rechnungsnummer** Jahr und Zähler setzen.
Alle Einstellungen werden in `config.json` gespeichert (neben den Programmdateien).
## Berechnung (wie in der Excel-Vorlage)
- Position-Gesamt = `Anzahl × Einzelpreis` (Personengebühren zusätzlich `× Personen`).
- Preise sind **brutto**; die USt wird herausgerechnet
(7 %: `÷107×7`, 19 %: `÷119×19`).
- Optionale **Beherbergungssteuer** = Satz % auf das Netto der Beherbergungs-Positionen.
## Dateien
| Datei | Zweck |
|-------|-------|
| `app.py` | GUI (Eingabe + Einstellungen) |
| `berechnung.py` | Rechenkern (eine Quelle für GUI + PDF) |
| `pdf_renderer.py` | PDF-Erstellung (reportlab) |
| `config.py` | Standardwerte + Laden/Speichern der `config.json` |
| `config.json` | gespeicherte Einstellungen (wird beim 1. Start angelegt) |
| `rechnungen/` | Ausgabeordner für die PDFs |
## Voraussetzungen
- Python 3.10+
- `reportlab` (`pip install reportlab`)
- `tkinter` (bei Standard-Windows-Python dabei)

4
Rechnung starten.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
REM Startet das Rechnungstool ohne Konsolenfenster.
cd /d "%~dp0"
start "" pythonw.exe app.py

925
app.py Normal file
View file

@ -0,0 +1,925 @@
# -*- 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 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 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
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)
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)])
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
style.configure("TSeparator", background=self.col_line)
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen."""
d.configure(bg=self.col_bg)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)
# ---------------------------------------------------------------- Menue
def _build_menu(self):
# 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_name = tk.StringVar()
self.k_zusatz = tk.StringVar()
self.k_strasse = tk.StringVar()
self.k_plzort = tk.StringVar()
felder = [
("Anrede (optional)", self.k_anrede),
("Name / 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)
ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
kunde.columnconfigure(1, weight=1)
# Rechnungsinfos (rechts)
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 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))
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="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))
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
if not self.k_name.get().strip():
if not messagebox.askyesno("Kein Kundenname",
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
return
kunde_zeilen = [self.k_anrede.get(), self.k_name.get(), self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()]
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,
}
out_dir = config.ensure_output_dir(self.cfg)
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 ""
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(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
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_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__":
app = RechnungsApp()
app.mainloop()

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

176
berechnung.py Normal file
View file

@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""
Rechnungsberechnung - die einzige Stelle, an der gerechnet wird.
Wird sowohl von der GUI (Live-Summen) als auch vom PDF-Renderer benutzt,
damit Bildschirm und Ausdruck garantiert identisch sind.
Logik (BRUTTO-Preise, USt wird herausgerechnet):
- Katalog-Preise sind BRUTTO (Steuer ist bereits enthalten).
- Position-Brutto = anzahl * preis (Standard)
= anzahl * preis * personen (pro_person-Positionen, Pos. 5+6)
- Je Steuersatz: enthaltene USt = Brutto-Summe * satz/(100+satz)
Netto = Brutto-Summe - USt
(7 % = ermaessigt/Beherbergung, 19 % = regulaer)
- Rechnungsbetrag (Leistungen) = Summe der Brutto-Positionen (USt ist schon drin).
- Beherbergungssteuer (optional): satz% auf den Rechnungsbetrag, OBENDRAUF,
direkt darunter ausgewiesen.
- Gesamtbetrag = Rechnungsbetrag + Beherbergungssteuer
"""
from dataclasses import dataclass, field
from typing import List
def runde(x):
"""Kaufmaennisch auf 2 Nachkommastellen runden."""
return round(float(x) + 1e-9, 2)
@dataclass
class PositionsZeile:
nr: int
leistung: str
satz: int # USt-Satz in Prozent
preis: float # Brutto-Einzelpreis (Steuer enthalten)
anzahl: float # Tage / Menge
personen: float # nur relevant bei pro_person
pro_person: bool
brutto: float = 0.0 # Brutto-Gesamt dieser Zeile (anzahl*preis[*personen])
netto: float = 0.0 # darin enthaltenes Netto (Brutto - enthaltene USt)
@dataclass
class Steuerblock:
satz: int
netto: float = 0.0 # Netto-Basis dieses Satzes
ust: float = 0.0 # darauf entfallende USt
bezeichnung: str = "" # Name des Satzes (z.B. "Mehrwertsteuer")
extra_blatt: bool = False
@property
def brutto(self):
return runde(self.netto + self.ust)
@dataclass
class Aufschlag:
"""Steuer, die auf die Endsumme (Brutto inkl. USt) aufgeschlagen wird (z.B. Beherbergungssteuer)."""
satz: float
bezeichnung: str = ""
basis: float = 0.0 # Brutto-Endsumme, auf die der Aufschlag gerechnet wird
betrag: float = 0.0
extra_blatt: bool = True
@dataclass
class Rechnung:
zeilen: List[PositionsZeile] = field(default_factory=list) # nur aktive (anzahl>0)
bloecke: List[Steuerblock] = field(default_factory=list) # USt-Bloecke (auf Netto je Satz)
aufschlaege: List[Aufschlag] = field(default_factory=list) # Aufschlaege auf die Endsumme
netto_gesamt: float = 0.0 # Summe aller Positionen (netto)
ust_gesamt: float = 0.0 # Summe aller USt-Betraege
brutto: float = 0.0 # netto + USt = Rechnungsbetrag der Leistung
gesamt: float = 0.0 # brutto + alle Aufschlaege
def zeile_brutto(preis, anzahl, personen, pro_person):
"""Brutto-Gesamt einer einzelnen Katalogzeile (Preise sind brutto)."""
a = float(anzahl or 0)
p = float(preis or 0)
if pro_person:
return runde(a * p * float(personen or 0))
return runde(a * p)
def berechne(katalog, eingaben, cfg):
"""
katalog : Liste der Katalog-Dicts (config['katalog'])
eingaben : dict {position_nr: {'anzahl': x, 'personen': y}}
cfg : geladene Konfiguration (fuer Steuersaetze + Bettensteuer)
Gibt eine Rechnung mit allen berechneten Werten zurueck.
"""
r = Rechnung()
for pos in katalog:
nr = pos["nr"]
e = eingaben.get(nr, {})
anzahl = float(e.get("anzahl", 0) or 0)
personen = float(e.get("personen", 0) or 0)
if anzahl <= 0:
continue # Position nicht auf der Rechnung
if pos["pro_person"] and personen <= 0:
continue # pro-Person-Position ohne Personenzahl -> kein Betrag
brutto = zeile_brutto(pos["preis"], anzahl, personen, pos["pro_person"])
if brutto <= 0:
continue
satz = int(pos["satz"])
netto = runde(brutto * 100 / (100 + satz)) if satz else brutto # USt herausrechnen
r.zeilen.append(PositionsZeile(
nr=nr, leistung=pos["leistung"], satz=satz,
preis=float(pos["preis"]), anzahl=anzahl, personen=personen,
pro_person=bool(pos["pro_person"]), brutto=brutto, netto=netto,
))
# Je Steuersatz EINEN Block bilden: die Brutto-Summe dieses Satzes herausrechnen
# (enthaltene USt = Brutto * satz/(100+satz)). Es wird NIE eine Steuer neu
# aufgeschlagen - die Preise sind bereits brutto.
brutto_je_satz = {}
for z in r.zeilen:
brutto_je_satz[z.satz] = round(brutto_je_satz.get(z.satz, 0.0) + z.brutto, 2)
r.brutto = runde(sum(z.brutto for z in r.zeilen)) # Rechnungsbetrag der Leistungen (brutto)
saetze = cfg.get("steuersaetze", [])
info = {int(s.get("satz", -1)): s for s in saetze}
netto_total = 0.0
ust_total = 0.0
for satz in sorted(brutto_je_satz.keys()):
bru = runde(brutto_je_satz[satz])
if bru <= 0:
continue
ust = runde(bru * satz / (100 + satz)) # in diesem Block ENTHALTENE USt
netto = runde(bru - ust)
s = info.get(satz, {})
r.bloecke.append(Steuerblock(satz=satz, netto=netto, ust=ust,
bezeichnung=s.get("bezeichnung", ""),
extra_blatt=bool(s.get("extra_blatt", False))))
netto_total += netto
ust_total += ust
r.netto_gesamt = runde(netto_total)
r.ust_gesamt = runde(ust_total)
# r.brutto (= Summe der Brutto-Positionen) entspricht netto_gesamt + ust_gesamt.
# Aufschlaege: NUR Saetze mit "extra_blatt" (z.B. Beherbergungssteuer) -> werden auf
# den Rechnungsbetrag (brutto) OBENDRAUF gerechnet und direkt darunter ausgewiesen.
# Normale USt-Saetze (extra_blatt=False) werden NIE aufgeschlagen.
positions_saetze = set(brutto_je_satz.keys())
summe = r.brutto
for s in saetze:
if not s.get("extra_blatt", False):
continue
satz = float(s.get("satz", 0) or 0)
if satz <= 0 or int(satz) in positions_saetze:
continue
betrag = runde(r.brutto * satz / 100)
if betrag <= 0:
continue
r.aufschlaege.append(Aufschlag(satz=satz, bezeichnung=s.get("bezeichnung", ""),
basis=r.brutto, betrag=betrag,
extra_blatt=True))
summe = runde(summe + betrag)
r.gesamt = summe
return r
def eur(x):
"""1234.5 -> '1.234,50 €' (deutsche Schreibweise)."""
s = f"{float(x):,.2f}" # 1,234.50
s = s.replace(",", "#").replace(".", ",").replace("#", ".") # 1.234,50
return s + ""
def eur_kurz(x):
"""Wie eur() aber ohne ''-Suffix (fuer enge Tabellenspalten)."""
s = f"{float(x):,.2f}"
return s.replace(",", "#").replace(".", ",").replace("#", ".")

167
config.py Normal file
View file

@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""
Konfiguration fuer das Rechnungstool Campinghof Bartl.
Haelt die Standardwerte (Firmendaten, Steuersaetze, Leistungskatalog,
Rechnungsnummer-Zaehler) und kuemmert sich um Laden/Speichern der config.json.
Alle Werte sind ueber das Einstellungs-Menue in der App aenderbar.
"""
import json
import os
import sys
# Pfade je nach Modus:
# - normal (python): alles neben den .py-Dateien
# - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar),
# gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS
if getattr(sys, "frozen", False):
BASE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = BASE_DIR
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)
# pro_person = True -> Gesamt = anzahl * preis * personen (Pos. 5 + 6)
# False -> Gesamt = anzahl * preis
DEFAULT_KATALOG = [
{"nr": 1, "leistung": "SP Wohnwagen/ Wohnmobil pro Nacht inkl. Pkw, Strom, Entsorgung, Markise oder Vorzelt", "satz": 7, "preis": 13.0, "pro_person": False},
{"nr": 2, "leistung": "SP Zelt bis 2 Personen inkl. Strom", "satz": 7, "preis": 9.0, "pro_person": False},
{"nr": 3, "leistung": "SP Zelt bis 4 Personen inkl. Strom", "satz": 7, "preis": 11.0, "pro_person": False},
{"nr": 4, "leistung": "SP Zelt bis 8 Personen inkl. Strom", "satz": 7, "preis": 14.0, "pro_person": False},
{"nr": 5, "leistung": "Personengebühr Erwachsene ab 15 Jahre", "satz": 7, "preis": 8.0, "pro_person": True},
{"nr": 6, "leistung": "Personengebühr Kinder ab 2 Jahre bis 14 Jahre", "satz": 7, "preis": 5.0, "pro_person": True},
{"nr": 7, "leistung": "Gästezimmer belegt mit 1-2 Personen pro Nacht", "satz": 7, "preis": 60.0, "pro_person": True},
{"nr": 8, "leistung": "Gästezimmer jede weitere Person pro Nacht", "satz": 7, "preis": 15.0, "pro_person": True},
{"nr": 9, "leistung": "Waschmaschinengebühr pro Wäsche", "satz": 19, "preis": 4.0, "pro_person": False},
{"nr": 10, "leistung": "Frischwasser tanken Wohnmobil", "satz": 19, "preis": 4.0, "pro_person": False},
{"nr": 11, "leistung": "Fahrradverleih", "satz": 19, "preis": 15.0, "pro_person": False},
{"nr": 12, "leistung": "Strompauschale für Heizung/Klimaanlage", "satz": 19, "preis": 7.0, "pro_person": False},
{"nr": 13, "leistung": "Bettwäsche-Paket pro Person", "satz": 19, "preis": 5.0, "pro_person": False},
{"nr": 14, "leistung": "Handtücher-Paket pro Person", "satz": 19, "preis": 5.0, "pro_person": False},
{"nr": 15, "leistung": "Endreinigung Ferienwohnung", "satz": 19, "preis": 40.0, "pro_person": False},
{"nr": 16, "leistung": "Gasfüllung 5 Kg (zuzüglich Pfand 30 €)", "satz": 19, "preis": 18.5, "pro_person": False},
{"nr": 17, "leistung": "Gasfüllung 11 Kg (zuzüglich Pfand 35 €)", "satz": 19, "preis": 31.5, "pro_person": False},
]
# Firmen- und Fusszeilen-Daten Platzhalter.
# Die echten Daten stehen in der config.json neben dem Programm (nicht im Repo)
# und werden im Menü "Einstellungen -> Firmendaten & Texte" gepflegt.
DEFAULT_FIRMA = {
"name": "Mein Campingplatz",
"untertitel": "Camping & Ferienwohnung",
"absender": "Mein Campingplatz - Musterstraße 1 - 00000 Musterstadt",
"inhaber": "Inhaber Vorname Nachname",
"bank": "Bank Musterbank",
"iban": "IBAN DE00 0000 0000 0000 0000 00",
"bic": "SWIFT/BIC XXXXDE00XXX",
"steuernummer":"Steuernummer 000/000/00000",
"homepage": "Homepage www.example.de",
"email": "E-Mail kontakt@example.de",
"telefon": "Telefon +49 (0) 000 0000000",
"logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner)
}
# Festtexte unten auf der Rechnung (aus der Vorlage).
DEFAULT_TEXTE = {
"dank1": "Vielen Dank für Ihren Besuch auf unserem Campinghof. Wir hoffen, Sie hatten einen "
"angenehmen Aufenthalt und würden uns freuen, Sie bald wieder bei uns begrüßen zu dürfen.",
"dank2": "Unser Campinghof hat 365 Tage für Sie geöffnet. Wir bieten auch über den Winter "
"Abstellplätze für einen monatlichen Betrag von 50,00 € an.",
"hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *",
}
DEFAULT_CONFIG = {
"firma": DEFAULT_FIRMA,
"texte": DEFAULT_TEXTE,
"katalog": DEFAULT_KATALOG,
# Alle Steuern als EINE benannte Liste.
# - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet.
# - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt.
"steuersaetze": [
{"satz": 7, "bezeichnung": "Mehrwertsteuer", "extra_blatt": False},
{"satz": 19, "bezeichnung": "Mehrwertsteuer", "extra_blatt": False},
{"satz": 5, "bezeichnung": "Beherbergungssteuer", "extra_blatt": True},
],
# Rechnungsnummer im Format JJJJ-NNN (z.B. 2026-013). Zaehler des zuletzt
# vergebenen Werts -> beim Anlegen wird +1 vorgeschlagen.
"rechnung_jahr": 2026,
"rechnung_zaehler": 13,
# Ordner, in dem die fertigen PDF-Rechnungen gespeichert werden.
# Leer -> beim ersten Start wird der Nutzer gefragt (Default siehe default_output_dir()).
"output_dir": "",
}
def _merge(default, loaded):
"""Tief mergen: fehlende Keys aus default ergaenzen, vorhandene aus loaded behalten."""
if isinstance(default, dict) and isinstance(loaded, dict):
out = {}
for k, v in default.items():
out[k] = _merge(v, loaded[k]) if k in loaded else v
# zusaetzliche Keys aus loaded mitnehmen (z.B. erweiterter Katalog)
for k, v in loaded.items():
if k not in out:
out[k] = v
return out
return loaded
def load_config():
"""config.json laden; fehlende Werte aus DEFAULT_CONFIG ergaenzen. Legt Datei an, falls fehlend."""
if not os.path.exists(CONFIG_PATH):
save_config(DEFAULT_CONFIG)
return json.loads(json.dumps(DEFAULT_CONFIG))
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
loaded = json.load(f)
cfg = _merge(DEFAULT_CONFIG, loaded)
return cfg
except (json.JSONDecodeError, OSError) as e:
print(f"[config] Fehler beim Laden ({e}) - nutze Defaults")
return json.loads(json.dumps(DEFAULT_CONFIG))
def save_config(cfg):
"""config.json schreiben (UTF-8, eingerueckt)."""
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
print(f"[config] gespeichert -> {CONFIG_PATH}")
except OSError as e:
print(f"[config] FEHLER beim Speichern: {e}")
raise
def format_rechnungsnummer(jahr, zaehler):
"""2026, 13 -> '2026-013'"""
return f"{jahr}-{zaehler:03d}"
def default_output_dir():
"""Sinnvoller Standardordner, der auf jedem Windows-Profil existiert."""
return os.path.join(os.path.expanduser("~"), "Documents", "Campinghof Rechnungen")
def ensure_output_dir(cfg=None):
"""
Gibt den konfigurierten Ausgabeordner zurueck und legt ihn an.
Faellt bei leerem/unbrauchbarem Pfad auf default_output_dir() zurueck.
"""
d = ""
if cfg:
d = (cfg.get("output_dir") or "").strip()
if not d:
d = default_output_dir()
try:
os.makedirs(d, exist_ok=True)
return d
except OSError as e:
print(f"[config] Ausgabeordner '{d}' nicht nutzbar ({e}) -> Standard")
d = default_output_dir()
os.makedirs(d, exist_ok=True)
return d

357
pdf_renderer.py Normal file
View file

@ -0,0 +1,357 @@
# -*- coding: utf-8 -*-
"""
PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab).
render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF
im Layout der Excel-Vorlage:
Firmenkopf -> Absenderzeile -> Kundenadresse | Rechnungsinfos
-> Positionstabelle -> Summen + Steueraufstellung
-> Danktexte -> Bankfusszeile
"""
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
)
import config
from berechnung import eur, eur_kurz, runde
# ---- Farben / Masse ---------------------------------------------------------
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
MARGIN_BOT = 34 * mm # Platz fuer die Fusszeile
PAGE_W, PAGE_H = A4
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # ~174mm
# ---- Absatz-Stile -----------------------------------------------------------
def _styles():
return {
"leistung": ParagraphStyle("leistung", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"cell_r": ParagraphStyle("cell_r", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"cell_c": ParagraphStyle("cell_c", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_CENTER),
"th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_CENTER),
"th_l": ParagraphStyle("th_l", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_LEFT),
"sum_r": ParagraphStyle("sum_r", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"addr": ParagraphStyle("addr", fontName="Helvetica", fontSize=10,
leading=13, textColor=DARK),
"addr_label": ParagraphStyle("addr_label", fontName="Helvetica-Bold",
fontSize=9, leading=12, textColor=ACCENT),
"info_l": ParagraphStyle("info_l", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"info_v": ParagraphStyle("info_v", fontName="Helvetica", fontSize=9,
leading=13, textColor=DARK, alignment=TA_LEFT),
"h": ParagraphStyle("h", fontName="Helvetica-Bold", fontSize=11,
leading=14, textColor=ACCENT, spaceBefore=4, spaceAfter=4),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15,
leading=19, textColor=ACCENT, spaceAfter=4),
"dank": ParagraphStyle("dank", fontName="Helvetica", fontSize=8.5,
leading=11, textColor=DARK, spaceAfter=4),
"hinweis": ParagraphStyle("hinweis", fontName="Helvetica-Oblique", fontSize=8.5,
leading=11, textColor=ACCENT, alignment=TA_CENTER, spaceBefore=4),
"total_l": ParagraphStyle("total_l", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=DARK, alignment=TA_RIGHT),
"total_v": ParagraphStyle("total_v", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=ACCENT, alignment=TA_RIGHT),
}
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _kopf_fuss(canvas, doc, cfg):
firma = cfg["firma"]
canvas.saveState()
# --- Kopf ---
logo = (firma.get("logo") or "").strip()
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
if logo and not os.path.isabs(logo):
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
_cand = os.path.join(_base, logo)
if os.path.exists(_cand):
logo = _cand
break
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
scale = min(box_w / iw, box_h / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
except Exception as e: # noqa: BLE001
print("[pdf] Logo konnte nicht geladen werden:", e)
if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo
top = PAGE_H - 15 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 16)
canvas.drawRightString(PAGE_W - MARGIN_R, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 9.5)
canvas.drawRightString(PAGE_W - MARGIN_R, top - 5.5 * mm, firma.get("untertitel", ""))
else:
top = PAGE_H - 16 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 19)
canvas.drawString(MARGIN_L, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 10.5)
canvas.drawString(MARGIN_L, top - 6.2 * mm, firma.get("untertitel", ""))
line_y = top - 9.5 * mm
canvas.setStrokeColor(ACCENT)
canvas.setLineWidth(1.1)
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile ---
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6)
canvas.line(MARGIN_L, fy + 13 * mm, PAGE_W - MARGIN_R, fy + 13 * mm)
canvas.setFont("Helvetica", 7.3)
canvas.setFillColor(DARK)
col_l = MARGIN_L
col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l):
canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_m):
canvas.drawString(col_m, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_r):
canvas.drawRightString(col_r, fy + 9 * mm - i * 3.6 * mm, t)
canvas.restoreState()
# ---- Hauptfunktion ----------------------------------------------------------
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
"""
# Absenderzeile (klein, unterstrichen)
story.append(Paragraph(
f"<u>{cfg['firma'].get('absender','')}</u>",
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
story.append(Spacer(1, 5 * mm))
# Kundenadresse (links) | Rechnungsinfos (rechts)
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
for z in kopf.get("kunde_zeilen", []):
if str(z).strip():
kunde_flow.append(Paragraph(str(z), st["addr"]))
if len(kunde_flow) == 1:
kunde_flow.append(Paragraph("&nbsp;", st["addr"]))
info_rows = [
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
("Rechnungsdatum:", kopf.get("datum", "")),
]
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
info_rows.append(("Abreise:", kopf.get("abreise", "")))
if kopf.get("naechte") not in (None, "", 0, "0"):
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
info_tbl = Table(
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
colWidths=[39 * mm, 25 * mm])
info_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]))
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
head.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
story.append(head)
story.append(Spacer(1, 7 * mm))
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
titel = "Rechnungsübersicht" + (" Beherbergungssteuer (für das Amt)" if gesamtrechnung_betont else "")
story.append(Paragraph(titel, st["h"]))
# Positionstabelle
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
data = [[
Paragraph("Pos.", st["th"]),
Paragraph("Leistung", st["th_l"]),
Paragraph("USt&nbsp;%", st["th"]),
Paragraph("Anzahl<br/>Tage/Menge", st["th"]),
Paragraph("Pers.", st["th"]),
Paragraph("E.-Preis", st["th"]),
Paragraph("Gesamt", st["th"]),
]]
for z in rechnung.zeilen:
pers = eur_kurz(z.personen).replace(",00", "") if z.pro_person else ""
data.append([
Paragraph(str(z.nr), st["cell_c"]),
Paragraph(z.leistung, st["leistung"]),
Paragraph(str(z.satz), st["cell_c"]),
Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]),
Paragraph(pers, st["cell_c"]),
Paragraph(eur_kurz(z.preis), st["cell_r"]),
Paragraph(eur_kurz(z.brutto), st["cell_r"]),
])
if len(data) == 1: # keine Position erfasst
data.append([Paragraph("", st["cell_c"]),
Paragraph("<i>(keine Positionen erfasst)</i>", st["leistung"]),
"", "", "", "", ""])
items = Table(data, colWidths=col_w, repeatRows=1)
items.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -1), 0.35, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(items)
story.append(Spacer(1, 3 * mm))
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
summen_rows = [[
Paragraph("Nettobetrag:", st["info_l"]),
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
]]
for b in rechnung.bloecke:
name = b.bezeichnung or "USt"
summen_rows.append([
Paragraph(f"{name} {b.satz} %:", st["info_l"]),
Paragraph(eur(b.ust), st["cell_r"]),
])
gr_idx = None
if rechnung.aufschlaege:
gr_idx = len(summen_rows) # Zeilen-Index der Zwischensumme
gr_l = st["total_l"] if gesamtrechnung_betont else st["info_l"]
gr_v = st["total_v"] if gesamtrechnung_betont else st["cell_r"]
summen_rows.append([
Paragraph("Zwischensumme:", gr_l),
Paragraph(eur(rechnung.brutto), gr_v),
])
for a in rechnung.aufschlaege:
satz_txt = eur_kurz(a.satz).replace(",00", "")
summen_rows.append([
Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]),
Paragraph(eur(a.betrag), st["cell_r"]),
])
summen_rows.append([
Paragraph("Gesamtbetrag:", st["total_l"]),
Paragraph(eur(rechnung.gesamt), st["total_v"]),
])
style_cmds = [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 1.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 1.5),
("LINEABOVE", (0, -1), (1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT),
("TOPPADDING", (0, -1), (-1, -1), 4),
]
if gesamtrechnung_betont and gr_idx is not None:
# Zwischensumme wie der Gesamtbetrag hervorheben: zwei Streifen
style_cmds += [
("LINEABOVE", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("LINEBELOW", (0, gr_idx), (1, gr_idx), 0.8, ACCENT),
("TOPPADDING", (0, gr_idx), (-1, gr_idx), 4),
]
summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
summen.setStyle(TableStyle(style_cmds))
story.append(summen)
# Hinweis zur Beherbergungssteuer
for a in rechnung.aufschlaege:
if not a.bezeichnung:
continue
satz_txt = eur_kurz(a.satz).replace(",00", "")
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
story.append(Spacer(1, 8 * mm))
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
texte = cfg.get("texte", {})
block = []
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
def render_rechnung(pfad, cfg, rechnung, kopf):
"""
pfad : Zielpfad der PDF
cfg : geladene Konfiguration
rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
Seite 1 = Kunden-Rechnung.
Seite 2 = 1:1-Kopie fuers Amt, nur die Zwischensumme fett + zwei Streifen.
"""
st = _styles()
doc = SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Seite 1: Kunde
if rechnung.aufschlaege:
story.append(PageBreak())
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Seite 2: Amt
doc.build(
story,
onFirstPage=lambda c, d: _kopf_fuss(c, d, cfg),
onLaterPages=lambda c, d: _kopf_fuss(c, d, cfg),
)
return pfad