Compare commits

...

34 commits
v1.5.4 ... main

Author SHA1 Message Date
TheMockTv
fb04f5ad74 Fassung 1.8.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:14 +02:00
TheMockTv
2b09d15cd0 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_bilder.py schrieb ein eigenes
  Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende
  nicht sagen, WAS fehlschlug.

* 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.

* Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und
  einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten
  config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler
  neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die
  24 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
5babfc3e2e Zwei echte Fehler aus der Pruefkette behoben
## 1. Der Darstellungsdialog zerstoerte sich beim ersten Klick selbst

_oberflaeche_neu() loeschte ALLE Kinder des Hauptfensters - und ein
tk.Toplevel IST ein Kind. Der offene Dialog wurde also mitgeloescht, die
Zeile danach fasste eine Leiche an:

    _tkinter.TclError: invalid command name ".!toplevel"

Die Farbe wechselte zwar und nichts ging verloren, aber das Fenster
verschwand beim ersten Klick, jede weitere Umstellung brauchte einen neuen
Menueaufruf, und jedes Mal landete ein Traceback im Log. In BEIDEN
Programmen.

Behoben: fremde Toplevels bleiben stehen. Damit ueberlebt auch ein offener
Ordnerwahl- oder Einstellungsdialog den Wechsel.

⚠️ Warum mein Pruefstand es nicht fand: er setzte cfg["darstellung"] von Hand
und rief _oberflaeche_neu() direkt - also alles ausser dem Weg, den ein
Mensch geht. Den Dialog oeffnete er zwar, mass aber nur dessen Position.
pruef_darstellung drueckt jetzt den Radioknopf (.invoke()) und prueft, dass
der Dialog danach noch lebt UND dass ein ZWEITER Klick im selben Dialog
wirkt - das war vorher unmoeglich.

## 2. Unerreichbarer Code nach einem return

_fenstersymbol() hatte zwei Zeilen der alten Inline-Fassung hinter dem
return stehen. Rest meines symbol_setzen-Umbaus; per AST ueber alle Dateien
beider Ordner belegt (genau ein Treffer).

Beides gefunden von der Pruefkette nach R23. Die Aufraeumbefunde derselben
Runde (doppelter Code, tote Variablen, 34x dasselbe pruefe()) kommen
getrennt - eine Fehlerbehebung soll fuer sich pruefbar bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 18:10:40 +02:00
TheMockTv
08426b3d9b 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:33 +02:00
TheMockTv
b5bf6f401f 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.8.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:52:00 +02:00
TheMockTv
9794632ae4 Alles unter ravokk - und ein Deinstallieren laesst nichts liegen
Seine Ansagen
  "gibt es noch das popup install ordner auswaehlen, das stimmt ja nicht"
  "wie soll das rechnungstool und steuertool wissen wo es ist, das muss in
   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"
  "es gibt ravokk als main verzeichnis, da liegt config und sqlite drin, und
   dann gibt es den ordner rechnungstool und steuertool"
  "ravokk ist meine firma und die programme gehoeren zu der firma"
  "du solltest die auch mal deinstallieren und neu installieren ... dass der
   ordner dann leer ist und sauber - will keine tools die unsauber sind"

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

  Die Daten liegen NEBEN dem Programm, nicht darin. Nur dadurch koennen
  beide Forderungen gleichzeitig gelten: der Programmordner wird beim
  Deinstallieren restlos geleert, und die Belege bleiben zehn Jahre stehen
  (§ 147 AO).

Kein Ordner-Dialog mehr beim ersten Start
  gemeinsam.standard_ordner() nennt den festen Ort, beide Programme legen
  ihn selbst an. Gefragt wird nur noch, wer ihn ausdruecklich verschieben
  will (Menue -> Gemeinsamer Ordner) - etwa auf ein Netzlaufwerk.

  Der Knopf hiess "Install-Ordner auswaehlen..." und der Text riet zum
  "Ordner, in dem die Programme liegen". Beides war falsch und seit dem
  Installer erst recht: die beiden liegen in getrennten Unterordnern, und
  ein Deinstallieren haette das Nummernbuch mitgenommen.

⚠ Dabei gefunden: das Journal lag im TEMP-Ordner
  Der Merkzettel der installierten Fassung zeigte auf
  %TEMP%/abnahme_bst/Daten - haengengeblieben aus einem Abnahmelauf vom
  03.09. Windows raeumt den Temp-Ordner automatisch auf; das Journal waere
  ohne Vorwarnung verschwunden. ablage.py verwirft einen solchen Merkzettel
  jetzt, statt ihm zu folgen.

Uninstaller
  Er raeumt jetzt auch, was der UPDATER angelegt hat - jede geladene Fassung
  und installiert.json. Die kannte der Installer nicht; mit jeder Fassung
  waere eine EXE mehr liegen geblieben. Gemessen vorher: zwei EXEs und der
  halbe Datenordner.

pruef_installation.py
  Installiert wirklich, legt eine Datei an wie der Updater es taete,
  deinstalliert und sieht nach: Programmordner leer, Daten unangetastet.
  Vorhandene Daten werden vorher gesichert und danach zurueckgelegt - ein
  Pruefstand fasst niemandem seine Buchhaltung an.

pruef_gemeinsam_automatisch.py
  Prueft, dass beide Programme denselben Ordner nennen und dass findet, was
  das andere eintraegt - ohne dass jemand einen Pfad eingegeben hat.

Symbol
  Das Windows-Symbol ist jetzt eine helle Kachel mit runden Ecken und dem
  Zeichen darauf, in allen Groessen von 16 bis 256 - "so wie das rustdesk,
  nur halt alle ecken in der rundung". Ein freigestelltes Logo ging auf
  einem dunklen Desktop unter. Die zweite, aufgehellte ICO ist damit
  ueberfluessig; das aufgehellte Logo IM Programm (Kopfzeile, Wasserzeichen)
  bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 10:43:09 +02:00
TheMockTv
3790b0a86f Logo in den Programmfarben, gross und oben links
Seine Ansagen
  "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"
  "ist mir zu klein geht unter mach es doch gros oben links weil da ist eh
   platz"
  "ja links ist auch gut beim rechnungstool"

Farben
  Statt die Helligkeit umzukehren wird das Zeichen jetzt auf die
  PROGRAMMfarben gesetzt: das Blaugruen wird Tuerkis (#06C6A4, wie
  col_accent), die zweite Haelfte weiss im Dunkeln und dunkelgrau im Hellen.
  Unterschieden wird nach SAETTIGUNG, nicht nach dem genauen Farbwert - so
  bleibt die Zuordnung 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.

Position
  Es stand klein (18 px) unten in der Statuszeile und ging dort unter. Jetzt
  34 px oben links in der Werkzeugleiste - die traegt sonst nur den Knopf
  "Anordnen", und der sitzt rechts. Im Steuerrechnungstool bleibt es rechts
  in der Kopfleiste: dort stehen links die Bedienelemente, 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:01:13 +02:00
TheMockTv
688dfb53c4 Popups gehen mittig ueber dem Programmfenster auf
Seine Ansage
  "da gehen noch die popups auf dem video monitor auf"
  "einfach so programmieren das die popup auf dem fenster mittig vom main
   fenster auf gehen"

Was falsch war
  An sechs Stellen stand `max(0, x)`. Das sollte verhindern, dass ein Dialog
  ausserhalb des Bildschirms aufgeht - klemmt aber jede NEGATIVE Koordinate
  auf null. Bei ihm steht der zweite Monitor links (x = -1920): jedes Popup
  sprang damit zurueck auf den Hauptbildschirm, obwohl das Programm nebenan
  lief. Dazu kamen feste Versaetze wie "+120+140", die mit der Fenstergroesse
  nichts zu tun hatten.

theme.mittig(dialog, ueber)
  Eine Stelle fuer alle Dialoge: mittig ueber dem Elternfenster, etwas
  oberhalb der Mitte (ein Drittel statt der Haelfte), damit der Blick dort
  ist und die Mitte des Formulars frei bleibt.

  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 muss. Die Sonderbehandlung im Testlauf konnte
  deshalb entfallen; geblieben ist dort nur der Verzicht auf grab_set.

theme.auf_bildschirm(x, y, breite, hoehe)
  Begrenzt eine Position auf den GESAMTEN Desktop ueber alle Monitore
  (SM_*VIRTUALSCREEN) statt auf den Hauptmonitor. Das war die eigentliche
  Absicht hinter dem alten max(0, ...).

Gemessen statt geglaubt: Dialogmitte und Fenstermitte liegen 8 Pixel
auseinander (Fensterrahmen), der Dialog liegt innerhalb des Fensters. Steht
als Pruefung in pruef_darstellung.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:52:58 +02:00
TheMockTv
adbb035cf1 Pruefstaende auf den zweiten Monitor, Desktop-Verknuepfung mit Symbol
Seine Ansagen
  "teste links auf dem 1080p monitor nicht mehr auf dem main ... weil da
   schau ich nun video"
  "und das steuertool hat noch kein installer und die rechnungstool hat das
   icon nicht auf dem desktop etc selbe beim steuertool"

Pruefplatz
  Der Platz fuer Pruefstandsfenster lag 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, waren aber nirgends zu sehen - das hat beim Bildermachen einige Zeit
  gekostet, ehe es auffiel.

  theme.pruefplatz() sucht jetzt den nicht-primaeren Monitor und liefert
  dessen Ecke. Gibt es keinen, bleibt es beim alten Verhalten. Das
  Steuerrechnungstool hatte ueberhaupt keine Sonderposition - dessen
  Pruefstandsfenster gingen mitten auf den Hauptbildschirm auf.

Verknuepfungen
  Der Haken "Verknuepfung auf dem Desktop" ist jetzt vorgesetzt statt
  abgewaehlt, und jede Verknuepfung traegt IconFilename ausdruecklich. Ohne
  das erbt sie ihr Symbol vom UPDATER - und Windows zeigte an ihrer Stelle
  das nackte Standardsymbol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:41:35 +02:00
TheMockTv
f7801f46f0 Darstellung waehlbar: hell, dunkel oder wie Windows
Seine Ansage
  "waere nun noch gut das mann in den einstellungen einstellen kann hell
   oder dunkel oder automatik das fehlt sollte eigendlich drin sein"

  Er hatte recht: beide Farbpaletten lagen laengst in theme.py, das Programm
  folgte aber starr dem Windows-Theme. Jetzt steht die Wahl unter
  Einstellungen -> Darstellung und wirkt SOFORT, nicht erst beim Neustart.

Umschalten heisst neu bauen
  Die Farben stehen an ueber sechzig Stellen fest in den Widgets. Sie einzeln
  nachzuziehen hiesse, ein Verzeichnis aller eingefaerbten Widgets zu fuehren
  - und wer beim naechsten neuen Widget vergisst, es einzutragen, hat einen
  hellen Fleck im dunklen Fenster, den niemand mehr findet. Neu bauen kann
  nichts vergessen.

  ⚠️ Dabei gingen im ersten Versuch Kundenname und Rechnungsnummer verloren:
  die _build_-Methoden legen ihre StringVars SELBST an, nach dem Neubau
  standen leere da. Gesichert werden jetzt ALLE Tk-Variablen des Fensters,
  nicht eine gepflegte Auswahl - wer spaeter ein Feld hinzufuegt, muss nichts
  eintragen.

Titelleiste, Symbol und Logo gehen mit
  Die Titelleiste gehoert Windows; sie folgt ueber
  DWMWA_USE_IMMERSIVE_DARK_MODE. _dark_titlebar konnte bisher nur DUNKEL und
  zeigt jetzt in beide Richtungen - vorher behielt man beim Wechsel auf hell
  eine schwarze Leiste ueber einem hellen Fenster.

  Sein Befund: "die svg fuer das dunkle heller machen weil die geht nun
  unter" und "icon musst wenn es dunkel ist mit aendern auf das hellere".
  Gemessen sind es zwei Farben: Petrol #004860 und ein fast schwarzes
  #181818 - letzteres liegt genau auf der Kachelfarbe #15171b. Umgestellt
  wird nur die HELLIGKEIT (L -> 1-L, gedeckelt bei 0.78), Farbton und
  Saettigung bleiben: sonst waere es nicht mehr sein Logo. Betrifft
  Wasserzeichen, Kopflogo und das Fenstersymbol; die aufgehellte ICO entsteht
  beim Bauen.

menueleiste.py
  Die selbstgebaute Menueleiste ist jetzt eine eigene Datei und liegt in
  beiden Programmen gleich - das Steuerrechnungstool hatte noch das native
  tk.Menu, das sich unter Windows nicht einfaerben laesst.

pruef_darstellung.py
  Prueft die drei Modi, dass beim Umschalten NICHTS verlorengeht, und dass
  danach kein Widget mehr eine Farbe der anderen Palette traegt - genau der
  helle Fleck, den man sonst nie wiederfindet.

Nebenbei: die beiden Dauertimer melden beim Beenden kein
"invalid command name ..._api_pumpe" mehr. Ein Log, in dem beim normalen
Beenden immer zwei Fehler stehen, liest irgendwann niemand mehr.

Fassung 1.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:34:16 +02:00
TheMockTv
20876102d5 Aufgabe festgehalten: Preisvorschlag beim Rechnungschreiben
Recherchiert, NICHT gebaut - auf seine Ansage "gut das kann auf die todo
jetzt nicht".

Kern: Was sein Freund beschreibt, ist mit hoher Wahrscheinlichkeit nicht
MyHammer, sondern die Grosshandelsanbindung seiner Handwerkersoftware. Ein
Heizungsventil ist ein Artikel, kein Gewerk.

MyHammer hat keine oeffentliche API, und das oeffentliche Preisradar traegt
selbst den Satz, seine Preise seien "mit Hilfe von KI zusammengetragen" -
als Rechnungsgrundlage untauglich. Automatisierte Abrufe beantwortet die
Seite mit 403. Ein Crawler ist damit dreifach erledigt: der Inhalt taugt
nicht, sie sperren technisch, und § 87b UrhG steht dagegen.

Der Weg, der funktioniert: DATANORM einlesen (offline, kostenlos, die Datei
bekommt der Betrieb von seinem Grosshaendler), spaeter Open Masterdata fuer
Tagespreise. Dazu die eigene Rechnungshistorie fuer die Arbeitszeit - die
Daten liegen schon im Nummernbuch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-07 00:06:25 +02:00
TheMockTv
42e459ad26 Layout: die Formularseite nimmt nur, was sie braucht
Sein Befund
  "die namen und datums bloecke koennen schmaler werden sind zu breit und
  verdrengen die posten block"

  Gemessen stimmte das genau: die sieben Spalten der Leistungstabelle
  brauchten 743 Pixel, sichtbar waren 618 - "E.-Preis" und "Gesamt" standen
  ausserhalb des Bildes.

Wo die Breite wirklich herkam
  An den Feldbreiten zu drehen half NICHTS. Die Arbeitsflaeche verteilte
  ueber eine uniform-Gruppe streng im Verhaeltnis 4:3; die Formularspalte war
  so breit, weil das Gewicht es vorschrieb, nicht weil der Inhalt es
  brauchte - 433 Pixel, ob die Felder nun 38 oder 22 Zeichen breit waren.

  Jetzt bekommt die Formularseite eine feste Mindestbreite und kein Gewicht,
  die Liste allen uebrigen Platz. Wird das Fenster groesser, waechst allein
  die Tabelle - genau dort nuetzt Breite, waehrend ein 600 Pixel breites Feld
  fuer eine Postleitzahl nichts besser macht. 433 -> 329.

Dazu passend
  - Startbreite 1060 -> 1180 und Mindestbreite 900 -> 1080. Unterhalb von
    1080 passen die sieben Spalten selbst dann nicht nebeneinander, wenn die
    Textspalte auf ihrer Mindestbreite steht.
  - Die Eingabefelder links auf das Mass gekuerzt, das sie wirklich brauchen
    (22 statt 38 Zeichen; Datum 12 statt 18). Das senkt die Mindestbreite der
    Formularseite - erst dadurch ist sie so klein moeglich.
  - "Anreise (TT.MM.JJJJ)" -> "Anreise". Die Form steht jetzt einmal als
    graue Zeile unter dem Block. Als Platzhalter IM Feld waere sie
    gefaehrlich: ein nicht geloeschter Platzhalter stuende als Anreisedatum
    auf der Rechnung.
  - Der Textumbruch der Leistungsspalte folgt jetzt ihrer echten Breite. Fest
    eingetragen schnitt er den Text ab, sobald die Spalte schmaler wurde.

pruef_spaltenbreiten.py
  Prueft ueber die ganze erlaubte Spanne, dass alle Spalten hineinpassen, die
  Formularseite lesbar bleibt und NICHT mitwaechst, und der Umbruch zur
  Spalte passt. Kein anderer Pruefstand haette das gefunden: es stuerzt
  nichts ab und rechnet nichts falsch - man sieht die Preise nur nicht.

  Er hat sofort einen zweiten Fehler gefunden: beim Aendern der
  Fenstergroesse lief der Umbruch nicht hinterher, weil ich abgebrochen
  hatte, sobald sich mein eigener Wert nicht mehr aenderte. Die Spalte kann
  aber breiter werden, ohne dass ich etwas aendere. Abbruch jetzt, wenn zwei
  Messungen dieselbe Spaltenbreite ergeben.

Fassung 1.7.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 23:41:24 +02:00
TheMockTv
8a391b8dfa Release-Anhaenge: der Name entscheidet - und ein Pruefstand dafuer
Der Fehler
  Das Release 1.7.0 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" (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. 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 verfaelschte Datei hat genau die
  richtige Groesse.

bauen.py legt jetzt release/ an
  Genau die vier Dateien, die ans Release gehoeren, unter genau den Namen,
  unter denen der Updater sie sucht. Damit haengt niemand mehr etwas
  Falsches an.

pruef_release.py
  Prueft gegen das echte Repository: gibt es zur Nummer aus version.py ein
  Release, heisst der Anhang exakt wie EXE_NAME, haengt die Pruefsumme dran,
  ist es die der zuletzt gebauten Datei - und findet passendes_release() es
  auch. Ohne Netz wird uebersprungen statt rot gemeldet.

muster/ ist raus
  Der Gast darin ist erfunden, der Betrieb nicht: IBAN, Steuernummer und die
  private Rufnummer des Auftraggebers stehen im Briefkopf. In ein
  oeffentliches Repository gehoert das nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 20:43:15 +02:00
TheMockTv
73042b771c Logo in der Statuszeile, Bau an einer Stelle, Startprobe fuer die EXE
Logo
  Das Programmlogo steht jetzt klein neben "powered by ravokk" - dort sitzt
  die Kennung des Werkzeugs, nicht die des Kunden. Eine eigene Kopfzeile hat
  dieses Programm nicht; oben sind die Reiter.

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. Genau das
  ist heute passiert. 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. "Der Prozess laeuft" beweist dabei nichts -
  er lief ja, waehrend nichts zu sehen war.

Aufgeraeumt
  "Rechnung Campinghof.exe" ist weg; gebaute Dateien gehoeren ins Release,
  nicht ins Repo. Die .spec-Dateien dagegen schon: ohne sie kann niemand die
  EXE nachbauen, die der Updater ausliefert.

Fassung 1.7.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 20:34:59 +02:00
TheMockTv
fec0e8449c Updater als Starter - die Nummer steht im Dateinamen
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."
  "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die
   heisst anders, dann neu machen"

DER AUFBAU

Der Updater IST der Starter. Er laeuft, BEVOR das Programm laeuft - damit gibt
es das Windows-Problem "laufende EXE laesst sich nicht ersetzen" gar nicht.

Und weil die installierte Datei die Nummer im Namen traegt
(Rechnungstool-1.6.0.exe), wird ueberhaupt nichts ueberschrieben: eine neue
Fassung ist eine neue Datei. Die vorige bleibt liegen und ist damit die
Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste. Der Updater
sieht am NAMEN, was da ist - er muss keiner Angabe glauben.

version.py: die Nummer an EINER Stelle, im Code, in die EXE kompiliert. Sie
entspricht dem Release-Tag ohne "v". Zusaetzlich fuehrt der Updater ein eigenes
Buch (installiert.json); wer eine Datei umbenennt, verursacht hoechstens einen
ueberfluessigen Download.

BEIM BAUEN AUFGEFALLEN

/releases/latest liefert NICHTS, weil alle Fassungen als Vorabversion
gekennzeichnet sind - Forgejo laesst Vorabversionen dort aus. Der Updater nimmt
deshalb die Liste und sucht selbst die hoechste Nummer. Waere das erst beim
Nutzer aufgefallen, haette der Updater nie ein Update gefunden.

Beide Repositories sind oeffentlich - kein Token noetig. Ein Token in einer
verteilten EXE waere ohnehin keins.

VOLLSTAENDIGKEIT

Geladen wird in einen Zwischenordner. Erst wenn GROESSE und (falls das Release
eine .sha256 anhaengt) PRUEFSUMME stimmen, kommt die Datei in den
Installationsordner. Sonst wird verworfen und die vorhandene Fassung gestartet -
lieber kein Update als ein kaputtes.

PRUEFSTAND

pruef_updater.py prueft vor allem, wann NICHT aktualisiert werden darf: gleiche
Fassung, aeltere im Netz, kein passender Anhang, Entwurf, kein Netz, falsche
Groesse, falsche Pruefsumme. Und den wichtigsten Punkt: eine halbe Datei
erreicht den Installationsordner nie.

Alle 18 Pruefstaende gruen.

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
05ec56e4b8 Erprobungshinweis: erst wenn das Fenster steht, und nach vorn geholt
Sein Befund: "die software haengt nun am dialog popup" - und gleich darauf:
"haengen tut er nicht, nur deine pruefung; wenn ich auf aktiv druecke, geht es".

Zwei getrennte Fehler, beide meine:

1. Der Hinweis lief an after(0). Da ist das Hauptfenster noch nicht gezeichnet -
   ein modaler Dialog daran wirkt eingefroren. Eine feste Wartezeit (150 ms) war
   aber auch falsch: die erreicht ein Pruefstand nie, der keine Ereignisschleife
   dreht, und pruef_api lief danach in die Zeitgrenze. Jetzt after_idle: laeuft,
   sobald Tk nichts mehr zu zeichnen hat - fuer den Nutzer nach dem Zeichnen,
   fuer den Pruefstand beim ersten update().

2. Der Dialog kam nicht nach vorn. Ein Hinweis, den man erst suchen muss, taugt
   nichts. theme.py holt das Meldungsfenster jetzt nach vorn und nimmt den
   Tastaturfokus (lift, kurz topmost, focus_force).

In der Aufgabenliste vermerkt: Updater als Starter (Schritt 4, mit den zwei
fehlenden Voraussetzungen Versionsnummer und Sichtbarkeit der Repositories),
Pcore-Design im Steuerrechnungstool (Schritt 5), und der ALTFEHLER in
pruef_wache - schon vor den heutigen Arbeiten rot, die Statuszeile wird vom
Ergebnis des Einlesens ueberschrieben.

Alle 17 Pruefstaende des Rechnungstools 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
4cdc286d5b Erprobungshinweis beim Start - mit Kenntnisnahme und Signatur unter Info
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 GEBAUT IST

hinweis.py - neu, liegt spaeter in BEIDEN Programmen gleich (wie gemeinsam.py):
Text, Fehleradresse je Programm, Kenntnisnahme lesen/schreiben, Signaturzeile.

  * Der Hinweis kommt als ERSTES beim Start. Erst nach der Bestaetigung faehrt
    der uebrige Start an (_start_fortsetzen) - vorher wird nichts geoeffnet,
    nichts gefragt, nichts geladen. Sonst stuenden beim Ablehnen noch Timer in
    der Warteschlange, die auf ein Fenster zugreifen, das es nicht mehr gibt.
  * "Nein" beendet das Programm. Es wird NICHTS vermerkt - ein Nein, das
    trotzdem als Kenntnisnahme gespeichert wuerde, waere eine Luege.
  * "Ja" wird mit Benutzername, Zeitpunkt und Fassung in der config vermerkt.
    Das ist die Signatur.
  * Aendert sich der TEXT (FASSUNG), wird erneut gefragt. Eine Zustimmung gilt
    fuer das, was dagestanden hat, nicht fuer alles Kuenftige.
  * Unter Hilfe -> Info steht derselbe Text dauerhaft, mit der Signaturzeile.

UMBENENNUNG

Das frueher "beherbergungssteuer" genannte Repository heisst jetzt
Steuerrechnungstool:
  https://git.pcore.de/TheMockTv/Steuerrechnungstool
  https://git.pcore.de/TheMockTv/rechnungstool
In beiden READMEs vermerkt, samt "git remote set-url". Der LOKALE Ordner heisst
weiter beherbergungssteuer - der Pfad steht in Pruefstaenden und Spec-Dateien
fest, das ist ein eigener Schritt.

PRUEFSTAND

pruef_hinweis.py prueft die ganze Strecke: Hinweis kommt und nennt Haftung,
Vorschau und Adresse; Nein schliesst das Fenster und vermerkt nichts; neuer
Start fragt wieder; Ja hinterlaesst Fassung, Zeit, Benutzer und eine lesbare
Signaturzeile; danach wird nicht mehr gefragt; bei geaenderter Fassung wieder.

Alle 17 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:55:56 +02:00
TheMockTv
95a9063ce1 Freigabe vor dem Beleg - mit Zeitstempel im gemeinsamen Nummernbuch
Sein Grund, woertlich (06.09.2026):

  "ich will fuer den pruefer sichtbar machen: ich wollte das so. die software
   dokumentiert das, und damit wird es nachvollziehbar, weil es einen checkmark
   nun gibt in der sqlite"  -  "das soll alles von anfang bis ende pruefbar sein"

WAS PASSIERT

Vor dem Schreiben eines Belegs fragt das Programm einmal ausdruecklich nach.
Der Text nennt, was gleich entsteht und was daran bindend ist:

  Rechnung: ausgestellt im Sinne des § 14 UStG; die Nummer ist danach vergeben
            und darf kein zweites Mal verwendet werden (§ 14 Abs. 4 Nr. 4 UStG);
            zehn Jahre aufzubewahren (§ 147 Abs. 3 AO); nachtraeglich nicht
            aenderbar (§ 146 Abs. 4 AO) - eine falsche Rechnung wird nicht
            ueberschrieben, sondern storniert.
  Angebot:  unverbindlich, KEINE Rechnung, weist keine Steuer aus, verbraucht
            keine Rechnungsnummer, wird nirgends gebucht.

Storno/Korrektur und Berichtigung haben ihre eigene Rueckfrage - sie wird nicht
verdoppelt (ein Schritt pro Fenster).

DIE SPUR

Ein Popup allein waere fuer einen Pruefer wertlos, es hinterlaesst nichts.
Deshalb bekommt die Tabelle "nummern" im gemeinsamen Buch eine Spalte
"freigabe": der ISO-Zeitstempel der ausdruecklichen Freigabe, an der
Rechnungsnummer haengend. Bestehende Buecher werden per ALTER TABLE nachgezogen;
alte Zeilen bleiben LEER - dort wurde nie gefragt, und ein nachtraeglich
erfundener Zeitstempel waere schlimmer als keiner.

gemeinsam.py liegt in beiden Programmen gleich und wurde mit hinuebergezogen.

PRUEFSTAND

pruef_freigabe.py prueft beide Richtungen, weil jede einzeln wertlos waere:
  NEIN -> kein PDF, Zaehler unveraendert, Nummer im Feld unveraendert,
          Nummernbuch leer
  JA   -> genau ein PDF, eine Nummer im Buch, Freigabe gesetzt und ein ISO-Datum

Die sieben Pruefstaende, die Belege erzeugen, melden die Freigabe jetzt
ausdruecklich an (pruefsteuerung bricht bei unangemeldeten Dialogen absichtlich
ab - kein stilles "ja").

Alle 16 Pruefstaende gruen.

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
b3cb19743f Amt-Blatt: die letzten Reste raus - das Programm behauptet keine zweite Seite mehr
Das zweite Blatt selbst ist seit dem 05.09.2026 draussen. Stehen geblieben waren
Saetze, die es weiter behaupten - einer davon sichtbar fuer den Nutzer:

  app.py:1629  "(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fuers Amt)"
               stand nach JEDER Rechnung mit Beherbergungssteuer im Fertig-Dialog
  app.py:1612  falscher Kommentar zum PDF-Aufruf
  pdf_renderer.py  zwei Kommentarbloecke, die erklaeren, was "auf dem Amt-Blatt" steht

WARUM DAS BLATT DRAUSSEN BLEIBT

