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>
176 lines
6.7 KiB
Python
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("#", ".")
|