rechnungstool/config.py
TheMockTv 2e3a191f19 Eigene Meldungsfenster, aktiver Reiter hervorgehoben, Pruefschalter als Funktion
Sein Fund: "der ohne gemeinsamen ordner popup ist noch hell" und "er hebt den
aktiven tab nicht hervor, der inaktive ist der hervorgehobene".

- Meldungen und Ja/Nein-Fragen kommen jetzt aus eigenen Fenstern im Theme
  (MeldungMixin). messagebox sind Fenster von WINDOWS - sie bleiben hell, egal
  was das Programm einstellt. Dieselbe Erfahrung steckt schon in der eigenen
  Menueleiste. 49 Aufrufe umgestellt; das Ordner-Fenster meldet ueber das
  Hauptfenster mit.
  ⚠️ Im Pruflauf laeuft beides weiter ueber messagebox - dort haengen die
  Pruefstaende ihre Antworten ein. Sonst warten sie auf einen Klick.
- Reiter: der OFFENE ist jetzt der hellere (Kachelfarbe), die ruhenden liegen
  auf dem dunklen Grund. Vorher war es andersherum.
- config.TESTLAUF ist eine FUNKTION geworden: Pruefstaende importieren config
  oft, bevor sie CAMPINGHOF_TEST setzen - der einmal berechnete Wert stand dann
  den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge, auf die
  niemand klickt. Genau daran hing pruef_api.
- Der Erststart-Dialog hat einen eigenen Schalter (_erststart_zeigen), statt am
  ganzen Pruefschalter zu haengen: ein Pruefstand kann IHN zeigen, ohne dass
  alle anderen Meldungen wieder echte Fenster aufmachen.
- Pruefstand-Fenster stehen nicht mehr in der Taskleiste (-toolwindow): sie
  liegen neben dem Bildschirm, und ein Eintrag, den man anklickt, ohne dass
  etwas erscheint, ist schlimmer als gar keiner.
- pruef_nummernsperre drueckt jetzt den Radioknopf "Meine Zaehlung
  weiterfuehren", statt nur ins Zahlenfeld zu schreiben - das Feld ist ohne
  diesen Klick gesperrt, wie beim Benutzer auch.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:17:13 +02:00

263 lines
13 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")
# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es
# aber nicht: kein Fenster mitten auf dem Bildschirm, kein Dialog, der Maus und
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
#
# ⚠️ Das ist eine FUNKTION, keine Konstante. Ein Pruefstand importiert config
# haeufig, bevor er CAMPINGHOF_TEST setzt - ein einmal berechneter Wert stuende
# dann fuer den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge,
# auf die niemand klickt. Genau daran hing pruef_api eine Runde lang.
def testlauf():
return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
TESTLAUF = testlauf() # Startwert; wer spaeter fragt, nimmt testlauf()
# 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)
# Bild fuer die Fusszeile: ein schmaler Streifen UNTER den Fusstexten. Firma,
# Anschrift, Bank und Steuernummer bleiben immer gezeichnet - sie kommen aus
# den Einstellungen und duerfen nicht in einem Bild verschwinden, das niemand
# nachlesen kann. Leer = die Rechnung sieht aus wie bisher.
"fussbild": "",
}
# 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 *",
}
# Aussehen der Rechnung. EINE Farbe - die helle Flaeche im Tabellenkopf wird im
# Renderer daraus gemischt. Jede Firma faerbt ihr Blatt selbst; ein Handwerker
# will kein Camping-Gruen.
DEFAULT_DESIGN = {
"akzent": "#1f5c3d",
}
DEFAULT_CONFIG = {
"firma": DEFAULT_FIRMA,
"design": DEFAULT_DESIGN,
"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.
# Einmal beim ersten Start gesetzt (er kommt aus einer Excel-Tabelle mit
# laufender Nummer). Danach steht der Zaehler fest - geaendert wird eine
# Rechnung nur noch ueber Storno oder Berichtigung.
"nummer_eingerichtet": False,
"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