Compare commits

...

23 commits
v1.6.6 ... main

Author SHA1 Message Date
TheMockTv
f7c5a1618d Fassung 1.9.2
Aufraeumrunde nach der Pruefkette. Fuer den Nutzer sichtbar ist ein Punkt:
der Wechsel von einem Menuetitel zum anderen braucht nur noch EINEN Klick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:41:34 +02:00
TheMockTv
96671027c8 Aufraeumen nach der Pruefkette: doppelter Code an eine Stelle
Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe
Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer
sich pruefbar - nach jeder liefen beide Pruefketten gruen.

* Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks.
  Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung
  des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt
  hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine
  Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird
  ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm()
  begrenzt; am unteren Fensterrand lief sie vorher hinaus.
  Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse.

* %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt
  fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren
  die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn
  eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG).

* pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py.
  Die zwei Ausreisser sind mitgezogen: pruef_storno_verrechnet.py zaehlte in
  einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug, pruef_bilder.py
  im Rechnungstool schrieb ein eigenes Format.

* dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden
  app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen
  denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es
  eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31)
  und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.

* pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft
  ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt
  == 1, und der Dateiname wird mitgeprueft.
  Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden
  Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die
  Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF.

* .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen.
  Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen
  Unterschied, den es im Repository gar nicht gibt.

* Die stummen "except OSError" in api.py, updater.py und einzelinstanz.py
  sagen jetzt, warum sie schweigen duerfen. Die stummen tk.TclError bleiben:
  dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung
  waere Rauschen.

* Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=),
  kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von
  theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes".

39 Pruefstaende, 0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:39:03 +02:00
TheMockTv
039a23478f Zwei echte Fehler aus der Pruefkette behoben
## 1. Der Temp-Schutz war geschrieben, aber nicht angeschlossen

_ist_temp() stand seit heute Vormittag in ablage.py, ausfuehrlich begruendet
("Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen") - und wurde von
NIEMANDEM gerufen. Der Schutz war ein Kommentar, keine Massnahme.

Zwei Pruefagenten haben das unabhaengig voneinander gefunden, einer mit
Messung: das Journal landete weiterhin im Temp-Ordner. Windows raeumt den von
selbst auf; es geht um aufbewahrungspflichtige Aufzeichnungen (§ 147 AO).

⚠️ Das ist die schlimmste Sorte Fehler - ein Kommentar, der eine
Schutzmassnahme zusichert, die es nicht gibt. Wer die Datei liest, haelt die
Falle fuer geschlossen. Und ich hatte sie dem Auftraggeber als behoben
gemeldet.

Behoben: _gemerkter_ordner() prueft jetzt wirklich.
pruef_temp_merkzettel.py sichert es ab - er prueft nicht, ob die FUNKTION
existiert, sondern ob sie WIRKT. Dazu die Gegenprobe, dass ein GUELTIGER
Merkzettel weiter befolgt wird: sonst haette der Schutz jemandem sein
Netzlaufwerk weggenommen.

⚠️ Im ersten Anlauf war dieser Pruefstand rot - weil ich seine Spielwiese mit
tempfile.mkdtemp() angelegt hatte und die selbst im Temp liegt. Wer das
Verhalten IM Temp-Ordner prueft, darf seine Spielwiese nicht dort aufbauen.

## 2. Der Darstellungsdialog zerstoerte sich beim ersten Klick selbst

Gleiche Ursache und gleiche Behebung wie im Rechnungstool: _oberflaeche_neu()
loeschte alle Kinder des Hauptfensters, und ein tk.Toplevel ist eines.
pruef_darstellung drueckt jetzt den Radioknopf statt cfg von Hand zu setzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:10:41 +02:00
TheMockTv
dcc556217b pruef_installation hinterlaesst den Zustand, den er vorfindet
War das Programm vorher installiert, ist es danach wieder installiert. Beim
ersten Lauf war es das NICHT - danach stand ein deinstalliertes Programm da,
und der naechste Schritt (den Updater starten lassen) lief ins Leere.

Ein Pruefstand, der aufraeumt, muss auch sich selbst aufraeumen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:57:35 +02:00
TheMockTv
8cdd106a08 Windows-Symbol in den Programmfarben
Sein Befund: "hast du bei dem windows icon das gleich farbige wie im
programm, wo als wasserzeichen gilt, gemacht - weil das schaut blau aus".

Er hatte recht: auf der Kachel stand das ROHE Logo mit seinem Petrol
#004860, waehrend im Programm (Kopfzeile, Wasserzeichen) laengst die
umgefaerbte Fassung laeuft. Zwei Farben fuer dasselbe Zeichen.

Jetzt geht das Logo auch fuers Symbol durch _umfaerben() - dieselbe
Funktion, dieselben Farben: Tuerkis fuer die Wolke, Dunkelgrau fuer das
Zeichen daneben. Die Kachel ist weiss, also gilt die HELLE Fassung.

Fassung 1.9.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:52:02 +02:00
TheMockTv
1562388e78 Alles unter ravokk - und ein Deinstallieren laesst nichts liegen
Gleiche Umstellung wie im Rechnungstool, gleiche gemeinsame Dateien
(gemeinsam.py, ordnerwahl.py, wasserzeichen.py, bauen.py).

  %LOCALAPPDATA%/ravokk/
      journal.sqlite3, berichte/, Nummernbuch, config.json   <- Daten
      Rechnungstool/                                          <- Programm
      Steuerrechnungstool/                                    <- Programm

⚠ Hier gefunden und behoben: das Journal der INSTALLIERTEN Fassung lag im
  Temp-Ordner. Der Merkzettel (ablage.py) zeigte auf
  %TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
  03.09.2026. Windows raeumt den Temp-Ordner von selbst auf; das Journal
  waere ohne Vorwarnung verschwunden. Ein Merkzettel, der dorthin zeigt,
  wird jetzt verworfen statt befolgt.

  Sein Echtbestand (238 Buchungen) lag davon unberuehrt in der Quelle und
  ist gesichert.

Kein Ordner-Dialog mehr beim ersten Start - der gemeinsame Ordner steht
fest. Der Knopf hiess dort "Install-Ordner auswaehlen...", was schon immer
falsch war: es geht um das gemeinsame Nummernbuch, nicht um den
Installationsort.