Der Auftraggeber wollte diese Version. Ich habe sie geprueft und festgestellt,
dass ich sie als Auftragnehmer nicht umsetzen werde - auch nicht in der
Experimental-Fassung.

Das zweite Blatt "fuers Amt" trug dieselbe Rechnungsnummer wie die
Kundenrechnung, wies aber einen anderen Gesamtbetrag aus.

  - § 14 Abs. 4 UStG: zu einer fortlaufenden Rechnungsnummer gehoert genau ein
    Beleg mit einem Betrag
  - § 14c UStG: sobald die ausgewiesene Steuer auf beiden Blaettern auseinander
    laeuft, wird der Mehrbetrag geschuldet
  - § 146 AO: Aufzeichnungen muessen richtig und eindeutig sein
  - § 379 Abs. 1 Nr. 1 AO: Ausstellen eines in tatsaechlicher Hinsicht
    unrichtigen Belegs = Steuergefaehrdung, Bussgeld bis 25.000 EUR
  - § 370 AO: fuehrt es zu verkuerzter Steuer, ist es Steuerhinterziehung

Ich werde keine Steuerstraftaten unterstuetzen. Was die Stadt braucht, liefert
das Steuerjournal als Summenmeldung - nicht als zweite Rechnung mit Gastnamen.

Vorgabe des Auftragnehmers: § sicher, mehr nicht.

Alle 15 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:33:18 +02:00
TheMockTv
c1e328c087 Kaesten verschiebbar wie bei OPNsense: Knopf, schieben, speichern
Seine Ansage: "rechnungsdaten oben links, darunter rechnung an kunde, den
tabellen kasten rechts - oder noch besser dnd einbauen" und dann genauer: "wie
bei opnsense hast so einen kleinen knopf, dann drueckst drauf, ab da kannst du
schieben, dann hast du einen save button, und dann ist die fest".

- Seine Anordnung ist die Vorgabe (flaeche.STANDARD).
- Verschieben muss man EINSCHALTEN. Ohne den Knopf sitzt alles fest, damit beim
  taeglichen Arbeiten nichts verrutscht. Im Modus stehen "Speichern" und
  "Verwerfen" daneben; erst Speichern schreibt in die config.
- Waehrend des Ziehens haengt ein Etikett am Zeiger und der gegriffene Kasten
  wird blass - vorher sah man nur das Ziel und es wirkte "statisch" (sein Fund).
- Getauscht statt frei abgelegt: freies Verschieben auf Pixel braucht Regeln
  fuer Ueberlappung, Groesse und leere Zellen, und am Ende steht das Fenster
  schief. Ein Tausch ist in einem Griff erledigt.
- Der freie Platz folgt dem Kasten mit der langen Liste. Feste Gewichte gingen
  nicht: die Liste wandert, und der Kundenkasten wurde unten abgeschnitten.
- Eine unbrauchbare Anordnung in der config faellt auf die Vorgabe zurueck.

Icons: die Material Symbols vom Hub (M:/projekte/material-symbols), einmal nach
PNG gewandelt und zur Laufzeit eingefaerbt (icons.py) - eine Datei je Icon,
nicht drei Fassungen fuer hell/dunkel/Akzent.

⚠️ Ein PhotoImage gehoert GENAU EINEM Tk-Fenster. Der Icon-Cache reichte Bilder
an das naechste weiter, sobald ein Pruefstand Journal und Rechnungstool
nacheinander faehrt: 'image "pyimage1" doesn't exist'. Das Fenster wird jetzt
mitgegeben, tk._default_root reicht dafuer nicht.

Neu: pruef_flaeche.py. Alle 15 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:53:42 +02:00
TheMockTv
5403e01dd5 Jedes Feld, in das man tippen kann, sieht gleich aus
Seine Ansage: "wenn, dann musst du die felder alle gleich machen, wo man was
eingeben kann". Die ttk-Felder hatten die neuen Farben schon, die klassischen
Widgets nicht: die beiden Danktext-Felder und die Auswahlliste im Storno
standen ohne Rahmen und mit dem alten dunklen Grund da.

Die Feldfarben stehen jetzt in der Palette (col_field, col_feldrand) statt
mitten in der Stil-Definition, und `feld_optionen()` gibt sie an tk.Text und
tk.Listbox weiter - eine Quelle fuer alle. Eine Auswahlliste bekommt dabei
keine Einfuegemarke: die Option kennt sie nicht und bricht daran ab (gefunden,
weil drei Pruefstaende sofort rot wurden).

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:44:12 +02:00
TheMockTv
2ec41fb4e4 Alle Eingabefelder heben sich vom Grund ab, nicht nur die in der Liste
Sein Fund: "in rechnung an kunden finde ich die felder auch zu dunkel". Der
Grund war derselbe wie in der Leistungsliste - das Feld lag fast auf der Farbe
der Kachel, man sah nicht, wo etwas einzutragen ist.

