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:
parent
fec0e8449c
commit
73042b771c
20 changed files with 1445 additions and 14 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -9,3 +9,18 @@ _tmp/
|
||||||
*.7z.*
|
*.7z.*
|
||||||
release/
|
release/
|
||||||
rechnungstool.pid
|
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/
|
||||||
|
|
|
||||||
95
AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md
Normal file
95
AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md
Normal 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
417
AUFGABE_ONLINE_API_MYSQL.md
Normal 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
48
Diagnose.spec
Normal 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
95
Installer.iss
Normal 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
48
Rechnungstool.spec
Normal 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
24
Updater.spec
Normal 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
64
app.py
|
|
@ -32,6 +32,7 @@ import config
|
||||||
import berechnung
|
import berechnung
|
||||||
import gemeinsam
|
import gemeinsam
|
||||||
import hinweis
|
import hinweis
|
||||||
|
import wasserzeichen
|
||||||
import ordnerwahl
|
import ordnerwahl
|
||||||
import pdf_renderer
|
import pdf_renderer
|
||||||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
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__()
|
super().__init__()
|
||||||
self.cfg = config.load_config()
|
self.cfg = config.load_config()
|
||||||
self.title(config.PROGRAMM)
|
self.title(config.PROGRAMM)
|
||||||
|
self._fenstersymbol()
|
||||||
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
|
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
|
||||||
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
|
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
|
||||||
self.geometry(f"1060x{hoehe}")
|
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.
|
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
|
||||||
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||||||
|
|
||||||
# after_idle statt after(0): laeuft, sobald Tk nichts mehr zu zeichnen hat -
|
# Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH
|
||||||
# also nachdem das Hauptfenster steht. Bei after(0) haengt der modale Dialog
|
# gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten
|
||||||
# an einem noch ungezeichneten Fenster und wirkt wie eingefroren; eine feste
|
# EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog
|
||||||
# Wartezeit dagegen erreicht ein Pruefstand nie, der keine Schleife dreht.
|
# sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein
|
||||||
self.after_idle(self._hinweis_pruefen)
|
# haengendes Programm (06.09.2026, am Installer aufgefallen).
|
||||||
|
self.after(50, self._hinweis_wenn_sichtbar)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Menue
|
# ---------------------------------------------------------------- Menue
|
||||||
|
|
@ -243,6 +246,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
||||||
|
|
||||||
self.flaeche = flaeche.Arbeitsflaeche(self, farben=self)
|
self.flaeche = flaeche.Arbeitsflaeche(self, farben=self)
|
||||||
self.flaeche.pack(side="top", fill="both", expand=True, padx=10, pady=(4, 0))
|
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):
|
def _anordnen_umschalten(self):
|
||||||
self.flaeche.anordnen_starten()
|
self.flaeche.anordnen_starten()
|
||||||
|
|
@ -611,6 +619,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
||||||
def _build_summen(self):
|
def _build_summen(self):
|
||||||
# Statuszeile ganz unten
|
# Statuszeile ganz unten
|
||||||
status = ttk.Frame(self, padding=(12, 2))
|
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")
|
status.pack(side="bottom", fill="x")
|
||||||
ttk.Label(status, text=config.MARKE, foreground=self.col_muted,
|
ttk.Label(status, text=config.MARKE, foreground=self.col_muted,
|
||||||
font=(theme.SCHRIFT_NAME, 8)).pack(side="right")
|
font=(theme.SCHRIFT_NAME, 8)).pack(side="right")
|
||||||
|
|
@ -644,6 +658,40 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
||||||
|
|
||||||
# --------------------------------------------------- Gemeinsames Buch
|
# --------------------------------------------------- Gemeinsames Buch
|
||||||
# ------------------------------------------------- Erprobungshinweis
|
# ------------------------------------------------- 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):
|
def _hinweis_pruefen(self):
|
||||||
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm.
|
"""Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm.
|
||||||
|
|
||||||
|
|
@ -1821,7 +1869,11 @@ if __name__ == "__main__":
|
||||||
if not fenster_nach_vorn(laufende_pid_lesen()):
|
if not fenster_nach_vorn(laufende_pid_lesen()):
|
||||||
_r = tk.Tk()
|
_r = tk.Tk()
|
||||||
_r.withdraw()
|
_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",
|
"Rechnungstool läuft bereits",
|
||||||
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
|
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
|
||||||
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
|
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
|
||||||
|
|
|
||||||
BIN
assets/programm.ico
Normal file
BIN
assets/programm.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/programm_logo.png
Normal file
BIN
assets/programm_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
86
assets/programm_logo.svg
Normal file
86
assets/programm_logo.svg
Normal 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
109
bauen.py
Normal 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()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF
|
||||||
im Layout der Excel-Vorlage:
|
im Layout der Excel-Vorlage:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Geprueft wird die ganze Strecke, weil jeder Teil fuer sich wertlos waere:
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -89,6 +90,25 @@ messagebox.showwarning = lambda *a, **k: None
|
||||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
|
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():
|
def gelesen():
|
||||||
return json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
return json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
||||||
|
|
||||||
|
|
@ -96,7 +116,7 @@ def gelesen():
|
||||||
# ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt -----------------------
|
# ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt -----------------------
|
||||||
antworten["Erprobungsfassung"] = False
|
antworten["Erprobungsfassung"] = False
|
||||||
f = A.RechnungsApp()
|
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]]
|
treffer = [t for t in gesehen if "Erprobungsfassung" in t[0]]
|
||||||
pruefe(bool(treffer), "der Hinweis kommt beim Start")
|
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()
|
gesehen.clear()
|
||||||
antworten["Erprobungsfassung"] = True
|
antworten["Erprobungsfassung"] = True
|
||||||
f = A.RechnungsApp()
|
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),
|
pruefe(any("Erprobungsfassung" in t[0] for t in gesehen),
|
||||||
"beim naechsten Start wird wieder gefragt")
|
"beim naechsten Start wird wieder gefragt")
|
||||||
|
|
||||||
|
|
@ -147,7 +167,7 @@ pruefe("gelesen und zur kenntnis genommen" in sig.lower(),
|
||||||
gesehen.clear()
|
gesehen.clear()
|
||||||
f.destroy()
|
f.destroy()
|
||||||
f = A.RechnungsApp()
|
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),
|
pruefe(not any("Erprobungsfassung" in t[0] for t in gesehen),
|
||||||
"nach dem Ja wird nicht noch einmal gefragt")
|
"nach dem Ja wird nicht noch einmal gefragt")
|
||||||
|
|
||||||
|
|
@ -156,7 +176,7 @@ gesehen.clear()
|
||||||
f.destroy()
|
f.destroy()
|
||||||
H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden
|
H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden
|
||||||
f = A.RechnungsApp()
|
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),
|
pruefe(any("Erprobungsfassung" in t[0] for t in gesehen),
|
||||||
"bei geaenderter Fassung wird erneut gefragt")
|
"bei geaenderter Fassung wird erneut gefragt")
|
||||||
|
|
||||||
|
|
|
||||||
123
pruef_selbstbezug.py
Normal file
123
pruef_selbstbezug.py
Normal 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
137
pruef_start_exe.py
Normal 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")
|
||||||
|
|
@ -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),
|
pruefe(not any("Wird geladen" in m for m in meldungen),
|
||||||
f"und nichts noch einmal geladen ({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)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
if fehler:
|
if fehler:
|
||||||
|
|
|
||||||
33
updater.py
33
updater.py
|
|
@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Laden und pruefen
|
# 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):
|
def sha256(pfad):
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with open(pfad, "rb") as f:
|
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.")
|
sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.")
|
||||||
return ziel
|
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 ...")
|
sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...")
|
||||||
zwischen = tempfile.mkdtemp(prefix="update_")
|
zwischen = tempfile.mkdtemp(prefix="update_")
|
||||||
geladen = os.path.join(zwischen, exe_name)
|
geladen = os.path.join(zwischen, exe_name)
|
||||||
|
|
@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
|
||||||
return hier_pfad
|
return hier_pfad
|
||||||
|
|
||||||
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
|
# 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"),
|
buch_schreiben(ordner, {"version": dort.lstrip("v"),
|
||||||
"datei": os.path.basename(ziel),
|
"datei": os.path.basename(ziel),
|
||||||
"sha256": erwartete_summe,
|
"sha256": erwartete_summe,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
|
||||||
Regel
|
Regel
|
||||||
-----
|
-----
|
||||||
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
|
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.
|
baut dem Updater eine Falle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VERSION = "1.6.0"
|
VERSION = "1.7.0"
|
||||||
|
|
||||||
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
|
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
|
||||||
REPOSITORY = "TheMockTv/rechnungstool"
|
REPOSITORY = "TheMockTv/rechnungstool"
|
||||||
|
|
|
||||||
106
wasserzeichen.py
Normal file
106
wasserzeichen.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue