Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer sich pruefbar - nach jeder liefen beide Pruefketten gruen. * Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks. Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm() begrenzt; am unteren Fensterrand lief sie vorher hinaus. Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse. * %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG). * pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py. Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug. * dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31) und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. * pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt == 1, und der Dateiname wird mitgeprueft. Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF. * .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen. Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen Unterschied, den es im Repository gar nicht gibt. * Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die 24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung waere Rauschen. * Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=), kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes". 39 Pruefstaende, 0 rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
298 lines
15 KiB
Python
298 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Konfiguration fuer das Rechnungstool (powered by ravokk).
|
||
|
||
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. der Ordner der Firma: %LOCALAPPDATA%/ravokk
|
||
2. geht das nicht, neben der EXE ein Unterordner "Daten"
|
||
3. und zuletzt der alte eigene Ordner unter LOCALAPPDATA
|
||
|
||
Warum unter "ravokk" (07.09.2026): dort liegen beide Programme und das
|
||
gemeinsame Nummernbuch beieinander - seine Ansage, *"ravokk ist meine
|
||
firma und die programme gehoeren zu der firma"*. Und weil die Daten damit
|
||
NEBEN dem Programmordner liegen statt darin, raeumt ein Deinstallieren nur
|
||
das Programm weg und laesst die Belege stehen (§ 147 AO).
|
||
|
||
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
|
||
import gemeinsam
|
||
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||
# NEBEN dem Programm, nicht darin: das Programm liegt in
|
||
# ravokk/Rechnungstool, die Daten eine Ebene darueber in ravokk. Genau so
|
||
# hat er es beschrieben - "da liegt config und sqlite drin, und dann gibt
|
||
# es den ordner rechnungstool und steuertool". Nur dadurch kann ein
|
||
# Deinstallieren den Programmordner restlos leeren, ohne die Belege
|
||
# anzufassen (§ 147 AO).
|
||
# Gerechnet wird der Pfad nur an EINER Stelle - siehe dort.
|
||
kandidat = gemeinsam.standard_ordner()
|
||
if not _beschreibbar(kandidat):
|
||
kandidat = os.path.join(EXE_DIR, "Daten")
|
||
if not _beschreibbar(kandidat):
|
||
kandidat = os.path.join(heim, "Rechnungstool")
|
||
if not _beschreibbar(kandidat):
|
||
return EXE_DIR # letzter Ausweg: wie frueher
|
||
# Was an einem frueheren Ort liegt, zieht mit um - sonst faengt das
|
||
# Programm ohne Firmendaten, Katalog und Zaehler neu an.
|
||
for name in ("config.json",):
|
||
neu_ = os.path.join(kandidat, name)
|
||
for alt in (os.path.join(EXE_DIR, "Daten", name),
|
||
os.path.join(EXE_DIR, name),
|
||
os.path.join(heim, "Rechnungstool", name)):
|
||
if os.path.exists(alt) and not os.path.exists(neu_):
|
||
try:
|
||
os.replace(alt, neu_)
|
||
print(f"[config] {name} aus {alt} übernommen")
|
||
break
|
||
except OSError as e:
|
||
# Nicht abbrechen - der naechste Ort wird noch probiert,
|
||
# und zur Not faengt das Programm eben neu an. Aber sagen,
|
||
# dass es schiefging: der Erfolgsfall schreibt eine Zeile,
|
||
# und wer sie sucht und nicht findet, wuesste sonst nicht,
|
||
# ob nichts da war oder ob die Uebernahme misslang. Es geht
|
||
# um Firmendaten, Leistungskatalog und Rechnungszaehler.
|
||
print(f"[config] {name} aus {alt} NICHT übernommen: {e}")
|
||
return kandidat
|
||
|
||
|
||
# Das Programm heisst Rechnungstool und kommt von ravokk. Der Firmenname des
|
||
# KUNDEN gehoert nicht in den Fenstertitel - das Werkzeug steht bei jedem
|
||
# Betrieb, der es einsetzt, und traegt seinen eigenen Namen.
|
||
PROGRAMM = "Rechnungstool"
|
||
MARKE = "powered by ravokk"
|
||
|
||
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
|