Jetzt gilt fuer JEDES Eingabefeld und die Auswahlliste derselbe Satz Farben
(#2c2e32 Grund, #57585b Rahmen, beim Tippen Akzent) - aus einer Quelle
gerechnet, nicht zweimal gepflegt. Liste.TEntry liest dieselben Werte.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:41:39 +02:00
TheMockTv
5c9e9c2627 Das Programm heisst Rechnungstool, nicht "Rechnungstool - Campinghof Bartl"
Sein Fund: "rechnungstool campinghof bartl ist falsch, das ist das rechnungstool
powered by ravokk". Stimmt - der Firmenname des KUNDEN gehoerte nie in den
Fenstertitel. Das Werkzeug steht bei jedem Betrieb, der es einsetzt, und traegt
seinen eigenen Namen; der Kunde steht auf der RECHNUNG.

Name und Marke stehen jetzt in config (PROGRAMM, MARKE) - Fenstertitel, das
Info-Fenster und die Fussleiste lesen von dort. Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:38:06 +02:00
TheMockTv
2faf8c1b9a Eingabefelder in der Liste heben sich vom Zeilenstreifen ab
Sein Fund: "bei der anzahl waere ein heller bg oder ein heller rahmen besser".
Die Felder standen auf den Zeilenstreifen und gingen darin unter - man sah
nicht, wo man tippt. Eigener Stil (Liste.TEntry): hellerer Grund, deutlicher
Rahmen, beim Tippen Rahmen und Fuellung in der Akzentfarbe.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:35:11 +02:00
TheMockTv
a715d1235b Tabellenzeilen sind durchgehende Streifen, das Grau hebt sich ab
Sein Fund: "schau mal zeile 1 und die ust, passen die hoehen nicht" und "das
grau muss heller als der bg sein".

Beides stimmte. Der Zebrastreifen sass an jedem LABEL einzeln - dadurch stand
neben der zweizeiligen Position 1 ein Satz halbhoher Kaestchen mit Luecken
darueber und darunter, und die USt-Zelle sass mittig statt in der Zeile. Jetzt
liegt der Hintergrund als EIN Streifen ueber alle Spalten, die Zellen sitzen
darin und fuellen die volle Zeilenhoehe. Die Kopfzeile bekommt denselben
Streifen, sonst stand der zweizeilige "Anzahl Tage/Menge" als Kasten heraus.

Der Streifen ist von #1b1e24 auf #232830 gegangen - vorher war der Unterschied
zum Kachelgrund kaum zu sehen, und genau dafuer ist er da.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:33:46 +02:00
TheMockTv
1c2b115108 Reiter selbst gebaut - clam liess den ruhenden nicht dunkel werden
Sein Fund, zweimal: "er hebt den aktiven tab nicht hervor, der inaktive ist der
hervorgehobene". Gemessen statt geraten: der clam-Standard faerbt die
Reiterflaeche #bab5ab, und weder background noch lightcolor/darkcolor haben das
im laufenden Fenster zuverlaessig ueberschrieben.

Statt weiter daran zu drehen ist die Leiste jetzt selbst gebaut (reiter.py) -
und sieht dabei aus wie die Reiter in seinen Web-Oberflaechen: flacher Knopf,
der offene in Akzentfarbe, fett, mit Balken darunter und der Flaeche der Kachel.

Nach aussen verhaelt sie sich wie ein Notebook: add, select, index("current"/
"end"), tab(i, "text") und <<NotebookTabChanged>>. Deshalb bleiben der
Storno-Weg (korrektur.py waehlt select(1)) und pruef_montage unveraendert -
alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:21:20 +02:00
TheMockTv
2e3a191f19 Eigene Meldungsfenster, aktiver Reiter hervorgehoben, Pruefschalter als Funktion
Sein Fund: "der ohne gemeinsamen ordner popup ist noch hell" und "er hebt den
aktiven tab nicht hervor, der inaktive ist der hervorgehobene".

- Meldungen und Ja/Nein-Fragen kommen jetzt aus eigenen Fenstern im Theme
  (MeldungMixin). messagebox sind Fenster von WINDOWS - sie bleiben hell, egal
  was das Programm einstellt. Dieselbe Erfahrung steckt schon in der eigenen
  Menueleiste. 49 Aufrufe umgestellt; das Ordner-Fenster meldet ueber das
  Hauptfenster mit.
  ⚠️ Im Pruflauf laeuft beides weiter ueber messagebox - dort haengen die
  Pruefstaende ihre Antworten ein. Sonst warten sie auf einen Klick.
- Reiter: der OFFENE ist jetzt der hellere (Kachelfarbe), die ruhenden liegen
  auf dem dunklen Grund. Vorher war es andersherum.
- config.TESTLAUF ist eine FUNKTION geworden: Pruefstaende importieren config
  oft, bevor sie CAMPINGHOF_TEST setzen - der einmal berechnete Wert stand dann
  den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge, auf die
  niemand klickt. Genau daran hing pruef_api.
- Der Erststart-Dialog hat einen eigenen Schalter (_erststart_zeigen), statt am
  ganzen Pruefschalter zu haengen: ein Pruefstand kann IHN zeigen, ohne dass
  alle anderen Meldungen wieder echte Fenster aufmachen.
- Pruefstand-Fenster stehen nicht mehr in der Taskleiste (-toolwindow): sie
  liegen neben dem Bildschirm, und ein Eintrag, den man anklickt, ohne dass
  etwas erscheint, ist schlimmer als gar keiner.
- pruef_nummernsperre drueckt jetzt den Radioknopf "Meine Zaehlung
  weiterfuehren", statt nur ins Zahlenfeld zu schreiben - das Feld ist ohne
  diesen Klick gesperrt, wie beim Benutzer auch.

Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 19:17:13 +02:00
TheMockTv
5ebb02b267 Oberflaeche im Pcore-Stil: Tuerkis, Kacheln, Glas-Knoepfe, Roboto
Seine Ansage: "schau dir unsern fivem server an und unsere webseiten, dann
weisst du wie mein design ist" - also die Palette aus _ui_www und der
Adminpage: Tuerkis #06C6A4 auf gestuften dunklen Flaechen (#0d0e10 Grund,
#15171b Kachel), Raender #25282e, Text #e8e8ea / #8a8e96, Rot #e85a5a.

Das PROGRAMM traegt diese Handschrift, die RECHNUNG die Farbe der jeweiligen
Firma - das Blatt gehoert dem Kunden, nicht uns.

- Roboto wird mitgeliefert und nur fuer den Prozess geladen
  (AddFontResourceEx mit FR_PRIVATE): keine Installation, keine Adminrechte,
  nach dem Schliessen ist der Rechner wie vorher. Fehlt sie, bleibt es bei
  Segoe UI. Die Schriftdateien gehen mit in die exe (spec).
- Abschnitte sind Kacheln mit Ueberschrift statt grauer Rahmen.
- Knoepfe im Glas-Stil wie in seinen Oberflaechen: getoente Flaeche, Rand in
  Akzent-Transparenz. Tk kann kein backdrop-filter - die Toenung wird
  ausgerechnet (theme.mischen), das Ergebnis ist auf einfarbigem Grund
  identisch. "PDF erstellen" fuehrt, "Storno" ist rot.
- Zebrastreifen in der Leistungsliste: bei 17 Zeilen verrutscht man sonst.
- Kein expand am offenen Reiter mehr - dadurch sprang beim Umschalten die
  ganze Leiste ("das taben macht den aktiven kleiner").
- Das Fenster fuer den gemeinsamen Ordner stand als einziges noch in Weiss da:
  es kam nie durch _style_dialog.

Dazu ein Fund, der nichts mit dem Design zu tun hat: die Pruefstaende erbten
"nummer_eingerichtet" aus der config DIESES Rechners. Sobald hier jemand die
Nummer einmal einrichtet, liefen sie in einen anderen Zweig und wurden rot -
ohne dass sich am Programm etwas geaendert haette. Sie legen den Zustand jetzt
selbst fest. Alle 14 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 18:52:42 +02:00
TheMockTv
0ad03fd1a4 Firmendialog passt sich dem Bildschirm an
Mit den neuen Bild- und Farbzeilen reichten 640 px nicht mehr: rechts fielen
"Entfernen" und unten "Speichern" aus dem Fenster - gesehen im laufenden
Programm, nicht im Code. Der Dialog richtet sich jetzt nach dem Bildschirm
(700-900 breit, hoehenbegrenzt) und die Felder sind etwas schmaler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 18:39:33 +02:00
TheMockTv
fe0c4a8e08 Im Pruflauf geht kein PDF-Betrachter mehr auf
Sein Fund: "wenn du testest, dann mach den tab auch zu". Ein Lauf ueber die
Pruefkette schreibt Dutzende Rechnungen, und nach jeder oeffnete das Programm
den Betrachter - am Ende klickt der Mensch am Rechner dreissig Fenster weg,
die er nie aufgemacht hat.

Geoeffnet wird jetzt nur noch an EINER Stelle (_oeffne_datei), und die haelt im
Testlauf still und meldet stattdessen, was zu sehen gewesen waere. Die zweite
Stelle, die frueher direkt os.startfile rief, geht ebenfalls darueber.

pruef_vorschau prueft jetzt den zurueckgegebenen Pfad - und dass wirklich
nichts aufgemacht wurde. Alle 14 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 18:31:07 +02:00
TheMockTv
4cf46e9932 Pruefstand-Fenster gehen neben dem Bildschirm auf, ohne Maus zu greifen
Sein Fund mitten im Lauf: "pack doch die maus nicht immer auf das programm,
weil ich kann dir sonst nichts schreiben". Im Testlauf oeffnen Fenster und
Dialoge jetzt bei x = Bildschirmbreite + 20 - auf dem zweiten Monitor, wenn
einer da ist -, und kein Dialog ruft mehr grab_set.

Unsichtbar machen (withdraw) waere falsch: dann sieht niemand mehr, was der
Pruefstand tut - und ein weggenommenes Fenster baut seine Dialoge nicht mehr
richtig auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 18:25:54 +02:00
TheMockTv
ca9ac212cf Amt-Blatt raus, Bedienung ueber die API, Erststart fragt nach zwei Wegen
Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen
anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen
Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet -
bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht,
rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen
_AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg.

Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key:
32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das
Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt
(compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein
aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im
Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer
Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen.

Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden
ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene
Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden.

pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl
bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete
in wait_window() ewig. Das war schon vor diesem Umbau so.

Neu: pruef_api.py. Alle 14 Pruefstaende gruen.

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
142dddc647 Bilder und Farbe gehoeren der Firma, dazu ein Vorschau-Knopf
Logo und ein Fussstreifen sind austauschbar (bilder.py: das gewaehlte Bild wird
KOPIERT, ein Pfad auf den Desktop waere morgen tot), die Akzentfarbe kommt aus
den Einstellungen - Hex-Feld und Farbwaehler. Firmenname, Anschrift, Bank und
Steuernummer bleiben gezeichnet; kein Bild kann eine Pflichtangabe verdecken.

Der Vorschau-Knopf zeigt das Blatt, bevor es eine Rechnung wird: kein
Nummernverbrauch, nichts im Rechnungsordner, "VORSCHAU - keine Rechnung" quer
ueber jeder Seite. Sonst kostet jeder Tippfehler einen Storno.

Neue Pruefstaende: pruef_bilder.py, pruef_vorschau.py

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-05 17:35:37 +02:00
TheMockTv
37b5c60266 Storno-Erklaerung steht jetzt auch auf dem Amtsblatt
Sein Fund: "in der pdf wird der satz nicht angezeigt, da ist dann ein
sprung in den rechnungs nummern".

Die Schlusstexte waren komplett auf die Kundenseite beschraenkt ("auf dem
Amt-Blatt unnoetig"). Fuer Dank- und Zahlungstexte stimmt das. Fuer die
STORNO-Erklaerung nicht: auf Seite 2 stand dann eine Stornonummer
(2026-456) neben einer voellig anderen Rechnungsnummer (2026-342), und
das Blatt, das die Stadt bekommt, sagte nirgends warum.

Jetzt traegt auch das Amtsblatt den Satz - "hebt die Rechnung X vom
Y vollstaendig auf, ist bereits mit Z abgerechnet" bzw. bei Version C
"die Leistung wird nicht abgerechnet". Die Beherbergungssteuer-Zeile
bleibt dort weiterhin aussen vor, die gehoert da nicht hin.

pruef_nur_storno an die Verrechnung angepasst (das Steuerjournal laesst
Storno und aufgehobene Rechnung wieder mitzaehlen, sie heben sich
gegenseitig auf): unterm Strich 0 Naechte und 0,00 Euro, gezaehlt werden
aber beide Belege. A und B unveraendert gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bEsoFUk16DfnNA7MjY36H
2026-09-04 01:19:50 +02:00
70 changed files with 7900 additions and 682 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

15
.gitignore vendored
View file

@ -9,3 +9,18 @@ _tmp/
*.7z.*
release/
rechnungstool.pid
# 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.
!Rechnungstool.spec
!Updater.spec
!Diagnose.spec
# Muster-PDFs. Der GAST darin ist erfunden - der BETRIEB nicht: sie tragen
# IBAN, Steuernummer und die private Rufnummer des Auftraggebers im Briefkopf.
# In ein oeffentliches Repository gehoert das nicht.
muster/

View file

@ -0,0 +1,95 @@
# Außendienst: Arbeitsprotokoll auf dem Tablet, Rechnung fällt heraus
Seine Idee vom 06.09.2026, wörtlich:
> „mach es an der kundenanzahl fest, und die bekommen eine vorab-nummer, die reserviert ist von der
> disponentin. die kunden haben eh eine kundennummer — heißt, es ist nur die rechnungsnummer, die
> zugeteilt wird. und hans hat nur die kundennummer mit adresse und wohnort im log, wo er
> protokolliert: anfahrt drücken, angekommen drücken … dann arbeit start und stop, dann noch: was
> hat er eigentlich gemacht — also gleich arbeitsprotokoll — und abfahrt. plus die rechnung für den
> kunden anhand des arbeitsblocks, den wir schon haben."
**Status: Idee. Nichts davon ist gebaut.**
---
## Was sich dadurch ändert
Die Rechnung wird **nicht mehr getippt, sondern fällt aus dem Arbeitsprotokoll heraus.** Wer ohnehin
Anfahrt, Ankunft, Beginn und Ende drückt, hat am Abend die Rechnung fast fertig, ohne sie
geschrieben zu haben.
Damit ist das **ein anderes Produkt** als das heutige Rechnungstool: eine Auftrags- und
Zeiterfassung, bei der die Rechnung das Ergebnis ist. Der Arbeitsdatensatz wird primär, die Rechnung
abgeleitet.
## Die Nummernlogik wird dadurch einfacher
- **Die Kundennummer hat der Kunde schon.** Reserviert wird nur die **Rechnungsnummer**.
- Die Blockgröße ergibt sich aus den **zugeteilten Aufträgen** — siehe
`AUFGABE_ONLINE_API_MYSQL.md`, Abschnitt „Woher die Blockgröße kommt".
- Hans hat auf dem Gerät nur, was er braucht: Kundennummer, Anschrift, Ort.
---
## ⭐⭐ ENTSCHEIDUNG (Andreas, 06.09.2026): Daten auf dem Server, Rechnung im BÜRO
> „das liegt auf dem server, und im büro wird die rechnung geschrieben. denke, das ist sauber —
> aber so weit sind wir noch nicht."
**Das räumt ein Problem ab, und zwar das größte.**
Wird die Rechnung im Büro geschrieben, **entfällt die Offline-Nummernvergabe komplett**. Das Tablet
erfasst nur Arbeit — und ein Arbeitsprotokoll braucht **keine Rechnungsnummer**. Die entsteht im
Büro, wo Netz da ist.
→ **Nummernkontingente, Puffer und Blockverlängerung werden damit von einer Voraussetzung zu einer
OPTION** — nötig erst, falls später doch beim Kunden live abgerechnet werden soll. Die Überlegungen
dazu bleiben in `AUFGABE_ONLINE_API_MYSQL.md` stehen, sind aber nicht mehr auf dem kritischen Weg.
**Das Tablet muss dann nur zweierlei können:**
1. den **Kundenstamm zum Nachschlagen** vorhalten (Zwischenspeicher mit sichtbarem Alter)
2. die **Protokolle zwischenspeichern**, bis wieder Netz da ist
Beides **ohne Nummernvergabe** — also ohne die Gefahr, dass zwei Geräte sich in die Quere kommen.
## ⚠️ Drei Punkte, die vorher geklärt sein müssen
### 1. ⭐ Erfasste Zeit ist NICHT berechnete Zeit
Anfahrt ist mal berechenbar, mal nicht. Pausen laufen mit, gehören aber nicht auf die Rechnung.
**Wird das Protokoll eins zu eins zur Rechnungszeile, streitet der Betrieb irgendwann mit dem Kunden
über eine Minute, die das Programm erfunden hat.**
> **Regel: Aus dem Protokoll wird ein VORSCHLAG, aus dem Vorschlag die Rechnung.** Das Protokoll
> bleibt unverändert stehen, auch wenn anders abgerechnet wird.
Dann ist beides sauber und man kann später erklären, warum abgewichen wurde.
### 2. Start/Stopp drücken heißt: das ist ARBEITSZEIT
Damit hängt Arbeitsrecht daran, nicht nur Steuerrecht:
- Aufzeichnungs- und Aufbewahrungspflicht (**§ 16 ArbZG**)
- Die Aufzeichnungen gehören dem **Arbeitsverhältnis**, nicht dem Kunden
- → **getrennt von den Kundendaten behandeln**, und sie dürfen nicht einfach in der Rechnung landen
### 3. ⚠️ „Anfahrt/Angekommen" ist Standortverfolgung von Mitarbeitern
Sobald Zeiten und Orte zusammenkommen, ist es Mitarbeiterüberwachung. In Deutschland heikel:
- braucht eine **Rechtsgrundlage** und klare **Zweckbindung** (Art. 88 DSGVO, § 26 BDSG)
- gibt es je einen **Betriebsrat**: mitbestimmungspflichtig (§ 87 Abs. 1 Nr. 6 BetrVG)
**Beherrschbar, solange der Mitarbeiter selbst drückt** und nichts im Hintergrund mitläuft. Ein
Programm, das ohne Zutun aufzeichnet, wo jemand ist, wäre etwas völlig anderes — und dann wird es
ernst.
---
## Was schon da ist und wiederverwendet wird
- der **Arbeits-/Leistungsblock** (Katalog) aus dem Rechnungstool
- der **Kundenstamm** (offen, siehe `AUFGABE_KUNDENSTAMM_UND_WEB.md`)
- die **Nummernkontingente** (siehe `AUFGABE_ONLINE_API_MYSQL.md`)

View file

@ -0,0 +1,139 @@
# Beweiskette: von Anfang bis Ende prüfbar
Seine Vorgabe vom 06.09.2026, wörtlich:
> „ich will für den prüfer sichtbar machen: ich wollte das so. die software dokumentiert das,
> und damit wird es nachvollziehbar, weil es einen checkmark nun gibt in der sqlite"
> — „das soll alles von anfang bis ende prüfbar sein"
> — „damit denke ich überfüllen wir sogar das gobd, sogar mehr, weil es nun eine beweiskette
> darstellt"
**Grundgedanke:** GoBD verlangt Nachvollziehbarkeit und Unveränderbarkeit — aber **keinen
dokumentierten Freigabe-Entscheid**. Den gibt es jetzt zusätzlich. Damit die Kette trägt, muss
jedes Glied für sich prüfbar sein und keines allein tragen.
---
## Schritt 1 — Freigabe vor dem Beleg ✅ ERLEDIGT (Commit 95a9063)
- Rückfrage vor dem Schreiben, Text nennt § 14 UStG, § 14 Abs. 4 Nr. 4 UStG, § 147 Abs. 3 AO,
§ 146 Abs. 4 AO; Angebot bekommt einen eigenen Text (ausdrücklich **keine** Rechnung).
- Storno/Korrektur und Berichtigung behalten ihre eigene Rückfrage — nicht verdoppelt.
- Spalte `freigabe` (ISO-Zeitstempel) in `nummern` im gemeinsamen Nummernbuch, mit
ALTER-TABLE-Migration. **Alte Zeilen bleiben leer** — dort wurde nie gefragt.
- `pruef_freigabe.py` prüft beide Richtungen. Alle 16 Prüfstände grün.
---
## Schritt 2 — Freigabe zusätzlich in die PDF-Metadaten ⬜ OFFEN
**Warum das nötig ist:** `gemeinsam.py` sagt über die SQLite selbst: *„Die Datei ist ein REGISTER,
keine zweite Buchhaltung: die Wahrheit bleiben die Rechnungs-PDFs. Geht das Buch verloren, fehlt
keine Rechnung — es muss nur neu gefüllt werden."* Ein solcher Neuaufbau kennt die
Freigabe-Zeitstempel **nicht**. Damit hinge die Beweiskette an der schwächsten Datei.
**Zu tun:** `freigabe` in `pdf_renderer._kenndaten()` aufnehmen (die maschinenlesbaren Kenndaten im
`/Subject`, die das Steuerjournal ohnehin liest). Dann steht der Zeitstempel an zwei Stellen und
übersteht den Verlust des Registers.
**Prüfstand:** `pruef_freigabe.py` erweitern — nach dem Ja muss `bestand.kenndaten_lesen(pdf)` den
Zeitstempel liefern, und er muss mit dem in der SQLite übereinstimmen.
---
## Schritt 3 — Steuerprüfungs-Ausdruck im Beherbergungssteuer-Tool ⬜ OFFEN
Sein Wortlaut: *„wir müssen im kassenbuch, was das beherbergungstool ist, einen button in die
einstellungen machen: steuerprüfungs-ausdruck. damit bekommt der prüfer einen ausdruck aus der db
und kann damit das in der tabelle prüfen auf richtigkeit, oder halt wenn der typ das als
papierversion hat."*
**Zu tun:** Knopf in den Einstellungen, der aus der Datenbank ein PDF erzeugt, das ein Prüfer gegen
die Belege halten kann.
**Vor dem Bauen zu klären:**
- Welcher Zeitraum? (Jahr, Monat, frei wählbar)
- Welche Spalten? Mindestens: Nummer, Datum, Art, Beträge, Vorgang/Storno-Bezug, **Freigabe**
- Wie wird gezeigt, dass nichts fehlt? Lückenlosigkeit der Nummern ist der Kern jeder Prüfung —
Lücken müssen **sichtbar** sein, nicht stillschweigend übersprungen.
- Summen je Zeitraum, damit der Prüfer gegenrechnen kann.
- Kennzeichnung als Auswertung, **nicht** als Beleg (sonst dasselbe Problem wie beim Amt-Blatt).
⚠️ **Nicht verwechseln:** Der bestehende „Bericht fürs Amt" ist die **Steueranmeldung** nach
kommunaler Satzung. Der Prüfungs-Ausdruck ist etwas anderes: eine Auswertung für die
Betriebsprüfung. Beide bleiben nebeneinander bestehen.
---
## Schritt 4 — Updater als Starter ⬜ OFFEN
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."* Dazu: *„einmal prüfen auf
vollständigkeit."*
**Warum das der richtige Aufbau ist:** Der Updater ist das, was der Nutzer startet. Er läuft
**bevor** das Programm läuft — damit gibt es das übliche Problem gar nicht, dass eine laufende EXE
sich nicht selbst ersetzen kann.
**Ablauf:**
1. Beim Start die Release-Angaben vom Repository holen.
2. Ist die dortige Version neuer als die installierte → Datei in einen **Zwischenordner** laden.
3. **Vollständigkeit prüfen** — Größe und Prüfsumme gegen die Angabe des Releases. Erst wenn beides
stimmt, wird ersetzt. Ein halb geladener Download darf die Installation nie erreichen.
4. Alte Fassung als **Rückfallebene** behalten; scheitert der Start, zurücktauschen.
5. Programm starten.
**⚠️ Zwei Voraussetzungen fehlen noch:**
- **Es gibt keine Versionsnummer im Programm.** Ohne die kann kein Updater vergleichen. Muss zuerst
eingeführt werden — an einer Stelle, aus der auch die EXE-Metadaten und der Info-Dialog lesen.
- **Sind die Repositories öffentlich?** Wenn nicht, braucht der Updater einen Zugangstoken — und
ein Token, das in einer verteilten EXE steckt, ist kein Geheimnis mehr. Dann müssten die Releases
öffentlich sein oder über eine andere Stelle ausgeliefert werden.
**Und Andreas' Ansage zu den Releases:** *„die alten als experimental taggen, nicht als stabil —
weil die sind nicht stabil."*
---
## Schritt 5 — Pcore-Design im Steuerrechnungstool ⬜ OFFEN
Sein Wunsch: *„aber bitte mit unserm theme style."*
**Befund:** Das Steuerrechnungstool hat **kein `theme.py`**. Es benutzt nackte ttk-Widgets mit
Segoe UI. Das Rechnungstool hat das ganze Pcore/Ravokk-Design — Farben, Roboto, Glass-Buttons,
eigene Meldungsfenster (`MeldungMixin`), hell und dunkel.
**Zu tun:** `theme.py` herüberziehen und die Oberfläche darauf umstellen. Das ist ein eigener Bau,
kein Nebenbei — Menüs, Tabellen, Dialoge, Statusleiste. Vorher planen.
---
## ✅ Erledigter Altfehler — `pruef_wache` (Steuerrechnungstool) **07.09.2026 behoben**
**Nicht von den Arbeiten am 06.09.2026 verursacht** — bei der Ausgangsmessung vor dem Umbau bereits
rot, nur mit anderem Text.
`pruef_wache.py` erwartete nach einem Push des Rechnungstools in der Statuszeile den Hinweis
**„Rechnungstool meldet"**. Tatsächlich stand dort das Ergebnis des anschließenden Einlesens.
**Die Frage war:** Fehler im Programm oder veraltete Erwartung im Prüfstand?
**Die Antwort: Fehler im Programm.** Sichtbar wurde das, weil der Prüfstand *mal grün und mal rot*
lief — je nachdem, wann er hinsah. Ein Prüfstand, der würfelt, ist nicht der eigentliche Befund,
sondern sein Symptom: die Meldung stand nur Sekundenbruchteile da, danach überschrieb sie das
Einlese-Ergebnis. Der Benutzer sah **nie**, dass eine Rechnung hereingekommen war.
**Behoben:** `melde()` kennt jetzt einen `vorrang` in Sekunden. Eine Meldung mit Vorrang lässt sich
in dieser Zeit nicht von einer gewöhnlichen überschreiben; die Meldung des Rechnungstools bekommt
sechs Sekunden. Dreimal hintereinander stabil grün.
⚠️ **Zum Merken:** Erst die Warteschleife im Prüfstand („warten, bis der Text dasteht" statt einmal
hinsehen) machte den Fehler *reproduzierbar* — und drehte ihn von zufällig grün auf konstant rot.
Ein wackelnder Prüfstand ist keine Nebensache, die man wegdrückt: er zeigt auf eine echte Stelle.
---
## Reihenfolge
Schritt 2 zuerst — er schließt eine Lücke in dem, was gerade gebaut wurde, und ist klein.
Schritt 3 danach, mit eigenem Plan, weil dort Entscheidungen offen sind.

View file

@ -0,0 +1,83 @@
# Rechnungstool — Kundenstamm und Weboberflaeche (offen, aufgenommen 06.09.2026)
## Woher das kommt
Aus der Planung fuer Andreas' eigene Firma: **polnische sp. z o.o., Verkauf ausschliesslich B2B,
Kunden ueberwiegend in Deutschland**. Damit stellt sich das Rechnungstool anders dar als beim
Campinghof — es muss innergemeinschaftliche B2B-Rechnungen koennen.
Sein Wort: *"da fehlt dann die kundennummer noch mit drin ... ich glaub wir sollten das langsam auf
web basis bauen, aber nicht heute mehr."*
## 1. Kundenstamm (fehlt bisher ganz)
Es gibt keinen Kundendatensatz. Fuer B2B-Rechnungen ins EU-Ausland ist er Pflicht, nicht Komfort.
Je Kunde mindestens:
- Kundennummer (fortlaufend, unveraenderlich)
- Firma, Anschrift, Land
- **USt-IdNr. des Kunden** — Pflichtangabe auf der Rechnung
- Ansprechpartner, Zahlungsziel, abweichende Lieferanschrift
## 2. Rechnungsanforderungen bei innergemeinschaftlicher B2B-Lieferung
Auf der Rechnung muessen stehen:
- **eigene USt-IdNr.** UND **USt-IdNr. des Kunden** (beide!)
- Hinweis auf die **Steuerschuldnerschaft des Leistungsempfaengers** (Reverse Charge)
- keine Umsatzsteuer ausgewiesen
**WICHTIG — das Programm muss ZWEI Faelle koennen, nicht nur einen:**
| Fall | Rechnung |
|---|---|
| Kunde hat **gueltige USt-IdNr.** | ohne Umsatzsteuer, beide IdNr. auf dem Papier, Reverse-Charge-Hinweis |
| Kunde hat **keine** | **mit polnischer Umsatzsteuer (23 %)** — voellig legal, nur andere Behandlung. **Der Auftrag ist NICHT verloren.** |
(Andreas' Sorge am 06.09.2026: *"dann kaufen die bei mir nicht"* — trifft nicht zu. Die USt-IdNr.
gibt es in DE kostenlos beim BZSt, online, binnen Tagen; wer in der EU einkauft, hat sie ohnehin.
Ohne Nummer wird schlicht mit polnischer Steuer fakturiert. Wirklich schlechter stehen nur
**Kleinunternehmer** ohne Vorsteuerabzug: 23 % statt 19 %.)
Dahinter haengen drei Nachweispflichten, an denen die Steuerfreiheit tatsaechlich haengt und die das
Programm mitfuehren sollte:
1. **Pruefung der USt-IdNr.** ueber das EU-Portal, qualifiziert, **mit Beleg und Datum je Auftrag**
speichern (ungueltige Nummer = er schuldet die Steuer selbst)
2. **Nachweis des Grenzuebertritts** — Frachtpapier oder **Gelangensbestaetigung**, dem Vorgang
zuordenbar ablegen
3. **Zusammenfassende Meldung** — Auswertung je Zeitraum, Kunde + Betrag, exportierbar
Das sind genau die Stellen, an denen Betriebspruefer zuerst nachsehen. Gehoert von Anfang an rein,
nicht nachtraeglich.
## ⚠️ FUND 06.09.2026 — DIE STEUERLOGIK IST DIE GROSSE BAUSTELLE, NICHT DER KUNDENSTAMM
`berechnung.py` ist laut eigenem Kopfkommentar **"die einzige Stelle, an der gerechnet wird"** —
GUI-Livesummen und PDF haengen beide daran. Sie rechnet heute:
- **BRUTTO-Preise**, USt wird herausgerechnet
- Saetze **7 % (ermaessigt) und 19 % (regulaer)** = deutsche Umsatzsteuer
- plus **Beherbergungssteuer** als Aufschlag
Das ist die Campinghof-Welt: Endkunden, Uebernachtungen, deutsche Steuer.
**Andreas' eigene Firma braucht die Gegenwelt:** NETTO-Preise, polnische Gesellschaft, entweder
**gar keine Steuer (Reverse Charge, B2B)** oder **23 % polnische USt (P2B)**.
→ **Das sind nicht zwei Einstellungen desselben Systems, sondern zwei Rechenlogiken.**
**Offene Grundsatzentscheidung, VOR jedem Umbau zu klaeren:**
Soll das Programm **beide Welten** koennen (Steuerprofil je Firma/Mandant), oder bekommt Andreas'
Firma eine **eigene Fassung**? Solange das offen ist, nicht an `berechnung.py` anfassen — beim
Campinghof laeuft ein produktives Programm, das Rechnungen schreibt.
**Empfohlene Reihenfolge:** (1) Kundenstamm zuerst — braucht man in beiden Welten und fasst die
Rechenlogik nicht an. (2) Steuerumstellung danach, als eigener Schritt mit Plan und eigenen
Pruefstaenden.
## 3. Weboberflaeche
Wunsch: das Tool langsam **auf Webbasis** umbauen. Noch nicht begonnen, kein Termin, ausdruecklich
**nicht heute**. Vor dem Umbau planen (Regel: Plan vor Neubau), nicht nebenbei anfangen.
## Status
**Offen. Nicht begonnen.** Erst ansprechen, wenn er das Thema selbst wieder aufmacht.

417
AUFGABE_ONLINE_API_MYSQL.md Normal file
View file

@ -0,0 +1,417 @@
# Online-Betrieb: API, MySQL, Tablet, Rechnungsversand
Seine Idee vom 06.09.2026, wörtlich:
> „die software ist aktuell nur lokal, und da reicht sqlite. wenn wir die online funktion einbauen,
> dann muss man einen api key eintragen, und die api verteilt das auf die mysql — dann ist das
> extern auf dem server und du kannst von überall über domain und apikey auch unterwegs per tablet
> beim kunden die rechnung schreiben, live oder vorab-rechnung, und der smtp-server schickt sie
> direkt dem kunden auf die hinterlegte e-mail. fertig. alles automatisch."
**Status: Idee. Nichts davon ist gebaut.** Der Schnitt ist richtig gedacht — lokal SQLite, zentral
MySQL, die API als einzige Tür dazwischen.
---
## 1. ⭐ Die Rechnungsnummer darf nur vom Server kommen
Heute stimmen sich zwei lokale Programme über eine gemeinsame SQLite ab. Zentral wird das
**einfacher**: eine Transaktion in MySQL vergibt die Nummer, fertig.
**Aber:** Auf dem Tablet beim Kunden ohne Empfang darf keine Nummer entstehen.
> **Regel: Offline entsteht ein ENTWURF, keine Rechnung.** Die Nummer wird vergeben, sobald der
> Server erreichbar ist.
Sonst gibt es irgendwann zwei Belege mit derselben Nummer — genau der Fehler, wegen dem das
Amt-Blatt herausgeflogen ist (§ 14 Abs. 4 Nr. 4 UStG).
Daraus folgt für die Oberfläche: Ein Entwurf muss **sichtbar** ein Entwurf sein, ohne Nummer, und
darf nicht ausgedruckt oder verschickt werden können.
## ⭐⭐ Das Offline-Problem — und seine Lösung: NUMMERNKONTINGENTE
Sein Einwand (06.09.2026):
> „das ding, was wir haben, ist das offline-problem. wenn das internet schwach oder weg ist, bringt
> das online nichts — also musst du auf dem client eine kopie per sqlite haben. wie willst es sonst
> machen?"
**Er hat recht, und das ersetzt die zu strenge Regel „offline nur Entwürfe" aus Punkt 1.**
### Was zwischengespeichert werden kann — und was nicht
| | offline möglich? |
|---|---|
| Kundenstamm, Preise, Einstellungen, alte Rechnungen zum Nachschlagen | **ja** — lokale SQLite, lesend, mit sichtbarem Alter |
| **Vergabe der Rechnungsnummer** | **nein** — zwei Geräte nähmen beide „die nächste" |
### Die Lösung: der Server gibt Blöcke im Voraus aus
So machen es Kassensysteme. Tablet A bekommt **2026-101 bis 2026-150**, Tablet B **2026-151 bis
2026-200**. Offline vergibt jedes Gerät aus **seinem eigenen** Block — eine Kollision ist
**unmöglich**, weil kein anderes Gerät diese Nummern besitzt. Bei Netz meldet es den Verbrauch und
holt einen neuen Block.
→ **Damit sind beim Kunden echte Rechnungen möglich, nicht nur Entwürfe.**
### Zwei Bedingungen, die dazugehören
1. **Lücken werden normal — und müssen erklärbar sein.** Kommt Tablet A nur bis 2026-137 und holt
dann einen neuen Block, fehlen 138–150. Das ist zulässig: § 14 UStG verlangt eine **einmalig
vergebene** fortlaufende Nummer, ausdrücklich auch in mehreren Kreisen (z. B. je Filiale).
**Aber das System muss zeigen können, welcher Block wem gehörte und welche Nummern daraus nie
verbraucht wurden.** Sonst sieht ein Prüfer eine Lücke und fragt nach der Rechnung.
→ Gehört in den **Steuerprüfungs-Ausdruck** (siehe `AUFGABE_BEWEISKETTE_PRUEFBAR.md`, Schritt 3).
2. **Der Block ist endlich.** Ist er verbraucht und kein Netz da, ist Schluss — dann wieder
Entwürfe. **Lieber ein Gerät, das sagt „ich kann gerade nicht", als eines, das sich Nummern
ausdenkt.**
### ⭐⭐ Woher die Blockgröße kommt: aus der Disposition (Andreas, 06.09.2026)
> „die disponentin hat jedem handwerker ja kunden zugeteilt — daraus kann ein block generiert
> werden. die software sagt: handwerker hans werner hat 10 kunden, also bekommt er block A mit 10
> nummern, der hans werner heißt. dann ist es erklärbar, weil hans die kunden im log hatte. wenn er
> mehr kunden hatte, bekommt er von anfang an 10 puffernummern als schutz. und wenn die nicht
> gebraucht werden, bekommt er sie für den nächsten tag wieder. wenn sie voll sind, überspringt er
> in den nächsten block. der server hält selber immer reserve."
**Der starke Punkt daran ist die Erklärbarkeit.** Ein Prüfer sieht reservierte Nummernbereiche und
fragt, warum ausgerechnet diese. Hier gibt es eine Antwort **aus dem Betrieb heraus**: Die
Disposition hat Hans an dem Tag zehn Kunden zugeteilt, also zehn Nummern plus Puffer. **Die
Zuteilung ist der Beleg für die Reservierung** — besser als jede technische Begründung.
### ⚠️ Zwei Korrekturen daran
**1. Der Block darf NICHT am Kalendertag hängen.**
*„Heute hast du 100 bis 110"* klingt logisch, ist aber gefährlich: Hat Hans zwei Tage keinen
Empfang, wäre er am zweiten Tag arbeitsunfähig, obwohl er noch Nummern hätte.
> **Der Block gehört ihm, bis er ihn zurückmeldet. Kein Ablaufdatum.** Die Disposition gibt morgens
> neue *dazu*, nimmt aber nichts weg, was er noch hat.
Am Bild der Disposition ändert das nichts — nur verhungert das Tablet nicht, wenn das Netz drei
Tage weg ist.
**2. Eine Nummer ist erst verbraucht, wenn ein BELEG entsteht.**
Nicht bei der Zuteilung, und **nicht durch einen Entwurf**. Nur so stimmt, dass unbenutzte Nummern
am nächsten Tag wieder gelten — sonst würden Nummern wiederverwendet, die schon auf einem
Bildschirm standen.
**Der Rest passt unverändert:** Puffer von Anfang an · Überspringen in den nächsten Block, wenn
voll · **Server hält eigene Reserve** für das, was die Disposition nicht wusste (Notfall,
Zusatzauftrag, Kunde ruft an).
### ⭐ Beim Synchronisieren: Verbrauch melden, nicht blind neu ausgeben
Sein Einwand (06.09.2026):
> „wenn das tablet lte wieder hat, kann er das syncen und bekommt einen neuen block — oder sagt
> einfach: eh, ich habe aber in dem block erst 10 % genutzt"
**Richtig, und das vermeidet Lücken statt sie nur zu erklären.** Das Gerät meldet seinen Stand:
*„2026-101 bis 110 verbraucht, 111 bis 150 gehören mir noch."* Der Server entscheidet:
| Lage | Antwort |
|---|---|
| noch viel übrig | **nichts tun** — kein neuer Block, keine verbrannten Nummern |
| fast leer, folgende Nummern frei | **den vorhandenen Block VERLÄNGERN** (101–150 → 101–180) → **keine Lücke** |
| fast leer, folgende Nummern gehören schon jemandem | neuer Block weiter hinten → Lücke, aber eine, die der Server **benennen** kann |
**Verlängern schlägt Neuausgabe.** Das ist der Unterschied zwischen einem System, das Lücken erzeugt
und sie hinterher erklären muss, und einem, das erst gar keine macht.
### Warum das nicht optional ist
> „kannst ja nicht kunden fragen nach wlan"
Beim Kunden im Keller, im Neubau ohne Anschluss, auf dem Hof: kein WLAN, oft auch kein
Mobilfunknetz. Nach dem Passwort fragen macht kein Handwerker.
**Zwei praktische Regeln daraus:**
1. **Der Block muss großzügig sein** — nicht zehn Nummern, sondern so viele, dass eine Woche Arbeit
hineinpasst. Nummern kosten nichts, ein abgerissener Vorgang beim Kunden schon.
2. **Das Gerät warnt, BEVOR es eng wird.** *„Noch 5 Nummern im Vorrat, bitte einmal ins Netz"* —
solange man noch in der Werkstatt steht. Eine Warnung, die erst beim Kunden kommt, ist wertlos.
Nachgefüllt wird **automatisch**, sobald irgendwo Netz da ist. Der Benutzer soll darüber nicht
nachdenken müssen.
## 2. ⚠️ Beim Datenschutz kippt die Lage vollständig
Heute lautet die Aussage im Programm: *Es verlässt nichts den Rechner.* Das ist der ganze
Datenschutztext. Sobald Gästedaten auf einem Server liegen, gilt die DSGVO in voller Breite:
- verschlüsselte Übertragung (TLS), keine Ausnahme
- **Zugriffstrennung je Betrieb** — ein API-Schlüssel ist ein Passwort, keine Zugriffskontrolle
- Löschkonzept und Aufbewahrungsfristen (§ 147 AO) müssen zusammenpassen
- **Wenn er das für ANDERE Betriebe hostet, ist er deren Auftragsverarbeiter** und braucht einen
Vertrag nach Art. 28 DSGVO. Das ist keine Formalie, sondern Voraussetzung dafür, dass die
Betriebe das Programm überhaupt einsetzen dürfen.
Der Datenschutztext im Programm muss dann neu geschrieben werden — der heutige wäre schlicht falsch.
## 3. ⭐⭐ E-Rechnung (ZUGFeRD) — jetzt entscheiden, nicht später
In Deutschland läuft die Pflicht zur elektronischen Rechnung im B2B an. Wer ein System baut, das
Rechnungen **per Mail verschickt**, sollte die PDF gleich mit **eingebettetem strukturiertem XML**
erzeugen (ZUGFeRD / PDF/A-3).
Der Unterschied zwischen „von Anfang an mitgedacht" und „später nachrüsten" ist hier ein halber
Neubau: PDF/A-3 stellt andere Anforderungen an Schriften und Farbprofile als das heutige PDF, und
das XML muss aus denselben Daten kommen wie das Blatt — nicht daneben gepflegt werden.
Stand schon auf der allerersten Aufgabenliste des Rechnungstools.
## 4. Versand
- **SMTP genügt zum Senden.** POP3/IMAP braucht man nur, um **Unzustellbarkeitsmeldungen** zu lesen
— und die *muss* man lesen. Sonst meldet das Programm „verschickt", und die Rechnung liegt
nirgends.
- **Eine per Mail verschickte Rechnung ist eine Rechnung** (§ 14 UStG). Für die GoBD muss das
Dokument **genau so archiviert werden, wie es verschickt wurde** — samt Versandnachweis.
- Zustellung ist nicht garantiert. Der Versandstatus gehört sichtbar in die Oberfläche, nicht nur
ins Log.
## ⭐ Das Produktbild (Andreas, 06.09.2026) — lokal ist der Normalfall
> „solange er den apikey mit domain nicht hat, kann er auch nur sqlite nutzen — reicht auch, wenn
> einer es als single programm für sich nutzt. will einer das online nutzen, dann trägt er beides
> ein, das programm synct einmal auf die mysql, fertig."
**Damit ist der Aufbau entschieden: local-first, Online ist eine Zusatzfunktion, die man
einschaltet.** Wer nichts einträgt, merkt vom Server nichts. Kein Konto, keine Pflicht, keine
Abhängigkeit.
### Die eine Regel, die den Umzug sicher macht
**„Synct einmal" ist eine EINMALIGE ÜBERNAHME, keine ständige Synchronisierung in beide
Richtungen.** Danach gehört die Nummernvergabe dem Server; die lokale SQLite ist nur noch
Zwischenspeicher für Entwürfe (siehe Punkt 1).
Zwei Richtungen dauerhaft offen zu halten hieße: zwei Stellen vergeben Nummern. Genau der Fehler,
wegen dem das Amt-Blatt herausflog.
### Und daraus folgt eine Entscheidung VOR der ersten Übernahme
Zwei Installationen, die beide 2026-001 bis 2026-050 vergeben haben, lassen sich **nicht** in eine
Datenbank übernehmen — dort lägen Nummern doppelt. Gehen je mehrere Betriebe auf denselben Server,
muss die **Trennung der Nummernkreise vorher** feststehen (eigene Datenbank je Betrieb, oder
Präfix je Betrieb). Nachträglich geht es nicht: die Belege sind dann schon beim Kunden.
### Was der Umzug NICHT gefährdet
**Die Wahrheit bleiben die Rechnungs-PDFs** — so steht es schon heute im Nummernbuch-Code: *„Die
Datei ist ein REGISTER, keine zweite Buchhaltung."* Geht bei der Übernahme etwas schief, fehlt
keine Rechnung; es muss nur neu eingelesen werden.
## ⭐⭐ Die Umschaltung ist eine Tür, die nur in eine Richtung aufgeht
Sein Modell (06.09.2026):
> „ein einmal sync, danach ist es only online. die sql ist dann deaktiviert für immer — außer er
> trägt den key und die domain aus, dann hat er aber einen alten datenstand."
> „das muss er mit einem popup — mit dem speichern-button, den es dafür gibt — am ende noch mal als
> ja/nein-frage kommen, ob ihm das bewusst ist, dass er sozusagen bei 0 anfängt."
### Harte Anforderung
**Beim SPEICHERN** von API-Schlüssel und Domain (nicht beim Eintippen) kommt eine Ja/Nein-Frage.
Beim Tippen weiß niemand, ob es ernst gemeint ist — beim Speichern schon.
Entwurf des Textes:
> **Auf Online-Betrieb umstellen?**
>
> Die vorhandenen Daten werden einmalig auf den Server übernommen. Danach läuft alles über den
> Server: Rechnungsnummern werden dort vergeben, und die lokale Datenbank wird **nicht mehr
> fortgeführt**.
>
> Entfernen Sie später Schlüssel und Domain, arbeitet das Programm wieder lokal weiter — dann aber
> mit dem Stand von **[Datum von heute]**. Alles, was zwischenzeitlich online entstanden ist, fehlt
> dort.
>
> Diese Umstellung lässt sich nicht rückgängig machen.
>
> Jetzt umstellen?
Dasselbe Muster wie beim Erprobungshinweis und bei der Freigabe vor dem Beleg: Der Nutzer wird
**vorher** aufgeklärt, und die Entscheidung wird mit Datum vermerkt.
### Die lokale Datenbank wird STILLGELEGT, nicht gelöscht
Sie ist der Stand von damals und muss lesbar bleiben — schon wegen § 147 AO.
### ⭐ Und der Punkt, der daran hängt: die PDFs müssen erreichbar bleiben
Heute gilt in beiden Programmen: **Die Wahrheit sind die Rechnungs-PDFs, die Datenbank ist nur ein
Register.** Wird künftig auf dem Server erzeugt, muss der Betrieb **jederzeit an alle seine Belege
herankommen — als Dateien**, nicht nur als Ansicht im Browser.
Der Grund ist **§ 147 AO: zehn Jahre Aufbewahrungspflicht.** Ein System, das den Betrieb von seinen
eigenen Belegen trennen kann — Server fällt aus, Vertrag endet, man zerstreitet sich — bringt den
BETRIEB in Schwierigkeiten.
→ **Empfehlung: automatische lokale Kopie jeder Rechnung, auch im Online-Betrieb.** Dann bleibt der
Satz „die Wahrheit sind die PDFs" wahr, und der Betrieb hat seine Unterlagen auch dann, wenn nichts
mehr läuft. Das ist zugleich ein Verkaufsargument.
## 📄 Paperless als Archiv (Andreas, 06.09.2026) — löst die Aufbewahrungsfrage
> „ich dachte später an ein paperless — da muss eh alles rein"
**Das ist die bessere Antwort auf § 147 AO als eine Exportfunktion.** Läuft jede Rechnung
automatisch in ein Dokumentensystem, liegt das Archiv **außerhalb** des Programms und überlebt
sowohl einen abgeschalteten Server als auch das Programm selbst.
**Anbindung ist klein:** Paperless-ngx nimmt Dokumente über eine REST-Schnittstelle **oder über
einen überwachten Ordner** entgegen — Datei hineinlegen, fertig. Für das Rechnungstool eine
zusätzliche Zeile beim Speichern, kein Umbau.
**⭐ Und die Verschlagwortung ist schon gebaut:** Rechnungsnummer, Datum, Beträge, Art und Vorgang
stehen bereits als maschinenlesbare Kenndaten im `/Subject` jeder PDF (das Steuerjournal liest sie
von dort). Daraus kann das Archiv seine Schlagworte ziehen — ohne Abtippen, ohne Texterkennung.
⚠️ **Ehrlicher Vorbehalt:** Ein Dokumentensystem macht **nicht automatisch GoBD-konform**. Es
erfüllt den Kern (Belege bleiben unverändert und vollständig), aber dazu gehört eine
**Verfahrensdokumentation** — wie Belege entstehen, wohin sie laufen, wer was darf. Die schreibt
kein Programm. Ein großer Teil davon steht allerdings schon in den Aufgabendateien und
Commit-Texten dieser Projekte.
## 🔑 Die API ist der Schlüssel zu allem (Andreas, 06.09.2026)
> „das tool schickt es an die api, die api verteilt das alles. die api ist der schlüssel zu allem —
> wie beim gta fivem server"
**Das ist seine eigene Serverregel, angewandt:** *Der Wille kommt vom Client, gerechnet wird auf dem
Server.* (Vgl. PcoreLife: nichts im Client, was Serversachen übernimmt.)
| | Client (Tool/Tablet) | Server (API) |
|---|---|---|
| Was | „Rechnung für diese Leistungen an diesen Kunden" | Nummer vergeben, Beträge und Steuer rechnen, PDF erzeugen |
| Danach | zeigt an, was zurückkommt | verteilt: MySQL · Paperless · Mailversand |
### ⭐ Folge 1: `berechnung.py` zieht auf den Server
Heute steht dort ausdrücklich *„die einzige Stelle, an der gerechnet wird"*. Zentral gehört sie auf
den Server.
**Das löst nebenbei die offene Frage aus `AUFGABE_KUNDENSTAMM_UND_WEB.md`:** die zwei Steuerwelten
(deutsche Bruttopreise 7/19 % gegen polnische Nettopreise mit Reverse Charge). Weiß der Server,
welcher Betrieb anfragt, weiß er auch, wie zu rechnen ist. **Der Client muss davon nichts wissen —
und die Frage „beide Welten in einem Programm oder zwei Fassungen" erledigt sich.**
### ⭐ Die Aufgabenteilung im Einzelnen (Andreas, 06.09.2026)
> „das tool schickt nur die metadaten, der server rendert die pdf und das tool bekommt den link.
> oder halt die vorschau muss es selber machen — das auf dem server ist dumm. und paperless macht
> den rest: archivieren und senden an die mitgegebene e-mail, und speichert das unter der
> kundennummer."
| Wer | Was |
|---|---|
| **Tool** | schickt die Metadaten · zeichnet die **Vorschau lokal** · bekommt den Link zum fertigen PDF |
| **Server (API)** | vergibt die Nummer · rechnet · rendert das endgültige PDF · **verschickt per SMTP** · verteilt |
| **Paperless** | archiviert · verschlagwortet (Kundennummer als Korrespondent/Schlagwort) |
**Vorschau lokal ist richtig:** ein Netzweg pro Tastendruck wäre sofort träge.
### ⚠️ Die Regel, die dazugehört: EIN Zeichner, zwei Ausführungsorte
Zeichnet das Tool die Vorschau und der Server das endgültige Blatt, gibt es **zwei Zeichner — und
zwei Zeichner driften auseinander**. Genau davor schützt sich der heutige Code bereits:
`berechnung.py` wird von Oberfläche UND PDF benutzt, *„damit Bildschirm und Ausdruck garantiert
identisch sind"*.
Eine Vorschau, die nicht dem entspricht, was hinausgeht, ist **schlimmer als keine** — sie erzeugt
Vertrauen, das nicht gerechtfertigt ist. (Und die Vorschau war seine ausdrückliche Begründung für
das Ganze: *„sonst haben die den storno-aufwand"*.)
→ **`pdf_renderer.py` und `berechnung.py` bleiben EINE gemeinsame Bibliothek**, die an zwei Orten
ausgeführt wird. Kein zweiter Code.
### ⚠️ Korrektur: Paperless archiviert, es VERSCHICKT NICHT
Paperless-ngx nimmt Dokumente entgegen, erkennt Text, verschlagwortet und lagert ein. **Rechnungen
an Kunden mailen kann es nicht.** Der Versand per SMTP gehört auf den Server / in die API.
Die Ablage unter der Kundennummer geht dagegen sauber — Paperless kennt Korrespondenten und
Schlagworte, die Kundennummer passt in beides.
### ⛔ Verworfen: PDF auf dem Client zeichnen und hochladen
Andreas hatte es erwogen (*„ob das schlau ist, weiß ich nicht"*). **Nein**, aus einem Grund:
Zeichnet der Client den endgültigen Beleg, entscheidet der Client, was darauf steht — und der
Server archiviert blind, was ankommt. Er könnte nicht mehr garantieren, dass zur Nummer, die er
vergeben hat, auch das Blatt gehört, das er ablegt. Eine ältere oder veränderte Fassung des Tools
erzeugte Dokumente, die niemand prüft.
**Der Beleg entsteht dort, wo auch die Nummer entsteht.** Dazu käme: jede künftige Oberfläche
(Tablet, Browser) müsste den ganzen Renderer mitschleppen.
Die **Vorschau** bleibt lokal — sie ist kein Beleg: ohne Nummer, mit Wasserzeichen, wie heute.
### ⭐ Kundenstamm offline: ein ZWISCHENSPEICHER, kein Sync
Seine Frage: *„wenn ein kunde eine kundennummer bekommt, wie willst die wiederfinden? musst die api
auch abfragen dafür — heißt, ohne internet wird da eh nichts. sonst musst wieder alles lokal und
online speichern, um das schnell zu halten, und dann nur eine art rsync raufpacken."*
**Der Instinkt stimmt — nur ist es kein Sync, sondern ein Spiegel.** Der Unterschied ist keine
Wortklauberei:
| | bedeutet | Folge |
|---|---|---|
| **Sync** | beide Seiten dürfen ändern | braucht Konfliktauflösung — wer gewinnt bei doppelter Änderung? |
| **Spiegel** | nur der Server besitzt die Daten, der Client hält eine Lesekopie | **keine Konflikte**, nur ein Stand, der mal älter ist |
**Und die Datenmenge passt:** Kunden sind wenige und ändern sich selten — anders als Rechnungen.
Den ganzen Kundenstamm lokal zu halten kostet nichts und macht die Suche sofort schnell, auch ohne
Netz.
**Sein Wort dafür: „eine Art Zwischenspeicher"** — und das ist ehrlicher als „Spiegel", weil es
schon sagt, dass er veralten darf.
**Zwei Dinge braucht er, sonst wird er selbst zur Fehlerquelle:**
1. **Er muss sein Alter zeigen.** Klein in der Oberfläche: *„Kundendaten vom 06.09.2026, 15:40"*.
Sonst sucht jemand einen Kunden, findet ihn nicht, legt ihn neu an — dabei war er nur seit
gestern nicht heruntergeladen. **Ein Zwischenspeicher, dem man sein Alter nicht ansieht, wird
irgendwann für die Wahrheit gehalten.**
2. **Er darf nie Quelle für etwas sein, das geschrieben wird.** Lesen und Suchen: ja. Eine Nummer
daraus vergeben: nie. Aufgefrischt wird beim Start (wenn Netz da ist) und auf Knopfdruck.
**Dieselbe Regel wie bei den Rechnungsnummern:** Ein offline angelegter Kunde bekommt seine Nummer
**erst vom Server** — bis dahin ist er ein Entwurf. Sonst vergeben zwei Tablets dieselbe
Kundennummer.
### Folge 2: der Client wird schlank
Das Tablet beim Kunden braucht kein PDF-Werkzeug, keine Steuerlogik, keinen Nummernkreis. Ein Weg
hinein, mehrere hinaus.
### Der Preis
**Ohne Server geht nichts außer Entwürfen** (siehe Punkt 1). Bei FiveM ist das dasselbe — nur hängt
hier ein Beleg dran, den jemand zehn Jahre aufbewahren muss.
### Sicherheit, weil „Schlüssel zu allem" wörtlich zutrifft
Wenn ein Schlüssel alles öffnet, muss er: **je Installation** vergeben sein (nicht einer für alle),
**widerrufbar**, **nur über TLS** unterwegs, und die API muss **Missbrauch begrenzen**
(Rate-Limit). Sonst ist ein einziger abhandengekommener Schlüssel der Zugang zu allen Gästedaten.
## 5. Offene Entscheidungen
- Wer betreibt den Server, und für wen? (nur Bartl, oder mehrere Betriebe)
- Ein Mandant je Datenbank oder ein gemeinsames Schema mit Mandantenspalte?
- Was passiert mit dem lokalen Betrieb — bleibt SQLite als Offline-Fassung, oder wird alles zentral?
- Wie kommt der Bestand in die MySQL, ohne dass Nummern doppelt vergeben werden?
---
**Nichts davon beginnen, bevor die Punkte 1 bis 3 entschieden sind** — sie bestimmen den Aufbau,
nicht die Ausstattung.

129
AUFGABE_PREISVORSCHLAG.md Normal file
View file

@ -0,0 +1,129 @@
# Preisvorschlag beim Rechnungschreiben
**Stand 07.09.2026 — recherchiert, noch nicht gebaut.** Kein Auftrag zum Loslegen;
das hier hält fest, was herausgekommen ist, damit es nicht noch einmal
recherchiert werden muss.
## Woher der Wunsch kommt
Sein Freund (Handwerk) berichtet: *„wenn er ein heizungsventiel auf die rechnung
macht dann schlägt das tool ihm ein preis vor"*, Abweichung nur zwei bis drei
Euro. Zuerst hieß es, das sei MyHammer.
## Was es tatsächlich ist
**Es ist mit hoher Wahrscheinlichkeit nicht MyHammer.** Ein Heizungsventil ist
ein *Artikel* mit Artikelnummer, kein Gewerk. Schlägt eine Software dazu einen
Preis vor, kommt der aus der **Großhandelsanbindung** — in der SHK-Branche seit
Jahrzehnten Standard. MyHammer ist dann die App daneben für die Auftragssuche.
⚠️ **Offen:** Welches Programm hat der Freund beim Rechnungschreiben offen? Das
klärt die Frage in einem Satz. Danach fragen, bevor irgendetwas gebaut wird.
Die zwei bis drei Euro passen zu dieser Erklärung: das ist die Differenz zwischen
einem alten Preisstand und dem tagesaktuellen Großhandelspreis.
### Was MyHammer wirklich hat — und warum es nichts nützt
- **Keine öffentliche API.** Keine Entwicklerdokumentation. Was an Schnittstellen
existiert, sind Einzelanbindungen an CRM-Systeme für *Leads* (400–1.200 €
einmalig), nicht für Preise.
- Das öffentliche **Preisradar** (394 Ratgeberseiten) trägt am Fuß den Satz:
*„Es handelt sich um Schätzungen, die auf Marktbeobachtung und Preisen auf
MyHammer basieren. Sie wurden mit Hilfe von KI zusammengetragen."* Spannen wie
„Tapete entfernen: 3 bis 15 Euro pro m²". **Als Rechnungsgrundlage untauglich** —
man würde KI-Schätzungen abrechnen.
- Automatisierte Abrufe beantwortet die Seite mit **HTTP 403**.
**Ein Crawler ist damit dreifach erledigt:** der Inhalt taugt nicht, sie sperren
technisch, und die Übernahme wesentlicher Teile einer fremden Datenbank fällt
unter § 87b UrhG. Nicht weiterverfolgen.
## Der Weg, der funktioniert — zwei Stufen
### Stufe 1: DATANORM einlesen ← hier anfangen
Der Handwerker lädt bei **seinem** Großhändler die DATANORM-Datei herunter; die
bekommt jeder Kunde per Mail oder aus dem Shop. Textdateien mit festen Satzarten:
| Satz | Inhalt |
|---|---|
| A / B | Artikelstamm (Nummer, Kurztext, Preis, Einheit) |
| T | Langtext |
| P | Preisänderung auf bestehende Artikel |
| Z | Rabatt- und Zuschlagsstaffeln |
| D | Maße |
Einlesen in die SQLite, danach schlägt das Tool beim Tippen Artikelnummer, Text
und Preis vor.
**Warum das der erste Schritt ist:** Es läuft **offline** — passt zu dem, was für
die Tablets im Außendienst gedacht ist ([[AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL]]).
Es kostet nichts, braucht keinen Vertrag und keine Verbandsmitgliedschaft. Und die
Daten gehören dem Betrieb: sein Lieferant gibt sie ihm für seinen Gebrauch. Kein
Crawling, keine fremden Daten.
⚠️ **Einschränkung, ehrlich:** Die offizielle DATANORM-Spezifikation ist *nicht*
frei verfügbar. Die Satzstruktur ist aber in mehreren Softwarehandbüchern
öffentlich beschrieben (mohr-it, es2000, EULANDA) — das reicht zum Bauen, aber
man baut gegen eine Beschreibung, nicht gegen eine Norm. Also: gegen echte
Dateien mehrerer Großhändler prüfen, nicht gegen eine selbst gebaute Musterdatei
([[feedback_selbst_arrangierte_messung_beweist_nichts]]).
### Stufe 2: Open Masterdata für Tagespreise
Webservice, Abruf einzelner Artikel über **GTIN** oder **Hersteller + Artikelnummer**
oder Großhandelsnummer. Liefert Grunddaten, Preise, Logistikdaten, Texte,
Dokumente. Synchron für einzelne Artikel, asynchron für Listen. Nachfolger von
DATANORM, entwickelt von ITEK.
- Herausgeber ist ein Arbeitskreis aus **BVBS, DG Haustechnik, ZVSHK und VEG**,
jährliche Aktualisierung.
- **Die technische Doku liegt in einem geschlossenen Bereich** — für
Verbandsmitglieder kostenlos, für andere gegen Gebühr, Zugang über ein
Kontaktformular. Das ist die Hürde: hier müsste jemand Mitglied sein oder zahlen.
- Freischaltung läuft je Betrieb über **seinen** Großhändler; nicht jeder
Großhändler macht mit.
### Was ausdrücklich NICHT gebraucht wird
**Sirados, BKI, DBD** liefern Kalkulationsdaten für *Leistungen* („Heizkörper-
ventil austauschen: 0,5 Std. + Material"), nicht für Artikel. Kostenpflichtig,
Sirados je nach Paket **200 bis 2.000 €**, Übergabe über GAEB. Für die hier
beschriebene Aufgabe der falsche Hebel — das wäre ein eigenes Thema, wenn es
einmal um Angebotskalkulation statt um Rechnungsschreiben geht.
## Die zweite Hälfte: die eigene Historie
Material ist nur der eine Teil. Der andere ist die **Arbeitszeit**, und dafür
braucht es niemanden von außen: ein Betrieb rechnet immer wieder dieselben 20 bis
50 Leistungen ab, mit seinen Sätzen, in seiner Gegend. Diese Daten liegen bereits
im Nummernbuch.
Zimmer streichen 25 m² — zuletzt 385 € (12.08.),
Mittel der letzten 6: 392 €, Spanne 370–410 €
Das ist belastbarer als jede Fremdschätzung, kostet nichts, und niemand kann es
abdrehen. MyHammer mittelt über alle Betriebe in Deutschland — hier geht es um
*diesen* Betrieb.
**Zusammen ergibt das den ganzen Preisvorschlag:** Material aus DATANORM bzw. OMD
(fremde Daten, tagesaktuell) plus Arbeitszeit aus der eigenen Rechnungshistorie
(eigene Daten, betriebsgenau). Genau so machen es die großen Programme auch.
## Reihenfolge, wenn es losgeht
1. Den Freund fragen, welches Programm er benutzt — klärt die Ausgangsfrage.
2. Vorschlag aus der **eigenen Historie** (braucht keine fremde Quelle, die Daten
sind da). Kleinster Schritt mit sofortigem Nutzen.
3. **DATANORM-Leser**, geprüft gegen echte Dateien mehrerer Großhändler.
4. Erst danach überlegen, ob Open Masterdata den Zugang wert ist.
## Quellen
- MyHammer Preisradar: https://www.my-hammer.de/preisradar
- Open Masterdata (ZVSHK): https://www.open-datapool.de
- Open Masterdata (ITEK): https://itek.de/wissen/verzeichnis-branchenstandards/open-masterdata
- DATANORM-Satzarten: https://www.mohr-it.de/Hilfe/DatanormStudio/datanormstruktur.html
- SIRADOS: https://www.sirados.de/handwerker
- § 87b UrhG: https://www.gesetze-im-internet.de/urhg/__87b.html

View file

@ -0,0 +1,107 @@
# Rechnungstool: rechtssicher, gestaltbar, mit Vorschau
**Stand 05.09.2026 — in Arbeit.** Programm `C:\claude\rechnungstool`,
Repo `git.pcore.de/TheMockTv/rechnungstool` (Pre-Tag `pre/rechtssicher-bilder`).
Seine Ansage: *„wir bauen den rechnungs ersteller so um das der nun richtig
rechtlich sicher ist und die kopf und fuss zeile mit eigenen bildern import"* —
und im Lauf des Umbaus dazu: *„das die firmen das designen können"*,
*„eine vorschau butten wäre gut … sonst haben die den storno auf wand"*,
*„das mit rechnung an das amt kommt raus, das ist nicht § conform"*.
## 1. Bilder und Farbe gehören der Firma (fertig)
Bis jetzt war das Blatt auf EINEN Betrieb gebaut: Camping-Grün fest im Code,
das Logo ein Pfad in der config.
- **`bilder.py`** (neu): ein gewähltes Bild wird in den Ordner `bilder/` neben
die `config.json` **kopiert**, gemerkt wird nur `bilder/logo.png`.
Der Grund: ein Pfad auf den Desktop ist tot, sobald dort jemand aufräumt —
und das fällt erst beim Drucken auf. Gleicher Name, anderes Bild → `-2`,
nie überschreiben.
- **Kopfbild** (Logo, oben links) und **Fußbild** (schmaler Streifen unten)
sind austauschbar. Das Layout bleibt, wie es war.
- **Firmenname, Anschrift, Bank, Steuernummer bleiben gezeichnet.** Kein Bild
kann sie ersetzen — die Steuernummer ist Pflichtangabe
(§ 14 Abs. 4 Nr. 2 UStG), und was in einem Bild steht, kann niemand prüfen.
(Ein Haken „Angaben stehen im Bild" war gebaut und ist auf seine Ansage hin
wieder rausgeflogen.)
- **Farbe der Rechnung**: EIN Wert (`design.akzent`), Hex-Feld **und**
Farbwähler in den Einstellungen. Die helle Fläche im Tabellenkopf wird daraus
gemischt — niemand muss zwei Farben aufeinander abstimmen. Eine unlesbare
Farbe fällt still auf die Vorgabe zurück, statt das Schreiben der Rechnung zu
verhindern.
Prüfstand: **`pruef_bilder.py`**.
## 2. Vorschau-Knopf (fertig)
Seine Begründung: *„sonst haben die den storno auf wand"* — eine ausgestellte
Rechnung lässt sich nur noch über Storno oder Berichtigung geradeziehen.
Der Knopf zeigt das fertige Blatt, **bevor** es eine Rechnung wird:
- verbraucht **keine** Rechnungsnummer, trägt nichts ins Nummernbuch,
- landet **nicht** im Rechnungsordner (Temp-Ordner),
- trägt quer über jeder Seite **„VORSCHAU – keine Rechnung"**,
- prüft **keine** Pflichtfelder — man will ja gerade sehen, was noch fehlt.
Prüfstand: **`pruef_vorschau.py`** (u. a.: die echte Rechnung danach trägt die
Marke NICHT).
## 3. Das Blatt „für das Amt" ist entfallen
Bis 05.09.2026 hing an jeder Rechnung mit Beherbergungssteuer ein zweites Blatt
mit **derselben Rechnungsnummer**, aber ohne die Steuer — also mit einem
**anderen Gesamtbetrag**.
Seine Ansage: *„das mit rechnung an das amt kommt raus … wenn so was in den
büchern von einem unternehmen drin ist, dann ist das ein genickbruch, wenn der
prüfer kommt"*. Vorgeschichte: Bartl wollte es so, weil es sonst seine
Steuerberaterin durcheinanderbringe; der Widerspruch stand schon damals.
Nachgeschlagen: mehrere Blätter zum selben Umsatz mit **gleicher** Nummer sind
für sich noch kein § 14c-Fall — aber unterschiedliche Endbeträge erwecken den
Eindruck, es werde über **verschiedene Umsätze** abgerechnet. Genau das fällt
bei einer Prüfung auf.
**Was an die Stelle tritt:** nichts im Rechnungstool. Das Steuerjournal liest
die Kenndaten (`/Subject`) der PDF und rechnet die Bemessungsgrundlage selbst
aus; ans Amt geht eine **Summenanmeldung**, keine Rechnungskopie mit Gastnamen.
Vorher geprüft: kein Programm liest die zweite Seite — `pruef_montage.py`
belegt weiter, dass das Journal die Datei einliest.
Mit dem Blatt entfielen: `_AmtMarke`, der Parameter `gesamtrechnung_betont` und
der doppelte Renderlauf (der existierte nur, um zu wissen, auf welcher Seite das
Amt-Blatt beginnt). Der Renderer ist dadurch deutlich kürzer.
## 4. Prüfstände nehmen keine Maus mehr
Sein Fund mitten im Lauf: *„pack doch die maus nicht immer auf das programm,
weil ich kann dir sonst nichts schreiben"*.
- `config.TESTLAUF` steht an **einer** Stelle (`--test` / `CAMPINGHOF_TEST=1`).
- Hauptfenster und **jeder** Dialog werden im Prüflauf durchsichtig gemacht und
aus dem Bild geschoben, und `grab_set` fällt weg.
- ⚠️ **Nicht `withdraw()`**: damit baut ein Fenster seine Dialoge nicht mehr
richtig auf — vier Prüfstände wurden davon rot und hätten eine Bedienung
geprüft, die es so nicht gibt.
- **`pruefsteuerung.py`** (neu): Meldungen wandern ins Protokoll, Ja/Nein-Fragen
in einen Fahrplan (`antwortet("Hausnummer", True)`). Eine **unangemeldete**
Frage bricht den Lauf ab — stillschweigend „ja" zu sagen würde genau die Fälle
übergehen, wegen denen es die Prüfung gibt.
## Offen
- **Betriebsart abfragen** (Campingplatz / Handwerker / Unternehmen) — sein
Zielbau: *„nicht jeder braucht das so wie der camping typ"*. Handwerker und
Unternehmen brauchen **freie Rechnungszeilen** statt des festen Katalogs; das
ist ein neuer Eingabemodus, kein Schalter.
- **§ 14-Lücke Leistungszeitpunkt**: ohne An-/Abreise (z. B. nur Gasfüllung)
steht heute kein Leistungsdatum auf dem Blatt — § 14 Abs. 4 Nr. 6 UStG.
- **Zahlungsziel** einstellbar.
- **E-Rechnung (ZUGFeRD)**: Pflicht ab 01.01.2028 für B2B — trifft die
Montage-Rechnung. Bibliothek `factur-x` ist geprüft und kann XML erzeugen
(Profil EN 16931), prüfen (XSD + Schematron) und einbetten (PDF/A-3).
- **GoBD**: PDFs nach dem Schreiben schreibgeschützt; Aufbewahrung seit BEG IV
8 statt 10 Jahre (§ 147 Abs. 3 AO).

48
Diagnose.spec Normal file
View file

@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
# Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
# aus als hier.
datas=[('assets/programm.ico', 'assets'),
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
('assets/fonts', 'assets/fonts'),
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe
# nur ihren Text, aber der Anordnen-Griff fehlt.
('assets/icons', 'assets/icons')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Diagnose',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
icon='assets/programm.ico',
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

127
Installer.iss Normal file
View file

@ -0,0 +1,127 @@
; Installer fuer das Rechnungstool (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 "Rechnungstool"
#define Herausgeber "ravokk"
#define ExeUpdater "Updater.exe"
#define Webseite "https://git.pcore.de/TheMockTv/rechnungstool"
[Setup]
AppId={{7F3A9C2E-51D4-4B86-9A17-2C0E8B4D6A31}
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=Rechnungstool-Setup-{#Version}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; Das Programmsymbol - auch fuer den Installer selbst und den
; Eintrag unter "Apps & Features".
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,5 +1,30 @@
# Rechnungstool Campinghof Bartl
> ### 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.
Desktop-Programm (Python/Tkinter), das die alte Excel-Rechnungsvorlage ersetzt:
Kundendaten + Anzahl eingeben → druckfertige **PDF**-Rechnung im Campinghof-Layout.

48
Rechnungstool.spec Normal file
View file

@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
# Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
# aus als hier.
datas=[('assets/programm.ico', 'assets'),
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
('assets/fonts', 'assets/fonts'),
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe
# nur ihren Text, aber der Anordnen-Griff fehlt.
('assets/icons', 'assets/icons')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Rechnungstool',
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',
)

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()

993
app.py

File diff suppressed because it is too large Load diff

Binary file not shown.

BIN
assets/fonts/Roboto.ttf Normal file

Binary file not shown.

BIN
assets/icons/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

BIN
assets/icons/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

BIN
assets/icons/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

BIN
assets/programm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
assets/programm_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

86
assets/programm_logo.svg Normal file
View file

@ -0,0 +1,86 @@
<?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;">
<rect id="Artboard1" x="0" y="0" width="512" height="512" style="fill:none;"/>
<clipPath id="_clip1">
<rect id="Artboard11" serif:id="Artboard1" x="0" y="0" width="512" height="512"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(1.71181,0,0,1.71181,-182.326,-182.373)">
<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);"/>
<g id="Rechts">
<g transform="matrix(1.08045,0,0,1.08946,-26.4473,-31.9847)">
<path d="M311.686,357.525L361.495,357.613L311.693,401.686L311.686,357.525Z" style="fill:none;stroke:rgb(38,38,40);stroke-width:4.49px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:1;"/>
</g>
<g transform="matrix(0.681839,0,0,1.4522,106.655,-49.5232)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(0.556676,0,0,1.50959,118.792,-61.5356)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(0.676877,0,0,2.05605,46.3212,-170.669)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(4.48954e-17,0.733197,-1.08299,6.63138e-17,560.556,13.6836)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(3.08855e-17,0.504399,-1.08299,6.63138e-17,560.556,115.222)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(3.13346e-17,0.511732,-1.08299,6.63138e-17,560.556,134.957)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(3.09753e-17,0.505865,-1.08299,6.63138e-17,560.556,159.477)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(3.09753e-17,0.505865,-1.08299,6.63138e-17,560.556,181.958)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(3.71495e-17,0.606698,-0.691771,4.23587e-17,450.899,169.989)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<g transform="matrix(6.01625e-17,0.982528,-0.691771,4.23587e-17,450.899,63.3131)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
</g>
<path d="M357.833,218.242L357.84,256L358.068,366.277L366.192,359.225L366.228,208.007C364.834,210.529 359.069,217.267 357.833,218.242Z" style="fill:rgb(38,38,40);"/>
<g transform="matrix(-0.988012,0,0,1,508.931,0)">
<path d="M166.719,248.342L233.736,247.827C241.376,246.629 246.996,242.211 248.7,233.593L249.007,178.945L236.779,178.962L256,154.176L256,140.213L219.977,187.353L240.538,187.306L240.516,232.777C239.978,235.67 236.567,240.101 232.286,239.877L166.716,240.019L166.719,248.342Z" style="fill:rgb(38,38,40);"/>
</g>
</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 id="Links">
<g transform="matrix(1,0,0,-1,-211.587,578.778)">
<path d="M357.519,173.141L357.84,256L357.465,370.771L365.776,360.268L365.738,173.141L357.519,173.141Z" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(6.01625e-17,0.982528,-1.28255,7.85334e-17,513.134,63.3131)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(3.71495e-17,0.606698,-1.12029,6.8598e-17,480.729,169.965)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(3.09753e-17,0.505865,-1.12029,6.8598e-17,480.729,181.968)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(-1.06695e-18,0.505865,-1.12029,-1.08738e-16,480.729,159.483)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(3.13346e-17,0.511732,-1.12032,6.85998e-17,480.738,135.022)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(3.08855e-17,0.504399,-1.12032,6.85998e-17,480.738,115.184)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(4.48954e-17,0.733197,-1.12032,6.85998e-17,480.738,13.6631)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(-0.733197,8.97907e-17,-2.14404e-16,-1.75074,422.168,730.675)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<g transform="matrix(-0.733197,8.97907e-17,-2.14404e-16,-1.75074,441.103,730.675)">
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
</g>
<path d="M166.719,248.342L233.736,247.827C241.376,246.629 246.996,242.211 248.7,233.593L249.007,178.945L236.779,178.962L256,154.176L256,140.213L219.977,187.353L240.538,187.306L240.516,232.777C239.978,235.67 236.567,240.101 232.286,239.877L166.777,240.019L166.719,248.342Z" style="fill:rgb(14,79,101);"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 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()

117
bilder.py Normal file
View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""Bilder fuer Kopf- und Fusszeile der Rechnung: ablegen, wiederfinden, laden.
Warum eine eigene Datei: Kopfbild und Fussbild brauchen dieselben drei Schritte
(uebernehmen -> aufloesen -> laden), und der PDF-Renderer, der Einstellungsdialog
und die Pruefstaende brauchen sie alle. Stuende die Logik im Renderer, muesste der
Dialog sie nachbauen - und beim naechsten Bild laufen die beiden auseinander.
⭐ Der wichtigste Teil ist `uebernehmen`: das gewaehlte Bild wird in den Ordner
`bilder/` neben der config.json KOPIERT, statt nur seinen Pfad zu merken. Sonst
zeigt die Einstellung auf den Desktop des Nutzers - und sobald er dort aufraeumt,
faellt das Logo still von der Rechnung. Gemerkt wird nur der kurze Name
("bilder/logo.png"), damit die Einstellung auch nach einem Umzug des Ordners passt.
"""
import os
import shutil
import config
# Erlaubte Formate. reportlab liest PNG/JPG/GIF direkt; SVG kann es nicht -
# deshalb steht es hier bewusst NICHT.
FORMATE = (".png", ".jpg", ".jpeg", ".gif")
ORDNER = "bilder"
def bilder_dir():
"""Ordner fuer die uebernommenen Bilder (neben der config.json). Wird angelegt."""
d = os.path.join(config.BASE_DIR, ORDNER)
os.makedirs(d, exist_ok=True)
return d
def uebernehmen(quelle):
"""Bild in den Bilderordner kopieren -> gibt den zu speichernden Pfad zurueck.
Liegt die Datei schon im Bilderordner, wird nichts kopiert. Ein Name, der
bereits vergeben ist, bekommt eine Nummer angehaengt - ein bestehendes Bild
wird NIE ueberschrieben, sonst waere das alte Logo mit einem Klick weg.
"""
quelle = (quelle or "").strip()
if not quelle:
return ""
if not os.path.exists(quelle):
print(f"[bilder] Quelle gibt es nicht: {quelle}")
return ""
ziel_dir = bilder_dir()
name = os.path.basename(quelle)
if os.path.dirname(os.path.abspath(quelle)) == os.path.abspath(ziel_dir):
return f"{ORDNER}/{name}"
stamm, endung = os.path.splitext(name)
ziel = os.path.join(ziel_dir, name)
n = 2
while os.path.exists(ziel) and not _gleich(quelle, ziel):
ziel = os.path.join(ziel_dir, f"{stamm}-{n}{endung}")
n += 1
try:
shutil.copyfile(quelle, ziel)
print(f"[bilder] uebernommen: {quelle} -> {ziel}")
except OSError as e:
print(f"[bilder] FEHLER beim Kopieren ({e}) - merke den Originalpfad")
return quelle
return f"{ORDNER}/{os.path.basename(ziel)}"
def _gleich(a, b):
"""Zwei Dateien byte-gleich? Verhindert 'logo-2.png' beim zweiten Waehlen desselben Bilds."""
try:
if os.path.getsize(a) != os.path.getsize(b):
return False
with open(a, "rb") as fa, open(b, "rb") as fb:
return fa.read() == fb.read()
except OSError:
return False
def aufloesen(pfad):
"""Gespeicherten Pfad -> existierender absoluter Pfad, sonst "".
Ein relativer Pfad wird erst neben dem Programm gesucht (BASE_DIR, dort liegt
der Bilderordner), dann im gebuendelten Ressourcenordner der .exe - so findet
die exe ihr mitgeliefertes Standardlogo, der Nutzer aber sein eigenes.
"""
pfad = (pfad or "").strip()
if not pfad:
return ""
if os.path.isabs(pfad):
return pfad if os.path.exists(pfad) else ""
for basis in (config.BASE_DIR, config.RESOURCE_DIR):
kandidat = os.path.join(basis, pfad)
if os.path.exists(kandidat):
return kandidat
return ""
def laden(pfad):
"""(ImageReader, breite, hoehe) oder (None, 0, 0). Meldet, warum es nicht ging."""
voll = aufloesen(pfad)
if not voll:
if (pfad or "").strip():
print(f"[bilder] nicht gefunden: {pfad}")
return None, 0, 0
try:
from reportlab.lib.utils import ImageReader
img = ImageReader(voll)
iw, ih = img.getSize()
return img, iw, ih
except Exception as e: # noqa: BLE001
print(f"[bilder] '{voll}' laesst sich nicht lesen: {e}")
return None, 0, 0
def einpassen(iw, ih, box_w, box_h):
"""Groesse im Kasten, Seitenverhaeltnis bleibt. Nie groesser als das Original."""
if not iw or not ih:
return 0, 0
skala = min(box_w / iw, box_h / ih)
return iw * skala, ih * skala

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Konfiguration fuer das Rechnungstool Campinghof Bartl.
Konfiguration fuer das Rechnungstool (powered by ravokk).
Haelt die Standardwerte (Firmendaten, Steuersaetze, Leistungskatalog,
Rechnungsnummer-Zaehler) und kuemmert sich um Laden/Speichern der config.json.
@ -42,9 +42,15 @@ def _daten_ordner():
Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf
einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern.
1. neben der EXE ein Unterordner "Daten"
2. geht das nicht (EXE liegt z.B. unter "Programme"), dann
der Ordner Rechnungstool unter LOCALAPPDATA
1. der Ordner der Firma: %LOCALAPPDATA%/ravokk
2. geht das nicht, neben der EXE ein Unterordner "Daten"
3. und zuletzt der alte eigene Ordner unter LOCALAPPDATA
Warum unter "ravokk" (07.09.2026): dort liegen beide Programme und das
gemeinsame Nummernbuch beieinander - seine Ansage, *"ravokk ist meine
firma und die programme gehoeren zu der firma"*. Und weil die Daten damit
NEBEN dem Programmordner liegen statt darin, raeumt ein Deinstallieren nur
das Programm weg und laesst die Belege stehen (§ 147 AO).
Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien
noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt
@ -52,25 +58,68 @@ def _daten_ordner():
"""
if not getattr(sys, "frozen", False):
return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist
kandidat = os.path.join(EXE_DIR, "Daten")
import gemeinsam
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
# NEBEN dem Programm, nicht darin: das Programm liegt in
# ravokk/Rechnungstool, die Daten eine Ebene darueber in ravokk. Genau so
# hat er es beschrieben - "da liegt config und sqlite drin, und dann gibt
# es den ordner rechnungstool und steuertool". Nur dadurch kann ein
# Deinstallieren den Programmordner restlos leeren, ohne die Belege
# anzufassen (§ 147 AO).
# Gerechnet wird der Pfad nur an EINER Stelle - siehe dort.
kandidat = gemeinsam.standard_ordner()
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool")
kandidat = os.path.join(EXE_DIR, "Daten")
if not _beschreibbar(kandidat):
return EXE_DIR # letzter Ausweg: wie frueher
kandidat = os.path.join(heim, "Rechnungstool")
if not _beschreibbar(kandidat):
return EXE_DIR # letzter Ausweg: wie frueher
# Was an einem frueheren Ort liegt, zieht mit um - sonst faengt das
# Programm ohne Firmendaten, Katalog und Zaehler neu an.
for name in ("config.json",):
alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)
if os.path.exists(alt) and not os.path.exists(neu_):
try:
os.replace(alt, neu_)
print(f"[config] {name} in den Datenordner übernommen")
except OSError:
pass
neu_ = os.path.join(kandidat, name)
for alt in (os.path.join(EXE_DIR, "Daten", name),
os.path.join(EXE_DIR, name),
os.path.join(heim, "Rechnungstool", name)):
if os.path.exists(alt) and not os.path.exists(neu_):
try:
os.replace(alt, neu_)
print(f"[config] {name} aus {alt} übernommen")
break
except OSError as e:
# Nicht abbrechen - der naechste Ort wird noch probiert,
# und zur Not faengt das Programm eben neu an. Aber sagen,
# dass es schiefging: der Erfolgsfall schreibt eine Zeile,
# und wer sie sucht und nicht findet, wuesste sonst nicht,
# ob nichts da war oder ob die Uebernahme misslang. Es geht
# um Firmendaten, Leistungskatalog und Rechnungszaehler.
print(f"[config] {name} aus {alt} NICHT übernommen: {e}")
return kandidat
# Das Programm heisst Rechnungstool und kommt von ravokk. Der Firmenname des
# KUNDEN gehoert nicht in den Fenstertitel - das Werkzeug steht bei jedem
# Betrieb, der es einsetzt, und traegt seinen eigenen Namen.
PROGRAMM = "Rechnungstool"
MARKE = "powered by ravokk"
BASE_DIR = _daten_ordner()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es
# aber nicht: kein Fenster mitten auf dem Bildschirm, kein Dialog, der Maus und
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
#
# ⚠️ Das ist eine FUNKTION, keine Konstante. Ein Pruefstand importiert config
# haeufig, bevor er CAMPINGHOF_TEST setzt - ein einmal berechneter Wert stuende
# dann fuer den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge,
# auf die niemand klickt. Genau daran hing pruef_api eine Runde lang.
def testlauf():
return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
TESTLAUF = testlauf() # Startwert; wer spaeter fragt, nimmt testlauf()
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)
@ -124,6 +173,11 @@ DEFAULT_FIRMA = {
"email": "E-Mail kontakt@example.de",
"telefon": "Telefon +49 (0) 000 0000000",
"logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner)
# Bild fuer die Fusszeile: ein schmaler Streifen UNTER den Fusstexten. Firma,
# Anschrift, Bank und Steuernummer bleiben immer gezeichnet - sie kommen aus
# den Einstellungen und duerfen nicht in einem Bild verschwinden, das niemand
# nachlesen kann. Leer = die Rechnung sieht aus wie bisher.
"fussbild": "",
}
# Festtexte unten auf der Rechnung (aus der Vorlage).
@ -135,8 +189,16 @@ DEFAULT_TEXTE = {
"hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *",
}
# Aussehen der Rechnung. EINE Farbe - die helle Flaeche im Tabellenkopf wird im
# Renderer daraus gemischt. Jede Firma faerbt ihr Blatt selbst; ein Handwerker
# will kein Camping-Gruen.
DEFAULT_DESIGN = {
"akzent": "#1f5c3d",
}
DEFAULT_CONFIG = {
"firma": DEFAULT_FIRMA,
"design": DEFAULT_DESIGN,
"texte": DEFAULT_TEXTE,
"katalog": DEFAULT_KATALOG,
"montage": DEFAULT_MONTAGE,

View file

@ -8,9 +8,12 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinter import ttk, messagebox, filedialog, colorchooser
import berechnung
import hinweis
import theme
import bilder
import config
from helfer import parse_num
@ -22,7 +25,7 @@ class EinstellungenMixin:
if (self.cfg.get("output_dir") or "").strip():
return
vorschlag = config.default_output_dir()
messagebox.showinfo(
self.melden_info(
"Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
@ -39,7 +42,7 @@ class EinstellungenMixin:
self.cfg["output_dir"] = d
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
self.melden_info("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
def _info(self):
d = tk.Toplevel(self)
@ -49,13 +52,24 @@ class EinstellungenMixin:
self._style_dialog(d)
frm = ttk.Frame(d, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
ttk.Label(frm, text=config.PROGRAMM, font=(theme.SCHRIFT_NAME, 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(frm, justify="left",
text="Daten eingeben, PDF erstellen, drucken.\n"
"Steuersätze, Preise und Firmendaten lassen sich\n"
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
# Der Erprobungshinweis steht hier dauerhaft - mit der Signaturzeile
# darunter. Sonst waere die Kenntnisnahme ein Klick, den spaeter
# niemand mehr nachlesen kann.
ttk.Separator(frm, orient="horizontal").pack(fill="x", pady=(4, 10))
ttk.Label(frm, justify="left", wraplength=430,
text=hinweis.text("rechnungstool")).pack(anchor="w")
_sig = hinweis.signatur(self.cfg)
if _sig:
ttk.Label(frm, justify="left", wraplength=430, foreground=self.col_accent,
text=_sig).pack(anchor="w", pady=(10, 0))
ttk.Separator(frm, orient="horizontal").pack(fill="x", pady=(10, 10))
ttk.Label(frm, text=config.MARKE, foreground=self.col_muted).pack(anchor="w")
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
d.bind("<Escape>", lambda e: d.destroy())
d.bind("<Return>", lambda e: d.destroy())
@ -73,7 +87,7 @@ class EinstellungenMixin:
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"),
ttk.Label(frm, wraplength=580, justify="left", font=(theme.SCHRIFT_NAME, 9, "bold"),
text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses "
"Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei "
"'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den "
@ -91,7 +105,7 @@ class EinstellungenMixin:
for w in liste.winfo_children():
w.destroy()
for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]):
ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid(
ttk.Label(liste, text=t, font=(theme.SCHRIFT_NAME, 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 3), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2)
@ -119,7 +133,7 @@ class EinstellungenMixin:
"bezeichnung": row["bez"].get().strip() or "USt",
"extra_blatt": bool(row["extra"].get())})
if not neu:
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
self.melden_warnung("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
return
self.cfg["steuersaetze"] = neu
config.save_config(self.cfg)
@ -175,11 +189,11 @@ class EinstellungenMixin:
name = v_leistung.get().strip()
preis = parse_num(v_preis.get())
if not name:
messagebox.showwarning("Bezeichnung fehlt",
self.melden_warnung("Bezeichnung fehlt",
"Bitte eine Bezeichnung eintragen.", parent=d)
return
if preis <= 0:
messagebox.showwarning("Preis fehlt",
self.melden_warnung("Preis fehlt",
"Bitte einen Monatspreis groesser als 0 eintragen.",
parent=d)
return
@ -246,7 +260,7 @@ class EinstellungenMixin:
for w in inner.winfo_children():
w.destroy()
for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]):
ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid(
ttk.Label(inner, text=t, font=(theme.SCHRIFT_NAME, 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 4), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1)
@ -287,7 +301,7 @@ class EinstellungenMixin:
"pro_person": bool(row["pro_person"].get()),
})
if not neu:
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
self.melden_warnung("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
return
self.cfg["katalog"] = neu
config.save_config(self.cfg)
@ -333,7 +347,10 @@ class EinstellungenMixin:
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("640x640")
breite = min(900, max(700, d.winfo_screenwidth() - 200))
hoehe = min(760, max(560, d.winfo_screenheight() - 140))
d.geometry(f"{breite}x{hoehe}")
d.minsize(700, 560)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
@ -348,53 +365,105 @@ class EinstellungenMixin:
for i, (key, lbl) in enumerate(felder):
ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Entry(frm, textvariable=v, width=44).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
vars_[key] = v
frm.columnconfigure(1, weight=1)
base = len(felder)
# Logo (Bilddatei) mit Durchsuchen / Entfernen
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
logo_row = ttk.Frame(frm)
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
# Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten).
# Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen -
# sonst laufen sie beim naechsten Umbau auseinander.
bild_vars = {}
def logo_waehlen():
p = filedialog.askopenfilename(
title="Logo auswählen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
if p:
v_logo.set(p)
def bild_zeile(zeile, key, beschriftung):
ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2)
kasten = ttk.Frame(frm)
kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2)
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
reihe = ttk.Frame(kasten)
reihe.pack(fill="x")
ttk.Entry(reihe, textvariable=v).pack(side="left", fill="x", expand=True)
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
def waehlen():
p = filedialog.askopenfilename(
title=beschriftung + " auswählen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")],
parent=d)
if p:
# Das Bild wird in den Ordner "bilder" neben das Programm
# KOPIERT. Der Pfad zum Desktop waere morgen tot - und das
# faellt erst beim Drucken auf.
v.set(bilder.uebernehmen(p))
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
ttk.Button(reihe, text="Durchsuchen…", command=waehlen).pack(side="left", padx=(6, 0))
ttk.Button(reihe, text="Entfernen", command=lambda: v.set("")).pack(side="left", padx=(4, 0))
bild_vars[key] = v
# Ausgetauscht wird nur das BILD. Firmenname, Anschrift, Bank und
# Steuernummer stehen weiter gezeichnet auf dem Blatt - sie kommen aus den
# Feldern darueber und duerfen in keinem Bild verschwinden.
bild_zeile(base, "logo", "Bild oben (Logo)")
bild_zeile(base + 1, "fussbild", "Bild unten (Streifen)")
# Farbe der Rechnung: Ueberschriften, Linie unter dem Kopf, Tabellenkopf.
# EINE Farbe - die helle Flaeche wird im Renderer daraus gemischt.
ttk.Label(frm, text="Farbe der Rechnung").grid(row=base + 2, column=0, sticky="w", pady=2)
farb_reihe = ttk.Frame(frm)
farb_reihe.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
v_farbe = tk.StringVar(value=(self.cfg.get("design", {}) or {}).get(
"akzent", config.DEFAULT_DESIGN["akzent"]))
probe = tk.Label(farb_reihe, width=6, relief="solid", borderwidth=1)
def probe_zeigen(*_):
wert = v_farbe.get().strip()
try:
probe.configure(background=wert)
except tk.TclError:
# Unlesbare Farbe: das Feld bleibt grau, gespeichert wird sie nicht.
probe.configure(background=self.col_field)
def farbe_waehlen():
_, hexwert = colorchooser.askcolor(color=v_farbe.get().strip() or None,
title="Farbe der Rechnung", parent=d)
if hexwert:
v_farbe.set(hexwert)
probe.pack(side="left")
ttk.Entry(farb_reihe, textvariable=v_farbe, width=12).pack(side="left", padx=(6, 0))
ttk.Button(farb_reihe, text="Wählen…", command=farbe_waehlen).pack(side="left", padx=(6, 0))
ttk.Button(farb_reihe, text="Zurücksetzen",
command=lambda: v_farbe.set(config.DEFAULT_DESIGN["akzent"])).pack(side="left", padx=(4, 0))
v_farbe.trace_add("write", probe_zeigen)
probe_zeigen()
ttk.Label(frm, text="Danktext 1").grid(row=base + 3, column=0, sticky="nw", pady=2)
t1 = tk.Text(frm, width=44, height=3, wrap="word", **self.feld_optionen(mehrzeilig=True))
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t1.grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Danktext 2").grid(row=base + 4, column=0, sticky="nw", pady=2)
t2 = tk.Text(frm, width=44, height=3, wrap="word", **self.feld_optionen(mehrzeilig=True))
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 5, column=0, sticky="w", pady=2)
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Entry(frm, textvariable=vh, width=44).grid(row=base + 5, column=1, sticky="ew", padx=(8, 0), pady=2)
def speichern():
for key, v in vars_.items():
self.cfg["firma"][key] = v.get()
self.cfg["firma"]["logo"] = v_logo.get().strip()
for key, v in bild_vars.items():
wert = v.get()
self.cfg["firma"][key] = wert.strip() if isinstance(wert, str) else bool(wert)
self.cfg.setdefault("design", {})["akzent"] = v_farbe.get().strip() or config.DEFAULT_DESIGN["akzent"]
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
self.cfg["texte"]["hinweis"] = vh.get().strip()
config.save_config(self.cfg)
self.title("Rechnungstool - " + self.cfg["firma"].get("name", ""))
self.title(config.PROGRAMM)
d.destroy()
btns = ttk.Frame(frm)
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
btns.grid(row=base + 6, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
@ -407,7 +476,7 @@ class EinstellungenMixin:
jahr = self.cfg.get("rechnung_jahr")
zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0)
hoch = self.hoechster_stand(jahr)
messagebox.showinfo(
self.melden_info(
"Rechnungsnummer",
f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n"
f"Die nächste Rechnung bekommt {jahr}-{max(zaehler, hoch) + 1:03d}.\n\n"
@ -454,7 +523,7 @@ class EinstellungenMixin:
# (§ 14 Abs. 4 Nr. 4 UStG).
hoch = self.hoechster_stand(jahr)
if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch:
messagebox.showwarning(
self.melden_warnung(
"Nummer schon vergeben",
f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n"
f"Der Zähler kann nicht auf {zaehler} zurückgesetzt werden – sonst "

View file

@ -41,6 +41,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."""

308
flaeche.py Normal file
View file

@ -0,0 +1,308 @@
# -*- coding: utf-8 -*-
"""Arbeitsflaeche: die Kaesten des Fensters lassen sich verschieben.
Seine Ansage: *"rechnungsdaten oben links und darunter rechnung an kunde und den
tabellen kasten rechts - oder noch besser dnd einbauen, das man die kaesten
selber schieben kann"*. Beides ist hier drin: die genannte Anordnung ist die
Vorgabe, und wer sie anders haben will, zieht die Kaesten um.
WIE ES FUNKTIONIERT
-------------------
Wie bei OPNsense: normalerweise sitzt alles fest. Ein kleiner Knopf oben rechts
schaltet das ANORDNEN ein - dann zeigen die Kaesten einen Griff, lassen sich
ziehen, und daneben stehen "Speichern" und "Verwerfen". Erst mit Speichern
steht die neue Anordnung; Verwerfen holt die alte zurueck.
So kann beim taeglichen Arbeiten nichts verrutschen - man muss das Verschieben
erst absichtlich einschalten.
Die Flaeche ist ein Raster aus zwei Spalten. Jeder Kasten sitzt in einer Zelle
und kann sich ueber beide Zeilen strecken. Gezogen wird am KOPF des Kastens;
der Kasten unter dem Zeiger wird hervorgehoben, beim Loslassen tauschen die
beiden ihre Plaetze.
Getauscht wird, nicht "irgendwo abgelegt": ein freies Verschieben auf Pixel
braucht Regeln fuer Ueberlappung, Groesse und leere Zellen - und am Ende steht
das Fenster schief. Ein Tausch ist in einem Griff erledigt und kann nichts
kaputt machen.
Die Anordnung steht in der config (`fenster_layout`) und ueberlebt den Neustart.
"""
import tkinter as tk
from tkinter import ttk
import config
# Vorgabe: seine Anordnung. Je Kasten (Spalte, Zeile, Zeilen hoch).
STANDARD = {
"daten": (0, 0, 1),
"kunde": (0, 1, 1),
"leistungen": (1, 0, 2),
}
class Arbeitsflaeche(ttk.Frame):
# Wie breit die beiden Spalten mindestens sein muessen. Die Formularseite
# ist gemessen, nicht geschaetzt: das breiteste, was dort steht, ist die
# Beschriftung "Nachname / Firma *" neben einem 22 Zeichen breiten Feld.
MIN_FORMULAR = 330
MIN_LISTE = 520
def __init__(self, master, farben=None, beim_umbauen=None):
super().__init__(master)
self.f = farben if farben is not None else master
self.beim_umbauen = beim_umbauen # wird nach jedem Tausch gerufen
self.kaesten = {} # name -> dict(rahmen, kopf, titel)
self.layout = {}
self._vorher = {} # Stand vor dem Anordnen
self.anordnen_an = False # Verschieben eingeschaltet?
self._zieht = None
self._ziel = None
# Die Gewichte setzt `anordnen()` - sie haengen davon ab, WO die lange
# Liste gerade steht.
# ---- Aufbau -----------------------------------------------------------
def kasten(self, name, titel):
"""Legt einen Kasten an und gibt seinen Inhaltsrahmen zurueck."""
aussen = ttk.Frame(self, style="Karte.TFrame")
kopf = tk.Label(aussen, text=titel.upper(), anchor="w", cursor="fleur",
bg=self.f.col_card, fg=self.f.col_accent,
font=(self._schrift(), 9, "bold"), padx=12, pady=8)
kopf.pack(side="top", fill="x")
inhalt = ttk.Frame(aussen, style="Karte.TFrame", padding=(12, 0, 12, 12))
inhalt.pack(side="top", fill="both", expand=True)
kopf.bind("<Button-1>", lambda e, n=name: self._greifen(n))
kopf.bind("<B1-Motion>", lambda e, n=name: self._ziehen(e, n))
kopf.bind("<ButtonRelease-1>", lambda e, n=name: self._loslassen(n))
self.kaesten[name] = {"aussen": aussen, "kopf": kopf, "inhalt": inhalt,
"titel": titel}
return inhalt
def anordnen(self, layout=None):
"""Kaesten ins Raster setzen. Ohne Angabe: was in der config steht."""
self.layout = dict(layout or self._gespeichert())
for name, kasten in self.kaesten.items():
spalte, zeile, hoch = self.layout.get(name, STANDARD.get(name, (0, 0, 1)))
kasten["aussen"].grid(row=zeile, column=spalte, rowspan=hoch,
sticky="nsew", padx=6, pady=6)
self._platz_verteilen()
def _platz_verteilen(self):
"""Der freie Platz gehoert dem Kasten mit der langen Liste.
Feste Gewichte an festen Zellen gehen nicht: die Liste wandert beim
Verschieben, und wer das Gewicht an einer festen Zelle laesst,
schneidet danach den Kundenkasten unten ab - genau das ist beim ersten
Versuch passiert.
WARUM NICHT MEHR `uniform` (06.09.2026)
---------------------------------------
Vorher standen beide Spalten in einer uniform-Gruppe mit den Gewichten
4 und 3. Das teilt die Breite STRENG in diesem Verhaeltnis auf - bei
1040 Pixeln also 594 zu 446, ganz gleich, was in den Kaesten steht.
Sein Befund: *"die namen und datums bloecke koennen schmaler werden
sind zu breit und verdrengen die posten block"*. Er hatte recht, aber
an den Feldbreiten zu drehen half nichts: die Spalte war so breit, weil
das Gewicht es vorschrieb, nicht weil der Inhalt es brauchte.
Jetzt bekommen die Formularkaesten nur, was sie brauchen (feste
Mindestbreite, kein Gewicht), und der ganze uebrige Platz geht an die
Liste. Wird das Fenster groesser, waechst allein die Tabelle - genau
dort nuetzt die Breite, waehrend ein Adressfeld von 600 Pixeln nichts
besser macht.
"""
for z in range(2):
self.rowconfigure(z, weight=0, minsize=0)
for sp in range(2):
self.columnconfigure(sp, weight=0, uniform="", minsize=0)
lang = self.layout.get("leistungen")
if not lang:
for sp in range(2):
self.columnconfigure(sp, weight=1)
return
spalte, zeile, hoch = lang
for z in range(zeile, zeile + hoch):
self.rowconfigure(z, weight=1)
# Die Liste nimmt allen freien Platz ...
self.columnconfigure(spalte, weight=1, minsize=self.MIN_LISTE)
# ... die Formularseite genau so viel, wie ihr breitestes Feld braucht.
self.columnconfigure(1 - spalte, weight=0, minsize=self.MIN_FORMULAR)
# Steht die Liste nur in EINER Zeile, braucht die andere trotzdem Luft
# fuer ihren Inhalt - sonst wird sie zusammengequetscht.
if hoch == 1:
self.rowconfigure(1 - zeile, weight=1, minsize=170)
def zuruecksetzen(self):
self.anordnen(STANDARD)
self._speichern()
self._koepfe_zeigen()
# ---- Ziehen -----------------------------------------------------------
# ---- Anordnen ein- und ausschalten ------------------------------------
def anordnen_starten(self):
self._vorher = dict(self.layout)
self.anordnen_an = True
self._koepfe_zeigen()
def anordnen_speichern(self):
self.anordnen_an = False
self._speichern()
self._koepfe_zeigen()
def anordnen_verwerfen(self):
self.anordnen_an = False
if self._vorher:
self.anordnen(self._vorher)
self._koepfe_zeigen()
def _koepfe_zeigen(self):
"""Im Anordnen-Modus tragen die Koepfe einen Griff und einen Zeiger."""
import icons
for kasten in self.kaesten.values():
kopf = kasten["kopf"]
if self.anordnen_an:
bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self)
kopf.configure(image=bild, compound="left", cursor="fleur",
bg=self.f.col_sel)
kopf.bild = bild # sonst raeumt Python das Bild weg
else:
kopf.configure(image="", compound="none", cursor="",
bg=self.f.col_card)
kopf.bild = None
def _greifen(self, name):
if not self.anordnen_an:
return
self._zieht = name
self.kaesten[name]["kopf"].configure(bg=self.f.col_sel)
# Der gegriffene Kasten wird blass - man sieht, WAS man in der Hand hat.
self.kaesten[name]["aussen"].configure(style="Gegriffen.TFrame")
self._etikett_zeigen(name)
def _ziehen(self, ereignis, name):
if self._zieht is None or not self.anordnen_an:
return
self._etikett_bewegen()
ziel = self._kasten_unter(ereignis)
if ziel == self._ziel:
return
self._marke(self._ziel, False)
self._ziel = ziel if ziel != name else None
self._marke(self._ziel, True)
def _loslassen(self, name):
if self._zieht is None:
return
self._etikett_weg()
self.kaesten[name]["aussen"].configure(style="Karte.TFrame")
self.kaesten[name]["kopf"].configure(bg=self.f.col_sel)
self._marke(self._ziel, False)
ziel, self._zieht, self._ziel = self._ziel, None, None
if ziel and ziel != name:
# Getauscht wird sofort sichtbar - gespeichert erst mit dem Knopf.
self.layout[name], self.layout[ziel] = self.layout[ziel], self.layout[name]
self.anordnen(self.layout)
self._koepfe_zeigen()
if self.beim_umbauen:
self.beim_umbauen()
# ---- Das Etikett am Zeiger -------------------------------------------
def _etikett_zeigen(self, name):
"""Ein kleines Schild haengt am Zeiger, solange gezogen wird.
Ohne das sieht man nur, WOHIN es geht, aber nicht, dass man ueberhaupt
etwas in der Hand hat - es wirkte "statisch". Ein Widget selbst kann in
Tk nicht mitwandern; ein randloses Fensterchen kann es.
"""
import icons
self._etikett_weg()
try:
schild = tk.Toplevel(self)
schild.overrideredirect(True)
schild.attributes("-topmost", True)
try:
schild.attributes("-alpha", 0.85)
except tk.TclError:
pass
rahmen = tk.Frame(schild, bg=self.f.col_accent, padx=1, pady=1)
rahmen.pack()
bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self)
inhalt = tk.Label(rahmen, text=" " + self.kaesten[name]["titel"],
image=bild, compound="left",
bg=self.f.col_card, fg=self.f.col_accent,
font=(self._schrift(), 10, "bold"), padx=12, pady=8)
inhalt.bild = bild
inhalt.pack()
self._etikett = schild
self._etikett_bewegen()
except tk.TclError as e:
print("[flaeche] Etikett nicht moeglich:", e)
self._etikett = None
def _etikett_bewegen(self):
schild = getattr(self, "_etikett", None)
if schild is None:
return
try:
schild.geometry(f"+{self.winfo_pointerx() + 14}+{self.winfo_pointery() + 14}")
except tk.TclError:
self._etikett = None
def _etikett_weg(self):
schild = getattr(self, "_etikett", None)
if schild is not None:
try:
schild.destroy()
except tk.TclError:
pass
self._etikett = None
def _marke(self, name, an):
if not name:
return
self.kaesten[name]["kopf"].configure(
fg=self.f.col_accent_hell if an else self.f.col_accent)
self.kaesten[name]["aussen"].configure(
style="Ziel.TFrame" if an else "Karte.TFrame")
def _kasten_unter(self, ereignis):
"""Welcher Kasten liegt unter dem Zeiger?"""
x = self.winfo_pointerx() - self.winfo_rootx()
y = self.winfo_pointery() - self.winfo_rooty()
for name, kasten in self.kaesten.items():
w = kasten["aussen"]
lx, ly = w.winfo_x(), w.winfo_y()
if lx <= x <= lx + w.winfo_width() and ly <= y <= ly + w.winfo_height():
return name
return None
# ---- Merken -----------------------------------------------------------
def _schrift(self):
import theme
return theme.SCHRIFT_NAME
def _gespeichert(self):
cfg = getattr(self.f, "cfg", {}) or {}
roh = cfg.get("fenster_layout") or {}
aus = {}
for name in self.kaesten:
wert = roh.get(name)
# Nur uebernehmen, was auch wirklich passt - eine von Hand
# verstellte config darf das Fenster nicht zerlegen.
if (isinstance(wert, (list, tuple)) and len(wert) == 3
and all(isinstance(z, int) for z in wert)):
aus[name] = tuple(wert)
else:
aus[name] = STANDARD.get(name, (0, 0, 1))
return aus
def _speichern(self):
cfg = getattr(self.f, "cfg", None)
if cfg is None:
return
cfg["fenster_layout"] = {n: list(v) for n, v in self.layout.items()}
try:
config.save_config(cfg)
except OSError as e:
print("[flaeche] Anordnung nicht gespeichert:", e)

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 -------------------------------------------------------------

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}).")

62
icons.py Normal file
View file

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Icons aus dem Hub-Vorrat (Material Symbols), eingefaerbt zur Laufzeit.
Die SVGs liegen auf dem Hub (`M:/projekte/material-symbols`) und wurden einmal
nach PNG gewandelt - als weisse Silhouette mit Transparenz. Hier bekommen sie
ihre Farbe: dieselbe Datei taugt fuer die Akzentfarbe, fuer gedaempfte Schrift
und fuer das helle Thema, ohne dass jemand drei Fassungen pflegt.
Ohne Pillow oder ohne Datei gibt es einfach kein Bild zurueck - der Aufrufer
zeigt dann seinen Text. Ein fehlendes Icon darf kein Fenster verhindern.
"""
import os
import tkinter as tk
import config
# ⚠️ Ein PhotoImage gehoert zu GENAU EINEM Tk-Fenster. Wird ein zweites
# aufgemacht (ein Pruefstand faehrt Journal und Rechnungstool nacheinander),
# zeigt ein weitergereichtes Bild ins Leere: 'image "pyimage1" doesn't exist'.
# Deshalb steht das Fenster mit im Schluessel.
_zwischenlager = {} # (fenster, name, groesse, farbe) -> PhotoImage
def _pfad(name):
for basis in (config.BASE_DIR, config.RESOURCE_DIR,
os.path.dirname(os.path.abspath(__file__))):
p = os.path.join(basis, "assets", "icons", name + ".png")
if os.path.exists(p):
return p
return ""
def hole(name, groesse=18, farbe="#e8e8ea", master=None):
"""Icon als PhotoImage - oder None, wenn es nicht geht.
`master` ist das Widget, in dem das Bild landet. Es MUSS mitgegeben werden,
wenn mehr als ein Tk-Fenster laeuft: `tk._default_root` zeigt dann auf das
zuerst geoeffnete, und das Bild gehoert dem falschen Fenster.
"""
fenster = master if master is not None else tk._default_root
if fenster is None:
return None
schluessel = (id(fenster), name, groesse, farbe)
if schluessel in _zwischenlager:
return _zwischenlager[schluessel]
pfad = _pfad(name)
if not pfad:
print(f"[icons] nicht gefunden: {name}")
return None
try:
from PIL import Image, ImageTk
bild = Image.open(pfad).convert("RGBA")
bild = bild.resize((groesse, groesse), Image.LANCZOS)
# Die Silhouette steckt im Alphakanal - die Farbe kommt hier drueber.
flaeche = Image.new("RGBA", bild.size, farbe)
flaeche.putalpha(bild.getchannel("A"))
fertig = ImageTk.PhotoImage(flaeche, master=fenster)
except Exception as e: # noqa: BLE001 - ein Icon darf nichts blockieren
print(f"[icons] {name} nicht ladbar: {e}")
return None
_zwischenlager[schluessel] = fertig
return fertig

View file

@ -23,6 +23,7 @@ import tkinter as tk
from tkinter import ttk, messagebox
import berechnung
import theme
import config
import pdf_renderer
import berechnung
@ -68,7 +69,7 @@ class KorrekturMixin:
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo(
self.melden_info(
"Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
@ -92,7 +93,7 @@ class KorrekturMixin:
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
# obwohl der Nutzer sie behalten wollte.
messagebox.showerror(
self.melden_fehler(
"Storno konnte nicht gelöscht werden",
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
@ -103,7 +104,7 @@ class KorrekturMixin:
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
messagebox.showwarning(
self.melden_warnung(
"Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
@ -136,14 +137,14 @@ class KorrekturMixin:
"""
storno = modus == "storno"
if self.storno_stand:
messagebox.showinfo(
self.melden_info(
"Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return
if self.berichtigung_stand:
messagebox.showinfo(
self.melden_info(
"Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
@ -161,7 +162,7 @@ class KorrekturMixin:
if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
self.melden_warnung(
"Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return
@ -203,9 +204,9 @@ class KorrekturMixin:
# wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit
# "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist.
self._auswahl = tk.Listbox(frm, height=4, exportselection=False,
bg=self.col_field, fg=self.col_fg,
selectbackground=self.col_sel, selectforeground=self.col_fg,
relief="flat", font=("Consolas", 9))
selectbackground=self.col_sel,
selectforeground=self.col_fg,
font=("Consolas", 9), **self.feld_optionen(einfuegemarke=False))
self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self._auswahl.grid_remove()
self._kandidaten = []
@ -375,7 +376,7 @@ class KorrekturMixin:
rahmen = ttk.Frame(d, padding=18)
rahmen.pack(fill="both", expand=True)
ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}",
font=("Segoe UI", 13, "bold")).pack(anchor="w")
font=(theme.SCHRIFT_NAME, 13, "bold")).pack(anchor="w")
# ---- Schritt 1: A oder B ------------------------------------------
schritt1 = ttk.Frame(rahmen)
@ -384,17 +385,17 @@ class KorrekturMixin:
"Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet "
"wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12))
ttk.Label(schritt1, text="A – es folgt eine neue Rechnung",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu "
"geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt "
"trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 10))
ttk.Label(schritt1, text="C – nur Storno",
font=("Segoe UI", 10, "bold")).pack(anchor="w")
font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w")
ttk.Label(schritt1, wraplength=620, justify="left", text=(
"Die Leistung wird gar nicht abgerechnet: Buchung geplatzt oder die Rechnung "
"war komplett irrtümlich. Es folgt keine neue Rechnung und es gibt auch keine "
@ -411,7 +412,7 @@ class KorrekturMixin:
feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center")
feld.pack(side="left", padx=(8, 0))
stand = tk.Label(schritt2, text="", wraplength=620, justify="left",
font=("Segoe UI", 9))
font=(theme.SCHRIFT_NAME, 9))
stand.pack(anchor="w", pady=(8, 0))
def suchen(*_a):
@ -471,7 +472,7 @@ class KorrekturMixin:
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
fg="#a00000")
return
if gefunden["leicht"] and not messagebox.askyesno(
if gefunden["leicht"] and not self.frage(
"Weicht ab – trotzdem?",
f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) +
". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe "
@ -511,8 +512,7 @@ class KorrekturMixin:
d.protocol("WM_DELETE_WINDOW", d.destroy)
d.bind("<Escape>", lambda _e: d.destroy())
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
theme.mittig(d, self)
d.grab_set()
self.wait_window(d)
if wahl["art"] == "vorhanden":
@ -536,7 +536,7 @@ class KorrekturMixin:
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer:
messagebox.showerror("Keine freie Nummer",
self.melden_fehler("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
return
@ -571,7 +571,7 @@ class KorrekturMixin:
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
maengel = kunde_maengel(kunde)
if maengel:
if not messagebox.askyesno(
if not self.frage(
"Angaben zum Kunden unvollständig",
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
+ "\n- ".join(maengel) +
@ -619,7 +619,7 @@ class KorrekturMixin:
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
self.melden_fehler("Fehler beim Erstellen",
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
@ -645,7 +645,7 @@ class KorrekturMixin:
if art_folge == "vorhanden":
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if messagebox.askyesno(
if self.frage(
"Storno erstellt",
f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n"
f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es "
@ -662,7 +662,7 @@ class KorrekturMixin:
if fehlend:
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
messagebox.showinfo(
self.melden_info(
"Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "

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

@ -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 -*-
"""
PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab).
PDF-Renderer fuer die Rechnung (reportlab).
render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF
im Layout der Excel-Vorlage:
@ -10,47 +10,66 @@ im Layout der Excel-Vorlage:
"""
import os
import io
import json
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
Flowable,
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether,
)
import bilder
import config
from berechnung import eur, eur_kurz, runde
class _AmtMarke(Flowable):
"""Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt.
Nur so kann die Fusszeile die Kundenseite vom Amt-Blatt unterscheiden - sie wird
gezeichnet, bevor der Seiteninhalt steht, und weiss von sich aus nichts davon.
"""
def __init__(self, merker):
super().__init__()
self._merker = merker
def wrap(self, *_):
return (0, 0)
def draw(self):
self._merker["seite"] = self.canv.getPageNumber()
# ---- Farben / Masse ---------------------------------------------------------
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle
# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben
# aufeinander abstimmen muss.
AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping)
ACCENT = colors.HexColor(AKZENT_VORGABE)
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
def _farbe(wert, vorgabe):
"""'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck.
Eine kaputte Farbe in der config.json darf niemals das Schreiben der
Rechnung verhindern - dann steht der Gast an der Schranke.
"""
try:
return colors.HexColor(str(wert).strip())
except (ValueError, AttributeError):
if str(wert).strip():
print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}")
return colors.HexColor(vorgabe)
def _design_anwenden(cfg):
"""Firmenfarbe uebernehmen, bevor die Stile gebaut werden.
Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen.
Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei
Rechnungen gleichzeitig.
"""
global ACCENT, GREY_BG
design = (cfg or {}).get("design") or {}
ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE)
# Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder
# gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss.
GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red),
1 - 0.08 * (1 - ACCENT.green),
1 - 0.08 * (1 - ACCENT.blue))
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
@ -100,36 +119,46 @@ def _styles():
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _kopf_fuss(canvas, doc, cfg, amt=False):
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
def _vorschau_marke(canvas):
"""Schraeg ueber das Blatt: das hier ist KEINE Rechnung.
Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder
weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette
der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner
Buchhaltung steht.
"""
canvas.saveState()
canvas.setFont("Helvetica-Bold", 62)
canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22))
canvas.translate(PAGE_W / 2, PAGE_H / 2)
canvas.rotate(38)
canvas.drawCentredString(0, 0, "VORSCHAU")
canvas.setFont("Helvetica-Bold", 15)
canvas.drawCentredString(0, -22 * mm, "keine Rechnung")
canvas.restoreState()
def _kopf_fuss(canvas, doc, cfg, vorschau=False):
"""Kopf und Fuss einer Seite."""
firma = cfg["firma"]
canvas.saveState()
if vorschau:
_vorschau_marke(canvas)
# --- Kopf ---
logo = (firma.get("logo") or "").strip()
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
if logo and not os.path.isabs(logo):
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
_cand = os.path.join(_base, logo)
if os.path.exists(_cand):
logo = _cand
break
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
# kommen aus den Einstellungen und bleiben gezeichnet.
img, iw, ih = bilder.laden(firma.get("logo"))
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
scale = min(box_w / iw, box_h / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
except Exception as e: # noqa: BLE001
print("[pdf] Logo konnte nicht geladen werden:", e)
if img:
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo
@ -155,13 +184,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile ---
#
# STRIKTE TRENNUNG Kunde / Amt:
# Kundenseite : voller Briefkopf-Fuss - Bankverbindung und Steuernummer MUESSEN
# drauf, das ist die Rechnung, die der Gast bezahlt.
# Amt-Blatt : KEINE Bankverbindung, KEINE Steuernummer. Das Amt zieht nichts
# ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und
# die Kontaktdaten fuer Rueckfragen.
# Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
# Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6)
@ -172,9 +196,17 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R
rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
firma.get("steuernummer", "")]
fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
if fuss_img:
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
# welches Bild jemand einstellt.
dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm)
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l):
@ -265,12 +297,8 @@ def _montage_zeile(story, st, rechnung, kopf):
story.append(dreier)
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
"""
def _seite_inhalt(story, st, cfg, rechnung, kopf):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
# Absenderzeile (klein, unterstrichen)
story.append(Paragraph(
f"<u>{cfg['firma'].get('absender','')}</u>",
@ -358,15 +386,12 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(head)
story.append(Spacer(1, 7 * mm))
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
# Ueberschrift.
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
# eindeutig als Aufhebung erkennbar ist.
titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung",
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
if angebot:
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
@ -402,20 +427,14 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
Paragraph(f"{name} {b.satz} %:", st["info_l"]),
Paragraph(eur(b.ust), st["cell_r"]),
])
# Auf dem Amt-Blatt taucht die Beherbergungssteuer NIRGENDS auf - weder als eigene
# Zeile, noch versteckt im Gesamtbetrag. Das Amt bekommt die Rechnung so, wie sie
# ohne die Steuer aussaehe: Netto, Umsatzsteuern, Gesamt. Genau dieser Gesamtbetrag
# ist die Bemessungsgrundlage, aus der es die Steuer selbst errechnet.
#
# Deshalb entfaellt hier auch die Zwischensumme: sie waere betragsgleich mit dem
# Gesamtbetrag und wuerde nur zweimal dasselbe zeigen.
#
# Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen
# sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt.
# Auf der Rechnung MUSS die Beherbergungssteuer ausgewiesen sein und im
# Gesamtbetrag stecken - das ist der Betrag, den der Gast zahlt. Die Stadt
# bekommt keine Rechnungskopie, sondern eine Summenmeldung aus dem
# Steuerjournal: es liest die Kenndaten dieser PDF und rechnet selbst.
# Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# zahlt; aufgeschluesselt wird erst in der Rechnung.
if rechnung.aufschlaege and not gesamtrechnung_betont and not angebot:
if rechnung.aufschlaege and not angebot:
summen_rows.append([
Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), st["cell_r"]),
@ -427,7 +446,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
Paragraph(eur(a.betrag), st["cell_r"]),
])
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
endsumme = rechnung.gesamt
summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
@ -446,14 +465,13 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
summen.setStyle(TableStyle(style_cmds))
story.append(summen)
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
# Hinweis zur Beherbergungssteuer.
if angebot:
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
elif not gesamtrechnung_betont:
else:
for a in rechnung.aufschlaege:
if not a.bezeichnung:
continue
@ -471,85 +489,84 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(Spacer(1, 8 * mm))
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
texte = cfg.get("texte", {})
block = []
if art == "storno":
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# nichts geschuldet, sondern aufgehoben.
bezug = kopf.get("storno_zu", "")
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
if kopf.get("ersatz_vorhanden") and folgt:
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
# Gast auf eine Rechnung, die nie kommt.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
"verrechnet.", st["dank"]))
story.append(KeepTogether(block))
return
if kopf.get("nur_storno"):
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
# ---- Schlusstexte -------------------------------------------------------
texte = cfg.get("texte", {})
block = []
if art == "storno":
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# nichts geschuldet, sondern aufgehoben.
bezug = kopf.get("storno_zu", "")
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
if kopf.get("ersatz_vorhanden") and folgt:
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
# Gast auf eine Rechnung, die nie kommt.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet." + kette, st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
# Aussteller. Also bleibt der Satz drauf.
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
"verrechnet.", st["dank"]))
story.append(KeepTogether(block))
return
if kopf.get("nur_storno"):
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
elif angebot:
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph(
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
"stehen wir gern zur Verfügung.",
st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet." + kette, st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
# Aussteller. Also bleibt der Satz drauf.
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
elif angebot:
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph(
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
"stehen wir gern zur Verfügung.",
st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
def _kenndaten(rechnung, kopf):
@ -661,56 +678,44 @@ def _kenndaten(rechnung, kopf):
return json.dumps(daten, ensure_ascii=False)
def render_rechnung(pfad, cfg, rechnung, kopf):
def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
"""
pfad : Zielpfad der PDF
cfg : geladene Konfiguration
rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
EIN Blatt: die Rechnung fuer den Gast.
Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen
Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter
zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber
verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen.
Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten
(subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die
Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit
Gastnamen.
"""
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
st = _styles()
def bauen(ziel, amt_seite):
doc = SimpleDocTemplate(
ziel, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=({"storno": "Stornorechnung",
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
# Ein Angebot bekommt KEIN Blatt fuers Amt: gemeldet wird eine
# Uebernachtung erst, wenn sie abgerechnet ist.
if rechnung.aufschlaege and kopf.get("art") != "angebot":
story.append(PageBreak())
story.append(_AmtMarke(marke))
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
doc = SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=({"storno": "Stornorechnung",
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf)
def seite(c, d):
_kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite))
def seite(c, d):
_kopf_fuss(c, d, cfg, vorschau=vorschau)
doc.build(story, onFirstPage=seite, onLaterPages=seite)
# Zwei Durchlaeufe, und zwar aus einem konkreten Grund:
#
# Kopf und Fuss werden VOR dem Seiteninhalt gezeichnet - beim Zeichnen weiss die
# Seite also noch nicht, ob auf ihr gleich die Kunden- oder die Amts-Seite landet.
# Auf "Seite 2 ist das Amt" zu wetten, waere falsch, sobald die Kundenrechnung
# laenger als eine Seite wird: dann stuende die Bankverbindung ploetzlich auf dem
# Amt-Blatt und fehlte beim Kunden.
#
# Darum: erster Lauf ins Nichts, nur um zu erfahren, auf welcher Seite das
# Amt-Blatt beginnt. Zweiter Lauf schreibt die echte Datei.
marke = {}
bauen(io.BytesIO(), None)
bauen(pfad, marke.get("seite"))
doc.build(story, onFirstPage=seite, onLaterPages=seite)
return pfad

View file

@ -32,16 +32,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -63,12 +53,17 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}
@ -80,6 +75,11 @@ def askyesno(titel, text, **kw):
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
warnungen = []

167
pruef_api.py Normal file
View file

@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""Pruefstand: das Fenster ueber die API bedienen - mit Schluessel.
Warum es das gibt (seine Ansage): *"bau dir steuer logik per api rein, dann
kannst damit die sachen triggern, brauchst meine maus nicht"* und *"mach pw
rein, also schluessel wie bei ssh key, das nur du und das beherbergungssteuer
ding rein kann, weil muss ja sprechen"*.
Geprueft wird:
1. Ohne Schluessel geht NICHTS - auch nicht "nur nachsehen".
2. Mit falschem Schluessel ebenfalls nicht.
3. Mit dem Schluessel aus dem gemeinsamen Ordner laesst sich das Fenster
fuellen, nachlesen und eine Vorschau erzeugen.
4. Was einen BELEG schreibt, geht nur im Testlauf - und schreibt dann
tatsaechlich die Rechnung.
5. ping und schau_nach bleiben schluessellos, sonst kann ein aelteres
Steuerjournal nicht mehr anklopfen.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="api_pruef_")
ordner = os.path.join(tmp, "rechnungen")
gemeinsam_ordner = os.path.join(tmp, "install")
os.makedirs(ordner)
os.makedirs(gemeinsam_ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
roh["gemeinsam_ordner"] = gemeinsam_ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
os.environ["CAMPINGHOF_TEST"] = "1"
import api # noqa: E402
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True
f = A.RechnungsApp()
f.cfg["output_dir"] = ordner
f.buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
f.api.starten(f.buch, ordner=gemeinsam_ordner)
f.update()
port = f.api.port
pruefe(bool(port), f"die API laeuft ({port})")
schluessel = api.schluessel_holen(gemeinsam_ordner)
pruefe(len(schluessel) == 64, f"ein Schluessel liegt im gemeinsamen Ordner ({len(schluessel)} Zeichen)")
pruefe(os.path.exists(os.path.join(gemeinsam_ordner, api.SCHLUESSEL_DATEI)),
"und zwar als Datei neben dem Nummernbuch")
pruefe(api.schluessel_holen(gemeinsam_ordner) == schluessel,
"beim zweiten Lesen kommt derselbe zurueck (kein neuer je Start)")
def rufen(**nachricht):
"""Direkt anrufen - der Pruefstand spielt hier das andere Programm."""
was = nachricht.pop("was", "aktion")
return api._reden(port, was, zeitgrenze=20.0, **nachricht)
# Das Fenster muss waehrend des Anrufs weiterlaufen: die Aktion wird in seinem
# Faden ausgefuehrt. Also nebenher anrufen und die Ereignisschleife drehen.
def anrufen_und_drehen(**nachricht):
ergebnis = {}
def anruf():
ergebnis["antwort"] = rufen(**nachricht)
t = threading.Thread(target=anruf, daemon=True)
t.start()
while t.is_alive():
f.update()
return ergebnis.get("antwort")
# ---- 1. + 2. Ohne (oder mit falschem) Schluessel: kein Zutritt -------------
pruefe(anrufen_und_drehen(name="zustand") == "kein zutritt",
"ohne Schluessel: kein Zutritt")
pruefe(anrufen_und_drehen(name="zustand", schluessel="0" * 64) == "kein zutritt",
"falscher Schluessel: kein Zutritt")
# ---- 5. Die alten zwei Woerter bleiben schluessellos -----------------------
pruefe(anrufen_und_drehen(was="ping") == api.ANTWORT_HALLO,
"ping antwortet weiterhin ohne Schluessel")
# ---- 3. Mit Schluessel: fuellen, nachlesen, Vorschau -----------------------
antwort = anrufen_und_drehen(
name="setze", schluessel=schluessel,
daten={"kunde": {"vorname": "Anna", "nachname": "Beispiel",
"strasse": "Musterweg 1", "plzort": "12345 Musterstadt"},
"anreise": "01.09.2026", "abreise": "04.09.2026",
"positionen": [{"nr": sorted(f.anzahl_vars)[0], "anzahl": 3}]})
pruefe(isinstance(antwort, dict) and antwort.get("kunde", {}).get("nachname") == "Beispiel",
f"mit Schluessel laesst sich das Formular fuellen ({str(antwort)[:60]}…)")
pruefe(antwort.get("gesamt", 0) > 0, f"und es ist gerechnet ({antwort.get('gesamt')})")
antwort = anrufen_und_drehen(name="vorschau", schluessel=schluessel)
pfad = (antwort or {}).get("pfad", "")
pruefe(bool(pfad) and os.path.exists(pfad), f"die Vorschau entsteht ohne Maus ({os.path.basename(pfad)})")
pruefe(not os.listdir(ordner), "und sie landet NICHT im Rechnungsordner")
# ---- 4. Beleg schreiben ---------------------------------------------------
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, ohne den Pruefschalter.
# Umgeschaltet wird die Umgebungsvariable selbst - das Programm fragt sie bei
# jeder Aktion neu ab, genau wie beim echten Start.
os.environ.pop("CAMPINGHOF_TEST", None)
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
os.environ["CAMPINGHOF_TEST"] = "1"
pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
f"im echten Betrieb verweigert sie den Beleg ({antwort})")
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
"und es kam keine zweite Rechnung dazu")
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)
pruefe("kenne ich nicht" in str(antwort), f"unbekannte Aktion wird benannt ({str(antwort)[:50]}…)")
f.api.stoppen(f.buch)
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
print(f"(Ordner bleibt: {tmp})")
sys.exit(1)
shutil.rmtree(tmp, ignore_errors=True)
print("ALLES GRUEN")

137
pruef_bilder.py Normal file
View file

@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""Pruefstand: Kopf- und Fussbild auf der Rechnung.
Drei Dinge muessen stimmen, sonst faellt es erst beim Drucken auf:
1. Ein gewaehltes Bild wird KOPIERT (Ordner "bilder"), nicht nur verlinkt -
sonst ist das Logo weg, sobald der Nutzer seinen Desktop aufraeumt.
2. Das Fussbild steht auf der Kundenrechnung, aber NICHT auf dem Amt-Blatt:
ein Briefpapier-Band traegt Bank und Steuernummer, und die haben auf dem
Blatt fuers Amt nichts zu suchen.
3. Der Haken "Angaben stehen im Bild" laesst die gezeichneten Texte weg -
und zwar nur dort, wo er gilt.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
import config # noqa: E402
tmp = tempfile.mkdtemp(prefix="bilder_pruef_")
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
shutil.copy(os.path.join(QUELLE, "config.json"), config.CONFIG_PATH)
import bilder # noqa: E402
import pdf_renderer # noqa: E402
from berechnung import berechne # noqa: E402
from pruefhelfer import fehler, pruefe
def testbild(pfad, farbe):
"""Kleines PNG erzeugen - der Pruefstand braucht keine echten Kundenbilder."""
from PIL import Image
Image.new("RGB", (600, 120), farbe).save(pfad)
return pfad
# ---- 1. Uebernehmen: kopieren, nicht verlinken -----------------------------
fremd = os.path.join(tmp, "vom_desktop.png")
testbild(fremd, (10, 90, 60))
gespeichert = bilder.uebernehmen(fremd)
pruefe(gespeichert == "bilder/vom_desktop.png", f"Pfad wird kurz gemerkt: {gespeichert}")
pruefe(os.path.exists(os.path.join(tmp, "bilder", "vom_desktop.png")), "Bild liegt im Ordner bilder/")
# Das Original loeschen - die Rechnung muss trotzdem ein Bild haben.
os.remove(fremd)
pruefe(bool(bilder.aufloesen(gespeichert)), "Bild bleibt da, wenn das Original geloescht wird")
# Dasselbe Bild zweimal waehlen -> kein zweites Exemplar.
shutil.copy(os.path.join(tmp, "bilder", "vom_desktop.png"), fremd)
pruefe(bilder.uebernehmen(fremd) == "bilder/vom_desktop.png", "gleiches Bild zweimal -> kein Duplikat")
# Anderer Inhalt, gleicher Name -> das alte Bild wird NICHT ueberschrieben.
testbild(fremd, (200, 30, 30))
zweit = bilder.uebernehmen(fremd)
pruefe(zweit == "bilder/vom_desktop-2.png", f"gleicher Name, anderes Bild -> {zweit}")
# ---- 2. Rendern: Kunde vs. Amt --------------------------------------------
cfg = config.load_config()
cfg["firma"]["logo"] = bilder.uebernehmen(testbild(os.path.join(tmp, "kopf.png"), (20, 60, 120)))
cfg["firma"]["fussbild"] = bilder.uebernehmen(testbild(os.path.join(tmp, "fuss.png"), (120, 60, 20)))
cfg["firma"]["bank"] = "Bank Testbank"
mengen = {1: {"anzahl": 3, "personen": 0}}
r = berechne(cfg["katalog"], mengen, cfg)
kopf = {"art": "rechnung", "rechnungsnummer": "2026-999", "datum": "05.09.2026",
"kunde_zeilen": ["Herr", "Max Muster", "Musterweg 1", "04416 Markkleeberg"],
"anreise": "01.09.2026", "abreise": "04.09.2026", "naechte": 3}
pfad = os.path.join(tmp, "probe.pdf")
pdf_renderer.render_rechnung(pfad, cfg, r, kopf)
pruefe(os.path.exists(pfad), "PDF mit beiden Bildern entsteht")
def seiten_bilder_und_text(pdf):
"""Je Seite: (Anzahl eingebetteter Bilder, Text)."""
from pypdf import PdfReader
raus = []
for seite in PdfReader(pdf).pages:
xo = seite.get("/Resources", {}).get("/XObject", {})
try:
xo = xo.get_object()
except AttributeError:
pass
anzahl = sum(1 for k in xo if xo[k].get_object().get("/Subtype") == "/Image")
raus.append((anzahl, seite.extract_text() or ""))
return raus
seiten = seiten_bilder_und_text(pfad)
pruefe(len(seiten) == 1, f"genau EIN Blatt ({len(seiten)} Seiten)")
kunde_bilder, kunde_text = seiten[0]
pruefe(kunde_bilder == 2, f"das Blatt traegt Kopf- UND Fussbild ({kunde_bilder})")
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Rechnung")
pruefe("Steuernummer" in kunde_text,
"die Steuernummer steht auf der Rechnung (§ 14 Abs. 4 Nr. 2 UStG)")
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
# Farbe wirklich im PDF landet - und dass eine kaputte Eingabe das Schreiben
# NICHT verhindert, sondern still auf die Vorgabe zurueckfaellt.
cfg["design"] = {"akzent": "#8a1f1f"}
pfad2 = os.path.join(tmp, "probe_rot.pdf")
pdf_renderer.render_rechnung(pfad2, cfg, r, kopf)
pruefe(pdf_renderer.ACCENT.hexval().endswith("8a1f1f"),
f"gewaehlte Farbe steht im Blatt: {pdf_renderer.ACCENT.hexval()}")
pruefe(pdf_renderer.GREY_BG.red > 0.9,
"helle Flaeche wird aus der Farbe gemischt (bleibt hell)")
cfg["design"] = {"akzent": "gruen bitte"}
pfad3 = os.path.join(tmp, "probe_kaputt.pdf")
pdf_renderer.render_rechnung(pfad3, cfg, r, kopf)
pruefe(os.path.exists(pfad3), "kaputte Farbe verhindert die Rechnung NICHT")
pruefe(pdf_renderer.ACCENT.hexval().endswith("1f5c3d"), "kaputte Farbe faellt auf die Vorgabe zurueck")
cfg["design"] = {"akzent": pdf_renderer.AKZENT_VORGABE}
# Ohne Bilder muss das Blatt aussehen wie immer.
cfg["firma"]["logo"] = ""
cfg["firma"]["fussbild"] = ""
pfad4 = os.path.join(tmp, "probe_ohne_bild.pdf")
pdf_renderer.render_rechnung(pfad4, cfg, r, kopf)
seiten3 = seiten_bilder_und_text(pfad4)
pruefe(seiten3[0][0] == 0, "ohne Bilder: keine Bilder im PDF")
pruefe(cfg["firma"]["name"] in seiten3[0][1], "ohne Bild steht der Firmenname gezeichnet oben")
print()
print(f"Ordner: {tmp}")
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for f in fehler:
print(" -", f)
sys.exit(1)
print("ALLES GRUEN")

269
pruef_darstellung.py Normal file
View file

@ -0,0 +1,269 @@
# -*- 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"
GEPRUEFT WIRD DREIERLEI
1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt
Windows.
2. Beim Umschalten bleibt jede Eingabe stehen. Das ist der Punkt, an dem es
beim Bauen schiefging: die Mengen der Leistungszeilen hatte ich einzeln
gesichert, Kundenname und Rechnungsnummer nicht - die _build_-Methoden
legen ihre Variablen selbst an, beim Neubau standen also leere da. Wer
waehrend einer halb getippten Rechnung auf dunkel stellt, darf sie nicht
verlieren.
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 json
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)
import config # noqa: E402
# Auf einer Kopie arbeiten - die echte Konfiguration wird nicht angefasst.
tmp = tempfile.mkdtemp(prefix="darstellung_")
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["nummer_eingerichtet"] = True
roh.pop("darstellung", None)
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
import app as A # noqa: E402
import theme # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.RechnungsApp()
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")
# ---- Eingaben, die den Wechsel ueberleben muessen ------------------------
f.k_vorname.set("Erika")
f.k_name.set("Mustermann")
f.k_strasse.set("Musterweg 12")
f.k_plzort.set("04416 Markkleeberg")
f.r_nummer.set("2026-099")
f.r_anreise.set("01.09.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("3")
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
config.save_config(f.cfg)
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.k_name.get() == "Mustermann",
f"nach dem Wechsel steht der Kundenname noch da ({f.k_name.get()!r})")
pruefe(f.r_nummer.get() == "2026-099",
f"und die Rechnungsnummer ({f.r_nummer.get()!r})")
pruefe(f.k_plzort.get() == "04416 Markkleeberg",
f"und die Anschrift ({f.k_plzort.get()!r})")
pruefe(f.r_anreise.get() == "01.09.2026",
f"und das Anreisedatum ({f.r_anreise.get()!r})")
pruefe(f.anzahl_vars.get(erste) is not None
and f.anzahl_vars[erste].get() == "3",
"und die eingetragene Menge in der Leistungszeile")
# 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"
config.save_config(f.cfg)
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"
config.save_config(f.cfg)
f.destroy()
f = A.RechnungsApp()
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

@ -27,6 +27,10 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["output_dir"] = ordner
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: dlg_nummer zeigt sonst nur eine Meldung statt des
# Fensters, das hier geprueft werden soll.
roh["nummer_eingerichtet"] = False
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
@ -37,6 +41,8 @@ from tkinter import messagebox # noqa: E402
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen

View file

@ -26,16 +26,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -64,6 +54,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True, "Storno erstellt": False,
"Angaben zum Kunden": True, "Weicht ab": True}
@ -77,6 +68,11 @@ def askyesno(titel, text, **kw):
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")

137
pruef_flaeche.py Normal file
View file

@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die verschiebbaren Kaesten (Arbeitsflaeche).
Seine Ansage: *"wie bei opnsense hast so einen kleinen knopf, dann drueckst
drauf, ab da kannst du schieben, dann hast du einen save button - und dann ist
die fest"*.
Geprueft wird genau diese Reihenfolge:
1. Solange nicht "Anordnen" gedrueckt ist, laesst sich NICHTS verschieben -
beim taeglichen Arbeiten darf nichts verrutschen.
2. Im Anordnen-Modus haengt ein Etikett am Zeiger (man soll sehen, dass man
etwas in der Hand hat) und zwei Kaesten tauschen ihre Plaetze.
3. "Verwerfen" holt die Anordnung von vorher zurueck.
4. "Speichern" schreibt sie in die config - erst dann steht sie fest.
5. Der freie Platz gehoert immer dem Kasten mit der langen Liste, egal wo er
gerade sitzt. Sonst wird der Kundenkasten unten abgeschnitten.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
os.environ["CAMPINGHOF_TEST"] = "1"
tmp = tempfile.mkdtemp(prefix="flaeche_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["output_dir"] = ordner
roh["nummer_eingerichtet"] = True
roh.pop("fenster_layout", None) # ohne gespeicherte Anordnung starten
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
import app as A # noqa: E402
import flaeche as FL # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True
f = A.RechnungsApp()
f.update()
fl = f.flaeche
# ---- 1. Die Vorgabe ist seine Anordnung ----------------------------------
pruefe(fl.layout == FL.STANDARD,
f"Vorgabe: Rechnungsdaten oben links, Kunde darunter, Leistungen rechts ({fl.layout})")
# ---- 2. Ohne den Knopf laesst sich nichts schieben -----------------------
pruefe(not fl.anordnen_an, "beim Start ist das Verschieben AUS")
fl._greifen("kunde")
pruefe(fl._zieht is None, "ohne 'Anordnen' greift ein Zug ins Leere")
vorgabe = dict(fl.layout)
# ---- 3. Anordnen an: Etikett und Tausch ----------------------------------
f._anordnen_umschalten()
pruefe(fl.anordnen_an, "nach dem Knopf ist das Verschieben AN")
fl._greifen("kunde")
pruefe(fl._zieht == "kunde", "jetzt haelt man den Kasten")
pruefe(getattr(fl, "_etikett", None) is not None,
"und am Zeiger haengt ein Etikett - man SIEHT, dass man zieht")
fl._ziel = "daten"
fl._loslassen("kunde")
pruefe(getattr(fl, "_etikett", None) is None, "nach dem Loslassen ist das Etikett weg")
pruefe(fl.layout["kunde"] == vorgabe["daten"] and fl.layout["daten"] == vorgabe["kunde"],
f"die beiden haben die Plaetze getauscht ({fl.layout})")
# Gespeichert ist noch NICHTS - erst der Knopf schreibt.
in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout")
pruefe(in_datei is None, f"vor dem Speichern steht nichts in der config ({in_datei})")
# ---- 4. Verwerfen holt die alte Anordnung zurueck ------------------------
f._anordnen_verwerfen()
pruefe(fl.layout == vorgabe, f"Verwerfen stellt die Anordnung wieder her ({fl.layout})")
pruefe(not fl.anordnen_an, "und schaltet das Verschieben wieder aus")
# ---- 5. Speichern macht es fest ------------------------------------------
f._anordnen_umschalten()
fl._greifen("daten")
fl._ziel = "leistungen"
fl._loslassen("daten")
neu = dict(fl.layout)
f._anordnen_speichern()
in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout")
pruefe(in_datei == {n: list(v) for n, v in neu.items()},
f"Speichern schreibt die Anordnung in die config ({in_datei})")
pruefe(not fl.anordnen_an, "und schaltet das Verschieben aus")
# ---- 6. Der Platz folgt der langen Liste ---------------------------------
spalte, zeile, hoch = fl.layout["leistungen"]
gewicht_liste = fl.grid_rowconfigure(zeile)["weight"]
pruefe(gewicht_liste > 0,
f"die Zeile mit den Leistungen darf wachsen (Gewicht {gewicht_liste})")
breite_liste = fl.grid_columnconfigure(spalte)["weight"]
breite_andere = fl.grid_columnconfigure(1 - spalte)["weight"]
pruefe(breite_liste > breite_andere,
f"und ihre Spalte ist breiter ({breite_liste} zu {breite_andere})")
# Eine kaputte Anordnung in der config darf das Fenster nicht zerlegen.
f.cfg["fenster_layout"] = {"kunde": "kaputt", "daten": [0], "leistungen": None}
fl.anordnen()
pruefe(fl.layout == FL.STANDARD,
f"eine unbrauchbare Anordnung faellt auf die Vorgabe zurueck ({fl.layout})")
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
sys.exit(1)
shutil.rmtree(tmp, ignore_errors=True)
print("ALLES GRUEN")

155
pruef_freigabe.py Normal file
View file

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""Pruefstand: die Freigabe vor dem Beleg - und ihre Spur in der SQLite.
Sein Satz vom 06.09.2026:
"ich will fuer den pruefer sichtbar machen: ich wollte das so. die software
dokumentiert das, und damit wird es nachvollziehbar, weil es einen
checkmark nun gibt in der sqlite" - "das soll alles von anfang bis ende
pruefbar sein"
Geprueft wird beides, weil jedes einzeln wertlos waere:
1. NEIN -> es entsteht KEIN Beleg, die Nummer bleibt frei, im Buch steht
nichts. Ein Popup, das man verneint und trotzdem eine Rechnung
bekommt, waere schlimmer als gar keins.
2. JA -> der Beleg entsteht UND im Nummernbuch steht ein Zeitstempel der
Freigabe. Ohne diesen Eintrag koennte spaeter niemand belegen,
dass ausdruecklich freigegeben wurde.
"""
import json
import os
import re
import shutil
import sys
import tempfile
import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
tmp = tempfile.mkdtemp(prefix="freigabe_pruef_")
ordner = os.path.join(tmp, "rechnungen")
install = os.path.join(tmp, "install")
os.makedirs(ordner)
os.makedirs(install)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
roh["gemeinsam_ordner"] = install
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}
gefragt = []
def askyesno(titel, text, **kw):
gefragt.append(titel)
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
warnungen = []
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
# Im laufenden Programm oeffnet ein 400-ms-Timer das gemeinsame Buch. Den
# erreicht ein Pruefstand nie - also wird dieselbe Stelle direkt gerufen.
# Abgekuerzt wird damit nur das Warten, nicht der Weg.
f._frage_gemeinsam_falls_noetig()
pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist offen")
def kunde_fuellen():
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("4")
f.update()
# ---- 1. NEIN: es darf nichts entstehen ----------------------------------
antworten["jetzt erstellen?"] = False
kunde_fuellen()
zaehler_vorher = f.cfg.get("rechnung_zaehler")
nummer_vorher = f.r_nummer.get()
f.pdf_erstellen()
pdfs = [x for x in os.listdir(ordner) if x.lower().endswith(".pdf")]
pruefe(not pdfs, f"nach Nein: kein PDF im Ordner (gefunden: {pdfs})")
pruefe(any("jetzt erstellen?" in t for t in gefragt),
"die Freigabe wurde ueberhaupt gefragt")
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher,
"nach Nein: der Rechnungszaehler ist unveraendert")
pruefe(f.r_nummer.get() == nummer_vorher,
"nach Nein: die Nummer im Feld ist unveraendert")
buch = gemeinsam.Nummernbuch(install)
pruefe(not buch.nummern(), f"nach Nein: das Nummernbuch ist leer ({buch.nummern()})")
# ---- 2. JA: Beleg entsteht und die Freigabe steht in der SQLite ---------
antworten["jetzt erstellen?"] = True
kunde_fuellen()
f.pdf_erstellen()
pdfs = [x for x in os.listdir(ordner) if x.lower().endswith(".pdf")]
pruefe(len(pdfs) == 1, f"nach Ja: genau ein PDF ({pdfs})")
nummern = sorted(buch.nummern())
pruefe(len(nummern) == 1, f"nach Ja: eine Nummer im Buch ({nummern})")
freigabe = ""
if nummern:
with buch._verbindung() as con:
zeile = con.execute("SELECT freigabe FROM nummern WHERE nummer=?",
(nummern[0],)).fetchone()
freigabe = (zeile["freigabe"] if zeile else "") or ""
pruefe(bool(freigabe), "die Freigabe steht in der SQLite")
pruefe(bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", freigabe)),
f"der Zeitstempel ist ein ISO-Datum ({freigabe!r})")
_wecker.cancel()
f.destroy()
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Freigabe wirkt und wird dokumentiert")

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")

184
pruef_hinweis.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Pruefstand: der Hinweis auf die Erprobungsfassung und die Kenntnisnahme.
Sein Wortlaut vom 06.09.2026:
"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"
"und das alles auch noch mal in die info mit signatur: user hat dies
gelesen und zur kenntnis genommen"
Geprueft wird die ganze Strecke, weil jeder Teil fuer sich wertlos waere:
1. Der Hinweis kommt beim Start und nennt Haftung, Vorschau und die
Fehleradresse.
2. NEIN -> das Fenster ist zu, und in der Konfiguration steht NICHTS.
Ein "Nein", das trotzdem vermerkt wird, waere eine Luege.
3. Neuer Start -> es wird WIEDER gefragt.
4. JA -> Signatur mit Benutzer, Zeit und Fassung; danach wird nicht mehr
gefragt.
5. Andere Fassung des Textes -> es wird erneut gefragt. Eine Zustimmung gilt
fuer das, was dagestanden hat, nicht fuer alles Kuenftige.
"""
import json
import os
import time
import shutil
import sys
import tempfile
import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
tmp = tempfile.mkdtemp(prefix="hinweis_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["output_dir"] = ordner
roh["nummer_eingerichtet"] = True
for weg in ("hinweis_fassung", "hinweis_zeit", "hinweis_benutzer"):
roh.pop(weg, None)
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import hinweis as H # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
antworten = {"Fertig": False, "Hausnummer": True}
gesehen = []
def askyesno(titel, text, **kw):
gesehen.append((str(titel), str(text)))
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
def pumpen(fenster, bedingung, sekunden=6):
"""Ereignisschleife drehen, bis die Bedingung stimmt.
Der Hinweis wartet, bis das Fenster gezeichnet ist, und meldet sich per
after(). Ein einzelnes update() erreicht das nicht - hier wird die Schleife
gedreht wie mainloop() es taete. Abgekuerzt wird nichts.
"""
ende = time.time() + sekunden
while time.time() < ende:
try:
fenster.update()
except Exception: # noqa: BLE001 - Fenster kann zwischendurch weg sein
return bedingung()
if bedingung():
return True
time.sleep(0.02)
return bedingung()
def gelesen():
return json.load(open(config.CONFIG_PATH, encoding="utf-8"))
# ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt -----------------------
antworten["Erprobungsfassung"] = False
f = A.RechnungsApp()
pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen))
treffer = [t for t in gesehen if "Erprobungsfassung" in t[0]]
pruefe(bool(treffer), "der Hinweis kommt beim Start")
if treffer:
inhalt = treffer[0][1]
pruefe("ERPROBUNGSFASSUNG" in inhalt.upper(), "er nennt die Erprobungsfassung")
pruefe("Haftung" in inhalt, "er nennt die Haftung")
pruefe("Vorschau" in inhalt, "er verweist auf die Vorschau")
pruefe("git.pcore.de/TheMockTv/rechnungstool" in inhalt,
"er nennt die Adresse fuer Fehlermeldungen")
def fenster_zu(fenster):
"""True, wenn es das Fenster nicht mehr gibt.
Nach destroy() antwortet winfo_exists() nicht mehr mit 0, sondern wirft -
die ganze Anwendung ist weg. Beides heisst hier dasselbe.
"""
import tkinter
try:
return not fenster.winfo_exists()
except tkinter.TclError:
return True
pruefe(fenster_zu(f), "nach Nein ist das Fenster zu")
cfg = gelesen()
pruefe(not cfg.get("hinweis_zeit"), "nach Nein steht nichts in der Konfiguration")
# ---- 3. Neuer Start: es wird wieder gefragt ----------------------------
gesehen.clear()
antworten["Erprobungsfassung"] = True
f = A.RechnungsApp()
pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen))
pruefe(any("Erprobungsfassung" in t[0] for t in gesehen),
"beim naechsten Start wird wieder gefragt")
# ---- 4. Zustimmen: Signatur steht ---------------------------------------
cfg = gelesen()
pruefe(cfg.get("hinweis_fassung") == H.FASSUNG,
f"die Fassung ist vermerkt ({cfg.get('hinweis_fassung')!r})")
pruefe(bool(cfg.get("hinweis_zeit")), "der Zeitpunkt ist vermerkt")
pruefe(bool(cfg.get("hinweis_benutzer")), "der Benutzer ist vermerkt")
sig = H.signatur(f.cfg)
pruefe("gelesen und zur kenntnis genommen" in sig.lower(),
f"die Signaturzeile ist lesbar ({sig!r})")
# ---- 5. Kein zweites Mal fragen -----------------------------------------
gesehen.clear()
f.destroy()
f = A.RechnungsApp()
pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen), sekunden=2)
pruefe(not any("Erprobungsfassung" in t[0] for t in gesehen),
"nach dem Ja wird nicht noch einmal gefragt")
# ---- 6. Andere Fassung -> wieder fragen ---------------------------------
gesehen.clear()
f.destroy()
H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden
f = A.RechnungsApp()
pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen))
pruefe(any("Erprobungsfassung" in t[0] for t in gesehen),
"bei geaenderter Fassung wird erneut gefragt")
_wecker.cancel()
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 | Hinweis, Ablehnung und Signatur wirken")

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")

View file

@ -34,6 +34,10 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
@ -49,6 +53,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -62,18 +67,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)
# ---- Dialoge: jede Frage muss angemeldet sein --------------------------------
antworten = {}
@ -91,6 +84,11 @@ def askyesno(titel, text="", **kw):
raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}")
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else ""))
messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else ""))

