rechnungstool/config.py
TheMockTv 94c2c7881a Mini-API zwischen beiden Programmen, Proforma-Rechnung
MINI-API (seine Ansage: "die muessen ja sehen das die beiden auf sind"):
Jedes Programm macht beim Start einen winzigen Server auf 127.0.0.1 mit einem
Port, den das Betriebssystem aussucht, und traegt Port und PID in die
gemeinsame SQLite ein - die ist das Telefonbuch. Zwei Nachrichten reichen:
"ping" wird mit "hallo, bin noch da" beantwortet, "schau_nach" ist der Push.

Der Push traegt KEINE Daten. Er sagt nur "schau in die sqlite" - dort steht die
Meldung ohnehin. Eine Wahrheit, und wenn der Anruf nicht durchgeht, findet das
andere Programm die Meldung beim naechsten Nachsehen.

Gebunden wird nur auf Loopback: von aussen ist nichts erreichbar, Windows fragt
bei 127.0.0.1 nicht nach der Firewall, und die API fuehrt keine Befehle aus.
Kein Netzwerk-Server mit MySQL - dafuer braeuchte er Infrastruktur, die es
nicht gibt; auf verschiedenen Rechnern waere ohnehin ein anderer Bau noetig.

PROFORMA-RECHNUNG: ein Knopf, gewaehlt wird im Popup - die fruehere
Sicherheitsfrage IST diese Auswahl geworden (eine Frage statt zwei).

Seine Bedingung: "musst nur auf passen das der rechner dann die rechnungs
nummer nicht aendert". Die Proforma laeuft in einer EIGENEN Reihe (2026-P001);
Nummernfeld, Rechnungszaehler, Nummernbuch und die Meldung ans Journal bleiben
unberuehrt. Genau das prueft pruef_proforma.py zuerst.

An den Quellen geprueft, nicht an Ratgeberseiten:
* § 14 Abs. 1 S. 1 UStG / UStAE 14.1 - Rechnung ist jedes Dokument, mit dem
  abgerechnet wird, "gleichgueltig, wie dieses Dokument im Geschaeftsverkehr
  bezeichnet wird". Das Wort "Proforma" allein schuetzt also nicht.
* § 14c Abs. 2 UStG - wer Steuer gesondert ausweist, ohne berechtigt zu sein,
  schuldet den Betrag; Korrektur nur mit Antrag und Zustimmung des Finanzamts.
* § 14 Abs. 4 Nr. 4 UStG - die fortlaufende, einmalig vergebene Nummer gilt
  fuer Rechnungen.

Deshalb: Ueberschrift "Proforma-Rechnung", der Satz "keine Rechnung im Sinne
des § 14 UStG ... nicht zu bezahlen", "Proforma an:" statt "Rechnung an:", die
Nummer ausdruecklich als KEINE Rechnungsnummer, KEIN Steuersatz und KEIN
Steuerbetrag (auch die Beherbergungssteuer nicht - sonst waere das Blatt halb
ausgewiesen, halb nicht), dafuer "Voraussichtlicher Gesamtbetrag" mit dem
Hinweis, dass alles enthalten ist. Kein Blatt fuers Amt, kein Zahlungstext.

TESTLAUF: _testwahl sagt, was im Auswahlfenster geklickt worden waere;
pruef_proforma setzt _dialog_zeigen und drueckt die echten Knoepfe.

Pruefstaende: pruef_proforma.py (neu, mit dem echten Popup), pruef_montage,
pruef_storno, pruef_kette, pruef_kernregeln, pruef_nummernbuch, pruef_nummern,
pruef_dialoge - alle gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:22:48 +02:00

232 lines
11 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")
# 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,
# Die Proforma hat eine EIGENE Reihe (2026-P001): sie darf keine
# Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung.
"proforma_jahr": 0,
"proforma_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