Sein Einwand: 'er kann jetzt bescheissen, indem er das in den einstellungen aendert' - und sein Vorschlag: 'dann koennte man noch die pdfs nutzen als referenz, die haben auch die nummern'. Der hoechste je vergebene Stand kommt jetzt aus ZWEI Quellen: * den PDFs im Rechnungsordner (der Dateiname traegt die Nummer - kein Oeffnen noetig) * dem gemeinsamen Nummernbuch (kennt auch Nummern OHNE PDF, den Altbestand) Der Zaehler in der config taugt nicht als Wahrheit, er ist ja gerade das, was sich aendern laesst. Deshalb: beim Nummernvorschlag wird ein zurueckgedrehter Zaehler stillschweigend wieder angehoben, und die Einstellungen nehmen keinen Wert mehr an, der hinter dem Vergebenen liegt - mit Meldung, welcher Stand schon erreicht ist und woher er kommt. Nach VORN bleibt er frei: eine lueckenlose Folge verlangt das Gesetz nicht, jede Nummer aber nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Pruefstand pruef_nummernsperre.py: 17 Pruefungen - zurueckgedrehter Zaehler, Einstellungen vor und zurueck, nur Ordner, nur Buch, und beides weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
249 lines
9.8 KiB
Python
249 lines
9.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pruefstand: das unverbindliche Angebot.
|
|
|
|
Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Angebot - *"musst
|
|
nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst
|
|
muessen wir wieder die nummern aenderbar machen und dann macht er wieder
|
|
fehler"*.
|
|
|
|
Genau das ist hier die Hauptpruefung: das Angebot laeuft in einer EIGENEN
|
|
Reihe (2026-A001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt.
|
|
|
|
Dazu die Rechtslage (nachgeschlagen am 03.09.2026):
|
|
* keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach
|
|
§ 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen
|
|
* kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer
|
|
ausweist, schuldet sie)
|
|
* deutlich kennzeichnen, sonst kann das Finanzamt das Blatt als echte Rechnung
|
|
werten (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung ist gleichgueltig). "Angebot"
|
|
ist im Camping- und Hotelgeschaeft der uebliche Begriff, "Kostenanschlag"
|
|
gehoert ins Werkvertragsrecht (§ 632 Abs. 3, § 649 BGB).
|
|
|
|
Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
QUELLE = r"C:\claude\rechnungstool"
|
|
JOURNAL = r"C:\claude\beherbergungssteuer"
|
|
sys.path.insert(0, QUELLE)
|
|
|
|
fehler = []
|
|
|
|
|
|
def pruefe(bedingung, text):
|
|
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
|
kodierung = sys.stdout.encoding or "utf-8"
|
|
print(zeile.encode(kodierung, "replace").decode(kodierung))
|
|
if not bedingung:
|
|
fehler.append(text)
|
|
|
|
|
|
def _reissleine():
|
|
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
|
os._exit(2)
|
|
|
|
|
|
_wecker = threading.Timer(150, _reissleine)
|
|
_wecker.daemon = True
|
|
_wecker.start()
|
|
|
|
os.environ["CAMPINGHOF_TEST"] = "1"
|
|
tmp = tempfile.mkdtemp(prefix="angebot_pruef_")
|
|
ordner = os.path.join(tmp, "rechnungen")
|
|
os.makedirs(ordner)
|
|
|
|
import config # noqa: E402
|
|
|
|
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
|
|
config.CONFIG_PATH = os.path.join(tmp, "config.json")
|
|
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
|
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
|
|
roh["output_dir"] = ordner
|
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
|
|
import app as A # noqa: E402
|
|
import bestand as BE # noqa: E402
|
|
from tkinter import messagebox # noqa: E402
|
|
|
|
antworten = {"Fertig": False, "Hausnummer": True}
|
|
|
|
|
|
def askyesno(titel, text, **kw):
|
|
for schlue, wert in antworten.items():
|
|
if schlue in titel:
|
|
return wert
|
|
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
|
|
|
|
|
messagebox.askyesno = askyesno
|
|
messagebox.showinfo = lambda *a, **k: None
|
|
warnungen = []
|
|
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
|
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
|
|
|
|
f = A.RechnungsApp()
|
|
f.cfg["output_dir"] = ordner
|
|
# Dieser Pruefstand will das ECHTE Popup sehen und seine Knoepfe druecken.
|
|
f._dialog_zeigen = True
|
|
|
|
|
|
def knoepfe_von(fenster):
|
|
"""Alle Knopf-Widgets eines Fensters, egal wie tief."""
|
|
aus = []
|
|
|
|
def gehe(w):
|
|
for k in w.winfo_children():
|
|
if k.winfo_class() in ("TButton", "Button"):
|
|
aus.append(k)
|
|
gehe(k)
|
|
|
|
gehe(fenster)
|
|
return aus
|
|
|
|
|
|
def klicke_im_popup(teil):
|
|
"""Wartet auf das Popup und drueckt den Knopf, dessen Text `teil` enthaelt."""
|
|
def versuch(rest=40):
|
|
tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
|
if tops:
|
|
for k in knoepfe_von(tops[-1]):
|
|
if teil.lower() in str(k.cget("text")).lower():
|
|
k.invoke()
|
|
return
|
|
if rest:
|
|
f.after(100, lambda: versuch(rest - 1))
|
|
f.after(200, versuch)
|
|
|
|
|
|
def kunde(vorname, nachname):
|
|
f.k_vorname.set(vorname)
|
|
f.k_name.set(nachname)
|
|
f.k_strasse.set("Musterweg 1")
|
|
f.k_plzort.set("12345 Musterstadt")
|
|
f.r_anreise.set("01.08.2026")
|
|
f.r_abreise.set("04.08.2026")
|
|
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("3")
|
|
f.update()
|
|
|
|
|
|
# ---- 1. Der eine Knopf und das Popup --------------------------------------
|
|
knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)]
|
|
pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})")
|
|
pruefe(not any("Angebot" in t for t in knopf_texte),
|
|
"und kein zweiter Knopf fuers Angebot - gewaehlt wird im Popup")
|
|
|
|
kunde("Anna", "Vorab")
|
|
nummer_vorher = f.r_nummer.get()
|
|
zaehler_vorher = f.cfg.get("rechnung_zaehler")
|
|
pruefe(nummer_vorher == "2026-001", f"vorgeschlagen ist 2026-001 ({nummer_vorher})")
|
|
|
|
# ---- 2. Abbrechen schreibt nichts ----------------------------------------
|
|
klicke_im_popup("Abbrechen")
|
|
f.pdf_erstellen()
|
|
pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})")
|
|
|
|
# ---- 3. Angebot waehlen --------------------------------------------------
|
|
klicke_im_popup("Angebot")
|
|
f.pdf_erstellen()
|
|
pruefe(sorted(os.listdir(ordner)) == ["Angebote"],
|
|
f"im Rechnungsordner liegt nur der Unterordner ({sorted(os.listdir(ordner))})")
|
|
angebote = os.path.join(ordner, "Angebote")
|
|
dateien = sorted(os.listdir(angebote))
|
|
pruefe(dateien == ["Angebot_2026-A001_Vorab.pdf"],
|
|
f"das Angebot liegt im Unterordner Angebote ({dateien})")
|
|
|
|
# ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt --------------
|
|
pruefe(f.r_nummer.get() == nummer_vorher,
|
|
f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})")
|
|
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher,
|
|
f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})")
|
|
pruefe(f.cfg.get("angebot_zaehler") == 1,
|
|
f"nur der Angebots-Zaehler ist weitergelaufen ({f.cfg.get('angebot_zaehler')})")
|
|
|
|
# ---- 5. Was auf dem Blatt steht -----------------------------------------
|
|
from pypdf import PdfReader # noqa: E402
|
|
|
|
pfad = os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")
|
|
seiten = PdfReader(pfad).pages
|
|
pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})")
|
|
text = (seiten[0].extract_text() or "").replace("\n", " ")
|
|
pruefe("Unverbindliches Angebot" in text, "es steht deutlich Unverbindliches Angebot drauf")
|
|
pruefe("Rechnung" not in text.split("Unverbindliches Angebot")[0],
|
|
"und das Wort Rechnung steht nicht in der Ueberschrift")
|
|
pruefe("keine Rechnung im Sinne des § 14 UStG" in text,
|
|
"mit dem Satz, dass es keine Rechnung nach § 14 UStG ist")
|
|
pruefe("Angebotsnummer" in text, "die Nummer heisst Angebotsnummer")
|
|
pruefe("Rechnungsnummer" not in text,
|
|
"das Wort Rechnungsnummer kommt gar nicht mehr vor")
|
|
pruefe("2026-A001" in text and "2026-001" not in text,
|
|
"nur die Angebots-Nummer steht drauf, keine Rechnungsnummer")
|
|
pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text,
|
|
"kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)")
|
|
pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text,
|
|
"auch die Beherbergungssteuer steht nicht drauf - gar keine Steuer wird ausgewiesen")
|
|
pruefe("alle Steuern und Abgaben enthalten" in text,
|
|
"dafuer der Hinweis, dass alles im Betrag enthalten ist")
|
|
pruefe("Voraussichtlicher Gesamtbetrag" in text,
|
|
"der Betrag heisst voraussichtlich, nicht Rechnungsbetrag")
|
|
|
|
meta = BE.kenndaten_lesen(pfad) or {}
|
|
pruefe(meta.get("art") == "angebot", f"art = angebot ({meta.get('art')})")
|
|
pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0,
|
|
f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})")
|
|
|
|
# ---- 6. Danach die echte Rechnung: sie bekommt 2026-001 ----------------
|
|
antworten["Storno"] = True
|
|
klicke_im_popup("Rechnung 2026-001 erstellen")
|
|
f.pdf_erstellen()
|
|
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
|
|
f"die Rechnung liegt bei den Rechnungen ({sorted(os.listdir(ordner))})")
|
|
pruefe(f.cfg.get("rechnung_zaehler") == 1,
|
|
f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})")
|
|
|
|
# ---- 7. Das zweite Angebot zaehlt in seiner Reihe weiter ---------------
|
|
f.reset_formular()
|
|
kunde("Bernd", "Zweitvorab")
|
|
klicke_im_popup("Angebot")
|
|
f.pdf_erstellen()
|
|
pruefe(os.path.exists(os.path.join(angebote, "Angebot_2026-A002_Zweitvorab.pdf")),
|
|
f"das zweite Angebot heisst A002 ({sorted(os.listdir(angebote))})")
|
|
f.destroy()
|
|
|
|
# ---- 8. Das Steuerjournal bucht das Angebot NICHT ---------------------
|
|
sys.path.append(JOURNAL)
|
|
import db as BST # noqa: E402
|
|
|
|
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
|
journal.scanne(ordner, 5.0, voll=True)
|
|
buchungen = journal.buchungen(jahr=2026)
|
|
pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})")
|
|
pruefe(buchungen and buchungen[0].voll_nummer == "2026-001",
|
|
f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})")
|
|
pruefe(not any("A0" in b.voll_nummer for b in buchungen),
|
|
"keine Angebots-Nummer im Journal")
|
|
|
|
# ---- 9. Zweite Sicherung: auch ohne Kenndaten wird nichts gebucht -----
|
|
from pdf_parser import keine_buchung # noqa: E402
|
|
|
|
nur_name = os.path.join(ordner, "Angebot_2026-A099_OhneKenndaten.pdf")
|
|
shutil.copy(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf"), nur_name)
|
|
pruefe(keine_buchung(nur_name),
|
|
"ein Angebot wird auch am Dateinamen erkannt (falls die Kenndaten fehlen)")
|
|
pruefe(keine_buchung(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")),
|
|
"und alles im Unterordner Angebote wird uebersprungen")
|
|
pruefe(not keine_buchung(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
|
|
"eine echte Rechnung wird davon NICHT erfasst")
|
|
journal.schliessen()
|
|
|
|
if not fehler:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
else:
|
|
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
|
print("---")
|
|
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
|
sys.exit(1 if fehler else 0)
|