View file

@ -33,6 +33,10 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
@ -48,6 +52,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -61,18 +66,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)
antworten = {"Fertig": False, "Storno wirklich": True,
"endgültig erstellen": True,
@ -86,6 +79,11 @@ def askyesno(titel, text="", **kw):
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = \
lambda *a, **k: None

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")

View file

@ -37,6 +37,10 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
@ -45,6 +49,7 @@ 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
def _reissleine():
@ -57,16 +62,6 @@ _wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
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)
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}
@ -82,6 +77,11 @@ def askyesno(titel, text, **kw):
warnungen = []
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
@ -155,7 +155,10 @@ pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Mo
from pypdf import PdfReader # noqa: E402
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})")
# Seit 05.09.2026 gibt es nur noch EIN Blatt. Ein zweites mit derselben Nummer,
# aber anderem Gesamtbetrag ("fuer das Amt") sieht bei einer Pruefung aus, als
# werde ueber zwei verschiedene Umsaetze abgerechnet.
pruefe(len(seiten) == 1, f"genau EIN Blatt - kein zweites fuers Amt ({len(seiten)})")
kunde_seite = seiten[0].replace("\n", " ")
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
@ -166,9 +169,8 @@ pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
"500,00 und 525,00 stehen drauf")
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
amt_seite = seiten[1].replace("\n", " ")
pruefe("Beherbergungssteuer" not in amt_seite,
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1,
"der Gesamtbetrag steht genau einmal in der Datei")
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
sys.path.append(JOURNAL_PROG)

