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.*
|
||||
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/
|
||||
|
|
|
|||
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 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
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 -*-
|
||||
"""
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
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),
|
||||
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:
|
||||
|
|
|
|||
33
updater.py
33
updater.py
|
|
@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name):
|
|||
# ---------------------------------------------------------------------------
|
||||
# Laden und pruefen
|
||||
# ---------------------------------------------------------------------------
|
||||
def beschreibbar(ordner):
|
||||
"""Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.
|
||||
|
||||
Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein
|
||||
beschreibbarer - erst der Versuch zeigt es.
|
||||
"""
|
||||
probe = os.path.join(ordner, ".schreibprobe")
|
||||
try:
|
||||
with open(probe, "w", encoding="ascii") as f:
|
||||
f.write("x")
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def sha256(pfad):
|
||||
h = hashlib.sha256()
|
||||
with open(pfad, "rb") as f:
|
||||
|
|
@ -309,6 +325,16 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
|
|||
sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.")
|
||||
return ziel
|
||||
|
||||
# Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die
|
||||
# Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das
|
||||
# Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm
|
||||
# startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass
|
||||
# gar nichts mehr geht.
|
||||
if not beschreibbar(ordner):
|
||||
sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.")
|
||||
sagen("Es wird die vorhandene Fassung gestartet.")
|
||||
return hier_pfad
|
||||
|
||||
sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...")
|
||||
zwischen = tempfile.mkdtemp(prefix="update_")
|
||||
geladen = os.path.join(zwischen, exe_name)
|
||||
|
|
@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None):
|
|||
return hier_pfad
|
||||
|
||||
# Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort.
|
||||
shutil.move(geladen, ziel)
|
||||
try:
|
||||
shutil.move(geladen, ziel)
|
||||
except OSError as e: # noqa: BLE001
|
||||
sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).")
|
||||
sagen("Es wird die vorhandene Fassung gestartet.")
|
||||
return hier_pfad
|
||||
buch_schreiben(ordner, {"version": dort.lstrip("v"),
|
||||
"datei": os.path.basename(ziel),
|
||||
"sha256": erwartete_summe,
|
||||
|
|
|
|||
|
|
@ -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
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