Seine Ansage: 'er sollte es aber, wenn er neu startet, weil er ja eine Excel
hat, im Popup einmal setzen - das waere fair. Aber ab da ist die fest, nicht
aenderbar ausser ueber Storno und Berichtigen.'
Beim ersten Start fragt das Programm einmal, wo die Nummer weiterlaufen soll.
Vorgeschlagen wird, was Ordner und Nummernbuch hergeben; niedriger nimmt es
nicht an, und vor dem Uebernehmen wird noch einmal gefragt. Danach steht
nummer_eingerichtet in der config, und die Einstellungen zeigen den Zaehler nur
noch an - mit dem Verweis auf Storno und Berichtigung (§ 14 Abs. 4 Nr. 4 UStG,
§ 146 Abs. 4 AO).
Der Jahreswechsel bleibt automatisch: im neuen Jahr faengt es wieder bei 001 an,
und die Rechnungen des Vorjahres heben nichts an. Auch das steht jetzt im
Pruefstand ('beim jahreswechsel sollte die nach dem - zeichen auf 000 gehen,
weil neues jahr').
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
236 lines
11 KiB
Python
236 lines
11 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")
|
||
|
||
# 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.
|
||
# 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
|