View file

@ -23,16 +23,6 @@ import threading
TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
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,6 +56,7 @@ sys.path.remove(JOURNAL)
from datetime import date # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
@ -100,6 +91,10 @@ shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = rechnungen
roh["gemeinsam_ordner"] = gemeinsam_ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
@ -118,6 +113,11 @@ def askyesno(titel, text, **kw):
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
tool = A.RechnungsApp()

View file

@ -22,16 +22,6 @@ import threading
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
@ -55,14 +45,21 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: steht dort "eingerichtet", zeigt dlg_nummer nur eine
# Meldung statt des Fensters, das hier bedient werden soll.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402
import gemeinsam # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
warnungen = []
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda t, x, **k: ("Fertig" not in t)
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
@ -117,6 +114,15 @@ def knoepfe_von(w, aus=None):
return aus
def _alle_widgets(w, aus=None):
"""Jedes Widget unterhalb von w - egal wie tief."""
aus = [] if aus is None else aus
for k in w.winfo_children():
aus.append(k)
_alle_widgets(k, aus)
return aus
def felder_von(w, aus=None):
aus = [] if aus is None else aus
for k in w.winfo_children():
@ -204,7 +210,9 @@ pruefe(f.cfg.get("rechnung_zaehler") == 91,
f.cfg["nummer_eingerichtet"] = False
f.cfg["rechnung_jahr"] = JAHR
config.save_config(f.cfg)
A.TESTLAUF = False # den Erststart-Dialog wirklich zeigen
# Diesen einen Dialog will der Pruefstand wirklich sehen und bedienen. Alles
# andere bleibt im Pruflauf - sonst wartet der naechste Hinweis auf einen Klick.
f._erststart_zeigen = True
def erststart(zahl, erwartet_ja=True):
@ -223,11 +231,30 @@ def erststart(zahl, erwartet_ja=True):
top = tops[-1]
felder = felder_von(top)
if felder:
# Der Dialog fragt seit 05.09.2026 zuerst nach dem WEG: bei 0
# anfangen oder die eigene Zaehlung weiterfuehren. Ohne diesen
# Klick bleibt das Zahlenfeld gesperrt - hier wird also dasselbe
# gedrueckt wie beim Benutzer, nicht daran vorbei.
for w in _alle_widgets(top):
if w.winfo_class() == "TRadiobutton" and "weiterführen" in str(w.cget("text")):
w.invoke()
break
felder[0].delete(0, "end")
felder[0].insert(0, str(zahl))
for k in knoepfe_von(top):
if "bernehmen" in str(k.cget("text")):
k.invoke()
# Lehnt der Dialog die Zahl ab, BLEIBT er offen - das ist
# gewollt und wird weiter unten geprueft. Dann muss hier
# aber, wie ein Benutzer, "Spaeter" gedrueckt werden:
# sonst wartet das Programm in wait_window() ewig auf
# einen Klick, den im Pruefstand niemand macht.
top.update()
if top.winfo_exists():
for spaeter in knoepfe_von(top):
if "ter" in str(spaeter.cget("text")):
spaeter.invoke()
break
return
if rest:
f.after(100, lambda: klick(rest - 1))

View file

@ -31,16 +31,6 @@ QUELLE = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
sys.path.insert(0, QUELLE)
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
@ -69,7 +59,10 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
@ -183,14 +176,21 @@ j = Journal(os.path.join(tmp, "journal.sqlite3"))
j.scanne(ordner, 5.0, voll=True)
buchungen = j.buchungen(jahr=2026)
weg = aufgehobene(buchungen)
s = summiere(buchungen, weg)
# Verrechnet, nicht weggelassen (Entscheidung vom 04.09.2026, siehe
# modell.summiere im Steuerjournal): beide Zeilen stehen mit ihrem Vorzeichen
# in der Summe und heben sich gegenseitig auf.
s = summiere(buchungen)
print("--- was im Journal steht ---")
for b in buchungen:
print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Naechte {b.gezahlt:>8.2f}")
pruefe(len(buchungen) == 2, f"Rechnung und Storno stehen beide im Journal ({len(buchungen)})")
pruefe(s.buchungen == 0 and s.naechte == 0 and abs(s.gezahlt) < 0.005,
f"gemeldet wird NICHTS: 0 Rechnungen, 0 Naechte, 0,00 Euro "
f"(ist {s.buchungen}, {s.naechte}, {s.gezahlt})")
pruefe(len(weg) == 1, f"die Rechnung ist als aufgehoben erkannt ({len(weg)})")
pruefe(s.naechte == 0 and abs(s.gezahlt) < 0.005,
f"unterm Strich bleibt nichts uebrig: 0 Naechte, 0,00 Euro "
f"(ist {s.naechte}, {s.gezahlt})")
pruefe(s.buchungen == 2,
f"gezaehlt werden aber beide Belege - verrechnet, nicht weggelassen "
f"(ist {s.buchungen})")
j.schliessen()
f.destroy()

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")

114
pruef_selbstbezug.py Normal file
View file

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""Pruefstand: `self` darf nur dort stehen, wo es auch eines gibt.
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
--------------------------------------------
Nach dem Installieren meldete das Programm beim Start:
File "app.py", line 1824, in <module>
NameError: name 'self' is not defined
Im `if __name__ == "__main__"`-Block stand `self.melden_fehler(...)`. Dort gibt
es kein `self` - das Fenster wird ja gerade NICHT gebaut. Beim Umbau auf die
eigenen Meldungsfenster war die Zeile mitgezogen worden.
WARUM DAS KEINE SYNTAXPRUEFUNG FINDET
-------------------------------------
`ast.parse` und `py_compile` pruefen die FORM, nicht die Bedeutung. Ein
undefinierter Name faellt erst auf, wenn die Zeile ausgefuehrt wird - und diese
hier laeuft nur, wenn ein ZWEITES Fenster gestartet wird und sich das erste
nicht nach vorn holen laesst. Also fast nie. Genau deshalb ist sie durch alle
Pruefstaende gekommen.
WAS HIER GEPRUEFT WIRD
----------------------
Jede Verwendung des Namens `self` muss in einer Funktion stehen, die `self` auch
als ersten Parameter hat. Alles andere ist ein Ueberbleibsel aus einer Methode.
Geprueft werden beide Programme - der Fehler war in einem, haette aber genauso
im anderen stecken koennen.
"""
import ast
import io
import os
import sys
from pruefhelfer import fehler, pruefe
ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"]
def fundstellen(quelltext):
"""Alle Zeilen, in denen `self` steht, ohne dass es eines gibt."""
baum = ast.parse(quelltext)
treffer = []
def erster_parameter(knoten):
args = knoten.args
alle = list(args.posonlyargs) + list(args.args)
return alle[0].arg if alle else None
def durchgehen(knoten, hat_self):
for kind in ast.iter_child_nodes(knoten):
if isinstance(kind, (ast.FunctionDef, ast.AsyncFunctionDef)):
# Eine verschachtelte Funktion in einer Methode darf `self` aus
# der umgebenden Methode benutzen - das ist ein Abschluss und
# voellig richtig. Verfuegbar ist es also, sobald IRGENDEINE
# umgebende Funktion es als ersten Parameter hat.
durchgehen(kind, hat_self or erster_parameter(kind) == "self")
elif isinstance(kind, ast.Lambda):
durchgehen(kind, hat_self)
else:
if isinstance(kind, ast.Name) and kind.id == "self" and not hat_self:
treffer.append(kind.lineno)
durchgehen(kind, hat_self)
durchgehen(baum, False)
return sorted(set(treffer))
# ---- 1. Die Pruefung muss den bekannten Fehler FINDEN -------------------
# Sonst prueft sie nichts und wiegt uns in Sicherheit.
kaputt = (
"class A:\n"
" def m(self):\n"
" self.x = 1\n"
"\n"
'if __name__ == "__main__":\n'
" self.melden_fehler('geht nicht')\n"
)
gefunden = fundstellen(kaputt)
pruefe(gefunden == [6],
f"die Pruefung findet den bekannten Fehler in Zeile 6 (gefunden: {gefunden})")
sauber = (
"class A:\n"
" def m(self):\n"
" self.x = 1\n"
" f = lambda: self.x\n"
" return f\n"
)
pruefe(fundstellen(sauber) == [],
"und meldet sauberen Code nicht faelschlich")
# ---- 2. Beide Programme durchgehen -------------------------------------
for ordner in ORDNER:
for name in sorted(os.listdir(ordner)):
if not name.endswith(".py"):
continue
pfad = os.path.join(ordner, name)
try:
quelle = io.open(pfad, encoding="utf-8").read()
stellen = fundstellen(quelle)
except (OSError, SyntaxError) as e:
pruefe(False, f"{name} liess sich nicht lesen ({e})")
continue
if stellen:
pruefe(False, f"{os.path.basename(ordner)}/{name}: `self` ohne self "
f"in Zeile(n) {stellen}")
pruefe(True, f"{len(ORDNER)} Programme durchgegangen")
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Nirgends steht `self`, wo es keines gibt")

128
pruef_spaltenbreiten.py Normal file
View file

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Pruefstand: passt die Leistungstabelle in jedes erlaubte Fenster?
DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026)
--------------------------------------------
Sein Befund: *"die namen und datums bloecke koennen schmaler werden sind zu
breit und verdrengen die posten block"*. Gemessen stimmte das genau: die
sieben Spalten der Leistungstabelle brauchten 743 Pixel, sichtbar waren 618 -
"E.-Preis" und "Gesamt" standen ausserhalb des Bildes.
WARUM DAS KEIN ANDERER PRUEFSTAND FINDET
----------------------------------------
Es stuerzt nichts ab, es rechnet nichts falsch, kein Wert fehlt. Die Rechnung
wird korrekt erstellt - man sieht die Preise nur nicht. Ein Fehler, den man
ansehen muss, um ihn zu bemerken. Genau deshalb steht er hier als Messung.
Und die Ursache lag nicht dort, wo man sie vermutet: an den Feldbreiten links
zu drehen half NICHTS. Die Arbeitsflaeche verteilte die Breite ueber eine
uniform-Gruppe streng im Verhaeltnis 4:3 - die Spalte war so breit, weil das
Gewicht es vorschrieb, nicht weil der Inhalt es brauchte.
WAS HIER GEPRUEFT WIRD
----------------------
Ueber die ganze erlaubte Spanne von der Mindestbreite bis zu einem grossen
Fenster:
1. Alle Spalten der Tabelle passen nebeneinander in die sichtbare Flaeche.
2. Die Formularseite bleibt lesbar - sie darf nicht unter ihre gemessene
Mindestbreite fallen.
3. Die Formularseite waechst NICHT mit. Zusaetzliche Breite gehoert der
Tabelle; ein 600 Pixel breites Feld fuer eine Postleitzahl macht nichts
besser.
4. Der Textumbruch der Leistungsspalte folgt ihrer echten Breite. Ist er
groesser, wird der Text abgeschnitten; ist er viel kleiner, bricht er
unnoetig um.
"""
import os
import sys
import time
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
antworten = {"Erprobungsfassung": True, "Fertig": False, "Hausnummer": True}
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
for _n in ("showinfo", "showwarning", "showerror"):
setattr(messagebox, _n, lambda *a, **k: None)
import app as A # noqa: E402
from pruefhelfer import fehler, pruefe
f = A.RechnungsApp()
def einpendeln(sekunden=1.2):
"""Ereignisschleife drehen - Tk verteilt die Breite erst danach."""
ende = time.time() + sekunden
while time.time() < ende:
f.update()
time.sleep(0.02)
f.update_idletasks()
einpendeln(2.0)
# Die Mindestbreite ist die untere Grenze dessen, was der Benutzer einstellen
# kann - genau dort muss es noch passen.
MINDEST = f.minsize()[0]
print(f" (Mindestbreite laut minsize: {MINDEST})")
formular_breiten = []
for breite in (MINDEST, MINDEST + 40, 1180, 1400, 1600):
f.geometry(f"{breite}x680")
einpendeln()
pf = f.pos_frame
sichtbar = pf.master.winfo_width() # der Canvas darunter
spalten = [(pf.grid_bbox(column=i, row=0) or (0, 0, 0, 0))[2]
for i in range(len(A.RechnungsApp.COLS))]
gesamt = sum(spalten)
formular = f.flaeche.kaesten["kunde"]["aussen"].winfo_width()
formular_breiten.append(formular)
pruefe(gesamt <= sichtbar,
f"bei {breite:4d} px passen alle {len(spalten)} Spalten "
f"({gesamt} von {sichtbar} px)")
pruefe(formular >= 300,
f"bei {breite:4d} px bleibt die Formularseite lesbar ({formular} px)")
# Der Umbruch der Textspalte muss zur Spalte passen.
zellen = getattr(f, "_text_zellen", [])
if zellen:
umbruch = int(zellen[0].cget("wraplength"))
text_spalte = spalten[1]
pruefe(umbruch <= text_spalte,
f"bei {breite:4d} px bricht der Leistungstext INNERHALB der "
f"Spalte um ({umbruch} <= {text_spalte})")
pruefe(umbruch >= text_spalte - 30,
f"bei {breite:4d} px nutzt er die Spalte auch aus "
f"({umbruch} >= {text_spalte - 30})")
# Mehr Platz gehoert der Tabelle, nicht den Adressfeldern.
pruefe(len(set(formular_breiten)) == 1,
f"die Formularseite waechst nicht mit dem Fenster {formular_breiten}")
try:
f.destroy()
except Exception: # noqa: BLE001
pass
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Die Leistungstabelle passt in jedes erlaubte Fenster")

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

@ -29,6 +29,10 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
@ -44,6 +48,7 @@ from tkinter import messagebox # noqa: E402
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
import threading as _th
from pruefhelfer import fehler, pruefe
def _reissleine():
@ -57,18 +62,6 @@ _wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
# nicht mit einem UnicodeEncodeError abbrechen.
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)
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {}
@ -88,6 +81,11 @@ def askyesno(titel, text, **kw):
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier
# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py.
antworten["jetzt erstellen?"] = True
# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py.
antworten["Erprobungsfassung"] = True
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
warnungen = []

