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:
commit
dc170a9d9c
8 changed files with 1692 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
rechnungen/
|
||||||
|
config.json
|
||||||
|
_tmp/
|
||||||
|
*.7z.*
|
||||||
|
release/
|
||||||
53
README.md
Normal file
53
README.md
Normal 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
4
Rechnung starten.bat
Normal 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
925
app.py
Normal 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
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
176
berechnung.py
Normal file
176
berechnung.py
Normal 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
167
config.py
Normal 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
357
pdf_renderer.py
Normal 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(" ", 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 %", 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue