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
This commit is contained in:
TheMockTv 2026-09-06 20:34:38 +02:00
parent fec0e8449c
commit 73042b771c
20 changed files with 1445 additions and 14 deletions

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

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.

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

95
Installer.iss Normal file
View file

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

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',
)

64
app.py
View file

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

BIN
assets/programm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 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

109
bauen.py Normal file
View file

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

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:

View file

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

123
pruef_selbstbezug.py Normal file
View file

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

137
pruef_start_exe.py Normal file
View file

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

View file

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

View file

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

View file

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

106
wasserzeichen.py Normal file
View file

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