220
pruef_updater.py Normal file
View file

@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
"""Pruefstand: der Updater - vor allem die Faelle, in denen er NICHTS tun darf.
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."
"wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal,
die heisst anders, dann neu machen"
Ein Updater ist gefaehrlicher als das Programm, das er aktualisiert: Er
entscheidet ueber die Datei, die der Betrieb braucht. Deshalb wird hier weniger
geprueft, DASS er aktualisiert, als dass er es in den falschen Faellen NICHT
tut - und dass er nie etwas ueberschreibt.
Ohne Netz: die Release-Liste wird eingesetzt, nicht geholt.
"""
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
import updater as U # noqa: E402
from pruefhelfer import fehler, pruefe
EXE = "Rechnungstool.exe"
# ---- Versionen vergleichen ---------------------------------------------
pruefe(U.zerlegen("v1.6.0") == (1, 6, 0), "v1.6.0 wird zu (1, 6, 0)")
pruefe(U.zerlegen("") == (0,), "leere Angabe zaehlt als aelteste")
pruefe(U.zerlegen("kaputt") == (0,), "unlesbare Angabe zaehlt als aelteste")
pruefe(U.ist_neuer("v1.7.0", "1.6.9"), "1.7.0 ist neuer als 1.6.9 (nicht alphabetisch!)")
pruefe(not U.ist_neuer("v1.6.0", "1.6.0"), "Gleichstand ist NICHT neuer")
pruefe(U.ist_neuer("v1.10.0", "1.9.0"), "1.10.0 ist neuer als 1.9.0")
# ---- Die Nummer im Dateinamen ------------------------------------------
pruefe(U.dateiname(EXE, "v1.6.0") == "Rechnungstool-1.6.0.exe",
"aus Name und Tag wird Rechnungstool-1.6.0.exe")
pruefe(U.dateiname("Rechnungstool-1.5.0.exe", "v1.6.0") == "Rechnungstool-1.6.0.exe",
"eine schon vorhandene Nummer wird ersetzt, nicht angehaengt")
pruefe(U.basis_und_endung(EXE) == ("Rechnungstool", ".exe"), "Stamm und Endung sauber getrennt")
# ---- Das passende Release finden ---------------------------------------
def release(tag, dateien, entwurf=False, vorab=True):
return {"tag_name": tag, "draft": entwurf, "prerelease": vorab,
"assets": [{"name": n, "size": g,
"browser_download_url": f"http://x/{tag}/{n}"}
for n, g in dateien]}
liste = [release("v1.5.0", [(EXE, 10)]),
release("v1.6.0", [(EXE, 20)]),
release("v1.5.5", [(EXE, 15)])]
treffer = U.passendes_release(liste, EXE)
pruefe(treffer and treffer[0]["tag_name"] == "v1.6.0",
"aus einer unsortierten Liste wird die neueste genommen")
pruefe(U.passendes_release([release("v9.9.9", [("anderes.exe", 5)])], EXE) is None,
"ein Release ohne passende Datei zaehlt nicht")
pruefe(U.passendes_release([release("v9.9.9", [(EXE, 5)], entwurf=True)], EXE) is None,
"ein Entwurf zaehlt nicht")
# Vorabversionen MUESSEN zaehlen - alle Fassungen sind so gekennzeichnet.
pruefe(U.passendes_release([release("v2.0.0", [(EXE, 30)], vorab=True)], EXE) is not None,
"Vorabversionen zaehlen mit (sonst faende der Updater nie etwas)")
# ---- Vollstaendigkeit ---------------------------------------------------
tmp = tempfile.mkdtemp(prefix="upd_pruef_")
probe = os.path.join(tmp, "probe.bin")
with open(probe, "wb") as f:
f.write(b"A" * 1000)
summe = U.sha256(probe)
gut, _ = U.vollstaendig(probe, 1000, summe)
pruefe(gut, "richtige Groesse und Pruefsumme werden angenommen")
gut, grund = U.vollstaendig(probe, 999, summe)
pruefe(not gut and "Groesse" in grund, f"falsche Groesse wird abgelehnt ({grund})")
gut, grund = U.vollstaendig(probe, 1000, "0" * 64)
pruefe(not gut and "Pruefsumme" in grund, f"falsche Pruefsumme wird abgelehnt ({grund})")
gut, _ = U.vollstaendig(probe, 1000, "")
pruefe(gut, "ohne Pruefsumme entscheidet die Groesse allein")
gut, _ = U.vollstaendig(os.path.join(tmp, "gibtsnicht"), 1000, "")
pruefe(not gut, "eine fehlende Datei ist nicht vollstaendig")
# ---- Was liegt im Ordner? ----------------------------------------------
ordner = os.path.join(tmp, "install")
os.makedirs(ordner)
for name, inhalt in (("Rechnungstool-1.5.0.exe", b"ALT"),
("Rechnungstool-1.6.0.exe", b"AKTUELL"),
("nichts-zu-tun.txt", b"x")):
with open(os.path.join(ordner, name), "wb") as f:
f.write(inhalt)
fassung, pfad = U.neueste_installierte(ordner, EXE)
pruefe(fassung == "1.6.0", f"die neueste vorhandene Fassung wird am Namen erkannt ({fassung})")
pruefe(os.path.basename(pfad) == "Rechnungstool-1.6.0.exe", "und der Pfad stimmt")
pruefe(len(U.installierte(ordner, EXE)) == 2, "fremde Dateien im Ordner stoeren nicht")
# ---- Der ganze Durchlauf, ohne Netz -------------------------------------
meldungen = []
def sagen(t):
meldungen.append(str(t))
def alle_da():
return sorted(os.path.basename(p) for _f, p in U.installierte(ordner, EXE))
# 1. gleiche Fassung -> nichts laden, vorhandene starten
meldungen.clear()
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen,
holen_=lambda _r: [release("v1.6.0", [(EXE, 11)])])
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"gleiche Fassung: die vorhandene wird gestartet")
pruefe(alle_da() == ["Rechnungstool-1.5.0.exe", "Rechnungstool-1.6.0.exe"],
"und im Ordner hat sich nichts geaendert")
# 2. aeltere Fassung im Netz -> nichts tun
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen,
holen_=lambda _r: [release("v1.5.0", [(EXE, 11)])])
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"aeltere Fassung im Netz: es wird nicht aktualisiert")
# 3. kein Netz -> vorhandene starten und sagen, warum
meldungen.clear()
def wirft(_r):
raise OSError("kein Netz")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=wirft)
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"ohne Netz wird die vorhandene Fassung gestartet")
pruefe(any("Kein Update" in m for m in meldungen), "und es wird gesagt, warum")
# 4. neuere Fassung, aber Groesse passt nicht -> VERWERFEN
meldungen.clear()
falsch = release("v1.9.0", [(EXE, 999999)])
falsch["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [falsch])
pruefe(any("VERWORFEN" in m for m in meldungen), "falsche Groesse: das Update wird verworfen")
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"und die vorhandene Fassung wird gestartet")
pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")),
"die halbe Datei erreicht den Installationsordner NICHT - der wichtigste Punkt")
# 5. neuere Fassung, alles stimmt -> als NEUE Datei dazu
meldungen.clear()
richtig = release("v1.9.0", [(EXE, 1000)])
richtig["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/")
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe",
"die neue Fassung wird gestartet")
pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")),
"sie liegt als EIGENE Datei im Ordner")
pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.6.0.exe")),
"die vorige bleibt liegen - sie ist die Rueckfallebene")
pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.5.0.exe")),
"die vorvorige wurde aufgeraeumt (es bleiben immer zwei)")
buch = U.buch_lesen(ordner)
pruefe(buch.get("version") == "1.9.0",
f"der Updater fuehrt Buch ueber das, was er installiert hat ({buch})")
# 6. gleich noch einmal -> nichts laden, es ist ja jetzt aktuell
meldungen.clear()
vorher = alle_da()
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(any("Aktuell" in m for m in meldungen),
f"unmittelbar danach gilt es als aktuell ({meldungen})")
pruefe(alle_da() == vorher, "und es wird nichts noch einmal geladen")
# 7. Buch verloren, Datei aber da -> nicht noch einmal laden
# Kommt vor, wenn jemand installiert.json loescht. Der Dateiname allein traegt
# die Entscheidung: die 1.9.0 liegt da, also wird sie nur gestartet.
meldungen.clear()
os.remove(os.path.join(ordner, U.BUCH))
start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe",
"auch ohne Buch wird die richtige Datei gestartet")
pruefe(not any("Wird geladen" in m for m in meldungen),
f"und nichts noch einmal geladen ({meldungen})")
# 8. Ordner nicht beschreibbar -> kein Update, aber das Programm startet
# Sein Einwand: "was ist, wenn ein user den ordner aendert?" Liegt die
# Installation dann in einem geschuetzten Ordner, darf das Update scheitern -
# aber NIE der Start. Ein fehlgeschlagenes Update, nach dem gar nichts mehr
# geht, waere schlimmer als kein Update.
meldungen.clear()
gesperrt = os.path.join(tmp, "gesperrt")
os.makedirs(gesperrt)
with open(os.path.join(gesperrt, "Rechnungstool-1.6.0.exe"), "wb") as f:
f.write(b"INSTALLIERT")
echt_beschreibbar = U.beschreibbar
U.beschreibbar = lambda _o: False # so, als waere es "Programme"
try:
start = U.durchlauf(gesperrt, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig])
finally:
U.beschreibbar = echt_beschreibbar
pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe",
"geschuetzter Ordner: die vorhandene Fassung wird trotzdem gestartet")
pruefe(any("nicht geschrieben werden" in m for m in meldungen),
f"und es wird gesagt, warum ({meldungen})")
pruefe(not os.path.exists(os.path.join(gesperrt, "Rechnungstool-1.9.0.exe")),
"es wurde nichts abgelegt")
shutil.rmtree(tmp, ignore_errors=True)
if fehler:
print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt")
sys.exit(1)
print("GRUEN | Der Updater aktualisiert - und laesst in den falschen Faellen die Finger davon")

