diff --git a/.gitignore b/.gitignore
index 3833f58..a21aa42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md
new file mode 100644
index 0000000..c520033
--- /dev/null
+++ b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md
@@ -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`)
diff --git a/AUFGABE_ONLINE_API_MYSQL.md b/AUFGABE_ONLINE_API_MYSQL.md
new file mode 100644
index 0000000..31ca040
--- /dev/null
+++ b/AUFGABE_ONLINE_API_MYSQL.md
@@ -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.
diff --git a/Diagnose.spec b/Diagnose.spec
new file mode 100644
index 0000000..d030d6f
--- /dev/null
+++ b/Diagnose.spec
@@ -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,
+)
diff --git a/Installer.iss b/Installer.iss
new file mode 100644
index 0000000..86f6ce7
--- /dev/null
+++ b/Installer.iss
@@ -0,0 +1,95 @@
+; 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
+DefaultDirName={localappdata}\Programs\{#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]
+Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
+ GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked
+
+[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.
+Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"
+Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
+Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; Tasks: desktopicon
+
+[Run]
+Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
+ Flags: nowait postinstall skipifsilent
+
+[UninstallDelete]
+; Nur die Buchfuehrung des Updaters.
+; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein
+; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben.
+Type: files; Name: "{app}\installiert.json"
diff --git a/Rechnungstool.spec b/Rechnungstool.spec
new file mode 100644
index 0000000..57d1265
--- /dev/null
+++ b/Rechnungstool.spec
@@ -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,
+)
diff --git a/Updater.spec b/Updater.spec
new file mode 100644
index 0000000..a82ec2a
--- /dev/null
+++ b/Updater.spec
@@ -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',
+)
diff --git a/app.py b/app.py
index b833b73..ade214f 100644
--- a/app.py
+++ b/app.py
@@ -32,6 +32,7 @@ import config
import berechnung
import gemeinsam
import hinweis
+import wasserzeichen
import ordnerwahl
import pdf_renderer
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
@@ -63,6 +64,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
super().__init__()
self.cfg = config.load_config()
self.title(config.PROGRAMM)
+ self._fenstersymbol()
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
self.geometry(f"1060x{hoehe}")
@@ -118,11 +120,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
self.protocol("WM_DELETE_WINDOW", self._beenden)
- # after_idle statt after(0): laeuft, sobald Tk nichts mehr zu zeichnen hat -
- # also nachdem das Hauptfenster steht. Bei after(0) haengt der modale Dialog
- # an einem noch ungezeichneten Fenster und wirkt wie eingefroren; eine feste
- # Wartezeit dagegen erreicht ein Pruefstand nie, der keine Schleife dreht.
- self.after_idle(self._hinweis_pruefen)
+ # Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH
+ # gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten
+ # EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog
+ # sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein
+ # haengendes Programm (06.09.2026, am Installer aufgefallen).
+ self.after(50, self._hinweis_wenn_sichtbar)
# ---------------------------------------------------------------- Menue
@@ -243,6 +246,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
self.flaeche = flaeche.Arbeitsflaeche(self, farben=self)
self.flaeche.pack(side="top", fill="both", expand=True, padx=10, pady=(4, 0))
+ # Das Wasserzeichen liegt HINTER der Arbeitsflaeche, unten rechts. Es
+ # wird mit place() gesetzt, damit es die Anordnung der Kaesten nicht
+ # anfasst - die darf der Benutzer ja selbst verschieben. lower() legt
+ # es unter alles andere.
+ self._wasserzeichen_setzen()
def _anordnen_umschalten(self):
self.flaeche.anordnen_starten()
@@ -611,6 +619,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
def _build_summen(self):
# Statuszeile ganz unten
status = ttk.Frame(self, padding=(12, 2))
+ # Das Programmlogo neben der Marke - klein, immer sichtbar. Es steht
+ # rechts bei "powered by ravokk", weil dort die Kennung des Werkzeugs
+ # sitzt und nicht die des Kunden.
+ self._kopf_bild = wasserzeichen.kopf(18, master=self)
+ if self._kopf_bild is not None:
+ ttk.Label(status, image=self._kopf_bild).pack(side="right", padx=(0, 6))
status.pack(side="bottom", fill="x")
ttk.Label(status, text=config.MARKE, foreground=self.col_muted,
font=(theme.SCHRIFT_NAME, 8)).pack(side="right")
@@ -644,6 +658,40 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
# --------------------------------------------------- Gemeinsames Buch
# ------------------------------------------------- Erprobungshinweis
+
+ def _wasserzeichen_setzen(self, groesse=240):
+ """Das Programmlogo blass hinter die Arbeitsflaeche legen."""
+ bild = wasserzeichen.hole(groesse, 0.06, master=self)
+ if bild is None:
+ return
+ # Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
+ # zeigt eine leere Flaeche (der klassische PhotoImage-Fehler).
+ self._wz_bild = bild
+ self._wz = tk.Label(self.flaeche, image=bild, borderwidth=0,
+ background=self.col_bg)
+ self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-12, y=-12)
+ self._wz.lower()
+
+ def _fenstersymbol(self):
+ """Das Programmsymbol auf das Fenster legen."""
+ return wasserzeichen.symbol_setzen(self)
+ print("[symbol] assets/programm.ico nicht gefunden")
+ return False
+
+ def _hinweis_wenn_sichtbar(self, versuche=0):
+ """Wartet, bis das Fenster gezeichnet ist, und fragt dann.
+
+ winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm
+ ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach
+ rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das
+ Fenster absichtlich nicht sichtbar, und dort soll es trotzdem
+ laufen.
+ """
+ if not self.winfo_viewable() and versuche < 40:
+ self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1))
+ return
+ self._hinweis_pruefen()
+
def _hinweis_pruefen(self):
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm.
@@ -1821,7 +1869,11 @@ if __name__ == "__main__":
if not fenster_nach_vorn(laufende_pid_lesen()):
_r = tk.Tk()
_r.withdraw()
- self.melden_fehler(
+ # Hier gibt es kein self - das Fenster wird ja gerade NICHT gebaut.
+ # Deshalb die schlichte Windows-Meldung statt des eigenen Dialogs.
+ # (Frueher stand hier self.melden_fehler und ist beim Start eines
+ # zweiten Fensters mit NameError abgestuerzt.)
+ messagebox.showerror(
"Rechnungstool läuft bereits",
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
diff --git a/assets/programm.ico b/assets/programm.ico
new file mode 100644
index 0000000..cae16c9
Binary files /dev/null and b/assets/programm.ico differ
diff --git a/assets/programm_logo.png b/assets/programm_logo.png
new file mode 100644
index 0000000..6086c87
Binary files /dev/null and b/assets/programm_logo.png differ
diff --git a/assets/programm_logo.svg b/assets/programm_logo.svg
new file mode 100644
index 0000000..f16f814
--- /dev/null
+++ b/assets/programm_logo.svg
@@ -0,0 +1,86 @@
+
+
+
diff --git a/bauen.py b/bauen.py
new file mode 100644
index 0000000..2220498
--- /dev/null
+++ b/bauen.py
@@ -0,0 +1,109 @@
+# -*- 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"):
+ shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True)
+
+ 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")
+
+ print()
+ print(f"FERTIG | {stamm} {version.VERSION}")
+ for name in sorted(os.listdir(os.path.join(HIER, "dist"))):
+ p = os.path.join(HIER, "dist", name)
+ print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
+ setup = os.path.join(HIER, "setup")
+ for name in sorted(os.listdir(setup)) if os.path.isdir(setup) else []:
+ p = os.path.join(setup, name)
+ print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pdf_renderer.py b/pdf_renderer.py
index 9797acc..a72f2a7 100644
--- a/pdf_renderer.py
+++ b/pdf_renderer.py
@@ -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:
diff --git a/pruef_hinweis.py b/pruef_hinweis.py
index 6558969..09eb11c 100644
--- a/pruef_hinweis.py
+++ b/pruef_hinweis.py
@@ -22,6 +22,7 @@ Geprueft wird die ganze Strecke, weil jeder Teil fuer sich wertlos waere:
"""
import json
import os
+import time
import shutil
import sys
import tempfile
@@ -89,6 +90,25 @@ 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"))
@@ -96,7 +116,7 @@ def gelesen():
# ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt -----------------------
antworten["Erprobungsfassung"] = False
f = A.RechnungsApp()
-f.update() # laesst das after(0) laufen -> Hinweis kommt
+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")
@@ -129,7 +149,7 @@ pruefe(not cfg.get("hinweis_zeit"), "nach Nein steht nichts in der Konfiguration
gesehen.clear()
antworten["Erprobungsfassung"] = True
f = A.RechnungsApp()
-f.update()
+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")
@@ -147,7 +167,7 @@ pruefe("gelesen und zur kenntnis genommen" in sig.lower(),
gesehen.clear()
f.destroy()
f = A.RechnungsApp()
-f.update()
+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")
@@ -156,7 +176,7 @@ gesehen.clear()
f.destroy()
H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden
f = A.RechnungsApp()
-f.update()
+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")
diff --git a/pruef_selbstbezug.py b/pruef_selbstbezug.py
new file mode 100644
index 0000000..d8a6e84
--- /dev/null
+++ b/pruef_selbstbezug.py
@@ -0,0 +1,123 @@
+# -*- 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
+ 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
+
+ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"]
+
+fehler = []
+
+
+def pruefe(bedingung, text):
+ zeile = (" OK | " if bedingung else "FEHLER | ") + text
+ kod = sys.stdout.encoding or "utf-8"
+ print(zeile.encode(kod, "replace").decode(kod))
+ if not bedingung:
+ fehler.append(text)
+
+
+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")
diff --git a/pruef_start_exe.py b/pruef_start_exe.py
new file mode 100644
index 0000000..04f02fb
--- /dev/null
+++ b/pruef_start_exe.py
@@ -0,0 +1,137 @@
+# -*- 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
+
+fehler = []
+
+
+def pruefe(bedingung, text):
+ zeile = (" OK | " if bedingung else "FEHLER | ") + text
+ kod = sys.stdout.encoding or "utf-8"
+ print(zeile.encode(kod, "replace").decode(kod))
+ if not bedingung:
+ fehler.append(text)
+
+
+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
+p = subprocess.Popen([EXE])
+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")
diff --git a/pruef_updater.py b/pruef_updater.py
index e1bebef..227bc3c 100644
--- a/pruef_updater.py
+++ b/pruef_updater.py
@@ -196,6 +196,31 @@ pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe",
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:
diff --git a/updater.py b/updater.py
index f06b06c..5db2447 100644
--- a/updater.py
+++ b/updater.py
@@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name):
# ---------------------------------------------------------------------------
# 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:
@@ -309,6 +325,16 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
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)
@@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
return hier_pfad
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
- shutil.move(geladen, ziel)
+ 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,
diff --git a/version.py b/version.py
index 4da2e80..acce0ab 100644
--- a/version.py
+++ b/version.py
@@ -10,11 +10,11 @@ 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.6.0` -> VERSION "1.6.0". Wer das eine ohne das andere aendert,
+"v": Tag `v1.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert,
baut dem Updater eine Falle.
"""
-VERSION = "1.6.0"
+VERSION = "1.7.0"
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
REPOSITORY = "TheMockTv/rechnungstool"
diff --git a/wasserzeichen.py b/wasserzeichen.py
new file mode 100644
index 0000000..935ba0a
--- /dev/null
+++ b/wasserzeichen.py
@@ -0,0 +1,106 @@
+# -*- 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 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.
+ """
+ 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
+
+
+def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
+ """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))
+ 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.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):
+ """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)