rechnungstool/berechnung.py
TheMockTv dc170a9d9c 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>
2026-07-12 19:18:38 +02:00

176 lines
6.7 KiB
Python

# -*- 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("#", ".")