115
pruef_vorschau.py Normal file
View file

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""Pruefstand: der Vorschau-Knopf.
Die Vorschau soll zeigen, wie das Blatt aussehen WIRD - und dabei nichts
anfassen. Drei Zusicherungen, die alle drei still brechen koennten:
1. Sie verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch.
Sonst fehlt in der Buchhaltung eine Nummer, fuer die es kein Blatt gibt.
2. Sie legt nichts im Rechnungsordner ab - dort liegt die Buchhaltung.
3. Auf jeder Seite steht "VORSCHAU - keine Rechnung". Ein Blatt ohne diesen
Hinweis koennte ausgedruckt beim Gast landen und als Rechnung gelten.
"""
import json
import os
import shutil
import sys
import tempfile
import threading as _th
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="vorschau_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des
# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier
# jemand die Nummer einmal eingerichtet hat.
roh["nummer_eingerichtet"] = False
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402
from pruefhelfer import fehler, pruefe
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
# Geprueft wird er eigens in pruef_hinweis.py.
messagebox.askyesno = lambda *a, **k: True
# Im Pruflauf oeffnet das Programm nichts mehr (sonst stehen nach einem Lauf
# dreissig PDF-Fenster offen). Geprueft wird der Pfad, den die Vorschau
# zurueckgibt - und dass wirklich NICHTS aufgemacht wurde.
geoeffnet = []
A.os.startfile = lambda p, *a, **k: geoeffnet.append(p)
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
erste = sorted(f.anzahl_vars)[0]
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.anzahl_vars[erste].set("3")
f.update()
nummer_vorher = f.r_nummer.get()
zaehler_vorher = f.cfg.get("rechnung_zaehler")
pfad = f.vorschau_zeigen()
pruefe(bool(pfad), f"die Vorschau nennt ihr Blatt ({os.path.basename(str(pfad))})")
pruefe(not geoeffnet, f"im Pruflauf wird KEIN Betrachter aufgemacht ({geoeffnet})")
pruefe(os.path.exists(pfad), "Vorschau-PDF wurde geschrieben")
pruefe(os.path.abspath(ordner) not in os.path.abspath(pfad),
"Vorschau liegt NICHT im Rechnungsordner")
pruefe(not os.listdir(ordner), f"Rechnungsordner bleibt leer ({os.listdir(ordner)})")
pruefe(f.r_nummer.get() == nummer_vorher,
f"Rechnungsnummer unveraendert ({nummer_vorher} -> {f.r_nummer.get()})")
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, "Zaehler unveraendert")
from pypdf import PdfReader # noqa: E402
seiten = [s.extract_text() or "" for s in PdfReader(pfad).pages]
pruefe(all("VORSCHAU" in t for t in seiten),
f"'VORSCHAU' steht auf jeder Seite ({len(seiten)} Seiten)")
pruefe(all("keine Rechnung" in t for t in seiten), "'keine Rechnung' steht auf jeder Seite")
# Und die echte Rechnung danach traegt die Marke NICHT.
f.pdf_erstellen()
echte = [os.path.join(ordner, n) for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(echte) == 1, f"danach entsteht genau eine echte Rechnung ({len(echte)})")
if echte:
text = "".join((s.extract_text() or "") for s in PdfReader(echte[0]).pages)
pruefe("VORSCHAU" not in text, "die echte Rechnung traegt KEINE Vorschau-Marke")
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
sys.exit(1)
print("ALLES GRUEN")

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)

103
pruefsteuerung.py Normal file
View file

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""Steuerung fuer Pruefstaende: keine Fenster, keine Popups, keine Maus.
WARUM
-----
Ein Pruefstand baut das Programm auf, um es zu BEDIENEN - nicht, um es zu
zeigen. Solange er wie ein Benutzer arbeitet, reisst jeder Lauf dem Menschen am
Rechner Fokus und Maus weg, und jeder Dialog bleibt stehen und wartet auf einen
Klick, den niemand macht.
Deshalb zwei Dinge, beide nur im Testlauf (`--test` / `CAMPINGHOF_TEST=1`):
* Das Fenster wird gar nicht erst gezeigt (`withdraw()` in app.py).
* Alle Dialoge laufen hierueber statt ueber echte Fenster.
WAS EIN DIALOG HIER TUT
-----------------------
`showinfo/showwarning/showerror` werden ins PROTOKOLL geschrieben und
verschwinden. `askyesno` und Verwandte schlagen im Fahrplan nach:
pruefsteuerung.antwortet("Hausnummer", True)
Passt kein Eintrag, bricht der Lauf mit einer Meldung ab - ABSICHTLICH. Ein
unbekannter Dialog heisst: das Programm fragt etwas Neues, und niemand hat
entschieden, was der Pruefstand darauf antworten soll. Stillschweigend "ja" zu
sagen waere die faule Loesung und wuerde genau die Faelle uebergehen, wegen
denen es die Pruefung gibt.
BENUTZUNG IM PRUEFSTAND
-----------------------
import pruefsteuerung as PS
PS.antwortet("Storno wirklich", True)
...
PS.gemeldet("Kundenangaben fehlen") -> True, wenn das Programm es sagte
"""
from tkinter import messagebox
# Was das Programm gemeldet haette: Liste aus (art, titel, text).
PROTOKOLL = []
# Fahrplan fuer Ja/Nein-Fragen: Textstueck im Titel -> Antwort.
ANTWORTEN = {}
_aktiv = False
def antwortet(titelstueck, wert):
"""Legt fest, wie eine Frage beantwortet wird, deren Titel das Stueck enthaelt."""
ANTWORTEN[titelstueck] = wert
def zuruecksetzen():
PROTOKOLL.clear()
ANTWORTEN.clear()
def meldungen(stueck=""):
"""Alle Meldungen (oder die, in denen das Stueck vorkommt)."""
return [m for m in PROTOKOLL if stueck in m[1] or stueck in m[2]]
def gemeldet(stueck):
"""Hat das Programm etwas gemeldet, in dem das Stueck vorkommt?"""
return bool(meldungen(stueck))
def _melden(art):
def gemeldet_(titel="", text="", **_kw):
PROTOKOLL.append((art, str(titel), str(text)))
print(f"[dialog] {art}: {titel}")
return "ok"
return gemeldet_
def _fragen(vorgabe=None):
def gefragt(titel="", text="", **_kw):
PROTOKOLL.append(("frage", str(titel), str(text)))
for stueck, wert in ANTWORTEN.items():
if stueck in str(titel) or stueck in str(text):
print(f"[dialog] frage: {titel} -> {wert}")
return wert
if vorgabe is not None:
return vorgabe
raise AssertionError(
f"unangemeldeter Dialog: {titel!r}\n"
f" {text!r}\n"
" -> im Pruefstand mit pruefsteuerung.antwortet(<Stueck>, True/False) "
"beantworten")
return gefragt
def einschalten():
"""Dialoge umleiten. Mehrfaches Aufrufen schadet nicht."""
global _aktiv
if _aktiv:
return
messagebox.showinfo = _melden("info")
messagebox.showwarning = _melden("warnung")
messagebox.showerror = _melden("fehler")
messagebox.askyesno = _fragen()
messagebox.askokcancel = _fragen()
messagebox.askretrycancel = _fragen(False)
messagebox.askquestion = _fragen()
_aktiv = True
print("[pruefsteuerung] Dialoge laufen ohne Fenster")

