rechnungstool/config.py
TheMockTv 82d84f7aea Aus der Proforma-Rechnung wird ein unverbindliches Angebot
Seine Frage war: "was ist rechtssicher, kostenvoranschlag oder proforma - ich
bin kein jurist, deswegen sage ich ja § schauen". Nachgesehen:

* Kostenanschlag ist ein Begriff des WERKvertrags (§ 632 Abs. 3 BGB "Ein
  Kostenanschlag ist im Zweifel nicht zu verguten", § 649 BGB) - das ist der
  Handwerker-Fall. Bartl vermietet, das passt nicht.
* "Proforma-Rechnung" steht in keinem Gesetz (Zoll- und Exportbegriff) und
  traegt als Einziges das Wort "Rechnung".
* Das Angebot ist § 145 BGB: gebunden ist man daran, "es sei denn, dass er die
  Gebundenheit ausgeschlossen hat" - deshalb steht "unverbindlich" drauf.
* Im Camping- und Hotelgeschaeft ist "unverbindliches Angebot" der uebliche
  Begriff (auch in den Muster-AGB des Branchenverbands BVCD).

Seine Entscheidung: "dann mach angebot draus, ist das gaengigste" - Begruendung
"dann ist der name rechnung weg und erweckt nicht den anschein das man eine
bekommen hat".

Deshalb heisst es jetzt ueberall Angebot: Ueberschrift "Unverbindliches
Angebot", "Angebot an:", "Angebot-Nr.: 2026-A001" und "Datum:" statt
"Rechnungsdatum:" - das Wort Rechnung kommt im ganzen Kopf nicht mehr vor, und
genau das prueft pruef_angebot.py auch nach. Eigene Reihe 2026-A001, Dateiname
Angebot_..., eigener Zaehler (angebot_jahr/angebot_zaehler).

Steuerlich aendert der Name nichts (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung des
Dokuments ist gleichgueltig) - die Sicherheit kommt weiter vom fehlenden
Steuerausweis, siehe UStAE 14c.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:37:32 +02:00

232 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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):
EXE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR)
else:
EXE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = EXE_DIR
def _beschreibbar(ordner):
"""Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten."""
try:
os.makedirs(ordner, exist_ok=True)
probe = os.path.join(ordner, ".schreibprobe")
with open(probe, "w") as f:
f.write("x")
os.remove(probe)
return True
except OSError:
return False
def _daten_ordner():
"""Der Ordner, den das Programm sich beim ersten Start selbst anlegt.
Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf
einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern.
1. neben der EXE ein Unterordner "Daten"
2. geht das nicht (EXE liegt z.B. unter "Programme"), dann
der Ordner Rechnungstool unter LOCALAPPDATA
Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien
noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt
dass das Programm ohne Firmendaten und Zaehler neu anfaengt.
"""
if not getattr(sys, "frozen", False):
return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool")
if not _beschreibbar(kandidat):
return EXE_DIR # letzter Ausweg: wie frueher
for name in ("config.json",):
alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)
if os.path.exists(alt) and not os.path.exists(neu_):
try:
os.replace(alt, neu_)
print(f"[config] {name} in den Datenordner übernommen")
except OSError:
pass
return kandidat
BASE_DIR = _daten_ordner()
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},
]
# Die Montage-Rechnung ist eine eigene Sorte Beleg (eigener Reiter im Fenster):
# ein Monteur mietet monatsweise, nicht naechteweise. Deshalb steht sie NICHT im
# Leistungskatalog - sie hat eine einzige Zeile mit einem Monatspreis, und im
# Katalog wuerde sie zwischen den Nachtpreisen nur Unheil stiften.
# preis = BRUTTO je Monat (wie im Katalog), die USt wird herausgerechnet.
# Die Beherbergungssteuer kommt wie ueberall obendrauf (500 + 5 % = 525).
DEFAULT_MONTAGE = {
"leistung": "Miete für Montagearbeiter",
"satz": 7,
"preis": 500.0,
}
# 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,
"montage": DEFAULT_MONTAGE,
# Das Angebot hat eine EIGENE Reihe (2026-A001): es darf keine
# Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung.
"angebot_jahr": 0,
"angebot_zaehler": 0,
# 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