rechnungstool/config.py
TheMockTv 9794632ae4 Alles unter ravokk - und ein Deinstallieren laesst nichts liegen
Seine Ansagen
  "gibt es noch das popup install ordner auswaehlen, das stimmt ja nicht"
  "wie soll das rechnungstool und steuertool wissen wo es ist, das muss in
   ravokk liegen und dann zusammen, sonst kommt das eine nicht an das andere
   ran - die sind doch nur ueber die gemeinsame sqlite verbunden und mit api"
  "es gibt ravokk als main verzeichnis, da liegt config und sqlite drin, und
   dann gibt es den ordner rechnungstool und steuertool"
  "ravokk ist meine firma und die programme gehoeren zu der firma"
  "du solltest die auch mal deinstallieren und neu installieren ... dass der
   ordner dann leer ist und sauber - will keine tools die unsauber sind"

Die Struktur
  %LOCALAPPDATA%/ravokk/
      config.json, journal.sqlite3, Nummernbuch, berichte/   <- Daten
      Rechnungstool/                                          <- Programm
      Steuerrechnungstool/                                    <- Programm

  Die Daten liegen NEBEN dem Programm, nicht darin. Nur dadurch koennen
  beide Forderungen gleichzeitig gelten: der Programmordner wird beim
  Deinstallieren restlos geleert, und die Belege bleiben zehn Jahre stehen
  (§ 147 AO).

Kein Ordner-Dialog mehr beim ersten Start
  gemeinsam.standard_ordner() nennt den festen Ort, beide Programme legen
  ihn selbst an. Gefragt wird nur noch, wer ihn ausdruecklich verschieben
  will (Menue -> Gemeinsamer Ordner) - etwa auf ein Netzlaufwerk.

  Der Knopf hiess "Install-Ordner auswaehlen..." und der Text riet zum
  "Ordner, in dem die Programme liegen". Beides war falsch und seit dem
  Installer erst recht: die beiden liegen in getrennten Unterordnern, und
  ein Deinstallieren haette das Nummernbuch mitgenommen.

⚠ Dabei gefunden: das Journal lag im TEMP-Ordner
  Der Merkzettel der installierten Fassung zeigte auf
  %TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
  03.09. Windows raeumt den Temp-Ordner automatisch auf; das Journal waere
  ohne Vorwarnung verschwunden. ablage.py verwirft einen solchen Merkzettel
  jetzt, statt ihm zu folgen.

Uninstaller
  Er raeumt jetzt auch, was der UPDATER angelegt hat - jede geladene Fassung
  und installiert.json. Die kannte der Installer nicht; mit jeder Fassung
  waere eine EXE mehr liegen geblieben. Gemessen vorher: zwei EXEs und der
  halbe Datenordner.

pruef_installation.py
  Installiert wirklich, legt eine Datei an wie der Updater es taete,
  deinstalliert und sieht nach: Programmordner leer, Daten unangetastet.
  Vorhandene Daten werden vorher gesichert und danach zurueckgelegt - ein
  Pruefstand fasst niemandem seine Buchhaltung an.

pruef_gemeinsam_automatisch.py
  Prueft, dass beide Programme denselben Ordner nennen und dass findet, was
  das andere eintraegt - ohne dass jemand einen Pfad eingegeben hat.

Symbol
  Das Windows-Symbol ist jetzt eine helle Kachel mit runden Ecken und dem
  Zeichen darauf, in allen Groessen von 16 bis 256 - "so wie das rustdesk,
  nur halt alle ecken in der rundung". Ein freigestelltes Logo ging auf
  einem dunklen Desktop unter. Die zweite, aufgehellte ICO ist damit
  ueberfluessig; das aufgehellte Logo IM Programm (Kopfzeile, Wasserzeichen)
  bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:43:09 +02:00

290 lines
14 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 (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
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).
kandidat = os.path.join(heim, "ravokk")
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:
pass
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