94
reiter.py Normal file
View file

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""Reiterleiste im Pcore-Stil: Knopf mit Unterstrich statt ttk-Karteikarte.
WARUM NICHT ttk.Notebook
------------------------
Unter dem clam-Thema laesst sich die Reiterflaeche nicht zuverlaessig
einfaerben: der RUHENDE Reiter blieb hell und sah aus wie der ausgewaehlte -
also genau verkehrt herum. Sein Fund: *"er hebt den aktiven tab nicht hervor,
der inaktive ist der hervorgehobene"*.
Statt weiter an clam zu drehen, ist die Leiste hier selbst gebaut - und sieht
dabei aus wie die Reiter in seinen Web-Oberflaechen: ein flacher Knopf, der
offene in Akzentfarbe mit einem Balken darunter.
NACH AUSSEN wie ein Notebook
----------------------------
`add`, `select`, `index("current"/"end")`, `tab(i, "text")` und das Ereignis
`<<NotebookTabChanged>>` verhalten sich wie beim Original. So bleiben die
Aufrufer unveraendert - der Storno-Weg waehlt weiter `select(1)`, und die
Pruefstaende lesen die Reiternamen wie bisher.
"""
import tkinter as tk
from tkinter import ttk
import theme
class Reiterleiste(ttk.Frame):
def __init__(self, master, farben=None):
super().__init__(master)
self.f = farben if farben is not None else master
self._seiten = [] # (Rahmen, Text, Knopf, Balken)
self._aktiv = 0
self.leiste = ttk.Frame(self)
self.leiste.pack(side="top", fill="x")
self.bereich = ttk.Frame(self)
self.bereich.pack(side="top", fill="both", expand=True)
# ---- wie beim Notebook ------------------------------------------------
def add(self, rahmen, text=""):
nr = len(self._seiten)
kasten = ttk.Frame(self.leiste)
kasten.pack(side="left", padx=(0, 4))
knopf = tk.Label(kasten, text=text.strip(), cursor="hand2",
bg=self.f.col_bg, fg=self.f.col_muted,
font=(theme.SCHRIFT_NAME, 10), padx=18, pady=8)
knopf.pack(side="top", fill="x")
# Der Balken unter dem offenen Reiter - dasselbe Zeichen wie in den
# Web-Oberflaechen.
balken = tk.Frame(kasten, height=2, bg=self.f.col_bg)
balken.pack(side="top", fill="x")
knopf.bind("<Button-1>", lambda _e, i=nr: self.select(i))
knopf.bind("<Enter>", lambda _e, i=nr: self._schweben(i, True))
knopf.bind("<Leave>", lambda _e, i=nr: self._schweben(i, False))
self._seiten.append([rahmen, text, knopf, balken])
if nr == 0:
rahmen.pack(in_=self.bereich, fill="both", expand=True)
self._zeichnen()
def select(self, nr):
nr = int(nr)
if not (0 <= nr < len(self._seiten)) or nr == self._aktiv:
self._zeichnen()
return
self._seiten[self._aktiv][0].pack_forget()
self._aktiv = nr
self._seiten[nr][0].pack(in_=self.bereich, fill="both", expand=True)
self._zeichnen()
# Dasselbe Ereignis wie beim Notebook - die App haengt daran.
self.event_generate("<<NotebookTabChanged>>")
def index(self, was="current"):
return len(self._seiten) if was == "end" else self._aktiv
def tab(self, nr, key="text"):
return self._seiten[int(nr)][1] if key == "text" else ""
# ---- Aussehen ---------------------------------------------------------
def _schweben(self, nr, drueber):
if nr == self._aktiv:
return
knopf = self._seiten[nr][2]
knopf.configure(fg=self.f.col_fg if drueber else self.f.col_muted)
def _zeichnen(self):
for i, (_rahmen, _text, knopf, balken) in enumerate(self._seiten):
offen = (i == self._aktiv)
knopf.configure(
fg=self.f.col_accent if offen else self.f.col_muted,
bg=self.f.col_card if offen else self.f.col_bg,
font=(theme.SCHRIFT_NAME, 10, "bold") if offen
else (theme.SCHRIFT_NAME, 10))
balken.configure(bg=self.f.col_accent if offen else self.f.col_bg)

786
theme.py
View file

@ -5,11 +5,254 @@ 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"):
@ -23,35 +266,95 @@ def windows_dark_mode():
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 zum Windows-Theme (hell/dunkel) setzen."""
dark = windows_dark_mode()
"""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 = "#23272e" # Fenster-Hintergrund
self.col_fg = "#e6e6e6" # Schrift
self.col_field = "#2f343c" # Eingabefelder
self.col_sel = "#3a4a44" # aktiv/hover
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
self.col_line = "#3a3f47"
self.col_scroll = "#4a505a" # Scroll-Griff
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
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 = "#f3f3f3"
self.col_fg = "#1a1a1a"
self.col_bg = "#eef0f3"
self.col_card = "#ffffff"
self.col_fg = "#14161a"
self.col_field = "#ffffff"
self.col_sel = "#cfe3d8"
self.col_accent = "#1f5c3d"
self.col_line = "#cccccc"
self.col_scroll = "#b8b8b8"
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
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")
@ -62,28 +365,132 @@ class ThemeMixin:
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)
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)
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
# --- 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)])
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=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=field, foreground=fg,
background=field, arrowcolor=fg,
selectbackground=field, selectforeground=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)],
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
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", field)
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)
@ -93,45 +500,320 @@ class ThemeMixin:
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))
style.configure("TNotebook.Tab", background=field, foreground=self.col_muted,
bordercolor=self.col_line, padding=(16, 7),
font=("Segoe UI", 10))
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", bg), ("active", sel)],
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", ("Segoe UI", 10, "bold"))],
expand=[("selected", (1, 1, 1, 0))])
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."""
"""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):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)
"""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.8.2` -> VERSION "1.8.2". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
VERSION = "1.8.2"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/rechnungstool"
# Wie die ausgelieferte Datei im Release heisst.
EXE_NAME = "Rechnungstool.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)