Steuerrechnungstool/pruef_doppelte.py
TheMockTv 59d2db75af Storno und Berichtigung kommen an, gemeinsames Nummernbuch, kein Bearbeiten mehr
Storno: das Journal zaehlte die Uebernachtungen der aufgehobenen Rechnung
weiter mit (4 - 0 + 3 = 7 statt 3 gemeldeter Naechte). Die Betraege kuerzten
sich, die Naechte nicht. Der Storno traegt jetzt die negative Zahl, und das
Journal kennt Art, Bezug und Datum des Belegs (vier neue Spalten, alte
Journale bauen sich beim Oeffnen um).

Berichtigung: das Blatt wurde nur uebersprungen. Der berichtigte NAME kommt
jetzt in der Buchung an - im Amtsbericht stand sonst weiter der falsche.
Angewandt wird erst am Ende des Scans, das Blatt kann vor seiner Rechnung im
Ordner liegen.

Doppelte Nummern: eine stornierte Rechnung ist keine offene mehr und faellt aus
der Meldung. Zugeordnet wird ueber Nummer + Datum + Empfaenger, nicht ueber die
Nummer allein - bei einer doppelt vergebenen Nummer waere die kein eindeutiger
Bezug, und genau den verlangt § 31 Abs. 5 UStDV. Die Texte nennen jetzt den
Weg: eine der beiden im Rechnungstool stornieren und mit freier Nummer neu
ausstellen (§ 14 Abs. 4 Nr. 4 UStG - jede Nummer nur einmal).

Neue Spalte "Art": Storno zu ..., storniert, Neuausstellung. Aufgehobene Zeilen
stehen grau - geloescht wird nichts.

Gemeinsames Nummernbuch (gemeinsam.py, ordnerwahl.py - in beiden Programmen
dieselbe Datei): eine SQLite in einem Ordner, den beide kennen. Das Journal
traegt seine Nummern ein, auch die aus der Excel-Mappe ohne PDF - genau die
kannte das Rechnungstool nicht und vergab sie ein zweites Mal. Beim ersten
Start wird nach dem Ordner gefragt (auswaehlen, OK, Sicherheitsfrage, bei Nein
zurueck ins Feld); wer ihn nicht hat, kommt mit "Spaeter einrichten" weiter.

Kein Doppelklick zum Bearbeiten mehr (gui_dialoge.py geloescht): wer hier Betrag
oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg steht.
Loeschen bleibt.

--test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog (Pruefstaende).

Pruefstaende: pruef_storno_journal.py (neu, ueber BEIDE Programme: Storno,
Berichtigung, doppelte Nummer aufloesen), pruef_doppelte, pruef_berichtigung,
pruef_einzelinstanz - alle gruen.

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

194 lines
8 KiB
Python