Uninstaller raeumt jetzt auch die vom Updater geladenen Fassungen weg.
pruef_installation.py prueft beides zusammen: Programmordner leer, Daten
unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:43:24 +02:00
TheMockTv
319c680a62 Logo in den Programmfarben, einen Tick groesser
Gleiche Umstellung wie im Rechnungstool (gemeinsame wasserzeichen.py): das
Blaugruen wird Tuerkis (#06C6A4), die zweite Haelfte weiss im Dunkeln und
dunkelgrau im Hellen, unterschieden nach Saettigung.

"beim steuertool koennte es ein tick groesser sein" - 28 -> 34 Pixel, wie im
Rechnungstool. Die Stelle bleibt rechts in der Kopfleiste: links stehen die
Bedienelemente (Jahr, Einlesen, Bericht), ein Logo davor wuerde sie alle nach
rechts schieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 01:02:04 +02:00
TheMockTv
755c844ee4 Popups gehen mittig ueber dem Programmfenster auf
Gleiche Aenderung wie im Rechnungstool, gleiche theme.py: theme.mittig()
setzt jeden Dialog mittig ueber sein Elternfenster, theme.auf_bildschirm()
begrenzt auf den gesamten Desktop ueber alle Monitore statt auf den
Hauptmonitor.

Sein Befund: "da gehen noch die popups auf dem video monitor auf". Ursache
war ein `max(0, x)`, das jede negative Koordinate auf null klemmte - bei ihm
steht der zweite Monitor links (x = -1920).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:53:00 +02:00
TheMockTv
f57c7f52f4 Pruefstaende auf den zweiten Monitor, Desktop-Verknuepfung mit Symbol
Seine Ansage: "teste links auf dem 1080p monitor nicht mehr auf dem main ...
weil da schau ich nun video" - und danach "das nun der richtige das fuer
beide".

Dieses Programm hatte ueberhaupt keine Sonderposition fuer den Testlauf:
seine Pruefstandsfenster gingen mitten auf den Hauptbildschirm auf, waehrend
jemand daran arbeitete. Jetzt auf dem zweiten Monitor und ohne Eintrag in der
Taskleiste, wie im Rechnungstool.

Der Platz kommt aus theme.pruefplatz() - dieselbe Datei in beiden Programmen.

Dazu im Installer: der Haken "Verknuepfung auf dem Desktop" ist vorgesetzt
statt abgewaehlt, und jede Verknuepfung traegt IconFilename ausdruecklich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:41:36 +02:00
TheMockTv
9d7aa1eec7 Darstellung: das Programm hatte ueberhaupt kein Theme
Sein Befund
  "und beim steuertool fehlt das theam in genze oder wird nicht durch die
   automatik gesetzt"
  "nur die einstellunden hilfe leiste die ist noch hell und der icon und
   titel leiste auch"

  Er hatte recht, und zwar wortwoertlich: dieses Programm lief im
  Tk-Standard, mit dreimal fest eingetragenem foreground="black". Es gab hier
  gar keine theme.py.

Gebaut
  theme.py aus dem Rechnungstool uebernommen und dabei von config.py geloest
  - eine Datei, die in BEIDEN Programmen liegt, darf nichts aus einem der
  beiden importieren. Beim ersten Uebernehmen flog sie genau daran beim
  Import auseinander.

  Die Einstellungen liegen hier in der Journal-Tabelle, nicht in einer
  config.json. _einstellungen() ist die Bruecke: etwas mit .get() und []=,
  wie hinweis.py und theme.py es erwarten.

  Menueleiste aus menueleiste.py statt tk.Menu - ein natives Menue laesst
  sich unter Windows nicht einfaerben, das war der letzte helle Rest.
  Treeview-Stil in theme.py ergaenzt (ttk zeichnet Listen nicht ueber den
  Grundstil), Reiter schmaler, damit alle zwoelf Monatsnamen lesbar bleiben.
  Die Summenleisten in gui_monat.py holen ihre Farben jetzt vom Fenster statt
  sie fest zu tragen - sie waren ein weisser Block unten im dunklen Bild.

  Logo und Fenstersymbol werden fuer die dunkle Darstellung aufgehellt.

⚠️ Altfehler behoben: die Meldung des Rechnungstools stand nie
  pruef_wache lief mal gruen und mal rot - je nachdem, wann er hinsah. Das
  war kein Wackler im Pruefstand, sondern sein Symptom: meldet das
  Rechnungstool eine neue Rechnung, laeuft sofort das Einlesen an, und dessen
  Ergebnis landete in derselben Zeile. Der Benutzer sah NIE, dass etwas
  hereingekommen war. melde() kennt jetzt einen Vorrang in Sekunden; die
  Meldung des Rechnungstools bekommt sechs. Dreimal hintereinander stabil
  gruen.

  Ein wackelnder Pruefstand ist keine Nebensache, die man wegdrueckt.

AUFGABE_STEUERSAETZE_DYNAMISCH.md
  Seine naechste Ansage notiert: die festen Saetze sollen raus, dynamisch per
  Tabelle wie im Rechnungstool. Mit dem, worauf dabei zu achten ist - ein
  Satz gilt ab einem DATUM, und die Saetze bereits erfasster Buchungen
  bleiben stehen (§ 147 AO).

Fassung 1.9.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:34:38 +02:00
TheMockTv
e4619259cb Release-Anhaenge: der Name entscheidet - und ein Pruefstand dafuer
Der Fehler
  Das Release war angelegt, die EXE hing dran - und der Updater haette
  trotzdem nie etwas gefunden. Der Anhang trug die Nummer im Namen, gesucht
  wird aber "Steuerrechnungstool.exe" (version.EXE_NAME). Die Nummer kommt
  erst beim Ablegen dazu.

  Das Tueckische: nichts schlaegt fehl. Der Updater meldet freundlich
  "Aktuell. Kein Update noetig." und startet die alte Fassung.

  Zweiter Fund: die Pruefsummen standen nur im Release-TEXT. Der Updater
  liest sie aus einer angehaengten Datei <exe>.sha256. Ohne die prueft er nur
  die Groesse - und eine verfaelschte Datei hat genau die richtige Groesse.

bauen.py legt jetzt release/ an, pruef_release.py prueft gegen das echte
Repository nach. Beide Dateien sind in beiden Programmen gleich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 20:43:16 +02:00
TheMockTv
96aa51ac22 Kopflogo, Bau an einer Stelle, Startprobe fuer die EXE
Logo
  Das Programmlogo (Wolke mit Paragraphenzeichen) steht jetzt rechts in der
  Kopfleiste - damit sich die beiden Werkzeuge auf einen Blick unterscheiden.

bauen.py
  Der Weg von der Quelle zur Setup-Datei war eine Folge von Befehlen, die
  jemand von Hand eintippen musste. Wer einen Schritt vergisst, liefert eine
  Setup-Datei mit einer alten EXE aus - und sieht es ihr nicht an. Jetzt ist
  es ein Befehl, die Nummer kommt aus version.py, und am Ende stehen die
  SHA256-Summen fuer den Release-Text.

pruef_start_exe.py
  Prueft, was kein anderer Pruefstand prueft: ob die GEBAUTE EXE ein
  sichtbares Fenster zeigt. Alle uebrigen fahren das Programm aus der Quelle,
  dort trat der Fehler nie auf.

pruef_einzelinstanz.py wieder gruen
  Der Erprobungshinweis haette in jedem der drei Starts ein echtes Fenster
  geoeffnet - in einem eigenen Prozess laesst sich kein Dialog abfangen, es
  klickt niemand. Geprueft wird dort die SPERRE, nicht der Hinweis (der hat
  einen eigenen Pruefstand). Die Kenntnisnahme wird deshalb vorher
  eingetragen, und zwar mit genau dem Code, den auch der Klick auf "Ja"
  ausfuehrt. Das trifft nur die Kopie im Temp-Ordner: im echten Journal
  entsteht keine Zustimmung, die niemand gegeben hat.

Aufgeraeumt
  Beherbergungssteuer.exe und die gleichnamige .spec sind raus - das Programm
  heisst jetzt anders. Gebaute Dateien gehoeren ins Release, nicht ins Repo;
  die .spec-Dateien dagegen schon.

Fassung 1.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 20:35:23 +02:00
TheMockTv
b9094e7ebb updater.py und version.py nachgezogen (gleiche Dateien wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 16:42:35 +02:00
TheMockTv
40f53c85a0 Steuerrechnungstool: neuer Name und Erprobungshinweis wie im Rechnungstool
NAME
Das Programm heisst jetzt Steuerrechnungstool - wie sein Repository
(https://git.pcore.de/TheMockTv/Steuerrechnungstool). Umgestellt: Fenstertitel,
Kopfzeile, "laeuft bereits"-Meldung, Startdatei, Spec-Datei (EXE-Name), Logzeile.

NICHT umbenannt, mit Absicht:
  * "Beherbergungssteuer" als NAME DER STEUER - das ist ein Rechtsbegriff und
    steht weiter so in Berichten, Anmeldung und Oberflaeche.
  * LOCALAPPDATA\Beherbergungssteuer in ablage.py - dort liegt der Zeiger auf
    den Datenordner. Wer den umbenennt, kappt bestehenden Installationen den
    Weg zu ihren Daten.
  * Der lokale Ordner C:\claude\beherbergungssteuer - steht in Pruefstaenden
    und Spec-Dateien fest, eigener Schritt.

HINWEIS
hinweis.py aus dem Rechnungstool uebernommen (liegt in beiden Programmen
gleich). Der Hinweis kommt beim Start, Ablehnen beendet das Programm ohne
etwas zu vermerken, die Kenntnisnahme wird mit Benutzer, Zeit und Fassung in
der Einstellungstabelle des Journals gespeichert. Neu: Menue Hilfe -> Info mit
dem Text und der Signaturzeile.

PRUEFSTAENDE
Vier Prueflaeufe hatten gar keinen Dialogabfang - bisher fragte das Programm
beim Start nichts. Jetzt bleiben sie sonst vor einem echten Fenster stehen.
pruef_doppelte und pruef_storno_verrechnet haben einen bekommen, der bei
UNBEKANNTEN Fragen abbricht statt still "ja" zu sagen. pruef_storno_journal und
pruef_wache melden Hinweis und Freigabe ausdruecklich an. pruef_wache treibt
ausserdem die Ereignisschleife an (update), sonst kaeme after_idle nie dran.

pruef_wache: die Erwartung "im gemeinsamen Ordner liegt genau EINE Datei" ist
auf die richtige Menge gebracht - Nummernbuch UND Schluessel der Mini-API, der
seit dem 05.09.2026 dorthin gehoert. Alles darueber hinaus faellt weiter auf.

⚠️ pruef_wache bleibt ROT an einer Stelle, die NICHT von diesen Arbeiten kommt
(vor dem Umbau gemessen, war schon rot): die Statuszeile soll nach einem Push
"Rechnungstool meldet" zeigen, wird aber vom Ergebnis des Einlesens
ueberschrieben. Dokumentiert in AUFGABE_BEWEISKETTE_PRUEFBAR.md.

5 von 6 Pruefstaenden gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 16:24:43 +02:00
TheMockTv
f5f196151e README: Umbenennung nach Steuerrechnungstool vermerkt
Repository heisst jetzt https://git.pcore.de/TheMockTv/Steuerrechnungstool.
Der lokale Ordner bleibt vorerst beherbergungssteuer - der Pfad steht in
Pruefstaenden und Spec-Dateien fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:55:57 +02:00
TheMockTv
ad5be5f1a5 gemeinsam.py nachgezogen: Spalte freigabe (gleiche Datei wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:42:55 +02:00
TheMockTv
876ee78311 pdf_parser: der Satz von der "Seite 2 fuers Amt" raus
Die Rechnung des Rechnungstools ist seit dem 05.09.2026 EIN Blatt. Der
Kopfkommentar behauptete weiter, Seite 2 sei die 1:1-Kopie fuers Amt.
Die Leselogik war davon nie betroffen - sie liest ohnehin nur Seite 1.

ABGRENZUNG, DAMIT DAS NICHT VERWECHSELT WIRD

Der "Bericht fuers Amt" (Monats-/Jahresbericht als PDF) BLEIBT. Das ist die
Steueranmeldung nach der kommunalen Satzung: eine Summenmeldung ohne
Gastnamen, kein zweiter Beleg zu einem Umsatz. Ohne sie kann der Betreiber
seine Beherbergungssteuer nicht anmelden.

Herausgenommen wurde nur das, was rechtswidrig war: das zweite Rechnungsblatt
mit derselben Rechnungsnummer und einem anderen Gesamtbetrag (§ 14 Abs. 4
UStG, § 14c UStG, § 146 AO, § 379 Abs. 1 Nr. 1 AO, § 370 AO) - und das lag im
Rechnungstool, nicht hier.

Ansage des Auftragnehmers: was nach dem Gesetz richtig ist, bleibt; im
Rechnungserstellungstool musste es raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:33:36 +02:00
TheMockTv
7409fafb0b api.py nachgezogen: Schluessel und Bedienung (gleiche Datei wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 18:24:59 +02:00
TheMockTv
379afbd9c1 Amtsbericht: Spalte "Art" mit R/S/B und Legende unter der Tabelle
Sein Einwand: "du musst die markieren als rechnung und storno oder
berichtigung, und das kannst einfach mit buchstaben machen: r fuer
rechnung, s fuer storno, b fuer berichtigung. am footer kannst eine
erklaerung fuer die buchstaben machen."

Er hat recht - ohne die Marke muss man aus der Begruendung raten, was fuer
ein Beleg die Zeile ueberhaupt ist. Mit dem Buchstaben liest sich die
Kette von selbst:

  2026-900  R  storniert mit 2026-901
  2026-901  S  neue Rechnung 2026-902
  2026-902  B  Neuausstellung
  2026-911  S  abgerechnet mit 2026-905     (Doppelungsfall)

Unter der Tabelle steht die Legende: "R = Rechnung, S = Stornorechnung,
B = berichtigte Rechnung (Neuausstellung nach einem Storno)". Bewusst
direkt unter die Tabelle und nicht in die Fusszeile - wer eine einzelne
Zeile prueft, hat sie dann vor Augen und muss nicht blaettern.

Dazu "neu abgerechnet mit" auf "abgerechnet mit" gekuerzt: mit dem S
davor ist das "neu" ueberfluessig, und die Zeile bricht nicht mehr um.
Spaltenbreiten neu verteilt (18/18/9/42/Rest/12/18/9/18 mm).

Erklaerungs-PDF um die Art-Spalte ergaenzt. Pruefkette gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 02:29:48 +02:00
TheMockTv
b2d933cb7c Amtsbericht: die Kette zeigt vorwaerts - Storno nennt die neue Rechnung
Sein Fund: "an der storno verweist du auf die alte rechnung, muss aber
lauten neu zu und neue rechnungs nummer. heisst kette ist so: rechnung
storniert weist auf die storno nummer, storno verweist auf neue rechnung.
wer soll denn sonst wissen wo er suchen muss."

Er hat recht: eine Zeile, die nur zurueckzeigt, laesst den Leser stehen.
Jetzt sagt jede Zeile, wo es WEITERGEHT:

  Rechnung   -> "storniert mit 2026-901"
  Storno     -> "neue Rechnung 2026-902"      (Fall A)
             -> "neu abgerechnet mit 2026-343" (Fall B, Doppelung)
             -> "Storno zu 2026-900"           (Fall C / alte Belege ohne Angabe)
  neue R.    -> "Neuausstellung"

Dafuer wandert die Folgenummer aus den PDF-Kenndaten bis ins Journal:
Buchung.folge_nummer + folge_art (neu | vorhanden | ""), gelesen aus
korrektur_nummer/ersatz_vorhanden/nur_storno, zwei neue DB-Spalten, alte
Journale bauen sich wie gehabt selbst um.

An seinen ECHTEN Belegen geprueft: Storno 2026-455 -> folge 2026-369
(vorhanden), 2026-456 -> 2026-343 (vorhanden). Die Angabe steht in den
Kenndaten schon drin, sie wurde bisher nur nicht gelesen.

Fenster und Blatt zeigen dieselben Worte. Erklaerungs-PDF um eine
Kettentabelle ergaenzt. Pruefkette gruen (storno_verrechnet inkl.
September-Blatt, doppelte_leistung, nur_storno).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 02:19:49 +02:00
TheMockTv
b4db3b3849 Erklaerung: eigener Abschnitt zur Rechnungsnummer
Auf seine Ansage: "erklaere dem auch das die rechnungs nummer nun fest
ist und nicht mehr aenderbar ist ausser mit dem storno butten".

Nachgesehen, was das Programm wirklich erzwingt, und genau das
aufgeschrieben - vier Sperren:

* eine vergebene Nummer wird nicht wiederverwendet (geprueft gegen die
  PDFs im Ordner UND das gemeinsame Nummernbuch); die vorhandene
  Rechnung wird nicht ueberschrieben, es wird die naechste freie
  angeboten
* der Zaehler kann nicht zurueck, auch nicht ueber die Einstellungen
* beim Storno ist das Nummernfeld gesperrt, dort vergibt nur das
  Programm
* keine Nummer auf Vorrat, wenn nichts folgt

Dazu der Hinweis, dass das Gesetz keine LUECKENLOSE Folge verlangt,
sondern eine EINMALIGE (§ 14 Abs. 4 Nr. 4 UStG) - vorwaerts darf es also
springen, zurueck nicht. Das ist genauer als "nicht mehr aenderbar" und
haelt einer Rueckfrage der Steuerberaterin stand.

Blatt hat jetzt vier Seiten; liegt auf dem Desktop und auf M:.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 02:09:59 +02:00
TheMockTv
965278020a Erklaerung fuers Amt / die Steuerberaterin als PDF
Auf seine Bitte: ein Blatt, das erklaert, WAS an Rechnungsschreibung und
Steuerbuch geaendert wurde, WARUM, und auf welche Paragraphen es sich
stuetzt. Drei Seiten, im Briefkopf des Betriebs.

Inhalt: Ausgangslage (dieselbe Leistung mehrfach im Ordner, unvollstaendige
Empfaengerangaben), die zwei festen Wege (Berichtigung / Storno), die drei
Storno-Faelle A/B/C, Pflichtfelder, Umgang mit doppelten Nummern, wie ein
Storno in die Steueranmeldung eingeht - dazu eine Tabelle mit elf
Fundstellen (UStG, UStDV, AO, SaechsKAG, Beherbergungssteuersatzung
Markkleeberg) und was im Programm daraus folgt.

Am Ende die zwei Punkte, die noch offen sind: steuerbefreite
Uebernachtungen (§ 4 Abs. 1 Nr. 1 der Satzung - Kinder bis 18, im Katalog
gibt es eine eigene Position dafuer) und die Abrundung je Uebernachtung
(§ 3 Abs. 3). Beide wirken zugunsten der Stadt, sind aber ungebaut - das
gehoert offen aufs Blatt und nicht verschwiegen.

Als Skript und nicht von Hand geschrieben, weil der Text Paragraphen
nennt: aendert sich das Programm, aendert man den Text hier und erzeugt
das Blatt neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 01:57:20 +02:00
TheMockTv
ef3eef2852 Amtsbericht: Spalte heisst "Begruendung" und hat Platz
Sein Hinweis: "da ist noch so viel platz, die naechte und satz alleine
nehmen so viel raum ein auf dem blatt, da kannst locker zeile begruendung
oder so was setzen und da dann storno zu und die nummer vom storno".

Stimmt: die Zahlenspalten standen mit viel Luft nebeneinander, waehrend
die Begruendung klein gedruckt war. Neu verteilt (in mm):

  Datum 19 | Rechnung 19 | Begruendung 44 | Name Rest | Naechte 12 |
  Entgelt 19 | Satz 10 | Steuer 19

Damit passt "storniert mit 2026-901" in EINE Zeile, in normaler
Schriftgroesse statt 7,5 pt. Spaltenkopf heisst jetzt "Begruendung"
(sein Wort) und "Satz %" nur noch "Satz" - das Prozentzeichen steht
ohnehin nirgends im Wert.

Angesehen: Tabelle als Bild gerendert, alles einzeilig, auch mit langem
Namen. pruef_storno_verrechnet und pruef_berichtigung gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 01:40:03 +02:00
45 changed files with 4639 additions and 188 deletions

23
.gitattributes vendored Normal file
View file

@ -0,0 +1,23 @@
# Zeilenenden festlegen, damit sie nicht von der Maschine abhaengen.
#
# Neun Dateien liegen in beiden Programmen gleich und werden von Hand
# hinueberkopiert; pruef_gemeinsam_automatisch.py prueft das byteweise.
# Ohne diese Festlegung entscheidet core.autocrlf jeder Maschine einzeln,
# wie eine Datei im Arbeitsverzeichnis aussieht - und dann meldet die
# Pruefung einen Unterschied, den es im Repository gar nicht gibt.
# Genau so stand es am 07.09.2026 bei hinweis.py: gleicher Inhalt, die
# eine Fassung CRLF, die andere LF.
* text=auto
*.py text eol=crlf
*.iss text eol=crlf
*.md text eol=crlf
*.json text eol=crlf
# Binaeres nie anfassen.
*.png binary
*.ico binary
*.svg text eol=crlf
*.ttf binary
*.exe binary
*.pdf binary
*.sqlite3 binary

10
.gitignore vendored
View file

@ -10,3 +10,13 @@ __pycache__/
build/
dist/
*.spec
# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo.
*.exe
setup/
# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand
# die EXE nachbauen, die der Updater ausliefert.
!Steuerrechnungstool.spec
!Updater.spec
release/

View file

@ -0,0 +1,68 @@
# Steuersätze dynamisch statt fest
**Stand 07.09.2026 — notiert, noch nicht gebaut.**
## Seine Ansage
> *„die festen steuern müssen aus dem steuertool eh raus das muss wie beim
> rechnungstool dynamisch per tabelle sein die man einfach eintragen kann"*
## Wie es heute ist
Es gibt **einen** Satz für alles:
| Stelle | heute |
|---|---|
| `modell.py` | `STANDARD_SATZ` als Konstante |
| `app.py:101` | `self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))` |
| Menü | *Daten → Steuersatz ändern…* — ein einzelnes Eingabefeld |
| `db.py` | Spalte `satz REAL NOT NULL DEFAULT 5` je Buchung |
| `db.py:243` | `scanne(..., standard_satz=5.0)` — greift, wenn im PDF keiner steht |
Immerhin: der Satz steht **je Buchung** in der Datenbank, nicht nur global.
Historische Buchungen behalten also ihren Satz, wenn er einmal geändert wird.
Das ist die halbe Miete und darf beim Umbau nicht verlorengehen.
Das Rechnungstool kann es schon richtig: dort gibt es eine **Tabelle** von
Sätzen (Einstellungen → Steuersätze), und jede Leistung im Katalog trägt ihren
eigenen.
## Was gebraucht wird
Eine Tabelle im Steuerrechnungstool, in die man Sätze einträgt — wie im
Rechnungstool. Kein fester Wert mehr im Quelltext.
## Worauf beim Bauen zu achten ist
⚠️ **Ein Steuersatz gilt ab einem Datum, nicht „ab jetzt".** Ändert die Gemeinde
den Satz zum 1.1., dürfen die Buchungen des Vorjahres nicht mitwandern - sonst
stimmt die Meldung ans Amt für abgeschlossene Zeiträume plötzlich nicht mehr.
Die Tabelle braucht deshalb eine Spalte *gültig ab*, und die Zuordnung erfolgt
über das **Rechnungsdatum**, nicht über das heutige.
⚠️ **Die Sätze der bereits erfassten Buchungen bleiben stehen.** Sie stehen in
`buchungen.satz` und sind der Beleg dafür, womit damals gerechnet wurde
(§ 147 AO). Eine Änderung an der Tabelle darf sie nie rückwirkend überschreiben.
Wer alte Buchungen umrechnen will, muss das ausdrücklich anstoßen - und dann
gehört es protokolliert.
⚠️ **Was im PDF steht, gewinnt.** Der Satz aus der Tabelle ist nur die Annahme
für den Fall, dass die Rechnung selbst keinen nennt (`scanne(...,
standard_satz=...)`). Umgekehrt wäre es falsch: das Steuertool soll abbilden,
was abgerechnet wurde, nicht was hätte abgerechnet werden sollen.
Dazu passend die schon geltende Regel aus
[[reference_beherbergungssteuer_monat_und_kein_flag]]: kein vom Menschen
gesetzter Schalter in einer Rechnung, die er nicht nachprüfen kann.
## Reihenfolge, wenn es losgeht
1. Tabelle `steuersaetze` (gültig ab, Satz, Bemerkung) im Journal anlegen -
mit Migration wie bei den anderen Spalten.
2. Die Auswahl beim Einlesen über das Rechnungsdatum, nicht über „heute".
3. Den Dialog *Steuersatz ändern…* durch eine Tabelle ersetzen, wie im
Rechnungstool unter Einstellungen → Steuersätze.
4. `STANDARD_SATZ` bleibt als letzter Rückfall für eine leere Tabelle - und
nur dafür.
5. Prüfstand: eine Buchung von 2025 und eine von 2026, zwei verschiedene
Sätze, und die Änderung der Tabelle darf die alte Buchung nicht anfassen.

Binary file not shown.

125
Installer.iss Normal file
View file

@ -0,0 +1,125 @@
; Installer fuer das Steuerrechnungstool (Inno Setup 6)
;
; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum
; --------------------------------------------
; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach
; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In
; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update
; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage
; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es
; genauso, aus demselben Grund.
;
; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut
; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach
; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst
; sich deshalb problemlos ersetzen.
;
; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein
; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts
; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege
; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen.
;
; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt
; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein.
#define Name "Steuerrechnungstool"
#define Herausgeber "ravokk"
#define ExeUpdater "Updater.exe"
#define Webseite "https://git.pcore.de/TheMockTv/Steuerrechnungstool"
[Setup]
AppId={{B4D71E08-9C36-42AF-8E5B-13A7F0C29D64}
AppName={#Name}
AppVersion={#Version}
AppVerName={#Name} {#Version}
AppPublisher={#Herausgeber}
AppPublisherURL={#Webseite}
AppSupportURL={#Webseite}/issues
AppUpdatesURL={#Webseite}/releases
; --- pro Benutzer, ohne Adminrechte ---
PrivilegesRequired=lowest
; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen
; koennte, braeche sich den Updater: dort darf ein normaler Benutzer
; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus
; demselben Grund aus. In "Programme" gehoert hoechstens eine
; Verknuepfung - und die liegt ohnehin im Startmenue.
DisableDirPage=yes
; EIN Verzeichnis fuer beides - seine Ansage am 07.09.2026:
; "es gibt ravokk als main verzeichnis, da liegt config und sqlite
; drin, und dann gibt es den ordner rechnungstool und steuertool"
;
; %LOCALAPPDATA%\ravokk\
; campinghof-nummernbuch.sqlite3 <- gemeinsam, beide schauen hierher
; Rechnungstool\ <- Programm + Updater
; Steuerrechnungstool\ <- Programm + Updater
;
; Jedes Programm in seinem eigenen Unterordner: die beiden Updater heissen
; gleich und wuerden sich sonst gegenseitig ueberschreiben.
;
; Das Nummernbuch liegt eine Ebene DARUEBER und gehoert keinem der beiden.
; Ein Deinstallieren nimmt es deshalb nicht mit - und wer nur eines der
; Programme installiert, legt es trotzdem am richtigen Ort an
; (gemeinsam.standard_ordner).
DefaultDirName={localappdata}\ravokk\{#Name}
DefaultGroupName={#Name}
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
OutputDir=setup
OutputBaseFilename=Steuerrechnungstool-Setup-{#Version}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile=assets\programm.ico
UninstallDisplayIcon={app}\{#ExeProgramm}
ArchitecturesInstallIn64BitMode=x64compatible
; Erprobungsfassung - das steht auch im Programm selbst.
AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr.
[Languages]
Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
; Kein "unchecked": der Haken ist vorgesetzt. Er hatte nach dem
; Installieren kein Symbol auf dem Desktop und musste das Programm
; suchen (07.09.2026). Wer es nicht will, nimmt den Haken heraus.
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
GroupDescription: "Zusätzliche Verknüpfungen:"
[Files]
; Der Updater - das ist, was der Benutzer startet.
Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion
; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran,
; was installiert ist, und legt neue Fassungen daneben statt darueber.
Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm.
; IconFilename ausdruecklich: ohne das erbt die Verknuepfung das Symbol vom
; UPDATER, und Windows zeigte an ihrer Stelle das nackte Standardsymbol.
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Was der INSTALLER gelegt hat, raeumt Inno Setup von selbst weg. Hier
; steht, was der UPDATER angelegt hat - davon weiss der Installer nichts:
; <Name>-<Nummer>.exe jede geladene Fassung
; installiert.json die Buchfuehrung des Updaters
; Ohne diese Zeilen bleibt der Ordner voller alter EXEs stehen.
;
; NICHT geloescht werden Rechnungen, Journal und Nummernbuch. Die liegen
; ohnehin woanders (%LOCALAPPDATA%/ravokk) und muessen zehn Jahre bleiben
; (§ 147 AO). Deshalb steht hier auch kein 'Type: filesandordirs' auf
; {app} - das waere die bequeme, aber falsche Zeile.
Type: files; Name: "{app}\installiert.json"
Type: files; Name: "{app}\{#Name}-*.exe"
Type: files; Name: "{app}\Updater.exe"
; Zum Schluss den Ordner selbst - aber NUR, wenn er leer ist.
Type: dirifempty; Name: "{app}"

View file

@ -1,4 +1,31 @@
# Beherbergungssteuer – Campinghof Bartl
# Steuerrechnungstool – Campinghof Bartl
*(früher „Beherbergungssteuer“ – siehe Hinweis unten)*
> ### Umbenennung, 06.09.2026
>
> Die Repositories heissen jetzt:
>
> | bisher | jetzt |
> |---|---|
> | `beherbergungssteuer` | **`Steuerrechnungstool`** - https://git.pcore.de/TheMockTv/Steuerrechnungstool |
> | `rechnungstool` | unveraendert - https://git.pcore.de/TheMockTv/rechnungstool |
>
> Grund: „Beherbergungssteuer“ beschrieb nur noch einen Teil dessen, was das Programm tut.
> Alte Links leitet Forgejo weiter, bestehende Klone laufen weiter - das `origin` sollte
> trotzdem umgestellt werden:
>
> ```
> git remote set-url origin https://git.pcore.de/TheMockTv/Steuerrechnungstool.git
> ```
>
> **Fehler und Wünsche** bitte als Issue im jeweiligen Repository melden - die Adresse steht
> auch im Programm unter *Hilfe → Info* und im Hinweis beim Start.
>
> ⚠️ Der **lokale Ordner** heißt weiterhin `C:\claude\beherbergungssteuer`. Der Pfad steht fest
> verdrahtet in mehreren Prüfständen und in den Spec-Dateien - das Umbenennen ist ein
> eigener Schritt.
Ersetzt die Excel-Mappe `Beherbergungsteuer.xlsx`.

View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" pythonw app.py

39
Steuerrechnungstool.spec Normal file
View file

@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('assets/programm.ico', 'assets'), ('assets/programm_logo.png', 'assets'),],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Steuerrechnungstool',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
icon='assets/programm.ico',
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

24
Updater.spec Normal file
View file

@ -0,0 +1,24 @@
# -*- mode: python ; coding: utf-8 -*-
# Der Updater ist der Starter: winzige Konsolen-EXE, die vor dem Programm laeuft.
a = Analysis(
['updater.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['version'],
hookspath=[],
runtime_hooks=[],
excludes=['tkinter', 'reportlab', 'PIL', 'pypdf', 'pypdfium2', 'numpy'],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz, a.scripts, a.binaries, a.datas, [],
name='Updater',
debug=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
icon='assets/programm.ico',
)

View file

@ -44,6 +44,48 @@ def _beschreibbar(ordner):
return False
def _ist_temp(pfad):
"""Liegt dieser Pfad im Temp-Ordner von Windows?
⚠ Am 07.09.2026 gefunden: der Merkzettel zeigte auf
%TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
03.09. Die INSTALLIERTE Fassung fuehrte ihr Journal seitdem im
Temp-Ordner. Windows raeumt den automatisch auf (Datentraegerbereinigung,
Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden.
Ein Merkzettel, der dorthin zeigt, wird deshalb verworfen - lieber ein
frischer, richtiger Ordner als ein Journal auf Abruf.
"""
import tempfile
try:
temp = os.path.realpath(tempfile.gettempdir()).lower()
ziel = os.path.realpath(pfad).lower()
return ziel == temp or ziel.startswith(temp + os.sep)
except (OSError, ValueError):
return False
def firmen_ordner():
"""Das gemeinsame Dach: %LOCALAPPDATA%/ravokk.
Seine Ansage am 07.09.2026: *"es gibt ravokk als main verzeichnis, da
liegt config und sqlite drin, und dann gibt es den ordner rechnungstool
und steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma
und die programme gehoeren zu der firma"*.
Darunter liegt je Programm ein eigener Ordner, und das gemeinsame
Nummernbuch eine Ebene darueber - so nimmt ein Deinstallieren es nicht mit
(siehe gemeinsam.standard_ordner).
"""
# EINE Quelle fuer diesen Pfad. Er wurde an drei Stellen einzeln
# ausgerechnet - hier, in gemeinsam.py und in der config.py des
# Rechnungstools. Laeuft eine davon je auseinander, fuehren die beiden
# Programme zwei Nummernbuecher, und das faellt erst auf, wenn eine
# Rechnungsnummer zum zweiten Mal vergeben ist.
import gemeinsam
return gemeinsam.standard_ordner()
def _zeiger_datei():
"""Merkzettel, WO der Datenordner liegt - liegt bewusst ausserhalb davon."""
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
@ -66,6 +108,14 @@ def _gemerkter_ordner():
if not pfad or "\x00" in pfad or not os.path.isabs(pfad):
log.warning("Merkzettel unbrauchbar - es wird neu angelegt")
return None, None
# HIER wird _ist_temp tatsaechlich benutzt. Bis zum 07.09.2026 stand die
# Funktion samt Begruendung da und wurde von niemandem gerufen - der
# Schutz war ein Kommentar, keine Massnahme. Zwei Pruefagenten haben das
# unabhaengig gefunden, einer mit Messung: das Journal landete weiter im
# Temp-Ordner.
if _ist_temp(pfad):
log.warning("Merkzettel zeigt in den Temp-Ordner - verworfen: %s", pfad)
return None, None
try:
laufwerk = os.path.splitdrive(pfad)[0]
erreichbar = (not laufwerk) or os.path.exists(
@ -106,11 +156,22 @@ def daten_ordner():
log.warning("Datenordner %s ist nicht erreichbar - es wird nichts geändert", gemerkt)
return gemerkt if _beschreibbar(gemerkt) else EXE_DIR
kandidat = os.path.join(EXE_DIR, "Daten")
# Erste Wahl: der Ordner der Firma. Dort liegen beide Programme und das
# gemeinsame Nummernbuch - alles an einer Stelle, und ein Deinstallieren
# raeumt nur den Programmordner.
# NEBEN dem Programm, nicht darin - siehe firmen_ordner(). Journal,
# Berichte und Log liegen damit in ravokk, das Programm in
# ravokk/Steuerrechnungstool. Ein Deinstallieren leert nur den
# Programmordner.
kandidat = firmen_ordner()
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Beherbergungssteuer")
# Rueckfall wie frueher: neben der EXE, dann der alte eigene Ordner.
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
return EXE_DIR
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR),
"Beherbergungssteuer")
if not _beschreibbar(kandidat):
return EXE_DIR
_ordner_merken(kandidat)
for name in EIGENE_DATEN:
alt, neu = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)

185
api.py
View file

@ -18,11 +18,38 @@ einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
Zwei Nachrichten reichen:
Zwei Nachrichten reichen dafuer:
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die
Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur
wegzunehmen:
{"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}}
DER SCHLUESSEL
--------------
Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten
Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im
gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner
lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand.
Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu
verraten, ob der Name ueberhaupt existiert.
`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw.
"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres
Steuerjournal nicht mehr anklopfen.
WAS EINE AKTION DARF
--------------------
Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im
Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur
Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen,
Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in
einer Buchhaltung nichts zu suchen.
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
@ -42,6 +69,8 @@ hinueber.
import json
import logging
import os
import queue
import secrets
import socket
import socketserver
import sys
@ -50,6 +79,33 @@ import threading
log = logging.getLogger("api")
ANTWORT_HALLO = "hallo, bin noch da"
SCHLUESSEL_DATEI = "api_schluessel.txt"
def schluessel_holen(ordner):
"""Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen.
Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als
Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine
Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das
sich jemand merken muesste.
"""
if not ordner:
return ""
pfad = os.path.join(ordner, SCHLUESSEL_DATEI)
try:
if os.path.exists(pfad):
wert = open(pfad, encoding="utf-8").read().strip()
if wert:
return wert
wert = secrets.token_hex(32)
with open(pfad, "w", encoding="utf-8") as f:
f.write(wert)
log.info("neuer API-Schluessel angelegt: %s", pfad)
return wert
except OSError as e:
log.warning("API-Schluessel nicht nutzbar (%s)", e)
return ""
class _Handler(socketserver.StreamRequestHandler):
@ -66,28 +122,58 @@ class _Handler(socketserver.StreamRequestHandler):
if not zeile:
return
try:
was = str(json.loads(zeile).get("was", ""))
nachricht = json.loads(zeile)
was = str(nachricht.get("was", ""))
except (ValueError, AttributeError):
was = ""
nachricht, was = {}, ""
if was == "ping":
antwort = ANTWORT_HALLO
elif was == "schau_nach":
antwort = "ok"
self.server.melden() # Fenster benachrichtigen (threadsicher)
elif was == "aktion":
antwort = self._aktion(nachricht)
else:
antwort = "haeh?"
try:
self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8"))
except OSError:
# Der Rufer hat die Verbindung schon zugemacht - das ist der
# Normalfall bei "schau_nach", wenn das andere Programm beendet
# wurde, waehrend die Antwort unterwegs war. Die Arbeit ist
# getan; nur die Empfangsbestaetigung geht ins Leere.
pass
def _aktion(self, nachricht):
"""Eine Bedienung ausfuehren - nur mit gueltigem Schluessel."""
erwartet = getattr(self.server, "schluessel", "")
gegeben = str(nachricht.get("schluessel", ""))
# compare_digest statt "==": ein Vergleich, der bei der ersten falschen
# Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag.
if not erwartet or not secrets.compare_digest(erwartet, gegeben):
log.warning("Aktion ohne gueltigen Schluessel abgewiesen")
return "kein zutritt"
if self.server.aktion is None:
return "keine bedienung moeglich"
name = str(nachricht.get("name", ""))
daten = nachricht.get("daten") or {}
log.info("Aktion ueber die API: %s", name)
try:
return self.server.aktion(name, daten)
except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben
log.warning("Aktion %r fehlgeschlagen: %s", name, e)
return {"fehler": str(e)}
class _Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def __init__(self, adresse, bei_push):
def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
self.melden = bei_push
self.aktion = aktion
self.schluessel = schluessel
super().__init__(adresse, _Handler)
@ -99,19 +185,30 @@ class MiniApi:
und holt sie im Fenster mit after() ab.
"""
def __init__(self, programm, bei_push):
def __init__(self, programm, bei_push, aktion=None):
self.programm = programm
self.bei_push = bei_push
self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
self.server = None
self.faden = None
self.port = 0
self.schluessel = ""
def starten(self, buch):
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
def starten(self, buch, ordner=None):
"""Server aufmachen und im Telefonbuch (SQLite) eintragen.
Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er
fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen
Schluessel und damit auch keine Bedienung von aussen.
"""
if self.server is not None:
return self.port
if ordner is None and buch is not None:
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
self.schluessel = schluessel_holen(ordner)
try:
self.server = _Server(("127.0.0.1", 0), self.bei_push)
self.server = _Server(("127.0.0.1", 0), self.bei_push,
aktion=self.aktion, schluessel=self.schluessel)
except OSError as e:
log.warning("API konnte nicht starten: %s", e)
self.server = None
@ -150,12 +247,14 @@ class MiniApi:
self.port = 0
def _reden(port, was, zeitgrenze=1.5):
def _reden(port, was, zeitgrenze=1.5, **rest):
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
nachricht = {"was": was}
nachricht.update(rest)
try:
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
s.settimeout(zeitgrenze)
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
roh = s.makefile("r", encoding="utf-8").readline()
return json.loads(roh).get("antwort", "")
except (OSError, ValueError):
@ -192,3 +291,69 @@ def anklopfen(buch, programm):
if not eintrag or not eintrag.get("port"):
return False
return _reden(eintrag["port"], "schau_nach") == "ok"
def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0):
"""Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt.
Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster
bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen.
Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den
lesen darf, darf bedienen.
"""
if buch is None:
return None
try:
eintrag = buch.wer_laeuft(programm)
except Exception as e: # noqa: BLE001
log.debug("Telefonbuch nicht lesbar: %s", e)
return None
if not eintrag or not eintrag.get("port"):
return None
if ordner is None:
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze,
schluessel=schluessel_holen(ordner), name=name,
daten=daten or {})
class Warteschlange:
"""Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck.
Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht
aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das
Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus
und legt die Antwort zurueck.
Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die
Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer
Verbindung, die nie antwortet.
"""
def __init__(self):
self.eingang = queue.Queue()
def annehmen(self, name, daten, zeitgrenze=12.0):
"""Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten."""
fertig = threading.Event()
auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None}
self.eingang.put(auftrag)
if not fertig.wait(zeitgrenze):
return {"fehler": "das Fenster hat nicht geantwortet"}
return auftrag["antwort"]
def abarbeiten(self, ausfuehren):
"""Im Fensterfaden gerufen: alles erledigen, was eingegangen ist."""
while True:
try:
auftrag = self.eingang.get_nowait()
except queue.Empty:
return
try:
auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"])
except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen
log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e)
auftrag["antwort"] = {"fehler": str(e)}
finally:
auftrag["fertig"].set()

332
app.py
View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
"""
Beherbergungssteuer – Steuerjournal für den Campinghof Bartl.
Steuerrechnungstool – das Steuerjournal zum Rechnungstool.
Ersetzt die Excel-Mappe: Die Rechnungs-PDFs aus dem Rechnungstool sind die
Quelle, das Programm liest sie ein, führt das Journal (SQLite) und schreibt
auf Knopfdruck die Excel-Mappe fürs Amt.
Start: Doppelklick auf "Beherbergungssteuer starten.bat" oder `python app.py`
Start: Doppelklick auf "Steuerrechnungstool starten.bat" oder `python app.py`
"""
import os
@ -25,6 +25,10 @@ from tkinter import ttk, filedialog, messagebox
# zieht alte Dateien um; das darf nicht zweimal gleichzeitig laufen.
# Nur beim echten Start: Pruefstaende importieren app als Modul und sollen
# nicht an der Sperre des laufenden Programms scheitern.
import hinweis
import menueleiste
import theme
import wasserzeichen
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen,
fenster_nach_vorn, laufende_pid_lesen)
@ -37,7 +41,7 @@ if __name__ == "__main__":
_wurzel.withdraw()
_wurzel.attributes("-topmost", True)
messagebox.showerror(
"Beherbergungssteuer läuft bereits",
"Steuerrechnungstool läuft bereits",
"Das Steuerjournal ist schon geöffnet, das Fenster ließ sich aber "
"nicht nach vorn holen – bitte über die Taskleiste zurückwechseln.")
_wurzel.destroy()
@ -75,14 +79,37 @@ logging.basicConfig(
log = logging.getLogger("bst.app")
class App(tk.Tk):
class App(theme.ThemeMixin, tk.Tk):
def __init__(self):
super().__init__()
self.title("Beherbergungssteuer – Campinghof Bartl")
self.title("Steuerrechnungstool")
self._fenstersymbol()
self.geometry("1000x680")
self.minsize(820, 520)
if TESTLAUF:
# Ein Pruefstand bedient das Programm, waehrend nebenan jemand
# arbeitet. Das Fenster geht deshalb auf dem ZWEITEN Monitor auf -
# sichtbar, aber nicht im Weg. Seine Ansage am 07.09.2026: "teste
# links auf dem 1080p monitor nicht mehr auf dem main". Bisher
# ging es hier mitten auf den Hauptbildschirm.
platz = theme.pruefplatz()
if platz:
self.geometry(platz)
try:
# Kein Eintrag in der Taskleiste: ein Eintrag, den man
# anklickt, ohne dass etwas erscheint, ist schlimmer als
# gar keiner.
self.attributes("-toolwindow", True)
except tk.TclError:
pass
self.journal = db.Journal(JOURNAL)
# Die Einstellungen des Programms liegen in der Journal-Tabelle, nicht
# in einer config.json wie beim Rechnungstool. theme.py erwartet aber
# etwas mit .get() und []= - diese Ablage ist die Bruecke. So laeuft
# dieselbe theme.py in beiden Programmen, ohne Sonderfall.
self.cfg = self._einstellungen()
self._apply_theme()
self.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py
# Der eigene kleine Server. Klopft das Rechnungstool an, wird sofort
# nachgesehen - ohne auf den naechsten Durchgang der Wache zu warten.
@ -94,61 +121,242 @@ class App(tk.Tk):
self.suche = tk.StringVar()
self.suche.trace_add("write", lambda *_: self.aktualisieren())
os.makedirs(BERICHTE, exist_ok=True)
self._menue()
self._kopfleiste()
self._blaetter()
self._statusleiste()
self.bind("<F5>", lambda _e: self.einlesen())
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
self.protocol("WM_DELETE_WINDOW", self._beenden)
self.aktualisieren()
self._wasserzeichen_setzen()
# NICHTS laeuft an, bevor der Erprobungshinweis bestaetigt ist - sonst
# stuenden beim Ablehnen Timer in der Warteschlange, die auf ein
# Fenster zugreifen, das es nicht mehr gibt.
self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen
self._push_kasten = queue.Queue() # Zettel aus dem API-Faden
self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen
# Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH
# gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten
# EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog
# sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein
# haengendes Programm (06.09.2026, am Installer aufgefallen).
self.after(50, self._hinweis_wenn_sichtbar)
# ------------------------------------------------- Erprobungshinweis
def _einstellungen(self):
"""Die Einstellungstabelle des Journals als Wörterbuch.
hinweis.py und theme.py erwarten beide etwas mit .get() und []= - im
Rechnungstool ist das die config.json, hier die Tabelle im Journal.
Diese Ablage ist die Bruecke, damit beide Programme dieselben Dateien
benutzen koennen.
"""
journal = self.journal
class Ablage:
def get(self, schluessel, standard=""):
return journal.hole(schluessel, standard)
def __setitem__(self, schluessel, wert):
journal.setze(schluessel, str(wert))
return Ablage()
# hinweis.py ruft die Ablage unter dem alten Namen - beides zeigt auf
# dieselbe Tabelle.
_hinweis_ablage = _einstellungen
def _wieder(self, ms, was):
"""Einen sich selbst planenden Timer neu setzen - nur solange es das
Fenster noch gibt.
Ohne das melden die Dauertimer beim Beenden `invalid command name
..._wache`: Tk hat das Fenster samt Callback schon abgeraeumt, der
eingeplante Aufruf laeuft aber noch. Das stuerzt nichts ab, landet aber
als Fehler im Log - und ein Log, in dem beim normalen Beenden immer
zwei Fehler stehen, liest irgendwann niemand mehr.
Gleiche Loesung wie im Rechnungstool.
"""
try:
if self.winfo_exists():
self.after(ms, was)
except tk.TclError:
pass # Fenster ist weg - dann gibt es nichts mehr zu tun
# ------------------------------------------------------- Darstellung
def _oberflaeche_neu(self):
"""Die Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel.
Neu bauen statt umfaerben: die Farben stecken in den Widgets, und wer
sie einzeln nachzieht, vergisst beim naechsten neuen Widget eine
Stelle - dann leuchtet ein heller Fleck im dunklen Fenster, den
niemand mehr findet. Neu bauen kann nichts vergessen.
Die Werte haengen an Tk-Variablen (Jahr, Suchfeld) und am Journal -
beides ueberlebt. Gesichert werden sie trotzdem; warum, steht bei
variablen_sichern() im ThemeMixin.
"""
gesichert = self.variablen_sichern()
self.kinder_loeschen()
self._apply_theme()
self._menue()
self._kopfleiste()
self._blaetter()
self._statusleiste()
self.bind("<F5>", lambda _e: self.einlesen())
self.bind("<Control-f>", lambda _e: self.suchfeld.focus_set())
self.protocol("WM_DELETE_WINDOW", self._beenden)
self.variablen_zuruecksetzen(gesichert)
self.aktualisieren()
# Der Dialog selbst steht im ThemeMixin (theme.py). Gespeichert werden
# muss hier nichts: self.cfg ist die Ablage aus _einstellungen(), die
# jeden Schreibzugriff sofort in die Journaltabelle durchreicht.
DARSTELLUNG_HINWEIS = (
"Gilt für das Programmfenster. Das Menü bleibt in der Farbe von "
"Windows – ein natives Menü lässt sich dort nicht einfärben.")
def _fenstersymbol(self):
"""Das Programmsymbol auf das Fenster legen."""
return wasserzeichen.symbol_setzen(self, melden=log.info)
def _wasserzeichen_setzen(self, groesse=220):
"""Das Programmlogo blass in die untere rechte Ecke."""
bild = wasserzeichen.hole(groesse, 0.06, master=self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
if bild is None:
return
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
# zeigt eine leere Flaeche.
self._wz_bild = bild
self._wz = tk.Label(self, image=bild, borderwidth=0)
self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-14, y=-30)
self._wz.lower()
def _hinweis_wenn_sichtbar(self, versuche=0):
"""Wartet, bis das Fenster gezeichnet ist, und fragt dann.
winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm
ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach
rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das
Fenster absichtlich nicht sichtbar, und dort soll es trotzdem
laufen.
"""
if not self.winfo_viewable() and versuche < 40:
self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1))
return
self._hinweis_pruefen()
def _hinweis_pruefen(self):
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm."""
self.update_idletasks() # Fenster fertig zeichnen lassen
# Nach vorn holen, sonst liegt der Hinweis beim Start hinter dem
# Hauptfenster und wirkt wie ein Programm, das haengen geblieben ist.
self.lift()
self.focus_force()
ablage = self._hinweis_ablage()
if not hinweis.angenommen(ablage):
log.info("Erprobungshinweis: Kenntnisnahme noch offen (%s)", hinweis.FASSUNG)
if not messagebox.askyesno(hinweis.TITEL,
hinweis.text("steuerrechnungstool"),
parent=self, default="no"):
log.info("Erprobungshinweis abgelehnt - Programm wird beendet")
self.destroy()
return
zeile = hinweis.vermerken(ablage)
log.info("Erprobungshinweis: %s", zeile)
self._start_fortsetzen()
def _start_fortsetzen(self):
"""Der uebrige Start - laeuft erst nach der Kenntnisnahme an."""
if self.pdf_ordner and os.path.isdir(self.pdf_ordner):
self.after(200, lambda: self.einlesen(still=True))
self.after(400, self._frage_gemeinsam_falls_noetig)
# Beide Programme laufen nebeneinander: schreibt das Rechnungstool eine
# Rechnung, meldet es das hier herein - siehe _wache_jetzt().
self._briefkasten_stand = "?" # "?" = noch nicht nachgesehen
self._push_kasten = queue.Queue() # Zettel aus dem API-Faden
self.after(250, self._push_abholen)
self._nachricht_stand = 0 # bis hierher sind Meldungen gelesen
self.after(600, self._wache)
# Beim Zurueckklicken sofort nachsehen statt zwei Sekunden zu warten.
self.bind("<FocusIn>", lambda _e: self._wache_jetzt())
# ------------------------------------------------------------ Aufbau ----
def _menue(self):
leiste = tk.Menu(self)
datei = tk.Menu(leiste, tearoff=0)
datei.add_command(label="Rechnungs-Ordner wählen…", command=self.ordner_waehlen)
datei.add_separator()
datei.add_command(label="Monatsbericht fürs Amt (PDF)…", command=self.bericht_monat)
datei.add_command(label="Jahresbericht fürs Amt (PDF)…", command=self.bericht_jahr)
datei.add_separator()
datei.add_command(label="Excel-Tabelle einlesen…", command=self.altbestand_importieren)
datei.add_separator()
datei.add_command(label="Beenden", command=self._beenden)
leiste.add_cascade(label="Datei", menu=datei)
"""Eigene Menueleiste statt tk.Menu.
daten = tk.Menu(leiste, tearoff=0)
daten.add_command(label="Rechnungen einlesen (F5)", command=self.einlesen)
daten.add_command(label="Alle Rechnungen neu einlesen", command=lambda: self.einlesen(voll=True))
daten.add_separator()
daten.add_separator()
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
command=self.firma_waehlen)
daten.add_command(label="Gemeinsamer Ordner (Nummernbuch)…",
command=self.dlg_gemeinsam)
leiste.add_cascade(label="Daten", menu=daten)
self.config(menu=leiste)
`tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des
Betriebssystems: es nimmt Farben entgegen und zeichnet trotzdem im
Systemgrau. Sein Befund am 07.09.2026: *"datei und daten hilfe ist
immer noch hell"* - und er hatte recht, das war der letzte helle Rest
im dunklen Fenster. Die Leiste kommt jetzt aus menueleiste.py, der
gleichen Datei, die auch das Rechnungstool benutzt.
"""
self._menus = {
"Datei": [
("Rechnungs-Ordner wählen…", self.ordner_waehlen),
("-", None),
("Monatsbericht fürs Amt (PDF)…", self.bericht_monat),
("Jahresbericht fürs Amt (PDF)…", self.bericht_jahr),
("-", None),
("Excel-Tabelle einlesen…", self.altbestand_importieren),
("-", None),
("Beenden", self._beenden),
],
"Daten": [
("Rechnungen einlesen (F5)", self.einlesen),
("Alle Rechnungen neu einlesen", lambda: self.einlesen(voll=True)),
("-", None),
("Steuersatz ändern…", self.satz_aendern),
("Firmendaten des Rechnungstools wählen…", self.firma_waehlen),
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
("-", None),
("Darstellung…", self.dlg_darstellung),
],
"Hilfe": [("Info", self._info)],
}
self.menue = menueleiste.Menueleiste(self, self._menus, farben=self)
def _info(self):
"""Erprobungshinweis und Signatur - dauerhaft nachlesbar.
Ohne diese Seite waere die Kenntnisnahme ein Klick, den spaeter
niemand mehr nachlesen kann.
"""
d = tk.Toplevel(self)
d.title("Info")
d.transient(self)
d.resizable(False, False)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Steuerrechnungstool", font=("Segoe UI", 11, "bold")
).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=460,
text=hinweis.text("steuerrechnungstool")).pack(anchor="w", pady=(10, 0))
zeile = hinweis.signatur(self._hinweis_ablage())
if zeile:
ttk.Label(rahmen, justify="left", wraplength=460, text=zeile
).pack(anchor="w", pady=(12, 0))
ttk.Button(rahmen, text="OK", width=10, command=d.destroy
).pack(anchor="e", pady=(16, 0))
d.bind("<Escape>", lambda _e: d.destroy())
d.bind("<Return>", lambda _e: d.destroy())
d.grab_set()
def _kopfleiste(self):
leiste = ttk.Frame(self, padding=(10, 8))
leiste.pack(fill="x")
# Das Programmlogo rechts in der Kopfzeile - das Erkennungszeichen.
# Es steht rechts, damit es die Bedienelemente nicht nach hinten
# schiebt, wenn es einmal fehlt.
# 34 Pixel wie im Rechnungstool - bei 28 war es ihm zu klein
# ("beim steuertool koennte es ein tick groesser sein", 07.09.2026).
self._kopf_bild = wasserzeichen.kopf(34, master=self, melden=log.info,
dunkel=getattr(self, "_dark_mode", False))
if self._kopf_bild is not None:
ttk.Label(leiste, image=self._kopf_bild).pack(side="right")
ttk.Label(leiste, text="Jahr:").pack(side="left")
self.jahr_wahl = ttk.Combobox(leiste, width=6, state="readonly",
textvariable=self.jahr, values=self._jahre())
@ -190,9 +398,9 @@ class App(tk.Tk):
self.fuss = ttk.Frame(rahmen)
self.fuss.pack(fill="x")
self.status = ttk.Label(self.fuss, text="", foreground="black")
self.status = ttk.Label(self.fuss, text="", foreground=self.col_fg)
self.status.pack(side="left")
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground="black")
self.ordner_anzeige = ttk.Label(self.fuss, text="", foreground=self.col_fg)
self.ordner_anzeige.pack(side="right")
# Doppelt vergebene Rechnungsnummern. Die Leiste wird erst eingehaengt,
@ -307,7 +515,9 @@ class App(tk.Tk):
text = texte[-1] if len(texte) == 1 else f"{len(texte)} Meldungen, zuletzt: {texte[-1]}"
log.info("Rechnungstool meldet: %s", text)
self.einlesen(still=True)
self.melde(f"Rechnungstool meldet: {text}")
# Mit Vorrang: das Einlesen laeuft gleich darauf an und wuerde die
# Meldung sonst sofort ueberschreiben.
self.melde(f"Rechnungstool meldet: {text}", vorrang=6)
return True
def _letzte_nachricht(self):
@ -323,14 +533,32 @@ class App(tk.Tk):
except Exception as e: # noqa: BLE001 - die Wache darf nie sterben
log.warning("Wache: %s", e)
finally:
self.after(self.WACHE_SEKUNDEN * 1000, self._wache)
self._wieder(self.WACHE_SEKUNDEN * 1000, self._wache)
def suche_leeren(self):
self.suche.set("")
self.suchfeld.focus_set()
def melde(self, text: str):
def melde(self, text: str, vorrang: int = 0):
"""Eine Zeile in die Statusleiste schreiben.
`vorrang` in Sekunden: so lange darf eine gewoehnliche Meldung diese
hier NICHT ueberschreiben.
Warum es das braucht (07.09.2026): Meldet das Rechnungstool eine neue
Rechnung, laeuft sofort das Einlesen an - und dessen Ergebnis
("Nummernbuch: 1 Nummern eingetragen") landete in derselben Zeile.
Die Meldung stand also nur Sekundenbruchteile da; der Benutzer sah nie,
dass etwas hereingekommen war. Aufgefallen ist es an einem
Pruefstand, der mal gruen und mal rot war - je nachdem, wann er hinsah.
Das war kein Wackler im Pruefstand, sondern genau dieser Fehler.
"""
import time as _zeit
jetzt = _zeit.monotonic()
if vorrang <= 0 and jetzt < getattr(self, "_status_sperre", 0):
return # eine wichtigere Meldung steht noch
self.status.config(text=text)
self._status_sperre = jetzt + vorrang if vorrang > 0 else 0
log.info(text)
# ---- Gemeinsames Nummernbuch -------------------------------------------
@ -383,7 +611,7 @@ class App(tk.Tk):
except Exception as e: # noqa: BLE001 - darf das Fenster nie umwerfen
log.warning("Push: %s", e)
finally:
self.after(250, self._push_abholen)
self._wieder(250, self._push_abholen)
def _buch_oeffnen(self):
ordner = self.journal.hole("gemeinsam_ordner", "")
@ -406,13 +634,25 @@ class App(tk.Tk):
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
self.dlg_gemeinsam(erststart=True)
# Kein Dialog mehr beim ersten Start: der Ordner steht fest und ist
# in beiden Programmen derselbe (gemeinsam.standard_ordner).
ordner = gemeinsam.standard_anlegen()
if ordner:
self.journal.setze("gemeinsam_ordner", ordner)
self._buch_oeffnen()
if self.buch is None:
self.dlg_gemeinsam(erststart=True)
self._anmelden()
self._buch_fuellen()
def dlg_gemeinsam(self, erststart=False):
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", ""))
# Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen:
# den kennen beide Programme, er wird mitgesichert, und er ueberlebt
# ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt
# dafuer NICHT mehr - seit dem Installer sind das zwei getrennte.
ordner = ordnerwahl.ordner_abfragen(
self, "Steuerjournal",
self.journal.hole("gemeinsam_ordner", "") or self.pdf_ordner)
if not ordner:
if erststart:
messagebox.showwarning(
@ -604,7 +844,7 @@ class App(tk.Tk):
eingabe.grid(row=0, column=1)
ttk.Label(rahmen, text="Gilt für neu eingelesene und neue Buchungen.\n"
"Bereits erfasste Zeilen behalten ihren Satz.",
foreground="black").grid(row=1, column=0, columnspan=2, pady=(8, 10))
foreground=self.col_fg).grid(row=1, column=0, columnspan=2, pady=(8, 10))
def speichern():
from modell import zahl_aus_text
@ -737,7 +977,7 @@ class App(tk.Tk):
if __name__ == "__main__":
log.info("Beherbergungssteuer startet (Python %s)", sys.version.split()[0])
log.info("Steuerrechnungstool startet (Python %s)", sys.version.split()[0])
# Die Sperre selbst greift schon ganz oben, vor dem Datenordner.
eigene_pid_merken()
App().mainloop()

BIN
assets/programm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/programm_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

27
assets/programm_logo.svg Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-566.85,0)">
<g id="Artboard2" transform="matrix(1,0,0,1,566.85,0)">
<rect x="0" y="0" width="512" height="512" style="fill:none;"/>
<g transform="matrix(1.94766,0,0,2.05245,-243.041,-218.664)">
<g id="Links">
<path d="M222.131,215.66L222.13,208.593L238.702,219.494L222.133,230.399L222.131,215.66ZM216.151,130.644C218.188,126.804 220.833,123.524 224.142,119.685C234.021,108.226 250.634,106.769 256,106.556C265.488,106.18 280.606,111.644 287.029,118.435C289.041,120.563 302.548,133.524 301.132,155.668L293.14,155.646C293.608,141.507 288.592,132.589 281.714,125.1C276.056,118.94 266.39,114.408 256,114.561C242.327,114.761 218.593,124.295 219.086,155.601L210.89,155.613C211.196,148.263 211.937,142.778 213.154,138.311C205.649,135.18 198.203,136.466 193.569,139.394C183.234,145.923 181.557,158.851 184.735,165.224L184.044,165.286C162.105,162.842 155.274,179.91 154.506,190.518C153.545,203.798 166.473,214.688 176.844,215.811L222.131,215.66L222.13,223.927L175.884,223.738C156.922,221.252 145.975,205.354 146.25,190.467C146.541,174.685 156.471,160.659 175.619,157.066C176.48,145.398 181.102,139.165 187.459,134.119C195.54,127.705 207.218,127.153 216.151,130.644Z" style="fill:rgb(18,89,109);"/>
<path d="M228.019,252.582L228.078,252.582C227.944,248.705 228.245,244.088 231.648,238.835C232.85,236.98 237.587,231.659 244.591,228.229C248.987,226.077 251.828,221.17 251.928,217.367L252.1,180.838L241.711,180.855L257.771,156.928L257.771,143.768L227.834,188.271L245.057,188.511L244.885,216.83C244.671,218.4 244.258,219.657 242.763,220.467C234.731,224.82 225.142,231.462 228.019,252.582Z" style="fill:rgb(14,79,101);"/>
</g>
<g id="Rechts">
<g transform="matrix(-0.988012,0,0,1,508.931,0)">
<path d="M237.047,251.22C237.178,239.336 245.86,235.025 256.588,236.213L256.575,235.972C252.442,233.264 248.389,227.365 248.529,222.683L248.522,180.85L237.787,180.865L254.207,157.002L254.207,143.768L223.645,188.5L241.142,188.545L241.351,222.218C241.342,223.858 241.699,225.564 241.829,226.526L241.828,226.772C228.424,230.549 224.979,242.528 224.456,251.314L237.047,251.22Z" style="fill:rgb(38,38,40);"/>
</g>
<path d="M290.321,215.66L335.608,215.811C345.979,214.688 358.907,203.798 357.946,190.518C357.178,179.91 350.347,162.842 328.408,165.286L327.717,165.224C330.895,158.851 329.218,145.923 318.883,139.394C312.886,135.605 304.481,133.711 292.087,142.516L288.929,134.747C297.75,127.533 314.633,125.896 324.993,134.119C331.35,139.165 335.972,145.398 336.833,157.066C355.981,160.659 365.911,174.685 366.202,190.467C366.477,205.354 355.53,221.252 336.568,223.738L290.322,223.927L290.321,215.66ZM290.321,215.66L290.319,230.399L273.75,219.494L290.322,208.593L290.321,215.66Z" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
<path d="M851.907,348.191L860.766,356.196C875.59,345.97 881.708,334.438 881.778,321.058C881.863,304.967 873.648,293.16 861.58,285.668L812.266,258.32C800.634,252.557 789.781,232.581 806.866,214.138L806.854,213.679C771.946,231.774 777.593,260.635 790.222,270.44L799.315,277.718C818.663,292.911 843.341,301.213 850.523,307.455L850.991,307.887C862.325,316.409 870.637,330.48 851.907,348.191Z" style="fill:rgb(56,70,82);"/>
</g>
<g transform="matrix(0.584177,0,0,0.584177,-224.63,106.538)">
<path d="M775.465,379.883C777.214,420.92 813.794,430.9 837.42,425.799C854.616,422.087 866.406,413.927 871.906,398.762C874.547,391.476 876.695,367.794 861.667,355.002L853.134,346.714L803.048,318.965C791.6,310.022 779.665,299.004 799.076,277.362L790.324,270.421C776.315,279.645 765.735,298.665 772.73,317.019C775.78,325.023 779.864,332.602 796.367,342.874L842.363,369.055C866.733,390.878 840.321,405.594 834.774,406.552C802.323,412.156 798.439,391.489 795.956,379.852L775.465,379.883Z" style="fill:rgb(56,70,82);"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

138
bauen.py Normal file
View file

@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
"""Der ganze Weg von der Quelle zur Setup-Datei - an EINER Stelle.
WARUM DAS HIER STEHT
--------------------
Bis hierher lag der Bau in einer Folge von Befehlen, die jemand von Hand
eintippen musste: PyInstaller fuers Programm, PyInstaller fuer den Updater,
umbenennen, Inno Setup mit den richtigen Schaltern. Wer einen Schritt
vergisst, liefert eine Setup-Datei aus, die eine alte EXE enthaelt - und
niemand sieht es ihr an. Genau das ist am 06.09.2026 passiert (der Installer
schrieb ueber eine laufende, gesperrte EXE hinweg und meldete trotzdem Erfolg).
Ab jetzt ist der Bau ein Befehl:
python bauen.py
WAS ER TUT
----------
1. Die Nummer kommt aus version.py - nicht von der Kommandozeile. So kann sie
nicht auseinanderlaufen mit dem, was das Programm ueber sich selbst sagt.
2. Programm und Updater werden gebaut.
3. Die Programm-EXE bekommt die Nummer in den Namen. Der Updater erkennt daran,
welche Fassungen installiert sind, und legt neue DANEBEN statt darueber -
deshalb kann er auch dann austauschen, wenn Windows die alte Datei sperrt.
4. Inno Setup baut die Setup-Datei.
5. Zum Schluss stehen die SHA256-Summen da. Die gehoeren in den Release-Text:
nur damit laesst sich spaeter nachweisen, dass die heruntergeladene Datei
genau die gebaute ist.
"""
import hashlib
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
# Inno Setup liegt bei ihm unter LOCALAPPDATA (Installation ohne Adminrechte).
ISCC_ORTE = [
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Inno Setup 6", "ISCC.exe"),
r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
r"C:\Program Files\Inno Setup 6\ISCC.exe",
]
def summe(pfad):
h = hashlib.sha256()
with open(pfad, "rb") as f:
for brocken in iter(lambda: f.read(1 << 20), b""):
h.update(brocken)
return h.hexdigest()
def lauf(befehl, was):
print(f"--- {was}")
e = subprocess.run(befehl, cwd=HIER, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if e.returncode != 0:
print(e.stdout[-3000:])
print(e.stderr[-3000:])
raise SystemExit(f"{was} fehlgeschlagen (Rueckgabe {e.returncode})")
def iscc():
for p in ISCC_ORTE:
if p and os.path.exists(p):
return p
raise SystemExit("ISCC.exe nicht gefunden - Inno Setup 6 fehlt")
def main():
stamm, endung = os.path.splitext(version.EXE_NAME)
mit_nummer = f"{stamm}-{version.VERSION}{endung}"
# Die Bauordner werden geleert. Ein PyInstaller-Lauf ueber einen alten
# Bauordner uebernimmt sonst Reste, die es in der Quelle nicht mehr gibt -
# und in setup/ laege noch die Datei der VORIGEN Nummer herum. Beim
# Ausliefern greift man dann leicht zur falschen.
for ordner in ("build", "dist", "setup", "release"):
shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
# Das Windows-Symbol aus dem Logo bauen: helle Kachel mit runden Ecken,
# Zeichen darauf, in allen Groessen von 16 bis 256. Es wird HIER erzeugt
# und nicht zur Laufzeit - ein installiertes Programm soll nicht in seinen
# eigenen Ordner schreiben muessen.
sys.path.insert(0, HIER)
import wasserzeichen
ico = wasserzeichen.symbol_bauen(
os.path.join(HIER, "assets", "programm.ico"))
print(f"--- Symbol: {ico or 'FEHLT'}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"],
f"{stamm} {version.VERSION}")
lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", "Updater.spec"],
"Updater")
quelle = os.path.join(HIER, "dist", version.EXE_NAME)
ziel = os.path.join(HIER, "dist", mit_nummer)
os.replace(quelle, ziel)
lauf([iscc(), f"/DVersion={version.VERSION}", f"/DExeProgramm={mit_nummer}",
"Installer.iss"], "Setup")
# ---- release/ : genau das, was ins Release gehoert ---------------------
# Der Anhang, den der Updater sucht, heisst OHNE Nummer - so steht es in
# version.EXE_NAME, und danach sucht passendes_release(). Haengt man ihn
# mit Nummer an, findet der Updater nie ein Update und meldet stumm
# "aktuell". Die Nummer kommt erst beim Ablegen dazu (updater.dateiname).
#
# Daneben die Pruefsumme als eigene Datei: ohne sie prueft der Updater nur
# die GROESSE. Eine Datei, die unterwegs verfaelscht wurde, hat aber genau
# die richtige Groesse.
freigabe = os.path.join(HIER, "release")
os.makedirs(freigabe, exist_ok=True)
shutil.copy2(ziel, os.path.join(freigabe, version.EXE_NAME))
with open(os.path.join(freigabe, version.EXE_NAME + ".sha256"),
"w", encoding="ascii") as f:
f.write(summe(ziel) + chr(10))
shutil.copy2(os.path.join(HIER, "dist", "Updater.exe"),
os.path.join(freigabe, "Updater.exe"))
for name in os.listdir(os.path.join(HIER, "setup")):
shutil.copy2(os.path.join(HIER, "setup", name),
os.path.join(freigabe, name))
print()
print(f"FERTIG | {stamm} {version.VERSION}")
print()
print(" release/ - genau diese Dateien gehoeren ans Release:")
for name in sorted(os.listdir(freigabe)):
p = os.path.join(freigabe, name)
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
if __name__ == "__main__":
main()

View file

@ -198,6 +198,23 @@ def _ort(firma):
# ------------------------------------------------------------- Monat --------
def _art_kuerzel(b) -> str:
"""Ein Buchstabe je Beleg - seine Ansage vom 04.09.2026: *"du musst die
markieren als rechnung und storno oder berichtigung, und das kannst einfach
mit buchstaben machen"*. Ohne die Marke muss man aus der Begruendung raten,
was fuer ein Beleg die Zeile ueberhaupt ist.
R = Rechnung
S = Stornorechnung
B = berichtigte Rechnung (die Neuausstellung nach einem Storno)
"""
if b.storno_zu or b.art == "storno":
return "S"
if b.art == "korrektur":
return "B"
return "R"
def _art_text(b, weg) -> str:
"""Was in der Rechnungsspalte unter der Nummer steht.
@ -207,6 +224,17 @@ def _art_text(b, weg) -> str:
setzen"*. Dieselben Worte wie in der Spalte "Art" im Fenster.
"""
if b.storno_zu:
# Die Kette zeigt VORWAERTS: Rechnung -> Storno -> Rechnung, die nun gilt.
# Ein Storno, der nur zurueckzeigt, laesst den Leser stehen ("wer soll
# denn sonst wissen, wo er suchen muss" - 04.09.2026).
if b.folge_nummer and b.folge_art == "vorhanden":
return f"abgerechnet mit {b.folge_nummer}"
if b.folge_nummer:
return f"neue Rechnung {b.folge_nummer}"
if b.folge_art == "" and b.art == "storno" and not b.folge_nummer:
# Entweder "nur Storno" oder ein alter Beleg ohne die Angabe. Dann
# bleibt der Rueckbezug - besser als gar kein Hinweis.
return f"Storno zu {b.storno_zu}"
return f"Storno zu {b.storno_zu}"
if b.id in weg:
# Nicht nur "storniert": auch WOMIT. Sonst steht auf dem Blatt ein
@ -239,14 +267,16 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
# Eigene Spalte "Grund" statt einer zweiten Zeile unter der Nummer - seine
# Ansage vom 04.09.2026: "mach eine neue spalte mit grund oder so was".
kopf = ["Datum", "Rechnung", "Grund", "Name", "Nächte", "Entgelt", "Satz %", "Steuer"]
stile = ["th", "th", "th_l", "th_l", "th", "th", "th", "th"]
kopf = ["Datum", "Rechnung", "Art", "Begründung", "Name",
"Nächte", "Entgelt", "Satz", "Steuer"]
stile = ["th", "th", "th", "th_l", "th_l", "th", "th", "th", "th"]
daten = [[Paragraph(k, st[s_]) for k, s_ in zip(kopf, stile)]]
for b in liste:
daten.append([
Paragraph(b.datum.strftime("%d.%m.%Y"), st["zelle_c"]),
Paragraph(b.voll_nummer, st["zelle_c"]),
Paragraph(f'<font size="7.5">{_art_text(b, weg)}</font>', st["zelle"]),
Paragraph(f'<b>{_art_kuerzel(b)}</b>', st["zelle_c"]),
Paragraph(_art_text(b, weg), st["zelle"]),
Paragraph(b.nachname, st["zelle"]),
Paragraph(str(b.naechte), st["zelle_c"]),
Paragraph(eur_kurz(b.gezahlt), st["zelle_r"]),
@ -256,18 +286,22 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
if len(daten) == 1:
daten.append([Paragraph("", st["zelle_c"]),
Paragraph("<i>(keine Buchungen in diesem Monat)</i>", st["zelle"]),
"", "", "", "", "", ""])
"", "", "", "", "", "", ""])
# Namensspalte nur so breit wie der breiteste Name (Rest der Tabelle ist fest)
breiteste = max([stringWidth(b.nachname, "Helvetica", 8.5) for b in liste]
+ [stringWidth("Name", "Helvetica-Bold", 8)])
# Die feste Breite ist durch die Grund-Spalte gewachsen; der Name bekommt,
# was uebrig bleibt (34 mm reichen fuer die ueblichen Namen, lange brechen um).
name_w = min(max(breiteste + 6 * mm, 22 * mm), CONTENT_W - 140 * mm)
# Die Zahlenspalten brauchen wenig - "Naechte", "Satz" und die Betraege
# standen frueher mit viel Luft nebeneinander. Sein Hinweis vom 04.09.2026:
# *"da ist noch so viel platz, die naechte und satz alleine nehmen so viel
# raum ein, da kannst locker zeile begruendung setzen"*. Also enger gestellt
# und der Begruendung so viel gegeben, dass "storniert mit 2026-901" in EINE
# Zeile passt - in normaler Schriftgroesse, nicht kleingedruckt.
name_w = min(max(breiteste + 6 * mm, 22 * mm), CONTENT_W - 144 * mm)
tabelle = Table(daten, repeatRows=1, hAlign="LEFT",
colWidths=[20 * mm, 20 * mm, 34 * mm, name_w,
13 * mm, 20 * mm, 12 * mm, 21 * mm])
colWidths=[18 * mm, 18 * mm, 9 * mm, 42 * mm, name_w,
12 * mm, 18 * mm, 9 * mm, 18 * mm])
tabelle.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
@ -279,6 +313,14 @@ def render_monat(pfad, firma, jahr: int, monat: int, buchungen):
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(tabelle)
# Legende zu den Buchstaben - direkt unter der Tabelle, damit sie beim Lesen
# da ist und nicht auf der naechsten Seite. Wer nur eine Zeile prueft, muss
# nicht raten, was fuer ein Beleg sie ist.
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"<b>Art:</b> R = Rechnung · S = Stornorechnung · B = berichtigte Rechnung "
"(Neuausstellung nach einem Storno). Die Spalte Begründung nennt den Beleg, "
"bei dem es weitergeht.", st["klein"]))
_summenblock(story, st, s, satz_text)
story.append(Spacer(1, 3 * mm))

17
db.py
View file

@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS buchungen (
storno_zu TEXT NOT NULL DEFAULT '',
storno_datum TEXT NOT NULL DEFAULT '',
vorgang TEXT NOT NULL DEFAULT '',
folge_nummer TEXT NOT NULL DEFAULT '',
folge_art TEXT NOT NULL DEFAULT '',
pdf_pfad TEXT NOT NULL DEFAULT '',
pdf_mtime REAL NOT NULL DEFAULT 0,
pdf_groesse INTEGER NOT NULL DEFAULT 0
@ -127,7 +129,8 @@ class Journal:
"""
da = {z["name"] for z in self.con.execute("PRAGMA table_info('buchungen')")}
for name, vorgabe in (("art", "'rechnung'"), ("storno_zu", "''"),
("storno_datum", "''"), ("vorgang", "''")):
("storno_datum", "''"), ("vorgang", "''"),
("folge_nummer", "''"), ("folge_art", "''")):
if name not in da:
self.con.execute(
f"ALTER TABLE buchungen ADD COLUMN {name} TEXT NOT NULL DEFAULT {vorgabe}")
@ -180,6 +183,8 @@ class Journal:
storno_zu=(r["storno_zu"] if "storno_zu" in r.keys() else ""),
storno_datum=(r["storno_datum"] if "storno_datum" in r.keys() else ""),
vorgang=(r["vorgang"] if "vorgang" in r.keys() else ""),
folge_nummer=(r["folge_nummer"] if "folge_nummer" in r.keys() else ""),
folge_art=(r["folge_art"] if "folge_art" in r.keys() else ""),
)
def _pdf_bekannt(self, pfad: str) -> bool:
@ -204,8 +209,8 @@ class Journal:
"INSERT INTO buchungen "
" (jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz, "
" quelle, pdf_pfad, pdf_mtime, pdf_groesse, art, storno_zu, "
" storno_datum, vorgang) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
" storno_datum, vorgang, folge_nummer, folge_art) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(pdf_pfad) WHERE pdf_pfad <> '' DO UPDATE SET "
" jahr=excluded.jahr, rechnungsnummer=excluded.rechnungsnummer, "
" datum=excluded.datum, nachname=excluded.nachname, naechte=excluded.naechte, "
@ -213,10 +218,12 @@ class Journal:
" pdf_pfad=excluded.pdf_pfad, pdf_mtime=excluded.pdf_mtime, "
" pdf_groesse=excluded.pdf_groesse, art=excluded.art, "
" storno_zu=excluded.storno_zu, storno_datum=excluded.storno_datum, "
" vorgang=excluded.vorgang",
" vorgang=excluded.vorgang, folge_nummer=excluded.folge_nummer, "
" folge_art=excluded.folge_art",
(b.jahr, b.rechnungsnummer, b.datum.isoformat(), b.nachname, int(b.naechte or 0),
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse,
b.art or "rechnung", b.storno_zu or "", b.storno_datum or "", b.vorgang or ""))
b.art or "rechnung", b.storno_zu or "", b.storno_datum or "", b.vorgang or "",
b.folge_nummer or "", b.folge_art or ""))
self.con.commit()
return cur.lastrowid

312
doku_aenderungen_pdf.py Normal file
View file

@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
"""Erzeugt die Erklaerung fuers Amt / die Steuerberaterin als PDF.
python doku_aenderungen_pdf.py [Zielpfad.pdf]
Warum als eigenes Skript und nicht von Hand geschrieben: der Text nennt
Paragraphen und Betraege. Wenn sich etwas am Programm aendert, aendert man den
Text hier und erzeugt das Blatt neu - statt eine Word-Datei zu suchen, die
niemand mehr findet.
Briefkopf und Farben kommen aus derselben Konfiguration wie die Rechnungen
(firma.lade()), damit das Blatt erkennbar zum Betrieb gehoert.
"""
import os
import sys
from reportlab.lib import colors
from reportlab.lib.enums import TA_JUSTIFY
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import (BaseDocTemplate, Frame, KeepTogether, PageTemplate,
Paragraph, Spacer, Table, TableStyle)
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import firma as firma_lib # noqa: E402
ACCENT = colors.HexColor("#1f5c3d")
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#c8d0c8")
MUTED = colors.HexColor("#555555")
PAGE_W, PAGE_H = A4
MARGIN_L = MARGIN_R = 20 * mm
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
def stile():
return {
"titel": ParagraphStyle("titel", fontName="Helvetica-Bold", fontSize=17,
textColor=ACCENT, leading=21, spaceAfter=2),
"untertitel": ParagraphStyle("untertitel", fontName="Helvetica", fontSize=9.5,
textColor=MUTED, leading=13, spaceAfter=10),
"h": ParagraphStyle("h", fontName="Helvetica-Bold", fontSize=11.5,
textColor=ACCENT, leading=15, spaceBefore=9, spaceAfter=4),
"t": ParagraphStyle("t", fontName="Helvetica", fontSize=9.5, leading=13.5,
alignment=TA_JUSTIFY, spaceAfter=5),
"klein": ParagraphStyle("klein", fontName="Helvetica", fontSize=8, leading=11,
textColor=MUTED, spaceAfter=4),
"th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8.5, leading=11),
"z": ParagraphStyle("z", fontName="Helvetica", fontSize=8.5, leading=11.5),
}
def kopf_fuss(canvas, doc, cfg):
canvas.saveState()
f = cfg
canvas.setFont("Helvetica-Bold", 12)
canvas.setFillColor(ACCENT)
canvas.drawRightString(PAGE_W - MARGIN_R, PAGE_H - 15 * mm, f.get("name", ""))
canvas.setFont("Helvetica-Oblique", 8)
canvas.setFillColor(MUTED)
canvas.drawRightString(PAGE_W - MARGIN_R, PAGE_H - 20 * mm, f.get("untertitel", ""))
canvas.setStrokeColor(ACCENT)
canvas.setLineWidth(0.8)
canvas.line(MARGIN_L, PAGE_H - 23 * mm, PAGE_W - MARGIN_R, PAGE_H - 23 * mm)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(MUTED)
canvas.drawString(MARGIN_L, 12 * mm, f.get("absender", ""))
canvas.drawRightString(PAGE_W - MARGIN_R, 12 * mm, f"Seite {canvas.getPageNumber()}")
canvas.restoreState()
def tabelle(daten, st, breiten):
zeilen = [[Paragraph(z, st["th"] if i == 0 else st["z"]) for z in reihe]
for i, reihe in enumerate(daten)]
t = Table(zeilen, colWidths=breiten, repeatRows=1, hAlign="LEFT")
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -1), 0.3, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]))
return t
def bauen(pfad, cfg):
st = stile()
s = []
P = lambda text, art="t": s.append(Paragraph(text, st[art])) # noqa: E731
P("Änderungen an Rechnungsschreibung und Steuerbuch", "titel")
P("Campinghof Bartl · Stand 4. September 2026 · Rechnungstool v1.5.5, "
"Steuerbuch (Beherbergungssteuer) v1.6.7", "untertitel")
P("Warum überhaupt geändert wurde", "h")
P("Im laufenden Betrieb sind zwei Dinge aufgefallen, die beide dieselbe Ursache haben – "
"es gab keine feste Vorgabe, wie eine bereits geschriebene Rechnung korrigiert wird:")
P("<b>1. Dieselbe Leistung stand mehrfach im Rechnungsordner.</b> Wenn an einer Rechnung etwas "
"nicht stimmte, wurde mit dem alten Programm einfach eine neue geschrieben – mit einer neuen "
"Nummer. Die alte blieb liegen. Damit stand derselbe Aufenthalt zweimal im Ordner und wäre "
"zweimal in die Beherbergungssteuer-Anmeldung eingegangen.")
P("<b>2. Beim Empfänger stand teilweise nur der Vor- oder nur der Nachname.</b> Die "
"Anmeldekarten der Gäste enthalten alle Angaben; auf der Rechnung landete davon nicht immer "
"alles.")
P("Was jetzt gilt – feste Wege statt freier Hand", "h")
P("Eine geschriebene Rechnung lässt sich nicht mehr formlos ändern. Es gibt genau zwei Wege, "
"und beide erzeugen einen eigenen, nachvollziehbaren Beleg:")
s.append(tabelle([
["Fall", "Weg im Programm", "Was der Beleg sagt"],
["Angaben waren falsch, Leistung und Beträge stimmen "
"(z. B. Name, Anschrift)",
"<b>Berichtigung</b>",
"Eigenes Blatt, das sich auf die Rechnung bezieht. Die Rechnung <b>behält ihre "
"Nummer</b>, Beträge bleiben unverändert."],
["Die Rechnung selbst muss weg (falsche Leistung, "
"Doppelung, geplatzte Buchung)",
"<b>Storno</b> mit drei Auswahlmöglichkeiten",
"Eigene Stornorechnung mit Minusbeträgen, die die Rechnung nach Nummer, Datum und "
"Empfänger benennt."],
], st, [42 * mm, 38 * mm, CONTENT_W - 80 * mm]))
s.append(Spacer(1, 4 * mm))
P("Die drei Wege im Storno", "h")
P("Nach dem Storno muss auf dem Blatt stehen, wie es weitergeht – sonst wartet der Gast auf "
"eine Rechnung, die nie kommt, oder es bleibt eine Nummer unnötig reserviert. Deshalb fragt "
"das Programm zuerst:")
s.append(tabelle([
["", "Fall", "Folge auf dem Beleg"],
["A", "Es folgt eine neue, richtige Rechnung",
"Die Nummer der berichtigten Rechnung steht bereits auf dem Storno: "
"alte Nummer → Storno → neue Nummer."],
["B", "Die Leistung ist schon unter einer anderen Nummer abgerechnet "
"(der Doppelungsfall)",
"Das Storno verweist auf die <b>bleibende</b> Rechnung. Das Programm prüft, ob es diese "
"Nummer gibt und ob Gast, Zeitraum und Betrag dazu passen. Es wird keine Nummer "
"reserviert und kein Vorgang bleibt offen."],
["C", "Es wird gar nichts abgerechnet (Buchung geplatzt, Rechnung irrtümlich)",
"Auf dem Blatt steht, dass keine weitere Rechnung folgt. Es wird ebenfalls keine Nummer "
"reserviert."],
], st, [8 * mm, 52 * mm, CONTENT_W - 60 * mm]))
s.append(Spacer(1, 4 * mm))
P("Pflichtfelder beim Empfänger", "h")
P("Die Felder für den Empfänger sind jetzt <b>Pflicht</b>. Das ist keine Verschärfung durch "
"das Programm, sondern geltendes Recht: eine Rechnung muss den vollständigen Namen und die "
"vollständige Anschrift des Leistungsempfängers enthalten. Für die Beherbergungssteuer kommt "
"hinzu, dass die Meldescheine ohnehin Name, Wohnanschrift, Geburtsdatum sowie An- und "
"Abreise tragen und zusammen mit den Rechnungskopien aufzubewahren sind – die Angaben liegen "
"also vor und gehören auf den Beleg. Nur so lässt sich später zweifelsfrei sagen, "
"<b>wer zu welcher Rechnung gehört</b>.")
P("Die Rechnungsnummer steht fest", "h")
P("Eine einmal ausgestellte Rechnung behält ihre Nummer. Sie lässt sich im Programm nicht mehr "
"umschreiben – geändert wird ausschließlich über einen der beiden Belege: die "
"<b>Berichtigung</b> (die Rechnung behält ihre Nummer) oder den <b>Storno</b> (eigener Beleg "
"mit eigener Nummer). Vier Sperren sorgen dafür:")
s.append(tabelle([
["Sperre", "Wirkung"],
["Eine vergebene Nummer wird nicht wiederverwendet",
"Vor dem Schreiben prüft das Programm den Rechnungsordner <b>und</b> das gemeinsame "
"Nummernbuch. Ist die Nummer belegt, wird die vorhandene Rechnung <b>nicht</b> "
"überschrieben, sondern die nächste freie Nummer angeboten."],
["Der Zähler kann nicht zurück",
"Auch nicht über die Einstellungen. Als Quelle gelten die vorhandenen Rechnungs-PDFs und "
"das Nummernbuch – wer den Zähler kleiner setzt, hebt ihn beim nächsten Beleg "
"automatisch wieder an."],
["Beim Storno ist das Nummernfeld gesperrt",
"Dort vergibt ausschließlich das Programm: die Nummer des Stornos und – wenn eine neue "
"Rechnung folgt – gleich auch deren Nummer, damit die Kette auf dem Blatt steht."],
["Keine Nummer auf Vorrat",
"Verweist das Storno auf eine bereits bestehende Rechnung oder folgt gar nichts, wird "
"keine Nummer reserviert."],
], st, [58 * mm, CONTENT_W - 58 * mm]))
s.append(Spacer(1, 2 * mm))
P("Eine lückenlose Folge verlangt das Gesetz nicht – eine <b>einmalige</b> schon "
"(§ 14 Abs. 4 Nr. 4 UStG). Vorwärts darf die Nummerierung also springen, zurück nicht.",
"klein")
s.append(Spacer(1, 2 * mm))
P("Doppelte Rechnungsnummern werden angezeigt, nicht weggerechnet", "h")
P("Das Steuerbuch meldet jede Nummer, die mehr als einmal vergeben wurde – rot in der Liste, "
"im Monatsreiter markiert und in einer eigenen Übersicht mit allen betroffenen Belegen. "
"Gerechnet wird über das ganze Jahr, weil dieselbe Nummer in zwei Monaten liegen kann. "
"<b>Die Beträge bleiben dabei in allen Summen stehen</b>: das Buch soll mit dem "
"Rechnungsordner übereinstimmen. Was zu tun ist, entscheidet der Betrieb – der vorgesehene "
"Weg steht im Programm: eine der beiden Rechnungen stornieren und, falls nötig, mit freier "
"Nummer neu ausstellen. Sobald das geschehen ist, verschwindet die Meldung von selbst, weil "
"eine stornierte Rechnung keine offene mehr ist.")
P("Wie ein Storno in die Steueranmeldung eingeht", "h")
P("Die aufgehobene Rechnung bleibt mit ihrem Plus im Monat der Rechnung stehen, die "
"Stornorechnung steht mit ihrem Minus im Monat des Stornos. Über das Jahr gleicht sich das "
"aus. Bewusst wurde der sichere Weg gewählt: im ersten Monat wird eher zu viel gemeldet und "
"im zweiten entsprechend weniger – nie zuerst zu wenig. Fällt eine Korrektur in einen Monat, "
"der bereits angemeldet ist, ist eine berichtigte Anmeldung vorgesehen; das amtliche Formular "
"hat dafür ein eigenes Ankreuzfeld, und die Anmeldung steht ohnehin unter dem Vorbehalt der "
"Nachprüfung.")
P("Damit das Blatt fürs Amt aus sich heraus verständlich ist, trägt jede betroffene Zeile eine "
"<b>Begründung</b>, und zwar als <b>Kette, die vorwärts zeigt</b> – jede Zeile sagt, wo es "
"weitergeht:")
s.append(tabelle([
["Art", "Zeile im Bericht", "Begründung", "Bedeutung"],
["<b>R</b>", "die aufgehobene Rechnung", "storniert mit 2026-901",
"hier steht, welcher Beleg sie aufhebt"],
["<b>S</b>", "die Stornorechnung", "neue Rechnung 2026-902",
"hier steht, unter welcher Nummer die Leistung jetzt abgerechnet ist"],
["<b>S</b>", "Stornorechnung im Doppelungsfall", "abgerechnet mit 2026-343",
"die Leistung war bereits abgerechnet – das ist die Rechnung, die bleibt"],
["<b>B</b>", "die berichtigte Rechnung", "Neuausstellung",
"sie ersetzt die aufgehobene"],
], st, [9 * mm, 40 * mm, 40 * mm, CONTENT_W - 89 * mm]))
s.append(Spacer(1, 1 * mm))
P("Die Spalte <b>Art</b> markiert jeden Beleg mit einem Buchstaben – <b>R</b> Rechnung, "
"<b>S</b> Stornorechnung, <b>B</b> berichtigte Rechnung. Die Erklärung dazu steht auf jedem "
"Blatt unter der Tabelle.", "klein")
s.append(Spacer(1, 2 * mm))
P("So findet man von jedem Beleg aus den nächsten, auch wenn Rechnung und Storno in "
"verschiedenen Monaten liegen und gar nicht auf demselben Blatt stehen.")
P("Die Rechtsgrundlagen im Einzelnen", "h")
P("„Satzung“ meint die Satzung über die Erhebung einer Beherbergungssteuer in der Stadt "
"Markkleeberg vom 6. Februar 2024, anzuwenden auf Beherbergungsleistungen ab dem "
"1. Januar 2025.", "klein")
s.append(tabelle([
["Vorschrift", "Inhalt", "Was im Programm daraus folgt"],
["§ 14 Abs. 4 Nr. 1 UStG", "Vollständiger Name und vollständige Anschrift des "
"Leistungsempfängers", "Empfängerfelder sind Pflichtfelder"],
["§ 14 Abs. 4 Nr. 4 UStG", "Fortlaufende Nummer, vom Aussteller <b>einmalig</b> vergeben",
"Doppelte Nummern werden gemeldet; Stornos und Neuausstellungen bekommen eigene Nummern, "
"es werden keine reserviert, die nicht gebraucht werden"],
["§ 31 Abs. 5 UStDV", "Eine Rechnung kann berichtigt werden; das Dokument muss sich "
"<b>spezifisch und eindeutig</b> auf die Rechnung beziehen",
"Berichtigungsblatt bezieht sich auf Nummer und Datum; das Storno nennt zusätzlich den "
"Empfänger – bei einer doppelt vergebenen Nummer wäre die Nummer allein kein "
"eindeutiger Bezug"],
["§ 14c Abs. 1 UStG", "Wer in einer Rechnung mehr Steuer ausweist, als er schuldet, "
"schuldet auch den Mehrbetrag",
"Deshalb wird eine doppelt geschriebene Rechnung storniert und nicht einfach liegen "
"gelassen; das Berichtigungsblatt sagt ausdrücklich, dass es keine zweite Leistung ist"],
["§ 146 Abs. 4 AO", "Eine Buchung darf nicht so verändert werden, dass der ursprüngliche "
"Inhalt nicht mehr feststellbar ist",
"Kein freies Bearbeiten von Buchungen mehr; jede Änderung entsteht über einen eigenen "
"Beleg (Storno oder Berichtigung), gelöscht wird nichts"],
["§ 147 AO (über § 7 Abs. 4 der Satzung)", "Aufbewahrung der Rechnungskopien und "
"Meldescheine, Vorlage auf Anforderung", "Rechnungen bleiben als PDF im Ordner; das "
"Steuerbuch liest sie und legt sie nicht neu an"],
["§ 3 Abs. 1 und 3 der Satzung",
"Bemessungsgrundlage ist das Übernachtungsentgelt einschließlich Umsatzsteuer; "
"5 % je Übernachtung, abgerundet auf volle Cent; höchstens 21 Tage",
"Die Steuer wird auf die Zwischensumme der Rechnung gerechnet und auf dem Beleg "
"gesondert ausgewiesen"],
["§ 6 der Satzung", "Der Steueranspruch entsteht mit Beginn der Beherbergung; ohne Entgelt "
"entsteht keine Steuer", "Ein Angebot löst nichts aus; erst die Rechnung wird gebucht"],
["§ 7 Abs. 3 der Satzung", "Meldescheine mit Name, Wohnanschrift, Geburtsdatum sowie "
"An- und Abreise", "Die Angaben liegen vor und gehören auf die Rechnung"],
["§ 7 Abs. 5 der Satzung", "Die im Kalendermonat <b>vereinnahmte</b> Steuer ist bis zum "
"10. des Folgemonats selbst zu berechnen, anzumelden und abzuführen",
"Monatsbericht als Aufstellung zur Anmeldung; Nullmonate bleiben nachvollziehbar"],
["§§ 164, 168 AO (über § 3 Abs. 1 SächsKAG)", "Die Steueranmeldung steht einer Festsetzung "
"unter Vorbehalt der Nachprüfung gleich",
"Ein bereits angemeldeter Monat kann berichtigt werden – deshalb ist der sichere Weg, "
"im Zweifel eher zu viel zu melden"],
], st, [42 * mm, 55 * mm, CONTENT_W - 97 * mm]))
s.append(Spacer(1, 4 * mm))
P("Zwei Punkte, die noch offen sind", "h")
P("<b>1. Steuerbefreite Übernachtungen.</b> Nach § 4 Abs. 1 Nr. 1 der Satzung sind Kinder und "
"Jugendliche bis zum vollendeten 18. Lebensjahr von der Beherbergungssteuer befreit. Im "
"Leistungskatalog gibt es eine eigene Position „Personengebühr Kinder ab 2 Jahre bis 14 "
"Jahre“. Dieser Umsatz wird derzeit mitversteuert. Das amtliche Anmeldeformular sieht dafür "
"eine eigene Zeile vor („abzüglich Umsätze aus steuerbefreiten Übernachtungen“). Bis zur "
"Umstellung wird also eher zu viel als zu wenig gemeldet.")
P("<b>2. Abrundung je Übernachtung.</b> Nach § 3 Abs. 3 der Satzung wird der Steueranteil je "
"einzelner Übernachtung auf volle Cent abgerundet. Derzeit werden 5 % auf die Summe "
"gerechnet. Das amtliche Formular trägt dem mit einer eigenen Zeile Rechnung "
"(„tatsächlich einbehaltene Beherbergungssteuer“).")
P("Beide Punkte wirken zugunsten der Stadt und nicht zulasten – sie führen zu einer eher zu "
"hohen, nicht zu einer zu niedrigen Anmeldung.", "klein")
P("Zum Schluss", "h")
P("Die Programme ändern nichts rückwirkend und löschen nichts. Jede Korrektur ist ein eigener "
"Beleg mit eigener Nummer, der sagt, worauf er sich bezieht. Damit lässt sich zu jedem "
"Zeitpunkt nachvollziehen, welche Rechnung zu welchem Gast gehört, welche aufgehoben wurde "
"und womit – im Rechnungsordner, im Steuerbuch und auf dem Blatt für die Stadt.")
doc = BaseDocTemplate(pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=30 * mm, bottomMargin=18 * mm,
title="Änderungen an Rechnungsschreibung und Steuerbuch",
author=cfg.get("name", ""))
rahmen = Frame(MARGIN_L, 18 * mm, CONTENT_W, PAGE_H - 30 * mm - 18 * mm, id="f")
doc.addPageTemplates([PageTemplate(id="seite", frames=[rahmen],
onPage=lambda c, d: kopf_fuss(c, d, cfg))])
doc.build(s)
return pfad
if __name__ == "__main__":
ziel = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.expanduser("~"), "Desktop",
"Campinghof_Aenderungen_Rechnungswesen_2026-09-04.pdf")
cfg = firma_lib.lade()
bauen(ziel, cfg)
print("geschrieben:", ziel, os.path.getsize(ziel), "Bytes")

View file

@ -61,6 +61,10 @@ def eigene_pid_merken():
with open(pid_datei(), "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
# Der Merkzettel ist eine Bequemlichkeit, keine Bedingung: ohne ihn
# findet eine zweite Instanz das laufende Fenster nicht und meldet
# nur, dass das Programm schon laeuft. Daran darf der Start nicht
# scheitern - der Ordner kann schreibgeschuetzt sein.
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""

View file

@ -55,6 +55,9 @@ CREATE TABLE IF NOT EXISTS nummern (
quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal
herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell
pdf_pfad TEXT NOT NULL DEFAULT '',
freigabe TEXT NOT NULL DEFAULT '', -- ISO-Zeitstempel der
-- ausdruecklichen Freigabe
-- durch den Betreiber
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
);
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
@ -83,7 +86,51 @@ CREATE TABLE IF NOT EXISTS nachrichten (
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
"steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu",
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad")
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad",
"freigabe")
def standard_ordner():
"""Wo das Nummernbuch liegt, wenn niemand etwas anderes einstellt.
WARUM ES DIESEN FESTEN ORT GIBT (07.09.2026)
--------------------------------------------
Vorher fragte jedes Programm beim ersten Start nach einem Ordner - und der
Nutzer musste in BEIDEN denselben eintragen. Sein Einwand, wortwoertlich:
"wie soll das rechnungstool und steuertool wissen wo es ist, das muss
im ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an
das andere ran"
Er hat recht: die beiden sind ueber genau zwei Dinge verbunden - diese
SQLite-Datei und die kleine API, die "hier ist etwas Neues" ruft. Wenn
schon der Ort geraten werden muss, ist die Verbindung nur so gut wie die
Aufmerksamkeit dessen, der zweimal denselben Pfad tippt. Ein falscher
Buchstabe faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal
vergeben wurde.
Deshalb: ein fester Ort, den beide von selbst kennen. Gefragt wird nur
noch, wer ihn ausdruecklich verschieben will (Menue -> Gemeinsamer Ordner) -
etwa auf ein Netzlaufwerk, wenn die Programme auf zwei Rechnern laufen.
LOCALAPPDATA und nicht "Dokumente": dort liegen die Programmdaten ohnehin,
und niemand raeumt den Ordner versehentlich auf. Das Buch ist ein
REGISTER, kein Beleg - geht es verloren, fehlt keine Rechnung, es wird beim
naechsten Einlesen neu gefuellt.
"""
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(heim, "ravokk")
def standard_anlegen():
"""Den festen Ordner anlegen und zurueckgeben - oder '' wenn das nicht geht."""
ordner = standard_ordner()
try:
os.makedirs(ordner, exist_ok=True)
return ordner
except OSError as e:
log.warning("Gemeinsamer Ordner %s liess sich nicht anlegen: %s", ordner, e)
return ""
def pfad_zur_datei(ordner):
@ -197,6 +244,12 @@ class Nummernbuch:
spalten = {z["name"] for z in con.execute("PRAGMA table_info('laeuft')")}
if spalten and "port" not in spalten:
con.execute("ALTER TABLE laeuft ADD COLUMN port INTEGER NOT NULL DEFAULT 0")
# Ebenso die Freigabe-Spalte: ein Buch aus der Fassung vor dem 06.09.2026
# kennt sie noch nicht. Alte Zeilen bleiben leer - dort wurde nie gefragt,
# und ein nachtraeglich erfundener Zeitstempel waere schlimmer als keiner.
spalten_n = {z["name"] for z in con.execute("PRAGMA table_info('nummern')")}
if spalten_n and "freigabe" not in spalten_n:
con.execute("ALTER TABLE nummern ADD COLUMN freigabe TEXT NOT NULL DEFAULT ''")
return con
# ---- Lesen -------------------------------------------------------------

View file

@ -23,12 +23,37 @@ SPALTEN = [
QUELLE_TEXT = {"pdf": "PDF", "xlsx": "Excel", "manuell": "manuell"}
# Rueckfallfarben. Sie gelten nur, wenn das Fenster (noch) keine Farben
# traegt - etwa in einem Pruefstand, der ein Widget einzeln baut. Im laufenden
# Programm kommen sie aus theme.py ueber das Hauptfenster; siehe farben().
BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
RAHMEN = "#c3ccc5"
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
def farben(widget):
"""Die Farben des Fensters, zu dem dieses Widget gehoert.
Diese Datei zeichnet mit tk-Widgets (nicht ttk), und die tragen ihre Farbe
fest. Ohne diese Abfrage blieb die ganze Summenleiste hell, waehrend das
uebrige Fenster dunkel war - ein weisser Block unten im Bild
(07.09.2026 aufgefallen).
getattr mit Rueckfall statt einer harten Abhaengigkeit: so laesst sich ein
Widget aus dieser Datei auch ohne die App bauen.
"""
top = widget.winfo_toplevel()
return {
"bg": getattr(top, "col_card", BG),
"rahmen": getattr(top, "col_line", RAHMEN),
"betont": getattr(top, "col_accent", GRUEN),
"text": getattr(top, "col_fg", "black"),
"leise": getattr(top, "col_muted", "#4a544c"),
"warnung": getattr(top, "col_gefahr", WARNUNG),
}
class SummenLeiste(tk.Frame):
"""Kennzahlen als abgesetzte Felder statt einer langen Textzeile."""
@ -47,21 +72,23 @@ class SummenLeiste(tk.Frame):
]
def __init__(self, master, mit_schnitt: bool = False):
super().__init__(master, bg=BG, highlightbackground=RAHMEN, highlightthickness=1)
f = farben(master)
super().__init__(master, bg=f["bg"], highlightbackground=f["rahmen"],
highlightthickness=1)
self.werte = {}
self.felder = self.FELDER_JAHR if mit_schnitt else self.FELDER
for spalte, (schluessel, titel, betont) in enumerate(self.felder):
if spalte:
tk.Frame(self, bg=RAHMEN, width=1).grid(row=0, column=spalte * 2 - 1,
sticky="ns", pady=8)
zelle = tk.Frame(self, bg=BG)
tk.Frame(self, bg=f["rahmen"], width=1).grid(
row=0, column=spalte * 2 - 1, sticky="ns", pady=8)
zelle = tk.Frame(self, bg=f["bg"])
zelle.grid(row=0, column=spalte * 2, sticky="ew", padx=16, pady=8)
tk.Label(zelle, text=titel.upper(), bg=BG, fg="#4a544c",
tk.Label(zelle, text=titel.upper(), bg=f["bg"], fg=f["leise"],
font=("Segoe UI", 7, "bold")).pack(anchor="w")
wert = tk.Label(zelle, text="–", bg=BG,
fg=GRUEN if betont else "black",
wert = tk.Label(zelle, text="–", bg=f["bg"],
fg=f["betont"] if betont else f["text"],
font=("Segoe UI", 13 if betont else 12,
"bold" if betont else "normal"))
wert.pack(anchor="w")
@ -104,11 +131,15 @@ class MonatsTab(ttk.Frame):
self.baum.grid(row=0, column=0, sticky="nsew")
rollbalken.grid(row=0, column=1, sticky="ns")
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der Schriftfarbe
# Woher eine Buchung stammt, steht in der Spalte "Quelle" – nicht in der
# Schriftfarbe. Die normale Schriftfarbe kommt aus dem Fenster: fest
# "black" wuerde in der dunklen Darstellung schwarz auf dunkelgrau
# ergeben - also unlesbar.
f = farben(self)
for tag in ("pdf", "xlsx", "manuell"):
self.baum.tag_configure(tag, foreground="black")
self.baum.tag_configure(tag, foreground=f["text"])
# Rot ist einzig für den Fehlerfall reserviert: die Nummer gibt es zweimal.
self.baum.tag_configure("doppelt", foreground=WARNUNG)
self.baum.tag_configure("doppelt", foreground=f["warnung"])
# Storno und die von ihm aufgehobene Rechnung stehen NICHT grau da
# (Ansage des Auftraggebers 04.09.2026: "ausgrauen wuerde ich es nicht,
# ich wuerde es nur als Storno taggen"). Der Hinweis steht in der Spalte
@ -153,7 +184,13 @@ class MonatsTab(ttk.Frame):
else:
marke = (b.quelle,)
if b.storno_zu:
art_text = f"Storno zu {b.storno_zu}"
# Kette vorwaerts: Rechnung -> Storno -> die Rechnung, die gilt.
if b.folge_nummer and b.folge_art == "vorhanden":
art_text = f"abgerechnet mit {b.folge_nummer}"
elif b.folge_nummer:
art_text = f"neue Rechnung {b.folge_nummer}"
else:
art_text = f"Storno zu {b.storno_zu}"
elif b.id in aufgehoben:
art_text = f"storniert mit {aufgehoben[b.id].voll_nummer}"
elif b.art == "korrektur":

109
hinweis.py Normal file
View file

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""Der Hinweis auf die Erprobungsfassung - und die Kenntnisnahme des Nutzers.
WARUM ES DAS GIBT
-----------------
Beide Programme schreiben Belege, die steuerlich zaehlen. Solange sie erprobt
werden, muss der Betreiber wissen, woran er ist: dass es eine Erprobungsfassung
ist, dass fuer falsch ausgestellte Belege nicht gehaftet wird, und dass er jeden
Beleg vor dem Herausgeben SELBST ansehen muss - dafuer gibt es die Vorschau.
Sein Wortlaut (06.09.2026):
"bei jedem oeffnen, solange wir die testen, ein popup, dass dies eine
experimentelle software ist und ich keine haftung fuer irgendwelche falsch
ausgestellten uebernehme. der user muss selbst pruefen ueber den vorschau
button auf beiden tools, ob das alles seine richtigkeit hat. wenn nicht,
auf dem link eine issue erstellen. und das alles auch noch mal in die info
mit signatur: user hat dies gelesen und zur kenntnis genommen. wenn er nein
drueckt oder ablehnt, schliesst sich die software und geht beim naechsten
start wieder mit dem selben auf, bis er auf ja drueckt."
WIE ES WIRKT
------------
* Beim Start kommt der Hinweis, BEVOR gearbeitet werden kann.
* "Nein" beendet das Programm. Es wird nichts geschrieben, nichts gespeichert.
* "Ja" wird mit Benutzername, Zeitpunkt und Fassung in der Konfiguration
vermerkt - das ist die Signatur. Danach kommt der Hinweis nicht mehr.
* Aendert sich der TEXT (also FASSUNG), wird erneut gefragt. Eine Zustimmung
gilt fuer das, was dagestanden hat, nicht fuer alles Kuenftige.
* Unter "Info" steht derselbe Text dauerhaft, mit der Signaturzeile darunter.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import datetime
import getpass
# Fassung des HINWEISTEXTES, nicht des Programms. Wird der Text geaendert,
# gehoert dieses Datum mitgeaendert - dann wird die Kenntnisnahme neu eingeholt.
FASSUNG = "2026-09-06"
# Wo Fehler gemeldet werden. Je Programm eine eigene Adresse.
#
# Das frueher "Beherbergungssteuer" genannte Programm heisst seit dem
# 06.09.2026 STEUERRECHNUNGSTOOL - so wie sein Repository. Der lokale Ordner
# heisst noch beherbergungssteuer: der Pfad steht in mehreren Pruefstaenden und
# in den Spec-Dateien fest, das ist ein eigener Schritt.
ISSUES = {
"rechnungstool": "https://git.pcore.de/TheMockTv/rechnungstool",
"steuerrechnungstool": "https://git.pcore.de/TheMockTv/Steuerrechnungstool",
}
TITEL = "Erprobungsfassung – bitte lesen"
def text(programm):
"""Der Hinweistext. `programm` ist ein Schluessel aus ISSUES."""
url = ISSUES.get(programm, "")
return (
"Diese Software ist eine ERPROBUNGSFASSUNG.\n\n"
"Sie wird noch getestet. Für Belege, die falsch ausgestellt werden, "
"wird keine Haftung übernommen.\n\n"
"Prüfen Sie jeden Beleg selbst, bevor Sie ihn herausgeben oder "
"verbuchen. Nutzen Sie dafür die Vorschau – in diesem Programm und im "
"jeweils anderen. Stimmt etwas nicht, melden Sie es bitte als Fehler "
"unter:\n\n"
f" {url}\n\n"
"Mit „Ja\" bestätigen Sie, dass Sie diesen Hinweis gelesen und zur "
"Kenntnis genommen haben.\n"
"Mit „Nein\" wird das Programm beendet; der Hinweis erscheint beim "
"nächsten Start erneut."
)
def _stand(cfg):
return (str(cfg.get("hinweis_fassung", "") or ""),
str(cfg.get("hinweis_zeit", "") or ""),
str(cfg.get("hinweis_benutzer", "") or ""))
def angenommen(cfg):
"""Hat der Nutzer GENAU DIESE Fassung schon zur Kenntnis genommen?"""
fassung, zeit, benutzer = _stand(cfg)
return fassung == FASSUNG and bool(zeit) and bool(benutzer)
def vermerken(cfg):
"""Die Kenntnisnahme festhalten. Gibt die Signaturzeile zurueck."""
cfg["hinweis_fassung"] = FASSUNG
cfg["hinweis_zeit"] = datetime.datetime.now().isoformat(timespec="seconds")
try:
cfg["hinweis_benutzer"] = getpass.getuser()
except Exception: # noqa: BLE001 - ohne Benutzernamen ist es kein Grund zu scheitern
cfg["hinweis_benutzer"] = "unbekannt"
return signatur(cfg)
def signatur(cfg):
"""Die Zeile fuer den Info-Dialog - oder '' , wenn nichts vorliegt."""
fassung, zeit, benutzer = _stand(cfg)
if not (fassung and zeit and benutzer):
return ""
try:
wann = datetime.datetime.fromisoformat(zeit).strftime("%d.%m.%Y um %H:%M")
except ValueError:
wann = zeit
return (f"Gelesen und zur Kenntnis genommen von {benutzer} "
f"am {wann} (Fassung {fassung}).")

169
menueleiste.py Normal file
View file

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""Eine Menueleiste, die sich einfaerben laesst.
WARUM NICHT DAS MENUE VON TK
----------------------------
`tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des
Betriebssystems. Es nimmt Farben zwar entgegen, zeichnet aber trotzdem im
Systemgrau - ein weisser Balken ueber einem dunklen Fenster. Sein Befund am
07.09.2026 zum Steuerrechnungstool: *"nur die einstellunden hilfe leiste die
ist noch hell"*.
Diese Leiste ist deshalb aus gewoehnlichen Labels gebaut: eine Zeile mit den
Titeln, und beim Klick geht ein randloses Toplevel als Klappliste auf. Beides
traegt die Farben des Programms.
WAS SIE KANN UND WAS NICHT
--------------------------
Sie kann, was diese beiden Programme brauchen: Titel, Eintraege, Trennlinien.
Sie kann bewusst NICHT: Untermenues, Tastenkuerzel neben dem Eintrag,
Ankreuzfelder. Wer das braucht, baut es hier ein - nicht daneben.
BENUTZUNG
self.menue = menueleiste.Menueleiste(self, farben=self, menues={
"Datei": [("Beenden", self._beenden)],
"Hilfe": [("Info", self._info)],
})
`farben` ist das Fenster mit den col_*-Feldern (das ThemeMixin). Ein "-" als
Beschriftung ergibt eine Trennlinie.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import theme
import tkinter as tk
from tkinter import ttk
class Menueleiste(tk.Frame):
def __init__(self, master, menues, farben=None):
self.f = farben if farben is not None else master
super().__init__(master, bg=self.f.col_bg)
self.menues = menues
self._offen = None
self._anker = None # welcher Titel gerade offen ist
self._titel = {} # Label -> Eintraege, fuer den Wechsel im Klick
self.pack(side="top", fill="x")
for titel, eintraege in menues.items():
lbl = tk.Label(self, text=titel, bg=self.f.col_bg, fg=self.f.col_fg,
padx=8, pady=3)
lbl.pack(side="left")
self._titel[lbl] = eintraege
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.f.col_sel))
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.f.col_bg))
lbl.bind("<Button-1>",
lambda e, b=lbl, it=eintraege: self.aufklappen(b, it))
ttk.Separator(master, orient="horizontal").pack(side="top", fill="x")
# ---------------------------------------------------------------- Klappen
def aufklappen(self, anker, eintraege):
if self._offen: # zweiter Klick schliesst wieder
self.zuklappen()
return
pop = tk.Toplevel(self)
pop.overrideredirect(True)
# Der Rahmen ist der Hintergrund des Toplevels, einen Pixel breit -
# sonst zeichnet Windows einen weissen Rand um die Liste.
pop.configure(bg=self.f.col_line)
innen = tk.Frame(pop, bg=self.f.col_field)
innen.pack(padx=1, pady=1)
for beschriftung, befehl in eintraege:
if beschriftung == "-":
tk.Frame(innen, bg=self.f.col_line, height=1).pack(fill="x", pady=3)
continue
eintrag = tk.Label(innen, text=beschriftung, bg=self.f.col_field,
fg=self.f.col_fg, anchor="w", padx=16, pady=5)
eintrag.pack(fill="x")
eintrag.bind("<Enter>",
lambda e, w=eintrag: w.configure(bg=self.f.col_sel))
eintrag.bind("<Leave>",
lambda e, w=eintrag: w.configure(bg=self.f.col_field))
eintrag.bind("<Button-1>", lambda e, c=befehl: self._ausfuehren(c))
pop.update_idletasks()
# Auf den sichtbaren Bereich begrenzen, wie jede andere Position im
# Programm auch. Steht das Fenster am unteren oder rechten Rand, lief
# die Liste sonst hinaus und war nicht mehr zu treffen.
x, y = theme.auf_bildschirm(
anker.winfo_rootx(), anker.winfo_rooty() + anker.winfo_height(),
pop.winfo_reqwidth(), pop.winfo_reqheight())
pop.geometry(f"+{x}+{y}")
self._offen = pop
self._anker = anker
pop.grab_set()
pop.bind("<Button-1>", self._klick_ausserhalb, add="+")
pop.bind("<Escape>", lambda e: self.zuklappen())
faerben = getattr(self.f, "_dark_titlebar", None)
if faerben:
faerben(pop)
def _klick_ausserhalb(self, ereignis):
"""Klick neben die Liste: schliessen - und auf einem anderen Titel
gleich dessen Liste aufklappen.
WARUM DAS HIER STEHT UND NICHT IM LABEL (07.09.2026)
----------------------------------------------------
Solange eine Liste offen ist, haelt ihr `grab_set` alle Mausklicks der
Anwendung fest. Die `<Button-1>`-Bindung des Menuetitels feuert dann
gar nicht - der Klick landet hier. Vorher endete er damit beim blossen
Zuklappen, und man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. Das native tk.Menu, das diese Leiste ersetzt hat,
wechselte schon beim blossen Ueberfahren; zwei Klicks waren also eine
Verschlechterung gegenueber dem, was vorher da war.
Ein Klick auf DENSELBEN Titel schliesst weiterhin nur - sonst koennte
man eine Liste nie ueber ihren Titel wieder loswerden.
"""
pop = self._offen
if not pop:
return
if (pop.winfo_rootx() <= ereignis.x_root
<= pop.winfo_rootx() + pop.winfo_width()
and pop.winfo_rooty() <= ereignis.y_root
<= pop.winfo_rooty() + pop.winfo_height()):
return # der Klick lag in der Liste
vorher = self._anker
self.zuklappen()
getroffen = self._titel_bei(ereignis.x_root, ereignis.y_root)
if getroffen is not None and getroffen is not vorher:
self.aufklappen(getroffen, self._titel[getroffen])
def _titel_bei(self, x_root, y_root):
"""Welcher Menuetitel an dieser Bildschirmstelle liegt - oder None.
Nachgerechnet statt gefragt: `winfo_containing()` waere kuerzer, gibt
aber nichts zurueck, sobald das Fenster einer ANDEREN Anwendung ueber
dem Punkt liegt. Beim Bedienen ist das nie der Fall - beim Pruefen
schon, und dort meldete es dann einen Fehler, den es nicht gibt
(07.09.2026, zwei Pruefstaende liefen gleichzeitig auf demselben
Bildschirmplatz). Wo die eigenen Titel liegen, weiss diese Leiste
selbst; dafuer muss sie das Fenstersystem nicht fragen.
"""
for lbl in self._titel:
x, y = lbl.winfo_rootx(), lbl.winfo_rooty()
if (x <= x_root <= x + lbl.winfo_width()
and y <= y_root <= y + lbl.winfo_height()):
return lbl
return None
def _ausfuehren(self, befehl):
# Erst zuklappen, dann ausfuehren: sonst haelt der grab_set der Liste
# einen Dialog fest, der aus dem Eintrag heraus aufgeht.
self.zuklappen()
if befehl:
befehl()
def zuklappen(self):
if self._offen:
try:
self._offen.grab_release()
self._offen.destroy()
except tk.TclError:
pass
self._offen = None
self._anker = None

View file

@ -57,6 +57,11 @@ class Buchung:
storno_zu: str = "" # Nummer der aufgehobenen Rechnung
storno_datum: str = "" # deren Datum (TT.MM.JJJJ) - Teil des Bezugs
vorgang: str = "" # Klammer um Rechnung, Storno und Neuausstellung
# Worauf der Storno VORWAERTS zeigt. Ohne das endet die Kette am Storno und
# niemand weiss, wo die Leistung nun abgerechnet ist (seine Ansage 04.09.2026:
# "wer soll denn sonst wissen, wo er suchen muss").
folge_nummer: str = "" # Nummer der Rechnung, die danach gilt
folge_art: str = "" # neu | vorhanden | "" (es folgt nichts)
@property
def steuer(self) -> float:

View file

@ -6,6 +6,14 @@ Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
genannt werden - deshalb fragt jedes Programm beim ersten Start danach und
sagt ausdruecklich dazu, dass es derselbe sein muss.
⚠ NICHT der Ordner, in dem das Programm liegt. Frueher stand das so im Text
("am besten der Ordner, in dem die Programme liegen") und der Knopf hiess
sogar "Install-Ordner auswaehlen". Seit es einen Installer gibt, geht das gar
nicht mehr: die beiden Programme liegen in ZWEI getrennten Ordnern unter
%LOCALAPPDATA%/Programs, und ein Deinstallieren wuerde das Nummernbuch
mitnehmen. Vorgeschlagen wird deshalb der Ordner, in dem auch die Rechnungen
liegen - den kennen beide Programme ohnehin, und er wird mitgesichert.
Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal
bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine
Rechnungsnummer zum zweiten Mal vergeben wurde.
@ -25,6 +33,8 @@ import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import theme
import gemeinsam
BREITE = 780
@ -39,6 +49,18 @@ class OrdnerWahl(tk.Toplevel):
self.ergebnis = None
self.title("Gemeinsamer Ordner für das Nummernbuch")
self.transient(master)
# Dieses Fenster stand als einziges noch in Weiss da: es kam nie durch
# _style_dialog. Farben holt es sich jetzt vom Hauptfenster, damit es
# nicht wieder auseinanderlaeuft, wenn dort etwas geaendert wird.
self._bg = getattr(master, "col_bg", "#0d0e10")
self._fg = getattr(master, "col_fg", "#e8e8ea")
self._warn = getattr(master, "col_gefahr", "#e85a5a")
self._gut = getattr(master, "col_accent", "#06C6A4")
self._muted = getattr(master, "col_muted", "#8a8e96")
if hasattr(master, "_style_dialog"):
master._style_dialog(self)
else:
self.configure(bg=self._bg)
self.pfad = tk.StringVar(value=vorgabe or "")
rahmen = ttk.Frame(self, padding=18)
@ -47,7 +69,7 @@ class OrdnerWahl(tk.Toplevel):
breite = BREITE - 60
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
font=(theme.SCHRIFT_NAME, 13, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(rahmen, wraplength=breite, justify="left", text=(
"Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht "
"jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der "
@ -56,10 +78,10 @@ class OrdnerWahl(tk.Toplevel):
"eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben "
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12))
tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000",
font=("Segoe UI", 10, "bold"),
tk.Label(rahmen, wraplength=breite, justify="left", fg=self._warn, bg=self._bg,
font=(theme.SCHRIFT_NAME, 10, "bold"),
text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten "
"der Ordner, in dem die Programme liegen.").grid(
"der Ordner, in dem auch die Rechnungen liegen.").grid(
row=2, column=0, sticky="w", pady=(0, 14))
wahl = ttk.Frame(rahmen)
@ -67,11 +89,18 @@ class OrdnerWahl(tk.Toplevel):
wahl.columnconfigure(0, weight=1)
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
self.feld.grid(row=0, column=0, sticky="ew")
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
# "Install-Ordner" hiess der Knopf frueher - das war schon immer
# falsch und ist es seit dem Installer erst recht: hier geht es NICHT
# um den Ordner, in dem das Programm liegt, sondern um den, in dem die
# beiden Programme ihr gemeinsames Nummernbuch fuehren. Sein Befund am
# 07.09.2026: "gibt es noch das popup install ordner auswaehlen, das
# stimmt ja nicht".
ttk.Button(wahl, text="Ordner auswählen…", command=self.waehlen,
width=22).grid(row=0, column=1, sticky="w", padx=(10, 0))
self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left",
font=("Segoe UI", 9))
bg=self._bg,
font=(theme.SCHRIFT_NAME, 9))
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
knoepfe = ttk.Frame(rahmen)
@ -89,9 +118,8 @@ class OrdnerWahl(tk.Toplevel):
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
self.update_idletasks()
hoehe = max(self.winfo_reqheight(), 300)
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
y = master.winfo_rooty() + 90
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
self.geometry(f"{BREITE}x{hoehe}")
theme.mittig(self, master)
self.minsize(BREITE, hoehe)
self.grab_set()
self.lift()
@ -127,12 +155,12 @@ class OrdnerWahl(tk.Toplevel):
"""
ordner = self.pfad.get().strip()
if not ordner:
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
self.stand.configure(text="Noch kein Ordner gewählt.", fg=self._warn)
self.ok.state(["disabled"])
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.stand.configure(text=grund, fg="#a00000")
self.stand.configure(text=grund, fg=self._warn)
self.ok.state(["disabled"])
return
self.ok.state(["!disabled"])
@ -144,27 +172,39 @@ class OrdnerWahl(tk.Toplevel):
self.stand.configure(
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
"das ist der Ordner des anderen Programms. Genau richtig.",
fg="#1f5c3d")
fg=self._gut)
else:
self.stand.configure(
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg="#444444")
"anderen Programm denselben Ordner.", fg=self._muted)
def _melden(self, titel, text):
"""Meldung im Theme des Hauptfensters - sonst wie bisher."""
eltern = self.master
if hasattr(eltern, "melden_warnung"):
return eltern.melden_warnung(titel, text)
return messagebox.showwarning(titel, text, parent=self)
def _frage(self, titel, text):
eltern = self.master
if hasattr(eltern, "frage"):
return eltern.frage(titel, text)
return messagebox.askyesno(titel, text, parent=self)
def uebernehmen(self):
ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
parent=self)
self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.")
return
if not messagebox.askyesno(
if not self._frage(
"Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl",
icon="question", default="yes", parent=self):
"Nein = zurück zur Auswahl"):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set()
return

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Liest eine Rechnungs-PDF des Rechnungstools (Campinghof Bartl) und macht
Liest eine Rechnungs-PDF des Rechnungstools und macht
daraus eine Buchung fürs Steuerjournal.
Der PDF-Text ist zeilenweise aufgebaut: erst das Label, dann in der
@ -23,7 +23,8 @@ nächsten Zeile der Wert.
Gesamtbetrag:
91,35 €
Nur Seite 1 wird gelesen – Seite 2 ist die 1:1-Kopie fürs Amt.
Gelesen wird nur Seite 1. Ein zweites Blatt „fürs Amt“ gibt es seit dem
05.09.2026 nicht mehr – die Rechnung des Rechnungstools ist EIN Blatt.
"""
import os
@ -239,6 +240,10 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
storno_zu=str(meta.get("storno_zu") or ""),
storno_datum=str(meta.get("storno_datum") or ""),
vorgang=str(meta.get("vorgang") or ""),
folge_nummer=str(meta.get("korrektur_nummer") or ""),
folge_art=("" if meta.get("nur_storno")
else "vorhanden" if meta.get("ersatz_vorhanden")
else "neu" if meta.get("korrektur_nummer") else ""),
)
log.debug("aus Metadaten: %s | %s | %s | %s Nächte | %.2f €",
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt)

View file

@ -24,16 +24,6 @@ HIER = r"C:\claude\beherbergungssteuer"
TOOL = r"C:\claude\rechnungstool"
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)
# --- Reissleine: ein Pruefstand darf nie haengen ---
import threading as _th
@ -59,6 +49,7 @@ shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json"))
tool_config.CONFIG_PATH = os.path.join(tmp, "config.json")
import berechnung as tb # noqa: E402
import pdf_renderer # noqa: E402
from pruefhelfer import fehler, pruefe
cfg_tool = tool_config.load_config()

256
pruef_darstellung.py Normal file
View file

@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
"""Pruefstand: hell, dunkel, automatisch - und nichts geht dabei verloren.
Sein Wortlaut vom 07.09.2026:
"waere nun noch gut das mann in den einstellungen einstellen kann hell
oder dunkel oder automatik das fehlt sollte eigendlich drin sein"
"und beim steuertool fehlt das theam in genze oder wird nicht durch die
automatik gesetzt"
Er hatte recht: dieses Programm hatte ueberhaupt kein Theme - es lief im
Tk-Standard, mit dreimal fest eingetragenem foreground="black".
GEPRUEFT WIRD DREIERLEI
1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt
Windows.
2. Beim Umschalten bleibt der Stand stehen - Jahr und Suchfeld.
3. NACH dem Wechsel traegt kein Widget mehr eine Farbe der anderen Palette.
Das ist die eigentliche Falle beim Umfaerben: ein einzelnes vergessenes
Label leuchtet hell im dunklen Fenster, und niemand findet die Stelle
wieder. Deshalb wird der ganze Widget-Baum abgegangen und mit den Farben
der jeweils ANDEREN Palette verglichen.
"""
import os
import shutil
import sys
import tempfile
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
os.chdir(QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
from tkinter import messagebox # noqa: E402
messagebox.askyesno = lambda *a, **k: True
for _n in ("showinfo", "showwarning", "showerror"):
setattr(messagebox, _n, lambda *a, **k: None)
# Auf einer KOPIE des Programms arbeiten: der Datenordner liegt aus der Quelle
# heraus neben app.py (ablage.py), also legt eine Kopie im Temp-Ordner auch ein
# eigenes Journal an. Das echte Journal des Betriebs wird nicht angefasst -
# hier wird eine Einstellung geschrieben, und die gehoert nicht in seine Daten.
tmp = tempfile.mkdtemp(prefix="darstellung_")
prog = os.path.join(tmp, "programm")
os.makedirs(prog)
for name in os.listdir(QUELLE):
if name.endswith(".py"):
shutil.copy(os.path.join(QUELLE, name), os.path.join(prog, name))
for name in ("assets",):
if os.path.isdir(os.path.join(QUELLE, name)):
shutil.copytree(os.path.join(QUELLE, name), os.path.join(prog, name))
sys.path.insert(0, prog)
os.chdir(prog)
import app as A # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.App()
def pumpen(sekunden=1.0):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(2.0)
# ---- Ohne Einstellung: automatisch --------------------------------------
pruefe(theme.modus(f.cfg) == "auto",
f"ohne Eintrag gilt 'auto' ({theme.modus(f.cfg)!r})")
pruefe(f._dark_mode == theme.windows_dark_mode(),
"und folgt damit dem Windows-Theme")
# ---- Stand, der den Wechsel ueberleben muss ----------------------------
f.jahr.set(2025)
f.suche.set("Muster")
pumpen(0.5)
def farben(dunkel):
"""Die Palette der einen oder anderen Darstellung, als Menge."""
merker = f._dark_mode
f.cfg["darstellung"] = "dunkel" if dunkel else "hell"
f._apply_theme()
satz = {getattr(f, n).lower() for n in dir(f)
if n.startswith("col_") and isinstance(getattr(f, n), str)}
f._dark_mode = merker
return satz
HELL = farben(False)
DUNKEL = farben(True)
# Was in BEIDEN Paletten vorkommt, taugt nicht als Beweis.
NUR_HELL = HELL - DUNKEL
NUR_DUNKEL = DUNKEL - HELL
def fremdfarben(verboten):
"""Alle Widgets, die eine Farbe der falschen Palette tragen."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
for eigenschaft in ("bg", "background", "fg", "foreground"):
try:
wert = str(kind.cget(eigenschaft)).lower()
except tk.TclError:
continue
if wert in verboten:
treffer.append((str(kind), eigenschaft, wert))
durchgehen(kind)
durchgehen(f)
return treffer
# ---- Die drei Modi ------------------------------------------------------
for wunsch, soll_dunkel in (("hell", False), ("dunkel", True)):
f.cfg["darstellung"] = wunsch
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == soll_dunkel,
f"'{wunsch}' zeichnet {'dunkel' if soll_dunkel else 'hell'} "
f"(Hintergrund {f.col_bg})")
# Nichts darf verlorengehen.
pruefe(f.jahr.get() == 2025,
f"nach dem Wechsel steht das gewaehlte Jahr noch da ({f.jahr.get()})")
pruefe(f.suche.get() == "Muster",
f"und der Suchbegriff ({f.suche.get()!r})")
# Kein Rest der anderen Palette.
reste = fremdfarben(NUR_DUNKEL if not soll_dunkel else NUR_HELL)
pruefe(not reste,
f"in '{wunsch}' ist keine Farbe der anderen Darstellung "
f"uebrig ({reste[:3]})")
# ---- Dialoge gehen mittig ueber dem Programmfenster auf ------------------
# Seine Ansage: "einfach so programmieren das die popup auf dem fenster mittig
# vom main fenster auf gehen". Das ist zugleich die Antwort auf die
# Monitorfrage - steht das Programm auf dem zweiten Bildschirm, geht der
# Dialog dort auf. Vorher klemmte ein max(0, x) jede negative Koordinate auf
# null und riss die Fenster auf den Hauptmonitor zurueck.
d = f.dlg_darstellung()
pumpen(1.2)
d.update_idletasks()
mitte_dialog = d.winfo_rootx() + d.winfo_width() // 2
mitte_fenster = f.winfo_rootx() + f.winfo_width() // 2
pruefe(abs(mitte_dialog - mitte_fenster) <= 30,
f"der Dialog steht mittig ueber dem Fenster "
f"({mitte_dialog} vs. {mitte_fenster})")
pruefe(d.winfo_rootx() >= f.winfo_rootx() - 40
and d.winfo_rootx() + d.winfo_width() <= f.winfo_rootx() + f.winfo_width() + 40,
"und liegt waagerecht innerhalb des Fensters")
pruefe(d.winfo_rooty() >= f.winfo_rooty() - 40,
"und nicht oberhalb davon")
d.destroy()
pumpen(0.3)
# ---- Der Dialog muss den Klick UEBERLEBEN -------------------------------
# Gefunden am 07.09.2026 von der Pruefkette: _oberflaeche_neu() loeschte alle
# Kinder des Hauptfensters - und ein Toplevel IST ein Kind. Der offene Dialog
# wurde also beim ersten Klick mitgeloescht, die Zeile danach fasste eine
# Leiche an ("invalid command name .!toplevel").
#
# Dieser Pruefstand hat es NICHT gefunden, weil er cfg["darstellung"] von Hand
# setzte. Hier wird jetzt der Radioknopf gedrueckt - derselbe Weg, den ein
# Mensch geht.
d = f.dlg_darstellung()
pumpen(1.0)
def radioknoepfe(fenster):
"""Alle Radiobuttons unterhalb eines Fensters, mit ihrer Beschriftung."""
treffer = []
def durchgehen(w):
for kind in w.winfo_children():
if kind.winfo_class() == "TRadiobutton":
try:
treffer.append((str(kind.cget("text")), kind))
except tk.TclError:
pass
durchgehen(kind)
durchgehen(fenster)
return treffer
knoepfe = radioknoepfe(d)
pruefe(len(knoepfe) == len(theme.MODI),
f"der Dialog zeigt alle {len(theme.MODI)} Moeglichkeiten "
f"({[t for t, _ in knoepfe]})")
ziel = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["dunkel"]), None)
pruefe(ziel is not None, "der Knopf 'Dunkel' ist da")
if ziel is not None:
ziel.invoke() # der echte Klick
pumpen(1.2)
pruefe(bool(d.winfo_exists()),
"der Dialog lebt nach dem Klick noch")
pruefe(f._dark_mode is True,
f"und die Darstellung ist umgestellt (Hintergrund {f.col_bg})")
# Und noch einmal, denn genau das war vorher unmoeglich:
hell = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["hell"]), None)
if hell is not None:
try:
hell.invoke()
pumpen(1.0)
pruefe(f._dark_mode is False,
"ein ZWEITER Klick im selben Dialog wirkt auch")
except tk.TclError as e:
pruefe(False, f"der zweite Klick scheiterte ({e})")
try:
d.destroy()
except tk.TclError:
pass
pumpen(0.3)
# ---- Zurueck auf automatisch --------------------------------------------
f.cfg["darstellung"] = "auto"
f._oberflaeche_neu()
pumpen(0.8)
pruefe(f._dark_mode == theme.windows_dark_mode(),
"'auto' folgt wieder dem Windows-Theme")
# ---- Und die Einstellung ueberlebt den Neustart -------------------------
f.cfg["darstellung"] = "hell"
f.destroy()
f = A.App()
pumpen(2.0)
pruefe(f._dark_mode is False,
"die Einstellung steht beim naechsten Start wieder da")
try:
f.destroy()
except Exception: # noqa: BLE001
pass
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Hell, dunkel und automatisch wirken - ohne Datenverlust")

View file

@ -24,16 +24,6 @@ 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)")
@ -51,7 +41,30 @@ import ablage # noqa: E402
ablage.daten_ordner = lambda: tmp
import app as app_mod # noqa: E402 (liest ablage.daten_ordner beim Import)
from tkinter import messagebox # noqa: E402
# Kein echtes Fenster im Pruefstand: sonst steht der Lauf vor einem Dialog, den
# niemand anklickt. Der Erprobungshinweis wird hier bejaht - geprueft wird er
# eigens in pruef_hinweis.py des Rechnungstools. Eine UNBEKANNTE Frage bricht
# ab, statt still 'ja' zu sagen: sonst uebergeht der Lauf genau die Faelle,
# wegen denen es die Pruefung gibt.
_antworten = {"Erprobungsfassung": True, "jetzt erstellen?": True,
"Fertig": False, "Hausnummer": True}
def _askyesno(titel, text="", **kw):
for schluessel, wert in _antworten.items():
if schluessel in str(titel):
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = _askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
from modell import Buchung, doppelte_nummern, summiere # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")

View file

@ -24,16 +24,6 @@ import time
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)
laeufer = []
@ -54,6 +44,7 @@ _wecker.daemon = True
_wecker.start()
from einzelinstanz import pid_datei # noqa: E402
from pruefhelfer import fehler, pruefe
if os.path.exists(pid_datei()):
print(f" (Merkzettel: {pid_datei()})")
@ -67,6 +58,47 @@ for name in os.listdir(HIER):
shutil.copy(os.path.join(HIER, name), os.path.join(prog, name))
# Auch die Beilagen mitnehmen - ohne assets/ faende das Programm sein Logo
# nicht. Das stuerzt zwar nicht ab, aber ein Pruefstand soll das Programm
# fahren, wie es ausgeliefert wird, nicht eine abgespeckte Fassung.
for name in ("assets",):
quelle = os.path.join(HIER, name)
if os.path.isdir(quelle):
shutil.copytree(quelle, os.path.join(prog, name))
# Der Erprobungshinweis wuerde in JEDEM dieser drei Starts ein echtes Fenster
# oeffnen. Hier laeuft das Programm als eigener Prozess - da laesst sich kein
# Dialog abfangen, es klickt niemand, und der Pruefstand liefe in die
# Reissleine. Geprueft wird an dieser Stelle die SPERRE; der Hinweis hat seinen
# eigenen Pruefstand (pruef_hinweis.py).
#
# Also wird die Kenntnisnahme vorher eingetragen - mit genau dem Code, den auch
# der Klick auf "Ja" ausfuehrt (hinweis.vermerken auf die Einstellungstabelle).
# Nichts nachgebaut, nichts von Hand ins SQL geschrieben.
#
# Das trifft NUR die Kopie im Temp-Ordner: aus der Quelle heraus liegt der
# Datenordner neben app.py (ablage.daten_ordner()). Im echten Journal des
# Betriebs entsteht dadurch keine Zustimmung, die niemand gegeben hat.
def hinweis_vorab_eintragen():
import db
import hinweis
journal = db.Journal(os.path.join(prog, "journal.sqlite3"))
class Ablage:
def get(self, schluessel, standard=""):
return journal.hole(schluessel, standard)
def __setitem__(self, schluessel, wert):
journal.setze(schluessel, str(wert))
hinweis.vermerken(Ablage())
print(f" (Kenntnisnahme in der Kopie vermerkt, Fassung {hinweis.FASSUNG})")
hinweis_vorab_eintragen()
def starte():
# --test: kein Erststart-Dialog nach dem gemeinsamen Ordner. Die Sperre
# selbst greift unabhaengig davon - genau die wird hier geprueft.

View file

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""Pruefstand: finden sich die beiden Programme von SELBST?
Sein Einwand vom 07.09.2026, wortwoertlich:
"wie soll das rechnungstool und steuertool wissen wo es ist, das muss im
... ravokk liegen und dann zusammen, sonst kommt das eine nicht an das
andere ran - die sind doch nur ueber die gemeinsame sqlite verbunden und
mit api, die sagt hallo da ist was neues"
Er hat recht: die beiden haengen an genau zwei Faeden - dieser SQLite-Datei
und der kleinen API. Vorher fragte jedes Programm beim ersten Start nach einem
Ordner, und der Nutzer musste in BEIDEN denselben eintragen. Ein Buchstabe
daneben, und jedes Programm fuehrt sein eigenes Buch weiter - auffallen wuerde
das erst, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist
(§ 14 Abs. 4 Nr. 4 UStG).
GEPRUEFT WIRD
1. Beide Programme nennen denselben Standardordner. Das ist die Grundlage;
weichen sie hier ab, hilft alles Weitere nichts.
2. Der Ordner laesst sich anlegen, und darin entsteht EIN Nummernbuch.
3. Was das eine eintraegt, findet das andere - ohne dass jemand einen Pfad
eingegeben hat.
4. Die Dateien, die von sich sagen, sie laegen in beiden Programmen gleich,
sind auch wirklich gleich.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - und sie prueft sich unter Punkt 4 selbst mit, denn sie traegt
denselben Satz wie die anderen.
Angefasst wird dabei nichts Echtes: der Standardordner wird ueber die
Umgebungsvariable LOCALAPPDATA auf einen Temp-Ordner umgelenkt.
"""
import os
import shutil
import sys
import tempfile
RECHNUNG = r"C:\claude\rechnungstool"
STEUER = r"C:\claude\beherbergungssteuer"
# ---- 1. Beide Programme meinen denselben Ort ----------------------------
# Geladen wird jede Fassung EINZELN, damit wirklich die Datei des jeweiligen
# Programms befragt wird und nicht die zufaellig zuerst importierte.
import importlib.util # noqa: E402
from pruefhelfer import fehler, pruefe
def modul_aus(ordner, name):
pfad = os.path.join(ordner, name + ".py")
kennung = f"{name}_{os.path.basename(ordner)}"
spec = importlib.util.spec_from_file_location(kennung, pfad)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
tmp = tempfile.mkdtemp(prefix="gemeinsam_auto_")
os.environ["LOCALAPPDATA"] = tmp
g_rechnung = modul_aus(RECHNUNG, "gemeinsam")
g_steuer = modul_aus(STEUER, "gemeinsam")
a, b = g_rechnung.standard_ordner(), g_steuer.standard_ordner()
pruefe(a == b, f"beide Programme nennen denselben Ordner ({a})")
pruefe(a.startswith(tmp),
"und er haengt an LOCALAPPDATA, nicht an einem festen Pfad im Quelltext")
pruefe(os.path.basename(a) == "ravokk",
f"er heisst 'ravokk', wie er es wollte ({os.path.basename(a)})")
# ---- 2. Er wird angelegt, und darin liegt EIN Buch ----------------------
angelegt = g_rechnung.standard_anlegen()
pruefe(bool(angelegt) and os.path.isdir(angelegt),
f"der Ordner wird angelegt ({angelegt})")
pruefe(g_rechnung.DATEINAME == g_steuer.DATEINAME,
f"beide erwarten dieselbe Datei ({g_rechnung.DATEINAME})")
pruefe(g_rechnung.pfad_zur_datei(angelegt) == g_steuer.pfad_zur_datei(angelegt),
"und damit denselben vollen Pfad")
# ---- 3. Was das eine eintraegt, findet das andere -----------------------
buch_r = g_rechnung.Nummernbuch(angelegt)
buch_s = g_steuer.Nummernbuch(angelegt)
buch_r.eintragen("2026-777", jahr=2026, nachname="Probe", quelle="rechnungstool")
gefunden = [n for n in buch_s.nummern(2026) if n == "2026-777"]
pruefe(bool(gefunden),
"was das Rechnungstool eintraegt, sieht das Steuerjournal sofort")
buch_s.eintragen("2026-778", jahr=2026, nachname="Gegenprobe", quelle="journal")
zurueck = [n for n in buch_r.nummern(2026) if n == "2026-778"]
pruefe(bool(zurueck), "und umgekehrt genauso")
# GENAU eins - nicht "mindestens eins". Vorher stand hier `>= 1` mit einem
# any(); im Text darueber stand "genau ein Buch". Eine Pruefung, die nicht rot
# werden kann, prueft nichts: laege dort ein zweites Buch, weil eine der
# Fassungen einen anderen Dateinamen bildet, waere sie gruen geblieben - und
# das ist genau der Fall, den dieser Pruefstand abfangen soll.
#
# Die Begleitdateien von SQLite (-wal, -shm) zaehlen nicht mit; sie gehoeren
# zum selben Buch.
buecher = [n for n in os.listdir(angelegt) if n.endswith(".sqlite3")]
pruefe(len(buecher) == 1,
f"es liegt genau EIN Buch dort ({os.listdir(angelegt)})")
pruefe(buecher == [g_rechnung.DATEINAME],
f"und es traegt den Namen, den beide erwarten ({buecher})")
# ---- 4. Was "in beiden gleich" heisst, ist auch gleich ------------------
# Neun Dateien tragen im Kopf den Satz, sie laegen in beiden Programmen
# gleich. Das ist eine Zusage an den Naechsten, der eine davon anfasst - und
# geprueft hat sie bis zum 07.09.2026 niemand. Bei hinweis.py stimmte sie da
# schon nicht mehr: gleicher Inhalt, andere Zeilenenden.
#
# Die Liste steht bewusst NICHT hier, sondern wird aus dem Satz selbst
# gelesen. Eine Liste, die jemand von Hand nachfuehren muss, veraltet still
# (und still veraltete Pruefstaende fuehren in die Irre).
MARKE = "Diese Datei liegt in BEIDEN Programmen gleich"
gemeinsame = sorted(n for n in os.listdir(RECHNUNG) if n.endswith(".py")
and MARKE in open(os.path.join(RECHNUNG, n),
encoding="utf-8").read())
pruefe(len(gemeinsame) >= 5,
f"{len(gemeinsame)} Dateien sagen von sich, sie seien in beiden gleich")
for name in gemeinsame:
hier = os.path.join(RECHNUNG, name)
dort = os.path.join(STEUER, name)
if not os.path.isfile(dort):
pruefe(False, f"{name} fehlt im Steuerrechnungstool")
continue
roh_h, roh_d = open(hier, "rb").read(), open(dort, "rb").read()
if roh_h == roh_d:
pruefe(True, f"{name} ist byteweise gleich")
continue
# Nicht gleich - dann wenigstens sagen, WORAN es liegt. Das ist der
# Unterschied zwischen einer Meldung und einer Suche.
if roh_h.replace(b"\r\n", b"\n") == roh_d.replace(b"\r\n", b"\n"):
grund = "nur die Zeilenenden weichen ab"
else:
grund = "der Inhalt weicht ab"
pruefe(False, f"{name}: {grund}")
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Beide Programme finden dasselbe Nummernbuch ohne Nachfrage")

190
pruef_installation.py Normal file
View file

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
"""Pruefstand: installieren, deinstallieren - bleibt der Ordner sauber?
Seine Ansage vom 07.09.2026:
"du solltest die auch mal deinstallieren und neu installieren von windows
lassen, dass du auch pruefen kannst, dass der ordner dann leer ist und
sauber - will keine tools die unsauber sind"
Er hatte recht. Gemessen blieb nach dem Deinstallieren liegen:
Rechnungstool-1.7.1.exe, Rechnungstool-1.8.0.exe <- vom UPDATER geladen
Daten/config.json, Daten/rechnungstool.pid
Die EXEs kennt der Installer nicht - die hat der Updater abgelegt. Ohne eine
eigene Regel bleiben sie stehen, und mit jeder Fassung wird es eine mehr.
ZWEI DINGE MUESSEN GLEICHZEITIG STIMMEN
---------------------------------------
1. Der PROGRAMMordner ist danach restlos weg.
2. Die DATEN sind unangetastet - Rechnungen, Journal, Nummernbuch. Die sind
zehn Jahre aufbewahrungspflichtig (§ 147 AO); ein Deinstallieren darf sie
nie mitnehmen.
Beides zusammen geht nur, weil die Daten NEBEN dem Programm liegen:
%LOCALAPPDATA%/ravokk/ <- Daten, gemeinsames Nummernbuch
Rechnungstool/ <- Programm, wird geleert
Steuerrechnungstool/ <- Programm, wird geleert
Genau so hat er es beschrieben: *"es gibt ravokk als main verzeichnis, da liegt
config und sqlite drin, und dann gibt es den ordner rechnungstool und
steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma und die
programme gehoeren zu der firma"*.
⚠ DIESER PRUEFSTAND INSTALLIERT WIRKLICH. Er laeuft deshalb nur, wenn das
Setup gebaut ist, und er fasst NUR die Programmordner an - vorhandene Daten
werden vorher gesichert und danach zurueckgelegt.
Und er hinterlaesst den Zustand, den er vorgefunden hat: war das Programm
vorher installiert, ist es danach wieder installiert. Beim ersten Lauf war es
das NICHT - danach stand ein deinstalliertes Programm da, und der naechste
Schritt lief ins Leere. Ein Pruefstand, der aufraeumt, muss auch sich selbst
aufraeumen.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
from pruefhelfer import fehler, pruefe
LOKAL = os.environ.get("LOCALAPPDATA", "")
RAVOKK = os.path.join(LOKAL, "ravokk")
NAME = os.path.splitext(version.EXE_NAME)[0]
PROGRAMMORDNER = os.path.join(RAVOKK, NAME)
def setup_datei():
ordner = os.path.join(HIER, "release")
if not os.path.isdir(ordner):
return ""
for d in sorted(os.listdir(ordner)):
if d.startswith(NAME + "-Setup") and d.endswith(".exe"):
return os.path.join(ordner, d)
return ""
def beenden():
subprocess.run(["powershell", "-NoProfile", "-Command",
"Get-Process | Where-Object { $_.ProcessName -match "
f"'{NAME}|Updater' " + "} | Stop-Process -Force "
"-ErrorAction SilentlyContinue"], capture_output=True)
time.sleep(1.5)
def inhalt(ordner):
if not os.path.isdir(ordner):
return []
gefunden = []
for wurzel, _, dateien in os.walk(ordner):
for d in dateien:
gefunden.append(os.path.relpath(os.path.join(wurzel, d), ordner))
return sorted(gefunden)
setup = setup_datei()
if not setup:
print(f"UEBERSPRUNGEN | kein Setup in release/ (vorher: python bauen.py)")
sys.exit(0)
if not LOKAL:
print("UEBERSPRUNGEN | kein LOCALAPPDATA")
sys.exit(0)
# ---- Vorhandene Daten in Sicherheit bringen -----------------------------
# Hier laeuft echte Software auf einem echten Rechner. Was schon da ist, wird
# beiseitegelegt und hinterher zurueckgestellt - ein Pruefstand darf niemandem
# seine Buchhaltung anfassen.
sicher = tempfile.mkdtemp(prefix="installpruef_")
gesichert = []
if os.path.isdir(RAVOKK):
for d in os.listdir(RAVOKK):
quelle = os.path.join(RAVOKK, d)
if os.path.isfile(quelle):
shutil.copy2(quelle, os.path.join(sicher, d))
gesichert.append(d)
print(f" ({len(gesichert)} vorhandene Datei(en) gesichert)")
beenden()
war_installiert = os.path.exists(os.path.join(PROGRAMMORDNER, "unins000.exe"))
alter_uninstaller = os.path.join(PROGRAMMORDNER, "unins000.exe")
if os.path.exists(alter_uninstaller):
subprocess.run([alter_uninstaller, "/VERYSILENT", "/SUPPRESSMSGBOXES",
"/NORESTART"], capture_output=True)
time.sleep(6)
# ---- 1. Installieren ----------------------------------------------------
e = subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(5)
pruefe(e.returncode == 0, f"das Setup laeuft durch (Rueckgabe {e.returncode})")
pruefe(os.path.isdir(PROGRAMMORDNER),
f"das Programm landet unter ravokk ({PROGRAMMORDNER})")
dabei = inhalt(PROGRAMMORDNER)
pruefe(any(d.startswith(NAME + "-") for d in dabei),
f"die Programmdatei liegt dort ({dabei})")
pruefe("Updater.exe" in dabei, "und der Updater daneben")
# ---- 2. Eine Datei anlegen, wie der Updater es taete ---------------------
# So sieht der Ordner aus, nachdem das Programm ein paar Fassungen lang
# gelaufen ist. Genau diese Dateien blieben frueher liegen.
geerbt = os.path.join(PROGRAMMORDNER, f"{NAME}-9.9.9.exe")
with open(geerbt, "wb") as f:
f.write(b"als haette der Updater sie geladen")
with open(os.path.join(PROGRAMMORDNER, "installiert.json"), "w",
encoding="utf-8") as f:
f.write("{}")
# ---- 3. Eine Datendatei daneben, die ueberleben MUSS ---------------------
probe = os.path.join(RAVOKK, "pruefstand-beleg.txt")
with open(probe, "w", encoding="utf-8") as f:
f.write("steht fuer Rechnungen, Journal und Nummernbuch")
# ---- 4. Deinstallieren --------------------------------------------------
beenden()
u = os.path.join(PROGRAMMORDNER, "unins000.exe")
pruefe(os.path.exists(u), "es gibt einen Uninstaller")
if os.path.exists(u):
subprocess.run([u, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(7)
uebrig = inhalt(PROGRAMMORDNER)
pruefe(not os.path.isdir(PROGRAMMORDNER),
f"der Programmordner ist danach weg ({uebrig})")
pruefe(not uebrig,
f"und darin liegt nichts mehr - auch keine geladene Fassung ({uebrig})")
pruefe(os.path.exists(probe),
"die Daten daneben sind unangetastet (Rechnungen, Journal, Nummernbuch)")
pruefe(os.path.isdir(RAVOKK),
"und der Ordner der Firma steht noch")
# ---- Aufraeumen ---------------------------------------------------------
if os.path.exists(probe):
os.remove(probe)
if war_installiert:
# So hinterlassen, wie es vorgefunden wurde.
subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"],
capture_output=True)
time.sleep(5)
print(f" (war vorher installiert - wieder eingerichtet: "
f"{os.path.isdir(PROGRAMMORDNER)})")
for d in gesichert:
ziel = os.path.join(RAVOKK, d)
if not os.path.exists(ziel):
os.makedirs(RAVOKK, exist_ok=True)
shutil.copy2(os.path.join(sicher, d), ziel)
shutil.rmtree(sicher, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Deinstallieren laesst den Programmordner leer - und die Daten stehen")

184
pruef_menueleiste.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die eigene Menueleiste - Wechsel mit EINEM Klick, und am Rand.
WARUM ES DIESE LEISTE GIBT
--------------------------
`tk.Menu` ist unter Windows ein Menue des Betriebssystems. Es nimmt Farben
entgegen und zeichnet trotzdem im Systemgrau - ein weisser Balken ueber einem
dunklen Fenster. Sein Befund am 07.09.2026: *"nur die einstellunden hilfe
leiste die ist noch hell"*. Deshalb ist die Leiste aus gewoehnlichen Labels
gebaut.
WAS HIER GEPRUEFT WIRD
1. Ein Klick auf einen Titel klappt seine Liste auf, ein zweiter auf denselben
Titel klappt sie wieder zu.
2. Steht eine Liste offen und man klickt auf einen ANDEREN Titel, klappt
dessen Liste auf - mit EINEM Klick.
Das ging bis zum 07.09.2026 nicht. Solange eine Liste offen ist, haelt ihr
`grab_set` alle Mausklicks der Anwendung fest; die `<Button-1>`-Bindung des
Menuetitels feuert dann gar nicht. Der Klick landete beim Zuklappen und war
damit verbraucht - man musste ein zweites Mal klicken, um von "Datei" zu
"Hilfe" zu kommen. Das native tk.Menu wechselte schon beim blossen
Ueberfahren; zwei Klicks waren also eine Verschlechterung gegenueber dem,
was ersetzt wurde.
Geprueft wird das ueber ein echtes Klickereignis auf der offenen Liste, mit
den Bildschirmkoordinaten des anderen Titels - also genau der Weg, den der
grab_set erzwingt. Ein direkter Aufruf von aufklappen() wuerde am Problem
vorbeipruefen.
3. Die Liste bleibt auf dem Bildschirm. Steht das Fenster am unteren Rand,
lief sie vorher darueber hinaus und war nicht mehr zu treffen: die Position
wurde ungeprueft gesetzt, waehrend jede andere neue Position im Programm
ueber theme.auf_bildschirm() laeuft.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import os
import sys
import time
import tkinter as tk
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
import menueleiste # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe # noqa: E402
class Fenster(theme.ThemeMixin, tk.Tk):
"""Ein nacktes Fenster mit Farben - mehr braucht die Leiste nicht."""
def __init__(self):
super().__init__()
self.cfg = {} # theme.modus() will etwas mit .get()
self._apply_theme()
self.title("Pruefstand Menueleiste")
self.geometry("560x320" + theme.pruefplatz())
f = Fenster()
gerufen = []
f.menue = menueleiste.Menueleiste(f, {
"Datei": [("Neu", lambda: gerufen.append("neu")), ("-", None),
("Beenden", lambda: gerufen.append("beenden"))],
"Bearbeiten": [("Rueckgaengig", lambda: gerufen.append("rueck"))],
"Hilfe": [("Info", lambda: gerufen.append("info"))],
})
def pumpen(sekunden=0.4):
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
pumpen(1.0)
titel = {str(w.cget("text")): w for w in f.menue.winfo_children()
if isinstance(w, tk.Label)}
pruefe(set(titel) == {"Datei", "Bearbeiten", "Hilfe"},
f"alle drei Titel stehen in der Leiste ({sorted(titel)})")
def klick_auf_titel(name):
"""Auf einen Menuetitel klicken - so, wie es die Maus tut.
Steht schon eine Liste offen, MUSS das Ereignis an sie gehen: ihr grab_set
faengt jeden Klick der Anwendung ab. Genau das ist der Fall, um den es
unter Punkt 2 geht.
"""
w = titel[name]
x = w.winfo_rootx() + w.winfo_width() // 2
y = w.winfo_rooty() + w.winfo_height() // 2
ziel = f.menue._offen or w
if ziel is w:
# Achtung: Tk nennt die Bildschirmkoordinaten beim Erzeugen -rootx
# und -rooty; im Ereignis heissen sie dann x_root und y_root.
w.event_generate("<Button-1>", x=w.winfo_width() // 2,
y=w.winfo_height() // 2, rootx=x, rooty=y)
else:
ziel.event_generate("<Button-1>", x=-1, y=-1, rootx=x, rooty=y)
pumpen(0.4)
def offener_titel():
"""Welcher Titel gerade offen ist - oder None."""
anker = f.menue._anker
if anker is None or not f.menue._offen:
return None
return next((t for t, w in titel.items() if w is anker), "?")
# ---- 1. Aufklappen und wieder zuklappen ---------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", f"'Datei' klappt auf ({offener_titel()})")
klick_auf_titel("Datei")
pruefe(offener_titel() is None,
f"und derselbe Titel klappt wieder zu ({offener_titel()})")
# ---- 2. Der Wechsel: EIN Klick ------------------------------------------
klick_auf_titel("Datei")
pruefe(offener_titel() == "Datei", "'Datei' steht offen")
klick_auf_titel("Hilfe")
pruefe(offener_titel() == "Hilfe",
f"ein Klick auf 'Hilfe' wechselt hinueber ({offener_titel()})")
klick_auf_titel("Bearbeiten")
pruefe(offener_titel() == "Bearbeiten",
f"und weiter auf 'Bearbeiten' ({offener_titel()})")
# Ein Klick irgendwo neben die Leiste schliesst - und oeffnet nichts.
pop = f.menue._offen
pop.event_generate("<Button-1>", x=-1, y=-1,
rootx=f.winfo_rootx() + 300,
rooty=f.winfo_rooty() + 250)
pumpen(0.4)
pruefe(offener_titel() is None,
f"ein Klick daneben schliesst und oeffnet nichts ({offener_titel()})")
# ---- 3. Ein Eintrag fuehrt seinen Befehl aus ----------------------------
klick_auf_titel("Hilfe")
eintrag = None
for kind in f.menue._offen.winfo_children():
for enkel in kind.winfo_children():
if isinstance(enkel, tk.Label) and str(enkel.cget("text")) == "Info":
eintrag = enkel
eintrag.event_generate("<Button-1>", x=2, y=2)
pumpen(0.4)
pruefe(gerufen == ["info"], f"der Eintrag 'Info' fuehrt seinen Befehl aus ({gerufen})")
pruefe(offener_titel() is None, "und die Liste ist danach zu")
# ---- 4. Am unteren Bildschirmrand bleibt die Liste sichtbar -------------
# Das Fenster so weit nach unten schieben, dass unter der Leiste kein Platz
# mehr fuer die Klappliste ist.
links, oben = theme.auf_bildschirm(f.winfo_rootx(), f.winfo_rooty())
unten = oben + f.winfo_screenheight()
f.geometry(f"560x320+{links}+{unten - 120}")
pumpen(0.6)
klick_auf_titel("Datei")
pop = f.menue._offen
pruefe(pop is not None, "die Liste geht auch am unteren Rand auf")
if pop is not None:
x, y = pop.winfo_rootx(), pop.winfo_rooty()
hoehe, breite = pop.winfo_height(), pop.winfo_width()
grenze_x, grenze_y = theme.auf_bildschirm(x, y, breite, hoehe)
pruefe((x, y) == (grenze_x, grenze_y),
f"und steht vollstaendig auf dem Bildschirm "
f"(Liste {x},{y} bis {x + breite},{y + hoehe})")
f.destroy()
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die Menueleiste wechselt mit einem Klick und bleibt im Bild")

98
pruef_release.py Normal file
View file

@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""Pruefstand: passt das VEROEFFENTLICHTE Release zu dem, was der Updater sucht?
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
--------------------------------------------
Das Release war angelegt, die EXE hing dran - und der Updater haette trotzdem
nie etwas gefunden. Der Anhang hiess `Rechnungstool-1.7.0.exe`; gesucht wird
aber `Rechnungstool.exe` (so steht es in version.EXE_NAME, und danach sucht
passendes_release). Die Nummer kommt erst beim ABLEGEN dazu.
Das Tueckische daran: nichts schlaegt fehl. Der Updater meldet freundlich
"Aktuell. Kein Update noetig." und startet die alte Fassung. Ein Update, das
stumm nie ankommt, ist schlimmer als eines, das laut scheitert.
Zweiter Fund derselben Runde: die Pruefsummen standen nur im Release-TEXT. Der
Updater liest sie aus einer angehaengten Datei `<exe>.sha256`. Ohne die prueft
er nur die Groesse - und eine unterwegs verfaelschte Datei hat genau die
richtige Groesse.
WAS HIER GEPRUEFT WIRD
----------------------
Gegen das echte Repository, nicht gegen einen Nachbau:
1. Zu der Nummer in version.py gibt es ein Release.
2. Es haengt eine Datei mit EXAKT dem Namen aus version.EXE_NAME an.
3. Es haengt die Pruefsumme `<exe>.sha256` an.
4. Die Summe darin ist die der angehaengten Datei - und die stimmt mit dem
ueberein, was hier zuletzt gebaut wurde (release/<exe>).
5. passendes_release() findet dieses Release auch wirklich.
Ohne Netz wird uebersprungen statt rot gemeldet: ein fehlender Anschluss ist
kein Fehler des Programms.
"""
import os
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import updater # noqa: E402
import version # noqa: E402
from pruefhelfer import fehler, pruefe
try:
releases = updater.releases_holen(version.REPOSITORY)
except Exception as e: # noqa: BLE001 - ohne Netz ist hier nichts zu pruefen
print(f"UEBERSPRUNGEN | Repository nicht erreichbar ({e})")
sys.exit(0)
marke = "v" + version.VERSION
dieses = next((r for r in releases if (r.get("tag_name") or "") == marke), None)
pruefe(dieses is not None, f"zu Fassung {version.VERSION} gibt es ein Release ({marke})")
if dieses is None:
print("ROT | ohne Release ist der Rest nicht pruefbar")
sys.exit(1)
roh = dieses.get("assets") or []
anhaenge = {a.get("name"): a for a in roh}
# Doppelte Namen: Forgejo laesst zwei Anhaenge mit demselben Namen zu. Beim
# Ersetzen einer Datei blieb die alte einmal stehen (07.09.2026), und welche
# von beiden der Updater dann laedt, ist Glueckssache - unter Umstaenden die
# alte, mit der falschen Pruefsumme daneben.
doppelt = sorted({a["name"] for a in roh
if sum(1 for b in roh if b["name"] == a["name"]) > 1})
pruefe(not doppelt, f"kein Anhang haengt doppelt am Release ({doppelt})")
print(f" (Anhaenge: {', '.join(sorted(anhaenge)) or 'keine'})")
pruefe(version.EXE_NAME in anhaenge,
f"der Anhang heisst genau {version.EXE_NAME} - danach sucht der Updater")
pruefe(version.EXE_NAME + ".sha256" in anhaenge,
f"die Pruefsumme haengt als {version.EXE_NAME}.sha256 dran")
# ---- Die Summe muss zur Datei passen, und zwar zu der gebauten ------------
gebaut = os.path.join(HIER, "release", version.EXE_NAME)
url_summe = updater.summe_aus_release(dieses, version.EXE_NAME)
if url_summe:
veroeffentlicht = updater.text_holen(url_summe).strip().split()[0]
if os.path.exists(gebaut):
pruefe(veroeffentlicht == updater.sha256(gebaut),
"die veroeffentlichte Summe ist die der zuletzt gebauten Datei")
else:
print(f" (release/{version.EXE_NAME} fehlt - Summe nicht "
f"gegenpruefbar, vorher: python bauen.py)")
pruefe(len(veroeffentlicht) == 64 and all(c in "0123456789abcdef" for c in veroeffentlicht),
f"und sie sieht aus wie eine SHA256 ({veroeffentlicht[:16]}...)")
# ---- Und findet der Updater das Release ueberhaupt? -----------------------
treffer = updater.passendes_release(releases, version.EXE_NAME)
pruefe(treffer is not None, "passendes_release findet ueberhaupt ein Release")
if treffer:
pruefe((treffer[0].get("tag_name") or "") == marke,
f"und zwar dieses hier ({treffer[0].get('tag_name')})")
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Das Release ist so, wie der Updater es erwartet")

133
pruef_start_exe.py Normal file
View file

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""Pruefstand: kommt bei der GEBAUTEN EXE wirklich ein Fenster?
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
--------------------------------------------
Nach dem Installieren lief das Programm - im Taskmanager stand es da, es
verbrauchte Rechenzeit - und auf dem Bildschirm war NICHTS. Ursache: der
Erprobungshinweis ging auf, bevor das Hauptfenster gezeichnet war. Der modale
Dialog sperrte das Hauptfenster, und beide blieben unsichtbar.
WARUM DAS KEIN ANDERER PRUEFSTAND FINDET
----------------------------------------
Alle uebrigen Pruefstaende fahren das Programm aus der QUELLE. Dort ist das
Fenster sofort gezeichnet, der Fehler tritt nicht auf. Er gehoert zur gepackten
EXE - also muss genau die gestartet werden.
Und "der Prozess laeuft" beweist nichts: er lief ja. Geprueft wird deshalb, ob
Windows ein Fenster dieses Programms als SICHTBAR fuehrt.
ZWEI FALLEN, die hier schon zugeschnappt sind:
* Eine PyInstaller-Onefile-EXE ist zweistufig. Der gestartete Prozess packt
nur aus; das Fenster gehoert dem KIND. Wer nur die eigene PID betrachtet,
sieht nie ein Fenster.
* terminate() beendet nur den Auspacker. Das Kind laeuft weiter - und dessen
Einzelinstanz-Sperre haelt danach jeden weiteren Start ab. Es muss die
ganze Sippe beendet werden.
"""
import ctypes
import os
import subprocess
import sys
import time
from ctypes import wintypes
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
import version # noqa: E402
from pruefhelfer import fehler, pruefe
stamm, endung = os.path.splitext(version.EXE_NAME)
EXE = os.path.join(HIER, "dist", f"{stamm}-{version.VERSION}{endung}")
if not os.path.exists(EXE):
print(f"UEBERSPRUNGEN | {os.path.basename(EXE)} ist nicht gebaut "
f"(vorher: python bauen.py)")
sys.exit(0)
u = ctypes.windll.user32
# Mit --test: dann geht das Fenster auf dem ZWEITEN Monitor auf und nicht
# mitten auf dem Hauptbildschirm, wo gerade jemand arbeitet. Seine Ansage am
# 07.09.2026 - und dieser Pruefstand war der eine, der sie noch nicht befolgte,
# weil er die gebaute EXE ganz normal startet. Sichtbar bleibt das Fenster,
# und genau das wird hier ja geprueft.
p = subprocess.Popen([EXE, "--test"])
print(f" (gestartet: {os.path.basename(EXE)}, PID {p.pid})")
RUECKRUF = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
gefunden = []
verwandte = {p.pid}
def sippe():
alle = {p.pid}
try:
e = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | Where-Object "
f"{{ $_.ParentProcessId -eq {p.pid} }} | ForEach-Object {{ $_.ProcessId }}"],
capture_output=True, text=True, timeout=20)
for zeile in e.stdout.split():
if zeile.strip().isdigit():
alle.add(int(zeile.strip()))
except Exception: # noqa: BLE001 - dann bleibt es bei der eigenen PID
pass
return alle
def je_fenster(hwnd, _):
pid = wintypes.DWORD()
u.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value not in verwandte:
return True
laenge = u.GetWindowTextLengthW(hwnd)
puffer = ctypes.create_unicode_buffer(laenge + 1)
u.GetWindowTextW(hwnd, puffer, laenge + 1)
r = wintypes.RECT()
u.GetWindowRect(hwnd, ctypes.byref(r))
gefunden.append((puffer.value, bool(u.IsWindowVisible(hwnd)),
r.right - r.left, r.bottom - r.top))
return True
ende = time.time() + 60
sichtbar = False
vorzeitig_weg = False
while time.time() < ende and not sichtbar:
if p.poll() is not None:
vorzeitig_weg = True
break
verwandte = sippe()
gefunden.clear()
u.EnumWindows(RUECKRUF(je_fenster), 0)
# Ein "Fenster" von wenigen Pixeln ist Verwaltung (IME, Notifier), kein
# Programmfenster. Deshalb die Groesse mitpruefen.
sichtbar = any(s and b > 300 and h > 300 for _, s, b, h in gefunden)
time.sleep(0.5)
for titel, s, b, h in gefunden:
print(f" {titel!r:42s} sichtbar={s} {b}x{h}")
pruefe(not vorzeitig_weg,
f"das Programm bleibt am Leben (Rueckgabe {p.returncode})"
if vorzeitig_weg else "das Programm bleibt am Leben")
if vorzeitig_weg and p.returncode == 0:
print(" (Rueckgabe 0 heisst meist: es laeuft schon eine Fassung. "
"Erst beenden, dann noch einmal.)")
pruefe(sichtbar, "ein sichtbares Programmfenster ist da")
pruefe(any(str(t).startswith(stamm) and s for t, s, _, _ in gefunden),
f"und es traegt den Programmnamen ({stamm})")
subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True)
try:
p.wait(10)
except subprocess.TimeoutExpired:
p.kill()
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die gebaute EXE zeigt ein Fenster")

View file

@ -24,16 +24,6 @@ TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, TOOL)
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)")
@ -62,10 +52,15 @@ os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Berichtigte Rechnung erstellen": True,
"Berichtigung": True, "Storno erstellen": True}
antworten["Erprobungsfassung"] = True
antworten["jetzt erstellen?"] = True
def askyesno(titel, text, **kw):

View file

@ -30,17 +30,6 @@ from datetime import date
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
fehler = 0
def pruefe(bedingung, text):
global fehler
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 += 1
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -61,7 +50,30 @@ import bericht_pdf # noqa: E402
import firma as firma_lib # noqa: E402
from pypdf import PdfReader # noqa: E402
import app as app_mod # noqa: E402
from tkinter import messagebox # noqa: E402
# Kein echtes Fenster im Pruefstand: sonst steht der Lauf vor einem Dialog, den
# niemand anklickt. Der Erprobungshinweis wird hier bejaht - geprueft wird er
# eigens in pruef_hinweis.py des Rechnungstools. Eine UNBEKANNTE Frage bricht
# ab, statt still 'ja' zu sagen: sonst uebergeht der Lauf genau die Faelle,
# wegen denen es die Pruefung gibt.
_antworten = {"Erprobungsfassung": True, "jetzt erstellen?": True,
"Fertig": False, "Hausnummer": True}
def _askyesno(titel, text="", **kw):
for schluessel, wert in _antworten.items():
if schluessel in str(titel):
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = _askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
from gui_monat import SPALTEN # noqa: E402
from pruefhelfer import fehler, pruefe
pruefe(app_mod.BASE_DIR == tmp, f"Pruefstand laeuft im Testordner ({app_mod.BASE_DIR})")
@ -73,12 +85,14 @@ JAHR = 2026
FAKE = [
Buchung(date(JAHR, 8, 22), "900", JAHR, "Muster", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 8, 23), "901", JAHR, "Muster", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-900", storno_datum="22.08.2026"),
art="storno", storno_zu="2026-900", storno_datum="22.08.2026",
folge_nummer="2026-902", folge_art="neu"),
Buchung(date(JAHR, 8, 23), "902", JAHR, "Muster", 3, 39.0, quelle="manuell",
art="korrektur"),
Buchung(date(JAHR, 8, 22), "910", JAHR, "Spaet", 4, 52.0, quelle="manuell"),
Buchung(date(JAHR, 9, 2), "911", JAHR, "Spaet", -4, -52.0, quelle="manuell",
art="storno", storno_zu="2026-910", storno_datum="22.08.2026"),
art="storno", storno_zu="2026-910", storno_datum="22.08.2026",
folge_nummer="2026-905", folge_art="vorhanden"),
Buchung(date(JAHR, 8, 5), "905", JAHR, "Sauber", 2, 26.0, quelle="manuell"),
]
@ -149,7 +163,19 @@ pruefe("storniert mit 2026-901" in text,
"die aufgehobene Rechnung nennt den Storno, der sie aufhebt")
pruefe("storniert mit 2026-911" in text,
"auch dann, wenn dieser Storno in einem ANDEREN Monat liegt")
pruefe("Storno zu 2026-900" in text, "und der Storno nennt die Rechnung, die er aufhebt")
pruefe("neue Rechnung 2026-902" in text,
"der Storno zeigt VORWAERTS auf die Rechnung, die nun gilt")
# Der zweite Storno steht im SEPTEMBER - also dort nachsehen, nicht im August.
pfad_sep = os.path.join(tmp, "September.pdf")
bericht_pdf.render_monat(pfad_sep, firma_lib.lade(), JAHR, 9, alle)
text_sep = " ".join((PdfReader(pfad_sep).pages[0].extract_text() or "").split())
pruefe("abgerechnet mit 2026-905" in text_sep,
"und beim Doppelungsfall zeigt er auf die Rechnung, die bleibt")
pruefe("-52,00" in text_sep, "der September traegt weiter seinen Minusbetrag")
# Die Buchstaben-Marke: ohne sie muss man aus der Begruendung raten, was fuer ein
# Beleg die Zeile ist ("du musst die markieren als rechnung und storno").
pruefe("R = Rechnung" in text and "S = Stornorechnung" in text,
"unter der Tabelle steht die Erklaerung der Buchstaben")
pruefe("Neuausstellung" in text, "die neue Rechnung ist als Neuausstellung beschriftet")
# ---------------------------------------------------------------------------
@ -164,8 +190,8 @@ pruefe(len(reihen) == 5, f"der August zeigt alle fuenf Zeilen ({len(reihen)})")
pruefe(not any("aufgehoben" in t for _nr, _a, t in reihen),
f"keine Zeile ist ausgegraut ({sorted({t for _n, _a, t in reihen})})")
arten = {nr: art for nr, art, _t in reihen}
pruefe(arten.get("2026-901") == "Storno zu 2026-900",
f"der Storno ist als Storno getaggt ({arten.get('2026-901')!r})")
pruefe(arten.get("2026-901") == "neue Rechnung 2026-902",
f"der Storno zeigt auf die neue Rechnung ({arten.get('2026-901')!r})")
pruefe(arten.get("2026-900") == "storniert mit 2026-901",
f"die aufgehobene Rechnung nennt ihren Storno ({arten.get('2026-900')!r})")
pruefe(arten.get("2026-902") == "Neuausstellung",
@ -180,5 +206,5 @@ fenster.destroy()
_wecker.cancel()
print("---")
print("alles gruen" if not fehler else f"{fehler} FEHLER")
print("alles gruen" if not fehler else f"{len(fehler)} FEHLER")
sys.exit(1 if fehler else 0)

101
pruef_temp_merkzettel.py Normal file
View file

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Pruefstand: ein Merkzettel in den Temp-Ordner wird verworfen.
DER FALL, DER DAZU GEFUEHRT HAT (07.09.2026)
--------------------------------------------
Die installierte Fassung fuehrte ihr Journal in
`%TEMP%/abnahme_bst/Daten` - haengengeblieben aus einem Abnahmelauf vom 03.09.
Windows raeumt den Temp-Ordner von selbst auf (Datentraegerbereinigung,
Speicheroptimierung); das Journal waere ohne Vorwarnung verschwunden. Es geht
um aufbewahrungspflichtige Aufzeichnungen, zehn Jahre (§ 147 AO).
Ich habe daraufhin `_ist_temp()` geschrieben, ausfuehrlich begruendet - und
NICHT ANGESCHLOSSEN. Die Funktion stand da und wurde von niemandem gerufen;
der Schutz war ein Kommentar, keine Massnahme. Zwei Pruefagenten haben das
unabhaengig voneinander gefunden, einer davon mit einer Messung, die zeigte,
dass das Journal weiterhin im Temp landet.
⚠️ Das ist die schlimmste Sorte Fehler: ein Kommentar, der eine Schutzmassnahme
zusichert, die es nicht gibt. Wer die Datei liest, haelt die Falle fuer
geschlossen. Deshalb dieser Pruefstand - er prueft nicht, ob die FUNKTION
existiert, sondern ob sie WIRKT.
Angefasst wird nichts Echtes: LOCALAPPDATA zeigt waehrend des Laufs auf einen
eigenen Ordner, der danach wieder verschwindet.
⚠️ Dieser Ordner liegt AUSSERHALB des Temp-Ordners - im ersten Anlauf hatte
ich ihn mit tempfile.mkdtemp() angelegt, und der liegt selbst im Temp. Dann
erkennt _ist_temp auch die Vergleichspfade als Temp und der Pruefstand meldet
Fehler, die es nicht gibt. Wer das Verhalten IM Temp-Ordner prueft, darf seine
Spielwiese nicht dort aufbauen.
"""
import importlib
import os
import shutil
import sys
import tempfile
HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HIER)
echtes_lad = os.environ.get("LOCALAPPDATA", "")
spielwiese = os.path.join(os.path.expanduser("~"), ".pruef_merkzettel")
shutil.rmtree(spielwiese, ignore_errors=True)
os.makedirs(spielwiese, exist_ok=True)
os.environ["LOCALAPPDATA"] = spielwiese
# Als EXE tun, sonst nimmt ablage.py den Quellordner und der Merkzettel
# spielt gar keine Rolle.
sys.frozen = True
sys.argv = [os.path.join(spielwiese, "Steuerrechnungstool.exe")]
import ablage # noqa: E402
from pruefhelfer import fehler, pruefe
importlib.reload(ablage)
# ---- 1. Die Funktion erkennt einen Temp-Pfad ---------------------------
temp = tempfile.gettempdir()
pruefe(ablage._ist_temp(os.path.join(temp, "abnahme_bst", "Daten")),
"ein Pfad im Temp-Ordner wird als solcher erkannt")
pruefe(not ablage._ist_temp(os.path.join(spielwiese, "ravokk")),
"ein normaler Pfad nicht")
# ---- 2. Und sie WIRKT - das ist der Punkt ------------------------------
# Genau so sah der Merkzettel bei ihm aus.
falscher = os.path.join(temp, "abnahme_bst_pruefstand", "Daten")
os.makedirs(falscher, exist_ok=True)
zeiger = ablage._zeiger_datei()
os.makedirs(os.path.dirname(zeiger), exist_ok=True)
with open(zeiger, "w", encoding="utf-8") as f:
f.write(falscher)
gewaehlt = ablage.daten_ordner()
print(f" (Merkzettel zeigte auf {falscher})")
print(f" (gewaehlt wurde {gewaehlt})")
pruefe(not ablage._ist_temp(gewaehlt),
f"der Datenordner liegt NICHT im Temp-Ordner ({gewaehlt})")
pruefe(os.path.normcase(gewaehlt) != os.path.normcase(falscher),
"dem Merkzettel wurde nicht gefolgt")
pruefe("ravokk" in gewaehlt.lower(),
f"stattdessen der Ordner der Firma ({gewaehlt})")
# ---- 3. Ein GUELTIGER Merkzettel wird weiter befolgt -------------------
# Der Schutz darf nicht dazu fuehren, dass jemand seinen bewusst gewaehlten
# Ordner verliert - etwa ein Netzlaufwerk.
gewollt = os.path.join(spielwiese, "woanders", "Daten")
os.makedirs(gewollt, exist_ok=True)
with open(zeiger, "w", encoding="utf-8") as f:
f.write(gewollt)
pruefe(os.path.normcase(ablage.daten_ordner()) == os.path.normcase(gewollt),
"ein gueltiger Merkzettel wird weiter befolgt")
os.environ["LOCALAPPDATA"] = echtes_lad
shutil.rmtree(spielwiese, ignore_errors=True)
shutil.rmtree(os.path.dirname(falscher), ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Der Temp-Schutz ist angeschlossen und wirkt")

View file

@ -25,16 +25,6 @@ import time
JOURNAL = r"C:\claude\beherbergungssteuer"
TOOL = r"C:\claude\rechnungstool"
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)")
@ -66,11 +56,36 @@ del sys.modules["app"]
sys.path.remove(JOURNAL)
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
# Der Abfang MUSS vor dem ersten Fenster stehen: sonst geht ein echter Dialog
# auf und der Lauf wartet auf einen Klick, den niemand macht. Genau das ist am
# 06.09.2026 passiert - der Hinweis kam, bevor der Abfang gesetzt war.
# Erprobungshinweis und Freigabe vor dem Beleg werden hier bejaht - geprueft
# werden sie eigens in pruef_hinweis.py und pruef_freigabe.py des Rechnungstools.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Storno verwerfen": True}
antworten["Erprobungsfassung"] = True
antworten["jetzt erstellen?"] = 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
journal = JA.App()
# Der Erprobungshinweis haengt an after(0) - ohne Ereignisschleife kaeme er nie
# dran, und damit auch der verzoegerte Start nicht. update() treibt die Schleife
# einmal an, genau wie es mainloop() taete. Abgekuerzt wird nichts.
journal.update()
journal.withdraw()
journal.pdf_ordner = rechnungen
journal.journal.setze("pdf_ordner", rechnungen)
@ -85,8 +100,15 @@ pruefe(not gemeinsam.journal_laeuft(gemeinsam_ordner),
journal._anmelden()
pruefe(gemeinsam.journal_laeuft(gemeinsam_ordner),
"nach der Anmeldung sieht man an der PID, dass es laeuft")
pruefe(os.listdir(gemeinsam_ordner) == [gemeinsam.DATEINAME],
f"im gemeinsamen Ordner liegt genau EINE Datei ({os.listdir(gemeinsam_ordner)})")
# Die Absicht dieser Pruefung: im gemeinsamen Ordner liegt NICHTS Fremdes -
# keine Herzschlagdateien, keine Zwischenstaende. Erlaubt sind genau zwei:
# das Nummernbuch und der Schluessel der Mini-API, der seit dem Umbau vom
# 05.09.2026 dorthin gehoert (beide Programme muessen ihn lesen koennen).
# Alles darueber hinaus faellt weiterhin auf.
ERLAUBT = {gemeinsam.DATEINAME, "api_schluessel.txt"}
_liegt = set(os.listdir(gemeinsam_ordner))
pruefe(_liegt <= ERLAUBT,
f"im gemeinsamen Ordner liegt nichts Fremdes ({sorted(_liegt)})")
# ---- 1b. Die Mini-API: Port im Telefonbuch, und sie meldet sich -----------
import api # noqa: E402
@ -118,20 +140,9 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True, "Storno verwerfen": 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
tool = A.RechnungsApp()
tool.update() # dasselbe fuer das Rechnungstool
tool.cfg["output_dir"] = rechnungen
tool.cfg["gemeinsam_ordner"] = gemeinsam_ordner
tool._buch_oeffnen()
@ -166,6 +177,16 @@ pruefe(not journal._push_kasten.empty(),
"der Push des Rechnungstools ist im Fenster angekommen")
journal._push_abholen()
journal.update()
# Auf die Meldung WARTEN statt einmal hinzusehen. Das Einlesen laeuft
# nebenher und schreibt danach sein eigenes Ergebnis in dieselbe Zeile - wer
# genau dazwischen misst, sieht "Nummernbuch: 1 Nummern eingetragen" und
# meldet einen Fehler, den es nicht gibt. Genau das ist am 07.09.2026 einmal
# in einer Prueflauf-Runde passiert und beim naechsten Lauf nicht mehr.
for _ in range(60):
if "Rechnungstool meldet" in journal.status.cget("text"):
break
journal.update()
time.sleep(0.05)
buchungen = journal.journal.buchungen(jahr=2026)
pruefe(len(buchungen) == 1 and buchungen[0].nachname == "Meldung",
f"die Rechnung steht im Journal ({[(b.voll_nummer, b.nachname) for b in buchungen]})")

47
pruefhelfer.py Normal file
View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Das Gemeinsame aller Pruefstaende: eine Zeile schreiben und sie merken.
WARUM ES DIESE DATEI GIBT (07.09.2026)
--------------------------------------
Die Funktion pruefe() stand 35-mal im Quelltext, in sechs leicht verschiedenen
Fassungen. Keine davon war falsch - aber sie sahen unterschiedlich aus, und
wer 37 Laeufe hintereinander durchsieht, liest dann dreierlei Formen fuer
dieselbe Aussage. Wird an der Ausgabe je etwas geaendert, sind es 35 Stellen.
BENUTZUNG
from pruefhelfer import fehler, pruefe
pruefe(a == b, "beide nennen denselben Ordner")
...
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | ...")
`fehler` ist eine Liste und wird beim Importieren geteilt - jeder Pruefstand
laeuft in einem eigenen Prozess, sie gehoert also ihm allein.
Den Abschluss schreibt jeder Pruefstand selbst. Er sieht nur aehnlich aus:
manche raeumen dort noch einen Temp-Ordner ab, manche zaehlen die
fehlgeschlagenen Punkte einzeln auf. Das ist Inhalt, keine Wiederholung.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst steht in zwei Programmen zweierlei.
"""
import sys
# Alles, was nicht erfuellt war - der Text, nicht nur die Anzahl. Wer am Ende
# nur eine Zahl hat, muss den Lauf noch einmal lesen, um zu wissen, was fehlte.
fehler = []
def pruefe(bedingung, text):
"""Eine Zusage pruefen, das Ergebnis melden und Fehlschlaege merken."""
zeile = (" OK | " if bedingung else "FEHLER | ") + text
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)

819
theme.py Normal file
View file

@ -0,0 +1,819 @@
# -*- coding: utf-8 -*-
"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste.
Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum
baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben.
"""
import ctypes.wintypes
import os
import sys
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox
from tkinter import ttk
# ---------------------------------------------------------------------------
# Diese Datei liegt in BEIDEN Programmen gleich und darf deshalb NICHTS aus
# einem der beiden importieren. Frueher holte sie sich die Ordner und den
# Testlauf-Schalter aus config.py - die gibt es aber nur im Rechnungstool, und
# beim Uebernehmen ins Steuerrechnungstool flog sie beim Import auseinander.
# Die zwei Dinge, die gebraucht werden, stehen jetzt hier.
# ---------------------------------------------------------------------------
def _ordner():
"""Alle Orte, an denen assets/ liegen kann - Quelle wie gepackte EXE."""
hier = os.path.dirname(os.path.abspath(__file__))
aus_exe = getattr(sys, "_MEIPASS", "")
neben_exe = (os.path.dirname(os.path.abspath(sys.executable))
if getattr(sys, "frozen", False) else "")
return [p for p in (aus_exe, neben_exe, hier) if p]
def titelleiste_faerben(fenster, dunkel):
"""Die Titelleiste des Fensters mit einfaerben (Windows 10 ab 1809).
Tk kann das nicht - die Leiste mit Symbol, Titel und den drei Knoepfen
gehoert Windows, nicht dem Programm. Windows nimmt dafuer aber einen
Hinweis entgegen: DWMWA_USE_IMMERSIVE_DARK_MODE. Ohne den steht ein weisser
Balken ueber einem dunklen Fenster (sein Befund am 07.09.2026: "der icon
und titel leiste auch ... die sind noch hell").
Die Nummer des Attributs hat sich einmal geaendert: 19 in den fruehen
1809er-Fassungen, 20 seit 2004. Beide werden gesetzt; die falsche wird
ignoriert.
"""
if not sys.platform.startswith("win"):
return False
try:
fenster.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(fenster.winfo_id()) or int(fenster.frame(), 16)
wert = ctypes.c_int(1 if dunkel else 0)
gesetzt = False
for attribut in (20, 19):
ergebnis = ctypes.windll.dwmapi.DwmSetWindowAttribute(
ctypes.wintypes.HWND(hwnd), ctypes.c_int(attribut),
ctypes.byref(wert), ctypes.sizeof(wert))
gesetzt = gesetzt or ergebnis == 0
# Den Rahmen neu zeichnen lassen, ohne Groesse, Platz, Reihenfolge oder
# Fokus anzufassen. Ohne das bleibt die Leiste stehen, wie sie war -
# die Umstellung wirkt dann erst beim naechsten Start.
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
return gesetzt
except Exception: # noqa: BLE001 - ohne dunkle Titelleiste laeuft es weiter
return False
def pruefplatz():
"""Wo ein Pruefstand seine Fenster aufmachen soll: "+x+y" oder "".
Seine Ansage am 07.09.2026: *"teste links auf dem 1080p monitor nicht mehr
auf dem main ... weil da schau ich nun video"*.
Vorher lag der Platz bei `Bildschirmbreite + 20` - also RECHTS neben dem
Hauptmonitor. Bei ihm steht der zweite Monitor aber LINKS (x = -1920), und
rechts ist gar nichts: die Fenster gingen ins Leere. Sie liefen zwar, waren
aber nirgends zu sehen - das hat beim Bildermachen einige Zeit gekostet.
Gesucht wird deshalb der nicht-primaere Monitor. Gibt es keinen, bleibt es
beim alten Verhalten (neben den Bildschirm), denn im Weg sein soll ein
Pruefstand auch dann nicht.
"""
if not sys.platform.startswith("win"):
return ""
try:
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", ctypes.wintypes.DWORD), ("rcMonitor", RECT),
("rcWork", RECT), ("dwFlags", ctypes.wintypes.DWORD)]
gefunden = []
def je_monitor(hmon, hdc, lprect, daten):
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(info)):
r = info.rcWork
gefunden.append((bool(info.dwFlags & 1), # 1 = MONITORINFOF_PRIMARY
r.left, r.top, r.right - r.left))
return 1
RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HMONITOR,
ctypes.wintypes.HDC, ctypes.POINTER(RECT),
ctypes.wintypes.LPARAM)
ctypes.windll.user32.EnumDisplayMonitors(0, 0, RUECKRUF(je_monitor), 0)
# Der breiteste Nebenmonitor - bei mehreren der linkeste.
neben = sorted((m for m in gefunden if not m[0]),
key=lambda m: (-m[3], m[1]))
if neben:
_, x, y, _ = neben[0]
return f"+{x + 30}+{y + 40}"
except Exception: # noqa: BLE001 - dann eben der alte Weg
pass
return ""
def auf_bildschirm(x, y, breite=0, hoehe=0):
"""Eine Fensterposition auf den sichtbaren Bereich begrenzen.
Frueher stand an diesen Stellen `max(0, x)`. Das sollte verhindern, dass
ein Dialog ausserhalb des Bildschirms aufgeht - klemmt aber jede NEGATIVE
Koordinate auf null. Steht der zweite Monitor links (bei ihm x = -1920),
sprang damit jedes Popup zurueck auf den Hauptbildschirm, obwohl das
Programm nebenan lief. Sein Befund am 07.09.2026: *"da gehen noch die
popups auf dem video monitor auf"*.
Begrenzt wird deshalb auf den GESAMTEN Desktop ueber alle Monitore
(SM_*VIRTUALSCREEN), nicht auf den Hauptmonitor.
"""
if not sys.platform.startswith("win"):
return int(x), int(y)
try:
hole = ctypes.windll.user32.GetSystemMetrics
links, oben = hole(76), hole(77)
rechts, unten = links + hole(78), oben + hole(79)
if breite:
x = min(x, rechts - breite)
if hoehe:
y = min(y, unten - hoehe)
return int(max(links, x)), int(max(oben, y))
except Exception: # noqa: BLE001 - dann eben ungebremst
return int(x), int(y)
def mittig(dialog, ueber):
"""Einen Dialog mittig ueber sein Elternfenster setzen.
Seine Ansage am 07.09.2026: *"einfach so programmieren das die popup auf
dem fenster mittig vom main fenster auf gehen"*.
Das ist zugleich die Antwort auf die Monitorfrage: steht das Programm auf
dem zweiten Bildschirm, geht der Dialog dort auf - ohne dass irgendwo ein
Monitor gesucht werden muesste. Vorher standen an den Dialogen feste
Versaetze ("+120+140") und ein `max(0, x)`, das jede negative Koordinate
auf null klemmte und die Fenster damit auf den Hauptmonitor zurueckriss.
Etwas oberhalb der Mitte (ein Drittel statt der Haelfte): so sitzt der
Dialog dort, wo der Blick ohnehin ist, und verdeckt nicht die Mitte des
Formulars darunter.
"""
try:
dialog.update_idletasks()
breite = dialog.winfo_reqwidth() or dialog.winfo_width()
hoehe = dialog.winfo_reqheight() or dialog.winfo_height()
x = ueber.winfo_rootx() + (ueber.winfo_width() - breite) // 2
y = ueber.winfo_rooty() + (ueber.winfo_height() - hoehe) // 3
x, y = auf_bildschirm(x, y, breite, hoehe)
dialog.geometry(f"+{x}+{y}")
except tk.TclError:
pass # Fenster schon zu - dann ist die Position gleichgueltig
def testlauf():
"""Faehrt gerade ein Pruefstand das Programm?
Muss mit der Definition in app.py uebereinstimmen - beide Programme
schalten ueber `--test` oder die Umgebungsvariable.
"""
return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
# Schrift: Roboto, so wie in den Web-Oberflaechen. Sie wird NICHT auf dem
# Rechner installiert, sondern nur fuer diesen einen Prozess geladen (die
# Windows-Funktion AddFontResourceEx mit FR_PRIVATE). Damit braucht niemand
# Adminrechte, und nach dem Schliessen ist der Rechner wieder wie vorher.
# Findet sich die Datei nicht, bleibt es bei Segoe UI - das Programm muss auch
# ohne mitgelieferte Schrift laufen.
SCHRIFT_NAME = "Segoe UI"
SCHRIFT = (SCHRIFT_NAME, 10)
KLEIN = (SCHRIFT_NAME, 9)
_schrift_geladen = False
def schrift_vorbereiten():
"""Roboto laden und zur Schrift des Programms machen. Gibt den Namen zurueck."""
global SCHRIFT_NAME, SCHRIFT, KLEIN, _schrift_geladen
if _schrift_geladen:
return SCHRIFT_NAME
_schrift_geladen = True
if not sys.platform.startswith("win"):
return SCHRIFT_NAME
FR_PRIVATE = 0x10
geladen = 0
for basis in _ordner():
ordner = os.path.join(basis, "assets", "fonts")
if not os.path.isdir(ordner):
continue
for datei in sorted(os.listdir(ordner)):
if datei.lower().endswith((".ttf", ".otf")):
geladen += ctypes.windll.gdi32.AddFontResourceExW(
os.path.join(ordner, datei), FR_PRIVATE, 0)
if geladen:
break
if not geladen:
print("[schrift] Roboto nicht gefunden - bleibe bei Segoe UI")
return SCHRIFT_NAME
# Ob Windows sie wirklich angenommen hat, verraet erst die Schriftenliste.
if "Roboto" not in tkfont.families():
print("[schrift] Roboto liess sich nicht laden - bleibe bei Segoe UI")
return SCHRIFT_NAME
SCHRIFT_NAME = "Roboto"
SCHRIFT = (SCHRIFT_NAME, 10)
KLEIN = (SCHRIFT_NAME, 9)
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont",
"TkIconFont", "TkTooltipFont"):
try:
tkfont.nametofont(name).configure(family=SCHRIFT_NAME, size=10)
except tk.TclError:
pass
print("[schrift] Roboto geladen")
return SCHRIFT_NAME
def mischen(vorn, hinten, anteil):
"""Wie 'rgba(vorn, anteil)' auf 'hinten' - nur ausgerechnet statt gemalt.
Tk kann keine halbdurchsichtigen Flaechen (kein backdrop-filter, keine
Alpha-Kanaele auf Widgets). Seine Web-Oberflaechen bauen den Glas-Effekt aus
genau zwei Zutaten: eine getoente Flaeche und ein feiner Rand in
Akzent-Transparenz. Beides laesst sich vorher ausrechnen - das Ergebnis sieht
identisch aus, solange der Grund einfarbig ist. Und der ist es hier.
"""
v = vorn.lstrip("#")
h = hinten.lstrip("#")
teile = []
for i in (0, 2, 4):
a = int(v[i:i + 2], 16)
b = int(h[i:i + 2], 16)
teile.append(round(a * anteil + b * (1 - anteil)))
return "#%02x%02x%02x" % tuple(teile)
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
if not sys.platform.startswith("win"):
return False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
except OSError:
return False
# ---------------------------------------------------------------------------
# Welche Darstellung ist gewuenscht?
# ---------------------------------------------------------------------------
# Drei Moeglichkeiten, gespeichert in der config unter "darstellung":
#
# "auto" - folgt Windows (Voreinstellung, wie es bisher immer war)
# "hell" - immer hell
# "dunkel" - immer dunkel
#
# "auto" ist die Voreinstellung, weil sich das Programm dann verhaelt wie jedes
# andere: wer sein Windows abends auf dunkel stellt, bekommt es auch hier.
MODI = ("auto", "hell", "dunkel")
MODUS_NAMEN = {"auto": "Automatisch (wie Windows)",
"hell": "Hell",
"dunkel": "Dunkel"}
def modus(cfg):
"""Der eingestellte Modus - immer einer aus MODI."""
wert = str((cfg or {}).get("darstellung", "auto")).strip().lower()
return wert if wert in MODI else "auto"
def ist_dunkel(cfg):
"""Soll dunkel gezeichnet werden? Die eine Stelle, die das entscheidet."""
m = modus(cfg)
if m == "hell":
return False
if m == "dunkel":
return True
return windows_dark_mode()
class ThemeMixin:
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
# ----------------------------------------------------------------- Theme
def _apply_theme(self):
"""Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows)."""
schrift_vorbereiten() # Roboto, sonst Segoe UI
dark = ist_dunkel(getattr(self, "cfg", None))
self._dark_mode = dark
# Die Farben sind die aus seinen anderen Sachen (FiveM-Oberflaechen,
# Adminpage, Webseiten): Tuerkis #06C6A4 auf sehr dunklen, gestuften
# Flaechen. Das PROGRAMM traegt diese Handschrift - die RECHNUNG traegt
# die Farbe der jeweiligen Firma (Einstellungen -> Farbe der Rechnung).
# Beides zu vermischen waere falsch: das Blatt gehoert dem Kunden.
if dark:
self.col_bg = "#0d0e10" # Fenster-Hintergrund (tiefstes Grau)
self.col_card = "#15171b" # Kachel / Abschnitt
self.col_fg = "#e8e8ea" # Schrift
self.col_field = "#2c2e32" # Eingabefelder (heller als die Kachel)
self.col_feldrand = "#57585b" # Rahmen der Eingabefelder
self.col_sel = "#1f3d38" # aktiv/hover (Akzent, stark abgedunkelt)
self.col_accent = "#06C6A4" # Akzent (Pcore-Tuerkis)
self.col_accent_hell = "#08dbb5"
self.col_auf_accent = "#08221d" # Schrift AUF der Akzentflaeche
self.col_gefahr = "#e85a5a"
self.col_line = "#25282e"
self.col_scroll = "#2f333a"
self.col_muted = "#8a8e96"
self.col_zebra = "#232830" # jede zweite Zeile der Liste
else:
self.col_bg = "#eef0f3"
self.col_card = "#ffffff"
self.col_fg = "#14161a"
self.col_field = "#ffffff"
self.col_feldrand = "#b9bec6"
self.col_sel = "#d7f5ee"
self.col_accent = "#04886f" # dunkler, damit es auf Weiss lesbar ist
self.col_accent_hell = "#06C6A4"
self.col_auf_accent = "#ffffff"
self.col_gefahr = "#c0392b"
self.col_line = "#d8dce1"
self.col_scroll = "#c2c6cc"
self.col_muted = "#5c626b"
self.col_zebra = "#e4e8ee"
self.configure(bg=self.col_bg)
# Die Titelleiste gehoert Windows - sie muss eigens umgestellt werden.
titelleiste_faerben(self, dark)
# Und das Fenstersymbol: in der dunklen Titelleiste geht die dunkle
# Haelfte des Zeichens sonst unter. Es haengt hier und nicht im
# __init__, damit es beim Umschalten mitgeht - dort wird _apply_theme
# erneut gerufen, das __init__ nicht.
symbol = getattr(self, "_fenstersymbol", None)
if callable(symbol):
symbol()
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
self.col_sel, self.col_accent)
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg, font=SCHRIFT)
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
style.configure("TLabelframe.Label", background=bg, foreground=accent)
# --- Kacheln: ein Abschnitt ist eine Flaeche mit Rand, keine Linie ---
style.configure("Karte.TFrame", background=self.col_card,
bordercolor=self.col_line, relief="solid", borderwidth=1)
# Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die
# Ueberschrift in Akzentfarbe - genau wie die Kacheln in den
# Web-Oberflaechen.
# Der Kasten, den man gerade in der Hand hat: blass, gestrichelt.
style.configure("Gegriffen.TFrame", background=self.col_bg,
bordercolor=self.col_muted, relief="solid", borderwidth=1,
lightcolor=self.col_muted, darkcolor=self.col_muted)
# Der Kasten, auf den beim Anordnen gerade gezielt wird.
style.configure("Ziel.TFrame", background=self.col_card,
bordercolor=accent, relief="solid", borderwidth=2,
lightcolor=accent, darkcolor=accent)
style.configure("Karte.TLabelframe", background=self.col_card,
bordercolor=self.col_line, relief="solid", borderwidth=1,
lightcolor=self.col_card, darkcolor=self.col_card)
style.configure("Karte.TLabelframe.Label", background=self.col_card,
foreground=accent, font=(SCHRIFT_NAME, 9, "bold"))
style.configure("Karte.TEntry", fieldbackground=field, foreground=fg)
style.configure("Karte.TLabel", background=self.col_card, foreground=fg, font=SCHRIFT)
style.configure("KarteMuted.TLabel", background=self.col_card,
foreground=self.col_muted, font=KLEIN)
# Ueberschrift eines Abschnitts: klein, gross geschrieben, in Akzentfarbe -
# so wie die Kachelkoepfe in den Web-Oberflaechen.
style.configure("Abschnitt.TLabel", background=self.col_card,
foreground=accent, font=(SCHRIFT_NAME, 9, "bold"))
style.configure("Wert.TLabel", background=self.col_card, foreground=fg,
font=(SCHRIFT_NAME, 10))
style.configure("Summe.TLabel", background=bg, foreground=accent,
font=(SCHRIFT_NAME, 15, "bold"))
style.configure("Zebra.TLabel", background=self.col_zebra, foreground=fg, font=SCHRIFT)
# --- Knoepfe im Glas-Stil (wie in seinen Oberflaechen) ---
# Getoente Flaeche, feiner Rand in Akzent-Transparenz, Schrift in der
# Akzentfarbe. Kein Vollton: der wirkt in dieser Palette wie ein
# Fremdkoerper.
glas = mischen(accent, bg, 0.10) # ruhige Flaeche
glas_hover = mischen(accent, bg, 0.20)
rand = mischen(accent, bg, 0.35)
rand_hell = mischen(accent, bg, 0.55)
style.configure("TButton", background=glas, foreground=fg,
bordercolor=rand, padding=(14, 7), font=SCHRIFT,
focuscolor=rand, relief="solid", borderwidth=1,
lightcolor=rand, darkcolor=rand)
style.map("TButton",
background=[("pressed", mischen(accent, bg, 0.28)), ("active", glas_hover)],
bordercolor=[("active", rand_hell)],
lightcolor=[("active", rand_hell)], darkcolor=[("active", rand_hell)],
foreground=[("disabled", self.col_muted)])
# Der Knopf, um den es geht: dieselbe Glasflaeche, aber kraeftiger
# getoent, mit Akzentrand und Akzentschrift.
haupt = mischen(accent, bg, 0.22)
style.configure("Haupt.TButton", background=haupt,
foreground=self.col_accent_hell, bordercolor=self.col_accent,
lightcolor=self.col_accent, darkcolor=self.col_accent,
relief="solid", borderwidth=1,
padding=(18, 8), font=(SCHRIFT_NAME, 10, "bold"))
style.map("Haupt.TButton",
background=[("pressed", mischen(accent, bg, 0.45)),
("active", mischen(accent, bg, 0.34)),
("disabled", bg)],
foreground=[("disabled", self.col_muted)])
# Storno greift in eine ausgestellte Rechnung ein - gleicher Stil, rot.
g_rot = mischen(self.col_gefahr, bg, 0.10)
style.configure("Gefahr.TButton", background=g_rot, foreground=self.col_gefahr,
bordercolor=mischen(self.col_gefahr, bg, 0.35),
lightcolor=mischen(self.col_gefahr, bg, 0.35),
darkcolor=mischen(self.col_gefahr, bg, 0.35),
relief="solid", borderwidth=1,
padding=(14, 7), font=SCHRIFT)
style.map("Gefahr.TButton",
background=[("pressed", mischen(self.col_gefahr, bg, 0.30)),
("active", mischen(self.col_gefahr, bg, 0.20))],
bordercolor=[("active", mischen(self.col_gefahr, bg, 0.55))],
lightcolor=[("active", mischen(self.col_gefahr, bg, 0.55))],
darkcolor=[("active", mischen(self.col_gefahr, bg, 0.55))])
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
arrowcolor=bg, padding=(10, 3))
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
# Eingabefelder heben sich vom Grund ab - sonst sieht man nicht, wo etwas
# einzutragen ist. JEDES Feld, in das man tippen kann, traegt diese zwei
# Farben: die ttk-Felder ueber die Stile hier, die klassischen Widgets
# (Textfelder, Auswahlliste) ueber feld_optionen() weiter unten.
feld_hell, feld_rand = self.col_field, self.col_feldrand
style.configure("TEntry", fieldbackground=feld_hell, foreground=fg,
insertcolor=fg, bordercolor=feld_rand,
lightcolor=feld_rand, darkcolor=feld_rand,
relief="solid", borderwidth=1, padding=5)
style.map("TEntry",
bordercolor=[("focus", accent)], lightcolor=[("focus", accent)],
darkcolor=[("focus", accent)],
fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))])
# Die Felder IN der Leistungsliste stehen auf den Zeilenstreifen und
# gingen darin unter - man sah nicht, wo man tippt. Sie bekommen einen
# helleren Grund und einen deutlichen Rahmen, beim Tippen den Akzent.
style.configure("Liste.TEntry", fieldbackground=feld_hell, foreground=fg,
insertcolor=fg, bordercolor=feld_rand,
lightcolor=feld_rand, darkcolor=feld_rand,
relief="solid", borderwidth=1, padding=4)
style.map("Liste.TEntry",
bordercolor=[("focus", accent)], lightcolor=[("focus", accent)],
darkcolor=[("focus", accent)],
fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))])
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
# unsichtbar im Feld.
style.configure("TCombobox", fieldbackground=feld_hell, foreground=fg,
background=feld_hell, arrowcolor=fg,
bordercolor=feld_rand, lightcolor=feld_rand, darkcolor=feld_rand,
selectbackground=feld_hell, selectforeground=fg, padding=3)
style.map("TCombobox", fieldbackground=[("readonly", feld_hell)],
foreground=[("readonly", fg)], background=[("readonly", feld_hell)],
bordercolor=[("focus", accent)],
selectbackground=[("readonly", feld_hell)],
selectforeground=[("readonly", fg)])
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
self.option_add("*TCombobox*Listbox.background", feld_hell)
self.option_add("*TCombobox*Listbox.foreground", fg)
self.option_add("*TCombobox*Listbox.selectBackground", sel)
self.option_add("*TCombobox*Listbox.selectForeground", fg)
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
# Treeview - die Tabelle des Steuerrechnungstools. Ohne diesen Block
# bleibt sie weiss im dunklen Fenster: ttk zeichnet Listen nicht ueber
# den "."-Grundstil, sondern nur ueber den eigenen.
style.configure("Treeview", background=self.col_card,
fieldbackground=self.col_card, foreground=fg,
bordercolor=self.col_line, rowheight=24,
font=SCHRIFT)
style.configure("Treeview.Heading", background=bg, foreground=accent,
relief="flat", font=(SCHRIFT_NAME, 9, "bold"))
style.map("Treeview.Heading", background=[("active", self.col_sel)])
style.map("Treeview",
background=[("selected", self.col_sel)],
foreground=[("selected", fg)])
# Der Rahmen um die Liste - ttk zeichnet ihn sonst im Systemgrau.
style.configure("Treeview.field", bordercolor=self.col_line)
style.configure("TSeparator", background=self.col_line)
# Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den
# AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man
# ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die
# Akzentfarbe, die ruhenden bleiben zurueckgenommen.
style.configure("TNotebook", background=bg, bordercolor=self.col_line,
tabmargins=(2, 4, 2, 0), borderwidth=0)
# Der OFFENE Reiter gehoert zur Kachel darunter und ist deshalb heller
# als die ruhenden. Vorher war es andersherum: die ruhenden lagen auf der
# helleren Flaeche und sahen aus, als waeren sie ausgewaehlt.
# ⚠️ clam zeichnet die Reiterflaeche aus lightcolor/darkcolor, nicht aus
# background allein - werden die nicht mitgesetzt, bleibt der ruhende
# Reiter hell und sieht aus wie der ausgewaehlte.
style.configure("TNotebook.Tab", background=bg, lightcolor=bg, darkcolor=bg,
foreground=self.col_muted, bordercolor=self.col_line,
# Schmal genug, dass zwoelf Monatsreiter nebeneinander
# passen: bei (20, 9) waren "September" und "Dezember"
# im Steuerrechnungstool abgeschnitten.
padding=(9, 7), font=(SCHRIFT_NAME, 10), borderwidth=1)
style.map("TNotebook.Tab",
background=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
lightcolor=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
darkcolor=[("selected", self.col_card),
("active", mischen(accent, bg, 0.12))],
bordercolor=[("selected", accent)],
foreground=[("selected", accent), ("active", fg)],
font=[("selected", (SCHRIFT_NAME, 10, "bold"))])
# Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar
# Pixel, die anderen rueckten nach - beim Umschalten sprang die ganze
# Leiste. Erkannt wird der offene Reiter an Farbe und Fettschrift.
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def feld_optionen(self, mehrzeilig=False, einfuegemarke=True):
"""Farben und Rahmen fuer die klassischen Eingabe-Widgets (Text, Listbox).
ttk-Felder holen sich das aus dem Stil; tk.Text und tk.Listbox koennen
das nicht - sie muessen die Werte einzeln bekommen. Damit trotzdem JEDES
Feld gleich aussieht, kommen sie aus dieser einen Stelle.
"""
opts = {
"bg": self.col_field,
"fg": self.col_fg,
"relief": "solid",
"borderwidth": 1,
"highlightthickness": 0,
}
# Eine Auswahlliste hat keine Einfuegemarke - sie kennt die Option nicht
# und bricht daran ab.
if einfuegemarke:
opts["insertbackground"] = self.col_fg
if mehrzeilig:
opts["padx"] = 4
opts["pady"] = 3
return opts
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen.
Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht
gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus
und Tastatur fest, waehrend nebenan jemand schreibt.
"""
d.configure(bg=self.col_bg)
# Mittig ueber dem Programmfenster - und damit automatisch auf dem
# Bildschirm, auf dem das Programm gerade steht.
d.after(1, lambda: mittig(d, self))
if testlauf():
# Ohne grab_set: ein Dialog, der Maus und Tastatur festhaelt,
# blockiert den Menschen, der nebenher arbeitet.
d.grab_set = lambda *a, **k: None
try:
d.attributes("-toolwindow", True) # nicht in die Taskleiste
except tk.TclError:
pass
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Die Titelleiste eines Fensters an die Darstellung anpassen.
Frueher machte diese Methode die Leiste nur DUNKEL und tat sonst
nichts. Solange das Programm der Windows-Einstellung folgte, fiel das
nicht auf - seit man hell und dunkel selbst waehlen kann, sehr wohl:
wer von dunkel auf hell stellte, behielt eine schwarze Titelleiste ueber
einem hellen Fenster. Sie zeigt jetzt in beide Richtungen und ist nur
noch eine Weiterleitung; die Arbeit macht titelleiste_faerben().
"""
titelleiste_faerben(win, bool(getattr(self, "_dark_mode", False)))
# ------------------------------------------------ Hell / dunkel umstellen
# Der Hinweistext gehoert dem einzelnen Programm - er sagt etwas ueber
# DIESES Programm. Hier steht nur, was dasteht, wenn ihn niemand setzt.
DARSTELLUNG_HINWEIS = "Gilt nur für das Programmfenster."
def _cfg_speichern(self):
"""Die Einstellungen wegschreiben - falls das noetig ist.
Im Rechnungstool ist die Konfiguration ein gewoehnliches Woerterbuch
und muss ausdruecklich gespeichert werden. Im Steuerrechnungstool ist
sie eine Ablage, die jeden Schreibzugriff sofort in die
Journaltabelle durchreicht - dort ist hier nichts zu tun.
"""
return
def variablen_sichern(self):
"""Alle Tk-Variablen des Fensters als {Name: Wert}.
WARUM ALLE UND NICHT EINE AUSWAHL
---------------------------------
Die _build-Methoden legen ihre Variablen selbst an
(`self.k_name = tk.StringVar()`). Nach dem Neubau stehen also neue,
leere da. Beim ersten Versuch waren Kundenname und Rechnungsnummer
weg, waehrend die Mengen blieben - die hatte ich einzeln gesichert.
Durchgegangen wird deshalb, was das Objekt an Tk-Variablen traegt.
Wer spaeter ein Feld hinzufuegt, muss nichts eintragen; eine Liste zum
Pflegen waere genau die Sorte, die man beim naechsten Feld vergisst.
"""
gesichert = {}
for name in dir(self):
if name.startswith("__"):
continue
try:
wert = getattr(self, name)
except Exception: # noqa: BLE001 - Eigenschaften koennen werfen
continue
if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
gesichert[name] = wert.get()
except tk.TclError:
pass
return gesichert
def variablen_zuruecksetzen(self, gesichert):
"""Die gesicherten Werte in die neu gebauten Variablen zurueckgeben."""
for name, wert in gesichert.items():
neu = getattr(self, name, None)
if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar,
tk.BooleanVar)):
try:
neu.set(wert)
except tk.TclError:
pass
def kinder_loeschen(self):
"""Die Oberflaeche abraeumen - aber keine fremden Fenster.
Ein tk.Toplevel IST ein Kind des Hauptfensters. Der offene
Darstellungsdialog wurde hier bisher mitgeloescht, und die Zeile
danach fasste eine Leiche an ("invalid command name .!toplevel").
Gefunden am 07.09.2026 von der Pruefkette, mit dem echten Bedienweg
gemessen: Klick auf "Dunkel" -> Farbe wechselt, Fenster ist weg,
Traceback im Log.
Der Pruefstand hatte es nicht gefunden, weil er cfg["darstellung"] von
Hand setzte und _oberflaeche_neu() direkt rief - also alles ausser dem
Weg, den ein Mensch geht. Er klickt jetzt.
"""
for kind in self.winfo_children():
if not isinstance(kind, tk.Toplevel):
kind.destroy()
def dlg_darstellung(self):
"""Hell, dunkel oder wie Windows - und sofort sichtbar."""
d = tk.Toplevel(self)
d.title("Darstellung")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text="Darstellung",
font=(SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(rahmen, justify="left", wraplength=340,
foreground=self.col_muted,
text=self.DARSTELLUNG_HINWEIS).pack(anchor="w", pady=(6, 12))
gewaehlt = tk.StringVar(value=modus(self.cfg))
def uebernehmen():
neu = gewaehlt.get()
if neu == modus(self.cfg):
return
self.cfg["darstellung"] = neu
self._cfg_speichern()
self._oberflaeche_neu()
# Der Dialog gehoert noch zur alten Darstellung - er wird
# mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen.
self._style_dialog(d)
for wert in MODI:
ttk.Radiobutton(rahmen, text=MODUS_NAMEN[wert], value=wert,
variable=gewaehlt, command=uebernehmen).pack(
anchor="w", pady=3)
ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10))
ttk.Button(rahmen, text="Schließen", width=14,
command=d.destroy).pack(anchor="e")
mittig(d, self)
return d
class MeldungMixin:
"""Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms.
Warum nicht `messagebox`: das sind Fenster von Windows selbst. Sie sind
immer hell, egal was das Programm einstellt - mitten in einer dunklen
Oberflaeche blendet das und sieht nach Fremdkoerper aus. Dieselbe Erfahrung
steckt schon in der eigenen Menueleiste weiter oben.
⚠️ Im PRUEFLAUF geht beides weiter ueber `messagebox`. Die Pruefstaende
haengen ihre Antworten dort ein ("auf diese Frage antworte Ja") und lesen
dort mit, was das Programm gemeldet hat. Wuerde hier ein eigenes Fenster
aufgehen, warteten sie auf einen Klick, den niemand macht. Geprueft wird
damit derselbe Weg - nur die Anzeige unterscheidet sich.
"""
# Die drei Sorten Meldung. `parent=`, `icon=` und `default=` kommen aus den
# frueheren messagebox-Aufrufen mit - sie werden geschluckt, damit an den
# Aufrufstellen nichts umgeschrieben werden muss.
def melden_info(self, titel, text="", **_kw):
return self.melden(titel, text, "info")
def melden_warnung(self, titel, text="", **_kw):
return self.melden(titel, text, "warnung")
def melden_fehler(self, titel, text="", **_kw):
return self.melden(titel, text, "fehler")
def melden(self, titel, text, art="warnung"):
"""Eine Meldung mit einem OK-Knopf."""
if testlauf():
{"info": messagebox.showinfo,
"warnung": messagebox.showwarning,
"fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text)
return None
return self._meldungsfenster(titel, text, art, knoepfe=None)
def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw):
"""Ja/Nein. Gibt True oder False zurueck - wie askyesno."""
if testlauf():
return bool(messagebox.askyesno(titel, text))
return bool(self._meldungsfenster(titel, text, "frage",
knoepfe=(ja, nein), vorgabe=vorgabe))
def _meldungsfenster(self, titel, text, art, knoepfe=None, vorgabe=True):
farbe = {"fehler": self.col_gefahr, "warnung": self.col_gefahr,
"frage": self.col_accent}.get(art, self.col_accent)
d = tk.Toplevel(self)
d.title(titel)
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
antwort = {"wert": False}
rahmen = ttk.Frame(d, padding=20)
rahmen.pack(fill="both", expand=True)
tk.Label(rahmen, text=titel, bg=self.col_bg, fg=farbe, justify="left",
font=(SCHRIFT_NAME, 12, "bold")).pack(anchor="w")
tk.Label(rahmen, text=text, bg=self.col_bg, fg=self.col_fg, justify="left",
wraplength=460, font=(SCHRIFT_NAME, 10)).pack(anchor="w", pady=(10, 0))
leiste = ttk.Frame(rahmen)
leiste.pack(fill="x", pady=(20, 0))
def schliessen(wert):
antwort["wert"] = wert
d.destroy()
if knoepfe:
ja, nein = knoepfe
ttk.Button(leiste, text=ja, style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
ttk.Button(leiste, text=nein,
command=lambda: schliessen(False)).pack(side="right", padx=(0, 8))
d.bind("<Return>", lambda _e: schliessen(vorgabe))
else:
ttk.Button(leiste, text="OK", style="Haupt.TButton",
command=lambda: schliessen(True)).pack(side="right")
d.bind("<Return>", lambda _e: schliessen(True))
d.bind("<Escape>", lambda _e: schliessen(False))
d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False))
mittig(d, self)
# Nach vorn holen und den Tastaturfokus nehmen. Ohne das kann der Dialog
# beim Start hinter dem Hauptfenster liegen - der Nutzer sieht nur ein
# Fenster, das nicht reagiert, und haelt es fuer haengen geblieben.
d.lift()
d.attributes("-topmost", True)
d.after(100, lambda: d.attributes("-topmost", False))
d.focus_force()
d.grab_set()
d.focus_set()
self.wait_window(d)
return antwort["wert"]

404
updater.py Normal file
View file

@ -0,0 +1,404 @@
# -*- coding: utf-8 -*-
"""Der Updater - er ist der Starter.
SEIN BILD (06.09.2026)
----------------------
"der updater schaut beim starten erst: ist auf dem git link eine neue
version mit exe? wenn ja, erst laden, ersetzen und dann starten."
"einmal pruefen auf vollstaendigkeit."
"updater startet kurz als im ram und das main programm ist aus, sonst
kannst es nicht ersetzen."
WARUM DAS DER RICHTIGE AUFBAU IST
---------------------------------
Windows laesst eine laufende EXE nicht ueberschreiben. Ein Programm, das sich
selbst aktualisiert, muss deshalb Umwege gehen (Kopie im Temp, warten, bis der
eigene Prozess weg ist, tauschen). All das entfaellt, wenn der Updater das ist,
was der Nutzer startet: Er laeuft VOR dem Programm - die EXE ist gar nicht in
Benutzung, wenn sie ersetzt wird.
DIE NUMMER STEHT IM DATEINAMEN (seine Idee, 06.09.2026)
-------------------------------------------------------
"wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die
heisst anders, dann neu machen"
Die installierte Datei heisst `Rechnungstool-1.6.0.exe`. Daraus folgt dreierlei:
* Es wird nie etwas UEBERSCHRIEBEN - eine neue Fassung ist eine neue Datei.
Das Sperrproblem von Windows gibt es damit nicht einmal theoretisch.
* Die alte Fassung bleibt liegen und ist die Rueckfallebene, ohne dass
jemand eine Sicherung anlegen muesste.
* Der Updater sieht AM NAMEN, was installiert ist. Er muss keiner Angabe
glauben, die auseinanderlaufen koennte.
DIE REGEL, AN DER SOLCHE UPDATER SCHEITERN
------------------------------------------
Sie schreiben einen halb geladenen Download in die Installation, und das
Programm startet nie wieder. Deshalb hier: in einen Zwischenordner laden,
GROESSE UND PRUEFSUMME gegen die Angaben des Releases halten, und erst dann in
den Installationsordner legen.
AUFBAU
------
Dieser Teil ist eine BIBLIOTHEK ohne Fenster und ohne Netzzwang: jede
Entscheidung ist eine eigene Funktion, die sich einzeln pruefen laesst
(pruef_updater.py). Das Startprogramm steht unten unter __main__.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
API = "https://git.pcore.de/api/v1/repos"
# Wie die Buchfuehrung des Updaters heisst. Sie liegt neben der EXE und wird
# von IHM gefuehrt - nicht vom Programm. So kann nichts auseinanderlaufen:
# was hier steht, hat der Updater selbst installiert.
BUCH = "installiert.json"
# ---------------------------------------------------------------------------
# Versionen vergleichen
# ---------------------------------------------------------------------------
def zerlegen(fassung):
"""'v1.6.0' oder '1.6.0' -> (1, 6, 0). Unlesbares wird zu (0,).
Absichtlich nachsichtig: eine Nummer, die niemand versteht, darf kein
Update ausloesen - sie zaehlt als die aelteste denkbare.
"""
if not fassung:
return (0,)
teile = re.findall(r"\d+", str(fassung))
return tuple(int(t) for t in teile) if teile else (0,)
def ist_neuer(dort, hier):
"""Ist `dort` neuer als `hier`? Gleichstand ist NICHT neuer."""
return zerlegen(dort) > zerlegen(hier)
# ---------------------------------------------------------------------------
# Das passende Release finden
# ---------------------------------------------------------------------------
def passendes_release(releases, exe_name):
"""Das neueste Release, das eine Datei `exe_name` anhaengt.
Vorabversionen zaehlen MIT - alle Fassungen dieser Programme sind als
Vorabversion gekennzeichnet, weil sie erprobt werden. Wer hier
`/releases/latest` benutzt, bekommt nichts: Forgejo laesst Vorabversionen
dort aus. Genau das ist beim Bauen aufgefallen.
Entwuerfe (draft) zaehlen NICHT - die sind nicht veroeffentlicht.
"""
beste = None
for r in releases or []:
if r.get("draft"):
continue
anhang = next((a for a in r.get("assets") or []
if a.get("name") == exe_name), None)
if not anhang:
continue
if beste is None or ist_neuer(r.get("tag_name"), beste[0].get("tag_name")):
beste = (r, anhang)
return beste
def summe_aus_release(release, exe_name):
"""Die erwartete Pruefsumme aus dem Anhang `<exe>.sha256`, oder ''.
Das Release haengt neben der EXE eine kleine Textdatei mit der Pruefsumme
an. Fehlt sie, wird nur die Groesse geprueft - dann sagt das der Updater
aber auch, statt so zu tun, als haette er geprueft.
"""
name = exe_name + ".sha256"
for a in release.get("assets") or []:
if a.get("name") == name:
return a.get("browser_download_url") or ""
return ""
# ---------------------------------------------------------------------------
# Laden und pruefen
# ---------------------------------------------------------------------------
def beschreibbar(ordner):
"""Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.
Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein
beschreibbarer - erst der Versuch zeigt es.
"""
probe = os.path.join(ordner, ".schreibprobe")
try:
with open(probe, "w", encoding="ascii") as f:
f.write("x")
os.remove(probe)
return True
except OSError:
return False
def sha256(pfad):
h = hashlib.sha256()
with open(pfad, "rb") as f:
for brocken in iter(lambda: f.read(1 << 20), b""):
h.update(brocken)
return h.hexdigest()
def holen(url, ziel, melden=None):
"""Datei laden. Gibt die Anzahl geladener Bytes zurueck."""
with urllib.request.urlopen(url, timeout=60) as antwort, open(ziel, "wb") as raus:
gesamt = int(antwort.headers.get("Content-Length") or 0)
geladen = 0
while True:
brocken = antwort.read(1 << 20)
if not brocken:
break
raus.write(brocken)
geladen += len(brocken)
if melden and gesamt:
melden(geladen, gesamt)
return geladen
def vollstaendig(pfad, erwartete_groesse, erwartete_summe=""):
"""Ist die geladene Datei vollstaendig und unverfaelscht?
Rueckgabe: (True, "") oder (False, Grund). Ein halb geladener Download darf
die Installation NIE erreichen - lieber gar kein Update als ein kaputtes.
"""
if not os.path.exists(pfad):
return False, "Die geladene Datei ist nicht da."
ist = os.path.getsize(pfad)
if erwartete_groesse and ist != erwartete_groesse:
return False, (f"Groesse stimmt nicht: {ist} Bytes geladen, "
f"{erwartete_groesse} erwartet.")
if erwartete_summe:
hat = sha256(pfad)
if hat.lower() != erwartete_summe.strip().lower():
return False, "Pruefsumme stimmt nicht."
return True, ""
# ---------------------------------------------------------------------------
# Die Nummer steht im Dateinamen
# ---------------------------------------------------------------------------
def basis_und_endung(exe_name):
"""'Rechnungstool.exe' -> ('Rechnungstool', '.exe')."""
stamm, endung = os.path.splitext(exe_name)
stamm = re.sub(r"-\d+(?:\.\d+)*$", "", stamm) # eine Nummer wieder abschneiden
return stamm, endung or ".exe"
def dateiname(exe_name, fassung):
"""'Rechnungstool.exe' + 'v1.6.0' -> 'Rechnungstool-1.6.0.exe'."""
stamm, endung = basis_und_endung(exe_name)
return f"{stamm}-{str(fassung).lstrip('v')}{endung}"
def installierte(ordner, exe_name):
"""Was liegt im Ordner? Liste (Fassung, Pfad), neueste zuerst.
Gelesen wird der DATEINAME - keine Angabe, der man glauben muesste. Eine
Datei ohne Nummer (aus der Zeit vor dieser Fassung) zaehlt als aelteste.
"""
stamm, endung = basis_und_endung(exe_name)
muster = re.compile(re.escape(stamm) + r"(?:-(\d+(?:\.\d+)*))?" +
re.escape(endung) + r"$", re.I)
gefunden = []
try:
namen = os.listdir(ordner)
except OSError:
return []
for name in namen:
treffer = muster.match(name)
if treffer:
gefunden.append((treffer.group(1) or "0", os.path.join(ordner, name)))
gefunden.sort(key=lambda x: zerlegen(x[0]), reverse=True)
return gefunden
def neueste_installierte(ordner, exe_name):
"""(Fassung, Pfad) der neuesten vorhandenen Datei - oder ('0', '')."""
liste = installierte(ordner, exe_name)
return liste[0] if liste else ("0", "")
def aufraeumen(ordner, exe_name, behalten=2):
"""Alte Fassungen wegraeumen, die neuesten `behalten` bleiben liegen.
Nicht aus Ordnungsliebe: die vorletzte Fassung IST die Rueckfallebene.
Deshalb wird nie unter zwei geraeumt.
"""
liste = installierte(ordner, exe_name)
weg = []
for _fassung, pfad in liste[max(2, behalten):]:
try:
os.remove(pfad)
weg.append(os.path.basename(pfad))
except OSError:
# Laeuft die alte Fassung noch oder haelt ein Virenscanner die
# Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die
# Datei stoert nur, sie schadet nicht, und beim naechsten Mal
# klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg
# ist, taucht dort nicht auf.
pass
return weg
# ---------------------------------------------------------------------------
# Buchfuehrung
# ---------------------------------------------------------------------------
def buch_lesen(ordner):
pfad = os.path.join(ordner, BUCH)
try:
with open(pfad, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
def buch_schreiben(ordner, daten):
pfad = os.path.join(ordner, BUCH)
try:
with open(pfad, "w", encoding="utf-8") as f:
json.dump(daten, f, ensure_ascii=False, indent=2)
return True
except OSError:
return False
def releases_holen(repository):
url = f"{API}/{repository}/releases?limit=20"
with urllib.request.urlopen(url, timeout=30) as antwort:
return json.loads(antwort.read().decode("utf-8"))
def text_holen(url):
with urllib.request.urlopen(url, timeout=30) as antwort:
return antwort.read().decode("utf-8", "replace").split()[0]
# ---------------------------------------------------------------------------
# Der Ablauf
# ---------------------------------------------------------------------------
def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
"""Ein Durchlauf. Gibt den Pfad der Datei zurueck, die gestartet werden soll.
Nichts wird ueberschrieben: eine neue Fassung wird als eigene Datei
`<Name>-<Nummer>.exe` abgelegt. Die vorige bleibt liegen - sie ist die
Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste.
`holen_` laesst sich fuer den Pruefstand ersetzen, damit er ohne Netz
auskommt.
"""
holen_ = holen_ or releases_holen
hier, hier_pfad = neueste_installierte(ordner, exe_name)
buch = buch_lesen(ordner)
# Buch und Dateiname zusammen: wer eine Datei umbenennt, verursacht
# hoechstens einen ueberfluessigen Download, keinen Schaden.
if ist_neuer(buch.get("version") or "0", hier):
hier = buch.get("version")
try:
releases = holen_(repository)
except Exception as e: # noqa: BLE001 - ohne Netz wird einfach gestartet
sagen(f"Kein Update moeglich ({e}). Es wird die vorhandene Fassung gestartet.")
return hier_pfad
treffer = passendes_release(releases, exe_name)
if not treffer:
sagen("Im Repository liegt keine passende Datei. Vorhandene Fassung wird gestartet.")
return hier_pfad
release, anhang = treffer
dort = release.get("tag_name") or ""
if not ist_neuer(dort, hier):
sagen(f"Aktuell ({hier}). Kein Update noetig.")
return hier_pfad
ziel = os.path.join(ordner, dateiname(exe_name, dort))
if os.path.exists(ziel):
sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.")
return ziel
# Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die
# Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das
# Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm
# startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass
# gar nichts mehr geht.
if not beschreibbar(ordner):
sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...")
zwischen = tempfile.mkdtemp(prefix="update_")
geladen = os.path.join(zwischen, exe_name)
try:
holen(anhang["browser_download_url"], geladen)
erwartete_summe = ""
url_summe = summe_aus_release(release, exe_name)
if url_summe:
try:
erwartete_summe = text_holen(url_summe)
except Exception: # noqa: BLE001
sagen("Pruefsumme nicht ladbar - es wird nur die Groesse geprueft.")
else:
sagen("Dieses Release hat keine Pruefsumme - es wird nur die Groesse geprueft.")
gut, grund = vollstaendig(geladen, anhang.get("size") or 0, erwartete_summe)
if not gut:
sagen(f"Das Update wurde VERWORFEN: {grund}")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
try:
shutil.move(geladen, ziel)
except OSError as e: # noqa: BLE001
sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).")
sagen("Es wird die vorhandene Fassung gestartet.")
return hier_pfad
buch_schreiben(ordner, {"version": dort.lstrip("v"),
"datei": os.path.basename(ziel),
"sha256": erwartete_summe,
"vorher": os.path.basename(hier_pfad) if hier_pfad else ""})
weg = aufraeumen(ordner, exe_name)
if weg:
sagen("Alte Fassungen entfernt: " + ", ".join(weg))
sagen(f"Aktualisiert auf {dort}.")
return ziel
finally:
shutil.rmtree(zwischen, ignore_errors=True)
def starten(pfad):
"""Das Programm starten und den Updater beenden."""
subprocess.Popen([pfad], close_fds=True)
if __name__ == "__main__":
import version
ordner = os.path.dirname(os.path.abspath(
sys.executable if getattr(sys, "frozen", False) else __file__))
print(f"Updater - {version.REPOSITORY}")
zu_starten = durchlauf(ordner, version.REPOSITORY, version.EXE_NAME)
if zu_starten and os.path.exists(zu_starten):
print("Starte", os.path.basename(zu_starten))
starten(zu_starten)
else:
print(f"FEHLER: keine startbare Fassung von {version.EXE_NAME} in {ordner}")
input("Enter zum Schliessen ...")

23
version.py Normal file
View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""Die Fassung dieses Programms - an EINER Stelle.
Wozu
----
Ohne eine Nummer im Programm kann kein Updater vergleichen, ob das, was im
Repository liegt, neuer ist als das, was hier laeuft. Sie steht deshalb hier
und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
Regel
-----
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
"v": Tag `v1.9.2` -> VERSION "1.9.2". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.9.2"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/Steuerrechnungstool"
# Wie die ausgelieferte Datei im Release heisst.
EXE_NAME = "Steuerrechnungstool.exe"

236
wasserzeichen.py Normal file
View file

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""Das Programmlogo - als Fenstersymbol und als blasses Wasserzeichen.
WOHER DAS BILD KOMMT
--------------------
Die Quelle ist die SVG aus dem Entwurf (`assets/programm_logo.svg`). Sie wird
BEIM BAUEN einmal in 512 Pixel gerendert (`assets/programm_logo.png`) und in
eine `programm.ico` fuer Windows umgesetzt. Hier zur Laufzeit wird nur noch
skaliert.
Warum nicht direkt aus der SVG zeichnen? Weil dafuer `svglib` und `lxml` mit in
die EXE muessten - und bis 512 Pixel ist das Ergebnis pixelgenau dasselbe. Wird
das Wasserzeichen je groesser gebraucht, gehoert das umgestellt.
Warum ueberhaupt eine ICO? Weil Windows das Symbol einer EXE als Ressource in
der Datei erwartet, und dieses Format ist ICO. Das gilt fuer jede Sprache, nicht
nur fuer Python - eine SVG kann Windows dort nicht verwenden.
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import os
import sys
import tkinter as tk
_lager = {} # (fenster, groesse, deckkraft) -> PhotoImage
def _ordner():
"""Alle Orte, an denen die Bilder liegen koennen - Quelle wie EXE."""
hier = os.path.dirname(os.path.abspath(__file__))
aus_exe = getattr(sys, "_MEIPASS", "")
neben_exe = os.path.dirname(os.path.abspath(sys.executable)) \
if getattr(sys, "frozen", False) else ""
return [p for p in (aus_exe, neben_exe, hier) if p]
def pfad(name):
"""Voller Pfad zu einer Datei in assets/ - oder ''."""
for basis in _ordner():
p = os.path.join(basis, "assets", name)
if os.path.exists(p):
return p
return ""
def kachel(bild, kante, rand=0.16):
"""Das Logo auf eine helle Kachel mit runden Ecken setzen.
Seine Ansage am 07.09.2026: *"bei den windows icons kannst du einen
weissen bg setzen hinter die icons, mit abgerundeten ecken, und da das svg
drauf - das geht auf dem desktop unter"*.
Ein freigestelltes Zeichen ohne Flaeche verschwindet auf einem dunklen
Hintergrundbild - und welchen Hintergrund jemand eingestellt hat, weiss
das Programm nicht. Eine helle Kachel ist auf jedem sichtbar; so machen es
die meisten Programme, und deshalb sieht es auch nicht fremd aus.
`rand` ist der Abstand zum Kachelrand, als Anteil der Kantenlaenge - so
sieht es in jeder Groesse gleich aus. Die Rundung liegt fest bei 22 %,
etwa dem, was Windows fuer Kacheln verwendet.
"""
from PIL import Image, ImageDraw
kante = int(kante)
# In vierfacher Groesse zeichnen und herunterrechnen: sonst werden die
# runden Ecken bei 32 oder 48 Pixeln treppig.
fein = kante * 4
maske = Image.new("L", (fein, fein), 0)
ImageDraw.Draw(maske).rounded_rectangle(
(0, 0, fein - 1, fein - 1), radius=int(fein * 0.22), fill=255)
flaeche = Image.new("RGBA", (fein, fein), (0xFF, 0xFF, 0xFF, 255))
flaeche.putalpha(maske)
innen = max(1, int(fein * (1 - 2 * rand)))
zeichen = bild.convert("RGBA").resize((innen, innen), Image.LANCZOS)
versatz = (fein - innen) // 2
flaeche.paste(zeichen, (versatz, versatz), zeichen)
return flaeche.resize((kante, kante), Image.LANCZOS)
# Groessen, die eine Windows-ICO enthalten soll. 16 und 24 fuer Titelleiste
# und Listen, 32/48 fuer den Desktop, 256 fuer die grosse Ansicht im Explorer.
ICO_GROESSEN = (16, 24, 32, 48, 64, 128, 256)
def symbol_bauen(ziel):
"""Die Windows-ICO aus dem Logo erzeugen. Gibt den Pfad zurueck oder ''.
JEDE Groesse wird als Kachel gebaut: helle Flaeche, runde Ecken, Zeichen
darauf - *"so wie das rustdesk, nur halt alle ecken in der rundung"*. Ein
freigestelltes Logo verschwindet sonst auf einem dunklen
Desktop-Hintergrund.
Auch die 16 Pixel der Titelleiste: dort ist die Kachel zwar winzig, aber
sichtbar. Ein freigestelltes Zeichen war dort je nach Darstellung mal da
und mal nicht - und ein Symbol, das manchmal fehlt, ist schlechter als
eines, das immer klein ist.
Bei den kleinsten Groessen wird der Rand knapper gefasst: bliebe der
Abstand anteilig gleich, waere vom Zeichen bei 16 Pixeln fast nichts mehr
uebrig.
"""
quelle = pfad("programm_logo.png")
if not quelle:
return ""
try:
from PIL import Image
roh = Image.open(quelle).convert("RGBA")
# Dieselben Farben wie IM Programm: Tuerkis fuer die Wolke, Dunkelgrau
# fuer das Zeichen daneben. Vorher stand hier das rohe Logo mit seinem
# Petrol #004860 - sein Befund am 07.09.2026: "das schaut blau aus".
# Die Kachel ist weiss, also gilt die HELLE Fassung (dunkel=False).
zeichen = _umfaerben(roh, dunkel=False)
bilder = []
for g in ICO_GROESSEN:
bilder.append(kachel(zeichen, g, rand=0.10 if g <= 24 else 0.16))
# Pillow schreibt die uebrigen Groessen selbst aus dem groessten Bild -
# deshalb wird das groesste uebergeben und der Rest angehaengt.
bilder[-1].save(ziel, format="ICO",
sizes=[(g, g) for g in ICO_GROESSEN],
append_images=bilder[:-1])
return ziel
except Exception as e: # noqa: BLE001 - ohne eigenes Symbol laeuft es weiter
print("[symbol] konnte nicht gebaut werden:", e)
return ""
def symbol_setzen(fenster, melden=print):
"""Das Programmsymbol auf ein Fenster legen. True bei Erfolg.
Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das
steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes
hat.
Frueher gab es dafuer eine zweite, aufgehellte ICO und einen Schalter
`dunkel`. Seit das Symbol eine helle Kachel ist, ist es in jeder
Darstellung sichtbar - beides ist damit weggefallen.
"""
p = pfad("programm.ico")
if not p:
melden("[symbol] assets/programm.ico nicht gefunden")
return False
try:
fenster.iconbitmap(default=p)
return True
except Exception as e: # noqa: BLE001 - ohne Symbol laeuft es weiter
melden(f"[symbol] konnte nicht gesetzt werden: {e}")
return False
# Die Farben des Logos - gemessen, nicht geraten:
# #004860 das dunkle Blaugruen (Wolke, Pfeile)
# #181818 das fast schwarze Grau (Dokument bzw. Paragraphenzeichen)
#
# Seine Ansage am 07.09.2026: *"das dunkel blaue machen wir in unserm tuerkis
# gruen und die andere seite in weiss ... wenn es dunkel ist, und beim hellen
# machen wir weiss in dunkel grau"*. Das Zeichen traegt damit dieselbe Farbe
# wie das Programm - und die zweite Haelfte steht in beiden Darstellungen im
# Kontrast statt unterzugehen.
TUERKIS = (0x06, 0xC6, 0xA4) # die Programmfarbe, wie col_accent
NEUTRAL_DUNKEL = (0xF2, 0xF3, 0xF5) # fast weiss - auf dunklem Grund
NEUTRAL_HELL = (0x33, 0x36, 0x3B) # dunkelgrau - auf hellem Grund
def _umfaerben(bild, dunkel):
"""Das Logo auf die Programmfarben umsetzen.
Unterschieden wird nach SAETTIGUNG, nicht nach dem genauen Farbwert: das
Blaugruen ist kraeftig gesaettigt, das Grau gar nicht. So bleibt die
Zuordnung auch dann richtig, wenn die Vorlage einmal in einem anderen Ton
gezeichnet wird - und die weichen Kanten aus dem Skalieren gehen denselben
Weg wie die Flaeche, zu der sie gehoeren.
Die Deckkraft bleibt unangetastet; die Form kommt aus dem Alphakanal.
"""
import colorsys
bild = bild.convert("RGBA")
neutral = NEUTRAL_DUNKEL if dunkel else NEUTRAL_HELL
punkte = list(bild.getdata())
zwischen = {}
neu = []
for r, g, b, a in punkte:
if a == 0:
neu.append((r, g, b, a))
continue
schluessel = (r, g, b)
if schluessel not in zwischen:
_, _, saettigung = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
zwischen[schluessel] = TUERKIS if saettigung > 0.25 else neutral
neu.append(zwischen[schluessel] + (a,))
bild.putdata(neu)
return bild
def hole(groesse=240, deckkraft=0.06, master=None, melden=print, dunkel=False):
"""Das Logo blass als PhotoImage - oder None.
`deckkraft` 0..1, klein halten. Ein Wasserzeichen, das man LIEST, stoert
beim Arbeiten; eines, das man ahnt, gehoert zum Programm.
`master` MUSS mitgegeben werden, wenn mehr als ein Tk-Fenster laeuft:
sonst gehoert das Bild dem falschen Fenster und Tk meldet spaeter, es
kenne es nicht.
"""
fenster = master if master is not None else tk._default_root
if fenster is None:
return None
schluessel = (id(fenster), groesse, round(deckkraft, 3), bool(dunkel))
if schluessel in _lager:
return _lager[schluessel]
p = pfad("programm_logo.png")
if not p:
melden("[wasserzeichen] assets/programm_logo.png nicht gefunden")
return None
try:
from PIL import Image, ImageTk
bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS)
bild = _umfaerben(bild, dunkel)
bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft)))
fertig = ImageTk.PhotoImage(bild, master=fenster)
except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren
melden(f"[wasserzeichen] nicht ladbar: {e}")
return None
_lager[schluessel] = fertig
return fertig
def kopf(hoehe=28, master=None, melden=print, dunkel=False):
"""Das Logo fuer die Kopfzeile - volle Deckkraft, kleine Hoehe.
Getrennt von hole(): dort geht es um ein blasses Wasserzeichen, hier um ein
Erkennungszeichen. Zwei Zwecke, zwei Funktionen - sonst haette eine davon
einen Schalter, den man beim Lesen nachschlagen muss.
"""
return hole(hoehe, 1.0, master=master, melden=melden, dunkel=dunkel)