Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet - bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht, rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen _AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg. Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key: 32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt (compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen. Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden. pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete in wait_window() ewig. Das war schon vor diesem Umbau so. Neu: pruef_api.py. Alle 14 Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
256 lines
12 KiB
Python
256 lines
12 KiB
Python
# -*- 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 auf dem Bildschirm, kein Dialog, der Maus und
|
||
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
|
||
# Das Flag steht HIER, damit app.py, theme.py und die Pruefstaende dasselbe
|
||
# lesen - zwei Stellen wuerden auseinanderlaufen.
|
||
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
|
||
|
||
# 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
|