# -*- coding: utf-8 -*-
"""Pruefstand: zeigt das Steuerjournal doppelt vergebene Rechnungsnummern an?
Seit dem 03.09.2026 ist der Schluessel der Tabelle die PDF-Datei und nicht mehr
die Rechnungsnummer (siehe db._alte_nummern_sperre_loesen). Damit bleiben zwei
Rechnungen mit derselben Nummer beide stehen - was sie stillschweigend tun
wuerden, waere die Uebernachtung doppelt in der Meldung ans Amt. Genau deshalb
muss die Oberflaeche den Fund ZEIGEN.
Die Falle, die ein Test je Monat nicht faengt: dieselbe Nummer in ZWEI Monaten.
Kein Monat sieht fuer sich eine Dublette, das Jahr aber schon - deshalb wird
ueber das ganze Jahr gerechnet.
Geprueft wird am echten Fenster (App + MonatsTab), nicht nur am Rechenkern:
"rot faerben" ist eine Behauptung ueber die Anzeige.
"""
import os
import shutil
import sys
import tempfile
import threading
from datetime import date
HIER = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, HIER)
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)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
# ---- eigener Datenordner, damit das echte Journal unberuehrt bleibt --------
tmp = tempfile.mkdtemp(prefix="bst_doppelt_")
import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
import app as app_mod # noqa: E402 (liest ablage.daten_ordner beim Import)
from modell import Buchung, doppelte_nummern, summiere # noqa: E402
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
# ---- Buchungen: der Fall, den das Amt nicht sehen darf ---------------------
JAHR = 2026
BUCHUNGEN = [
# zwei verschiedene Gaeste unter EINER Nummer, im selben Monat
Buchung(date(JAHR, 8, 1), "020", JAHR, "Erst", 2, 100.0, quelle="manuell"),
Buchung(date(JAHR, 8, 5), "020", JAHR, "Zweit", 4, 200.0, quelle="manuell"),
# dieselbe Nummer, aber ueber die Monatsgrenze hinweg - die eigentliche Falle
Buchung(date(JAHR, 8, 20), "030", JAHR, "August", 1, 50.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "030", JAHR, "September", 3, 150.0, quelle="manuell"),
# und eine saubere Rechnung, die nicht angefasst werden darf
Buchung(date(JAHR, 8, 9), "021", JAHR, "Sauber", 2, 80.0, quelle="manuell"),
]
fenster = app_mod.App()
fenster.withdraw() # kein Fenster ins Gesicht, gerechnet wird trotzdem
for b in BUCHUNGEN:
fenster.journal.speichern(b)
fenster.jahr.set(JAHR)
fenster.aktualisieren()
fenster.update_idletasks()
alle = fenster.journal.buchungen(jahr=JAHR)
pruefe(len(alle) == len(BUCHUNGEN), f"alle {len(BUCHUNGEN)} Buchungen stehen im Journal ({len(alle)})")
# ---- 1. Rechenkern ---------------------------------------------------------
d = doppelte_nummern(alle)
pruefe(set(d) == {"2026-020", "2026-030"}, f"genau die zwei Dubletten gefunden ({sorted(d)})")
pruefe(all(len(v) == 2 for v in d.values()), "je zwei Belege pro Nummer")
pruefe("2026-021" not in d, "die saubere Rechnung wird nicht gemeldet")
pruefe(not doppelte_nummern([b for b in alle if b.rechnungsnummer == "021"]),
"ohne Dubletten bleibt die Meldung leer")
# ---- 2. Die Betraege bleiben vollstaendig ---------------------------------
s = summiere(alle)
pruefe(abs(s.gezahlt - 580.0) < 0.005, f"Jahressumme unveraendert 580,00 ({s.gezahlt})")
pruefe(abs(s.steuer - 29.0) < 0.005, f"Steuer unveraendert 29,00 ({s.steuer})")
def zeilen(monat):
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags).
Die Spalten kommen aus gui_monat.SPALTEN - nicht abgezaehlt, sonst
verrutscht der Pruefstand still, sobald eine Spalte dazukommt (genau das
ist beim Einbau der Spalte "Art" passiert).
"""
from gui_monat import SPALTEN
namen = [sp[0] for sp in SPALTEN]
i_name, i_nr = namen.index("nachname"), namen.index("nummer")
baum = fenster.monate[monat].baum
aus = []
for iid in baum.get_children():
w = baum.item(iid, "values")
aus.append((w[i_name], w[i_nr], tuple(baum.item(iid, "tags"))))
return aus
# ---- 3. Anzeige: rot + Warnzeichen ----------------------------------------
aug = zeilen(8)
pruefe(len(aug) == 4, f"August zeigt vier Zeilen ({len(aug)})")
rot_aug = {name for name, _nr, tags in aug if "doppelt" in tags}
pruefe(rot_aug == {"Erst", "Zweit", "August"}, f"im August sind die richtigen rot ({sorted(rot_aug)})")
pruefe(all(nr.startswith("\u26a0") for name, nr, tags in aug if "doppelt" in tags),
"die roten Zeilen tragen das Warnzeichen vor der Nummer")
sauber = [z for z in aug if z[0] == "Sauber"][0]
pruefe("doppelt" not in sauber[2] and not sauber[1].startswith("\u26a0"),
f"die saubere Rechnung bleibt schwarz und ohne Zeichen ({sauber})")
# ---- 4. Die Falle: dieselbe Nummer in zwei Monaten ------------------------
sep = zeilen(9)
pruefe(len(sep) == 1 and "doppelt" in sep[0][2],
f"September markiert seine Haelfte der Dublette ({sep})")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(titel[7].startswith("\u26a0") and titel[8].startswith("\u26a0"),
f"beide Reiter sind markiert ({titel[7]!r}, {titel[8]!r})")
pruefe(not any(t.startswith("\u26a0") for i, t in enumerate(titel) if i not in (7, 8)),
f"kein anderer Reiter ist markiert ({[t for t in titel if t.startswith(chr(0x26a0))]})")
# ---- 5. Die Warnleiste --------------------------------------------------
pruefe(bool(fenster.doppelt_leiste.winfo_manager()), "die Warnleiste ist eingeblendet")
text = fenster.warnung.cget("text")
pruefe("2026-020" in text and "2026-030" in text, f"sie nennt beide Nummern ({text[:70]}…)")
pruefe("Erst" in text and "Zweit" in text, "und die betroffenen Gaeste")
# ---- 6. Die Suche darf die Dublette nicht verstecken ---------------------
fenster.suche.set("Zweit")
fenster.update_idletasks()
gefiltert = zeilen(8)
pruefe(len(gefiltert) == 1 and "doppelt" in gefiltert[0][2],
f"auch im Suchtreffer bleibt die Zeile rot ({gefiltert})")
fenster.suche.set("")
fenster.update_idletasks()
# ---- 7. Das Fenster mit der Liste ---------------------------------------
vorher = set(fenster.winfo_children())
fenster.doppelte_zeigen()
fenster.update_idletasks()
neu = [w for w in fenster.winfo_children() if w not in vorher]
pruefe(len(neu) == 1, f"die Liste geht auf ({len(neu)} Fenster)")
if neu:
inhalt = ""
def sammeln(w):
global inhalt
import tkinter as tk
if isinstance(w, tk.Text):
inhalt += w.get("1.0", "end")
for k in w.winfo_children():
sammeln(k)
sammeln(neu[0])
pruefe("2026-020" in inhalt and "2026-030" in inhalt, "sie listet beide Nummern")
pruefe(all(n in inhalt for n in ("Erst", "Zweit", "August", "September")),
"mit allen vier Belegen")
pruefe("Sauber" not in inhalt, "und nicht die saubere Rechnung")
neu[0].destroy()
# ---- 8. Dublette aufloesen -> die Warnung muss verschwinden -------------
weg = [b for b in alle if b.nachname == "Zweit"][0]
fenster.journal.loeschen(weg.id)
weg2 = [b for b in alle if b.nachname == "September"][0]
fenster.journal.loeschen(weg2.id)
fenster.aktualisieren()
fenster.update_idletasks()
pruefe(not fenster._doppelte, f"keine Dubletten mehr ({sorted(fenster._doppelte)})")
pruefe(not fenster.doppelt_leiste.winfo_manager(), "die Warnleiste ist wieder weg")
pruefe(not any("doppelt" in t for _n, _nr, t in zeilen(8)), "keine rote Zeile mehr im August")
titel = [fenster.blaetter.tab(m - 1, "text") for m in range(1, 13)]
pruefe(not any(t.startswith("\u26a0") for t in titel), "und kein markierter Reiter mehr")
fenster.journal.schliessen()
fenster.destroy()
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Testordner bleibt zum Nachsehen: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)