diff --git a/.gitignore b/.gitignore index 3833f58..a21aa42 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,18 @@ _tmp/ *.7z.* release/ rechnungstool.pid + +# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo. +*.exe +setup/ + +# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand +# die EXE nachbauen, die der Updater ausliefert. +!Rechnungstool.spec +!Updater.spec +!Diagnose.spec + +# Muster-PDFs. Der GAST darin ist erfunden - der BETRIEB nicht: sie tragen +# IBAN, Steuernummer und die private Rufnummer des Auftraggebers im Briefkopf. +# In ein oeffentliches Repository gehoert das nicht. +muster/ diff --git a/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md new file mode 100644 index 0000000..c520033 --- /dev/null +++ b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md @@ -0,0 +1,95 @@ +# Außendienst: Arbeitsprotokoll auf dem Tablet, Rechnung fällt heraus + +Seine Idee vom 06.09.2026, wörtlich: + +> „mach es an der kundenanzahl fest, und die bekommen eine vorab-nummer, die reserviert ist von der +> disponentin. die kunden haben eh eine kundennummer — heißt, es ist nur die rechnungsnummer, die +> zugeteilt wird. und hans hat nur die kundennummer mit adresse und wohnort im log, wo er +> protokolliert: anfahrt drücken, angekommen drücken … dann arbeit start und stop, dann noch: was +> hat er eigentlich gemacht — also gleich arbeitsprotokoll — und abfahrt. plus die rechnung für den +> kunden anhand des arbeitsblocks, den wir schon haben." + +**Status: Idee. Nichts davon ist gebaut.** + +--- + +## Was sich dadurch ändert + +Die Rechnung wird **nicht mehr getippt, sondern fällt aus dem Arbeitsprotokoll heraus.** Wer ohnehin +Anfahrt, Ankunft, Beginn und Ende drückt, hat am Abend die Rechnung fast fertig, ohne sie +geschrieben zu haben. + +Damit ist das **ein anderes Produkt** als das heutige Rechnungstool: eine Auftrags- und +Zeiterfassung, bei der die Rechnung das Ergebnis ist. Der Arbeitsdatensatz wird primär, die Rechnung +abgeleitet. + +## Die Nummernlogik wird dadurch einfacher + +- **Die Kundennummer hat der Kunde schon.** Reserviert wird nur die **Rechnungsnummer**. +- Die Blockgröße ergibt sich aus den **zugeteilten Aufträgen** — siehe + `AUFGABE_ONLINE_API_MYSQL.md`, Abschnitt „Woher die Blockgröße kommt". +- Hans hat auf dem Gerät nur, was er braucht: Kundennummer, Anschrift, Ort. + +--- + +## ⭐⭐ ENTSCHEIDUNG (Andreas, 06.09.2026): Daten auf dem Server, Rechnung im BÜRO + +> „das liegt auf dem server, und im büro wird die rechnung geschrieben. denke, das ist sauber — +> aber so weit sind wir noch nicht." + +**Das räumt ein Problem ab, und zwar das größte.** + +Wird die Rechnung im Büro geschrieben, **entfällt die Offline-Nummernvergabe komplett**. Das Tablet +erfasst nur Arbeit — und ein Arbeitsprotokoll braucht **keine Rechnungsnummer**. Die entsteht im +Büro, wo Netz da ist. + +→ **Nummernkontingente, Puffer und Blockverlängerung werden damit von einer Voraussetzung zu einer +OPTION** — nötig erst, falls später doch beim Kunden live abgerechnet werden soll. Die Überlegungen +dazu bleiben in `AUFGABE_ONLINE_API_MYSQL.md` stehen, sind aber nicht mehr auf dem kritischen Weg. + +**Das Tablet muss dann nur zweierlei können:** +1. den **Kundenstamm zum Nachschlagen** vorhalten (Zwischenspeicher mit sichtbarem Alter) +2. die **Protokolle zwischenspeichern**, bis wieder Netz da ist + +Beides **ohne Nummernvergabe** — also ohne die Gefahr, dass zwei Geräte sich in die Quere kommen. + +## ⚠️ Drei Punkte, die vorher geklärt sein müssen + +### 1. ⭐ Erfasste Zeit ist NICHT berechnete Zeit + +Anfahrt ist mal berechenbar, mal nicht. Pausen laufen mit, gehören aber nicht auf die Rechnung. + +**Wird das Protokoll eins zu eins zur Rechnungszeile, streitet der Betrieb irgendwann mit dem Kunden +über eine Minute, die das Programm erfunden hat.** + +> **Regel: Aus dem Protokoll wird ein VORSCHLAG, aus dem Vorschlag die Rechnung.** Das Protokoll +> bleibt unverändert stehen, auch wenn anders abgerechnet wird. + +Dann ist beides sauber und man kann später erklären, warum abgewichen wurde. + +### 2. Start/Stopp drücken heißt: das ist ARBEITSZEIT + +Damit hängt Arbeitsrecht daran, nicht nur Steuerrecht: + +- Aufzeichnungs- und Aufbewahrungspflicht (**§ 16 ArbZG**) +- Die Aufzeichnungen gehören dem **Arbeitsverhältnis**, nicht dem Kunden +- → **getrennt von den Kundendaten behandeln**, und sie dürfen nicht einfach in der Rechnung landen + +### 3. ⚠️ „Anfahrt/Angekommen" ist Standortverfolgung von Mitarbeitern + +Sobald Zeiten und Orte zusammenkommen, ist es Mitarbeiterüberwachung. In Deutschland heikel: + +- braucht eine **Rechtsgrundlage** und klare **Zweckbindung** (Art. 88 DSGVO, § 26 BDSG) +- gibt es je einen **Betriebsrat**: mitbestimmungspflichtig (§ 87 Abs. 1 Nr. 6 BetrVG) + +**Beherrschbar, solange der Mitarbeiter selbst drückt** und nichts im Hintergrund mitläuft. Ein +Programm, das ohne Zutun aufzeichnet, wo jemand ist, wäre etwas völlig anderes — und dann wird es +ernst. + +--- + +## Was schon da ist und wiederverwendet wird + +- der **Arbeits-/Leistungsblock** (Katalog) aus dem Rechnungstool +- der **Kundenstamm** (offen, siehe `AUFGABE_KUNDENSTAMM_UND_WEB.md`) +- die **Nummernkontingente** (siehe `AUFGABE_ONLINE_API_MYSQL.md`) diff --git a/AUFGABE_ONLINE_API_MYSQL.md b/AUFGABE_ONLINE_API_MYSQL.md new file mode 100644 index 0000000..31ca040 --- /dev/null +++ b/AUFGABE_ONLINE_API_MYSQL.md @@ -0,0 +1,417 @@ +# Online-Betrieb: API, MySQL, Tablet, Rechnungsversand + +Seine Idee vom 06.09.2026, wörtlich: + +> „die software ist aktuell nur lokal, und da reicht sqlite. wenn wir die online funktion einbauen, +> dann muss man einen api key eintragen, und die api verteilt das auf die mysql — dann ist das +> extern auf dem server und du kannst von überall über domain und apikey auch unterwegs per tablet +> beim kunden die rechnung schreiben, live oder vorab-rechnung, und der smtp-server schickt sie +> direkt dem kunden auf die hinterlegte e-mail. fertig. alles automatisch." + +**Status: Idee. Nichts davon ist gebaut.** Der Schnitt ist richtig gedacht — lokal SQLite, zentral +MySQL, die API als einzige Tür dazwischen. + +--- + +## 1. ⭐ Die Rechnungsnummer darf nur vom Server kommen + +Heute stimmen sich zwei lokale Programme über eine gemeinsame SQLite ab. Zentral wird das +**einfacher**: eine Transaktion in MySQL vergibt die Nummer, fertig. + +**Aber:** Auf dem Tablet beim Kunden ohne Empfang darf keine Nummer entstehen. + +> **Regel: Offline entsteht ein ENTWURF, keine Rechnung.** Die Nummer wird vergeben, sobald der +> Server erreichbar ist. + +Sonst gibt es irgendwann zwei Belege mit derselben Nummer — genau der Fehler, wegen dem das +Amt-Blatt herausgeflogen ist (§ 14 Abs. 4 Nr. 4 UStG). + +Daraus folgt für die Oberfläche: Ein Entwurf muss **sichtbar** ein Entwurf sein, ohne Nummer, und +darf nicht ausgedruckt oder verschickt werden können. + +## ⭐⭐ Das Offline-Problem — und seine Lösung: NUMMERNKONTINGENTE + +Sein Einwand (06.09.2026): + +> „das ding, was wir haben, ist das offline-problem. wenn das internet schwach oder weg ist, bringt +> das online nichts — also musst du auf dem client eine kopie per sqlite haben. wie willst es sonst +> machen?" + +**Er hat recht, und das ersetzt die zu strenge Regel „offline nur Entwürfe" aus Punkt 1.** + +### Was zwischengespeichert werden kann — und was nicht + +| | offline möglich? | +|---|---| +| Kundenstamm, Preise, Einstellungen, alte Rechnungen zum Nachschlagen | **ja** — lokale SQLite, lesend, mit sichtbarem Alter | +| **Vergabe der Rechnungsnummer** | **nein** — zwei Geräte nähmen beide „die nächste" | + +### Die Lösung: der Server gibt Blöcke im Voraus aus + +So machen es Kassensysteme. Tablet A bekommt **2026-101 bis 2026-150**, Tablet B **2026-151 bis +2026-200**. Offline vergibt jedes Gerät aus **seinem eigenen** Block — eine Kollision ist +**unmöglich**, weil kein anderes Gerät diese Nummern besitzt. Bei Netz meldet es den Verbrauch und +holt einen neuen Block. + +→ **Damit sind beim Kunden echte Rechnungen möglich, nicht nur Entwürfe.** + +### Zwei Bedingungen, die dazugehören + +1. **Lücken werden normal — und müssen erklärbar sein.** Kommt Tablet A nur bis 2026-137 und holt + dann einen neuen Block, fehlen 138–150. Das ist zulässig: § 14 UStG verlangt eine **einmalig + vergebene** fortlaufende Nummer, ausdrücklich auch in mehreren Kreisen (z. B. je Filiale). + **Aber das System muss zeigen können, welcher Block wem gehörte und welche Nummern daraus nie + verbraucht wurden.** Sonst sieht ein Prüfer eine Lücke und fragt nach der Rechnung. + → Gehört in den **Steuerprüfungs-Ausdruck** (siehe `AUFGABE_BEWEISKETTE_PRUEFBAR.md`, Schritt 3). +2. **Der Block ist endlich.** Ist er verbraucht und kein Netz da, ist Schluss — dann wieder + Entwürfe. **Lieber ein Gerät, das sagt „ich kann gerade nicht", als eines, das sich Nummern + ausdenkt.** + +### ⭐⭐ Woher die Blockgröße kommt: aus der Disposition (Andreas, 06.09.2026) + +> „die disponentin hat jedem handwerker ja kunden zugeteilt — daraus kann ein block generiert +> werden. die software sagt: handwerker hans werner hat 10 kunden, also bekommt er block A mit 10 +> nummern, der hans werner heißt. dann ist es erklärbar, weil hans die kunden im log hatte. wenn er +> mehr kunden hatte, bekommt er von anfang an 10 puffernummern als schutz. und wenn die nicht +> gebraucht werden, bekommt er sie für den nächsten tag wieder. wenn sie voll sind, überspringt er +> in den nächsten block. der server hält selber immer reserve." + +**Der starke Punkt daran ist die Erklärbarkeit.** Ein Prüfer sieht reservierte Nummernbereiche und +fragt, warum ausgerechnet diese. Hier gibt es eine Antwort **aus dem Betrieb heraus**: Die +Disposition hat Hans an dem Tag zehn Kunden zugeteilt, also zehn Nummern plus Puffer. **Die +Zuteilung ist der Beleg für die Reservierung** — besser als jede technische Begründung. + +### ⚠️ Zwei Korrekturen daran + +**1. Der Block darf NICHT am Kalendertag hängen.** +*„Heute hast du 100 bis 110"* klingt logisch, ist aber gefährlich: Hat Hans zwei Tage keinen +Empfang, wäre er am zweiten Tag arbeitsunfähig, obwohl er noch Nummern hätte. + +> **Der Block gehört ihm, bis er ihn zurückmeldet. Kein Ablaufdatum.** Die Disposition gibt morgens +> neue *dazu*, nimmt aber nichts weg, was er noch hat. + +Am Bild der Disposition ändert das nichts — nur verhungert das Tablet nicht, wenn das Netz drei +Tage weg ist. + +**2. Eine Nummer ist erst verbraucht, wenn ein BELEG entsteht.** +Nicht bei der Zuteilung, und **nicht durch einen Entwurf**. Nur so stimmt, dass unbenutzte Nummern +am nächsten Tag wieder gelten — sonst würden Nummern wiederverwendet, die schon auf einem +Bildschirm standen. + +**Der Rest passt unverändert:** Puffer von Anfang an · Überspringen in den nächsten Block, wenn +voll · **Server hält eigene Reserve** für das, was die Disposition nicht wusste (Notfall, +Zusatzauftrag, Kunde ruft an). + +### ⭐ Beim Synchronisieren: Verbrauch melden, nicht blind neu ausgeben + +Sein Einwand (06.09.2026): + +> „wenn das tablet lte wieder hat, kann er das syncen und bekommt einen neuen block — oder sagt +> einfach: eh, ich habe aber in dem block erst 10 % genutzt" + +**Richtig, und das vermeidet Lücken statt sie nur zu erklären.** Das Gerät meldet seinen Stand: +*„2026-101 bis 110 verbraucht, 111 bis 150 gehören mir noch."* Der Server entscheidet: + +| Lage | Antwort | +|---|---| +| noch viel übrig | **nichts tun** — kein neuer Block, keine verbrannten Nummern | +| fast leer, folgende Nummern frei | **den vorhandenen Block VERLÄNGERN** (101–150 → 101–180) → **keine Lücke** | +| fast leer, folgende Nummern gehören schon jemandem | neuer Block weiter hinten → Lücke, aber eine, die der Server **benennen** kann | + +**Verlängern schlägt Neuausgabe.** Das ist der Unterschied zwischen einem System, das Lücken erzeugt +und sie hinterher erklären muss, und einem, das erst gar keine macht. + +### Warum das nicht optional ist + +> „kannst ja nicht kunden fragen nach wlan" + +Beim Kunden im Keller, im Neubau ohne Anschluss, auf dem Hof: kein WLAN, oft auch kein +Mobilfunknetz. Nach dem Passwort fragen macht kein Handwerker. + +**Zwei praktische Regeln daraus:** + +1. **Der Block muss großzügig sein** — nicht zehn Nummern, sondern so viele, dass eine Woche Arbeit + hineinpasst. Nummern kosten nichts, ein abgerissener Vorgang beim Kunden schon. +2. **Das Gerät warnt, BEVOR es eng wird.** *„Noch 5 Nummern im Vorrat, bitte einmal ins Netz"* — + solange man noch in der Werkstatt steht. Eine Warnung, die erst beim Kunden kommt, ist wertlos. + +Nachgefüllt wird **automatisch**, sobald irgendwo Netz da ist. Der Benutzer soll darüber nicht +nachdenken müssen. + +## 2. ⚠️ Beim Datenschutz kippt die Lage vollständig + +Heute lautet die Aussage im Programm: *Es verlässt nichts den Rechner.* Das ist der ganze +Datenschutztext. Sobald Gästedaten auf einem Server liegen, gilt die DSGVO in voller Breite: + +- verschlüsselte Übertragung (TLS), keine Ausnahme +- **Zugriffstrennung je Betrieb** — ein API-Schlüssel ist ein Passwort, keine Zugriffskontrolle +- Löschkonzept und Aufbewahrungsfristen (§ 147 AO) müssen zusammenpassen +- **Wenn er das für ANDERE Betriebe hostet, ist er deren Auftragsverarbeiter** und braucht einen + Vertrag nach Art. 28 DSGVO. Das ist keine Formalie, sondern Voraussetzung dafür, dass die + Betriebe das Programm überhaupt einsetzen dürfen. + +Der Datenschutztext im Programm muss dann neu geschrieben werden — der heutige wäre schlicht falsch. + +## 3. ⭐⭐ E-Rechnung (ZUGFeRD) — jetzt entscheiden, nicht später + +In Deutschland läuft die Pflicht zur elektronischen Rechnung im B2B an. Wer ein System baut, das +Rechnungen **per Mail verschickt**, sollte die PDF gleich mit **eingebettetem strukturiertem XML** +erzeugen (ZUGFeRD / PDF/A-3). + +Der Unterschied zwischen „von Anfang an mitgedacht" und „später nachrüsten" ist hier ein halber +Neubau: PDF/A-3 stellt andere Anforderungen an Schriften und Farbprofile als das heutige PDF, und +das XML muss aus denselben Daten kommen wie das Blatt — nicht daneben gepflegt werden. + +Stand schon auf der allerersten Aufgabenliste des Rechnungstools. + +## 4. Versand + +- **SMTP genügt zum Senden.** POP3/IMAP braucht man nur, um **Unzustellbarkeitsmeldungen** zu lesen + — und die *muss* man lesen. Sonst meldet das Programm „verschickt", und die Rechnung liegt + nirgends. +- **Eine per Mail verschickte Rechnung ist eine Rechnung** (§ 14 UStG). Für die GoBD muss das + Dokument **genau so archiviert werden, wie es verschickt wurde** — samt Versandnachweis. +- Zustellung ist nicht garantiert. Der Versandstatus gehört sichtbar in die Oberfläche, nicht nur + ins Log. + +## ⭐ Das Produktbild (Andreas, 06.09.2026) — lokal ist der Normalfall + +> „solange er den apikey mit domain nicht hat, kann er auch nur sqlite nutzen — reicht auch, wenn +> einer es als single programm für sich nutzt. will einer das online nutzen, dann trägt er beides +> ein, das programm synct einmal auf die mysql, fertig." + +**Damit ist der Aufbau entschieden: local-first, Online ist eine Zusatzfunktion, die man +einschaltet.** Wer nichts einträgt, merkt vom Server nichts. Kein Konto, keine Pflicht, keine +Abhängigkeit. + +### Die eine Regel, die den Umzug sicher macht + +**„Synct einmal" ist eine EINMALIGE ÜBERNAHME, keine ständige Synchronisierung in beide +Richtungen.** Danach gehört die Nummernvergabe dem Server; die lokale SQLite ist nur noch +Zwischenspeicher für Entwürfe (siehe Punkt 1). + +Zwei Richtungen dauerhaft offen zu halten hieße: zwei Stellen vergeben Nummern. Genau der Fehler, +wegen dem das Amt-Blatt herausflog. + +### Und daraus folgt eine Entscheidung VOR der ersten Übernahme + +Zwei Installationen, die beide 2026-001 bis 2026-050 vergeben haben, lassen sich **nicht** in eine +Datenbank übernehmen — dort lägen Nummern doppelt. Gehen je mehrere Betriebe auf denselben Server, +muss die **Trennung der Nummernkreise vorher** feststehen (eigene Datenbank je Betrieb, oder +Präfix je Betrieb). Nachträglich geht es nicht: die Belege sind dann schon beim Kunden. + +### Was der Umzug NICHT gefährdet + +**Die Wahrheit bleiben die Rechnungs-PDFs** — so steht es schon heute im Nummernbuch-Code: *„Die +Datei ist ein REGISTER, keine zweite Buchhaltung."* Geht bei der Übernahme etwas schief, fehlt +keine Rechnung; es muss nur neu eingelesen werden. + +## ⭐⭐ Die Umschaltung ist eine Tür, die nur in eine Richtung aufgeht + +Sein Modell (06.09.2026): + +> „ein einmal sync, danach ist es only online. die sql ist dann deaktiviert für immer — außer er +> trägt den key und die domain aus, dann hat er aber einen alten datenstand." + +> „das muss er mit einem popup — mit dem speichern-button, den es dafür gibt — am ende noch mal als +> ja/nein-frage kommen, ob ihm das bewusst ist, dass er sozusagen bei 0 anfängt." + +### Harte Anforderung + +**Beim SPEICHERN** von API-Schlüssel und Domain (nicht beim Eintippen) kommt eine Ja/Nein-Frage. +Beim Tippen weiß niemand, ob es ernst gemeint ist — beim Speichern schon. + +Entwurf des Textes: + +> **Auf Online-Betrieb umstellen?** +> +> Die vorhandenen Daten werden einmalig auf den Server übernommen. Danach läuft alles über den +> Server: Rechnungsnummern werden dort vergeben, und die lokale Datenbank wird **nicht mehr +> fortgeführt**. +> +> Entfernen Sie später Schlüssel und Domain, arbeitet das Programm wieder lokal weiter — dann aber +> mit dem Stand von **[Datum von heute]**. Alles, was zwischenzeitlich online entstanden ist, fehlt +> dort. +> +> Diese Umstellung lässt sich nicht rückgängig machen. +> +> Jetzt umstellen? + +Dasselbe Muster wie beim Erprobungshinweis und bei der Freigabe vor dem Beleg: Der Nutzer wird +**vorher** aufgeklärt, und die Entscheidung wird mit Datum vermerkt. + +### Die lokale Datenbank wird STILLGELEGT, nicht gelöscht + +Sie ist der Stand von damals und muss lesbar bleiben — schon wegen § 147 AO. + +### ⭐ Und der Punkt, der daran hängt: die PDFs müssen erreichbar bleiben + +Heute gilt in beiden Programmen: **Die Wahrheit sind die Rechnungs-PDFs, die Datenbank ist nur ein +Register.** Wird künftig auf dem Server erzeugt, muss der Betrieb **jederzeit an alle seine Belege +herankommen — als Dateien**, nicht nur als Ansicht im Browser. + +Der Grund ist **§ 147 AO: zehn Jahre Aufbewahrungspflicht.** Ein System, das den Betrieb von seinen +eigenen Belegen trennen kann — Server fällt aus, Vertrag endet, man zerstreitet sich — bringt den +BETRIEB in Schwierigkeiten. + +→ **Empfehlung: automatische lokale Kopie jeder Rechnung, auch im Online-Betrieb.** Dann bleibt der +Satz „die Wahrheit sind die PDFs" wahr, und der Betrieb hat seine Unterlagen auch dann, wenn nichts +mehr läuft. Das ist zugleich ein Verkaufsargument. + +## 📄 Paperless als Archiv (Andreas, 06.09.2026) — löst die Aufbewahrungsfrage + +> „ich dachte später an ein paperless — da muss eh alles rein" + +**Das ist die bessere Antwort auf § 147 AO als eine Exportfunktion.** Läuft jede Rechnung +automatisch in ein Dokumentensystem, liegt das Archiv **außerhalb** des Programms und überlebt +sowohl einen abgeschalteten Server als auch das Programm selbst. + +**Anbindung ist klein:** Paperless-ngx nimmt Dokumente über eine REST-Schnittstelle **oder über +einen überwachten Ordner** entgegen — Datei hineinlegen, fertig. Für das Rechnungstool eine +zusätzliche Zeile beim Speichern, kein Umbau. + +**⭐ Und die Verschlagwortung ist schon gebaut:** Rechnungsnummer, Datum, Beträge, Art und Vorgang +stehen bereits als maschinenlesbare Kenndaten im `/Subject` jeder PDF (das Steuerjournal liest sie +von dort). Daraus kann das Archiv seine Schlagworte ziehen — ohne Abtippen, ohne Texterkennung. + +⚠️ **Ehrlicher Vorbehalt:** Ein Dokumentensystem macht **nicht automatisch GoBD-konform**. Es +erfüllt den Kern (Belege bleiben unverändert und vollständig), aber dazu gehört eine +**Verfahrensdokumentation** — wie Belege entstehen, wohin sie laufen, wer was darf. Die schreibt +kein Programm. Ein großer Teil davon steht allerdings schon in den Aufgabendateien und +Commit-Texten dieser Projekte. + +## 🔑 Die API ist der Schlüssel zu allem (Andreas, 06.09.2026) + +> „das tool schickt es an die api, die api verteilt das alles. die api ist der schlüssel zu allem — +> wie beim gta fivem server" + +**Das ist seine eigene Serverregel, angewandt:** *Der Wille kommt vom Client, gerechnet wird auf dem +Server.* (Vgl. PcoreLife: nichts im Client, was Serversachen übernimmt.) + +| | Client (Tool/Tablet) | Server (API) | +|---|---|---| +| Was | „Rechnung für diese Leistungen an diesen Kunden" | Nummer vergeben, Beträge und Steuer rechnen, PDF erzeugen | +| Danach | zeigt an, was zurückkommt | verteilt: MySQL · Paperless · Mailversand | + +### ⭐ Folge 1: `berechnung.py` zieht auf den Server + +Heute steht dort ausdrücklich *„die einzige Stelle, an der gerechnet wird"*. Zentral gehört sie auf +den Server. + +**Das löst nebenbei die offene Frage aus `AUFGABE_KUNDENSTAMM_UND_WEB.md`:** die zwei Steuerwelten +(deutsche Bruttopreise 7/19 % gegen polnische Nettopreise mit Reverse Charge). Weiß der Server, +welcher Betrieb anfragt, weiß er auch, wie zu rechnen ist. **Der Client muss davon nichts wissen — +und die Frage „beide Welten in einem Programm oder zwei Fassungen" erledigt sich.** + +### ⭐ Die Aufgabenteilung im Einzelnen (Andreas, 06.09.2026) + +> „das tool schickt nur die metadaten, der server rendert die pdf und das tool bekommt den link. +> oder halt die vorschau muss es selber machen — das auf dem server ist dumm. und paperless macht +> den rest: archivieren und senden an die mitgegebene e-mail, und speichert das unter der +> kundennummer." + +| Wer | Was | +|---|---| +| **Tool** | schickt die Metadaten · zeichnet die **Vorschau lokal** · bekommt den Link zum fertigen PDF | +| **Server (API)** | vergibt die Nummer · rechnet · rendert das endgültige PDF · **verschickt per SMTP** · verteilt | +| **Paperless** | archiviert · verschlagwortet (Kundennummer als Korrespondent/Schlagwort) | + +**Vorschau lokal ist richtig:** ein Netzweg pro Tastendruck wäre sofort träge. + +### ⚠️ Die Regel, die dazugehört: EIN Zeichner, zwei Ausführungsorte + +Zeichnet das Tool die Vorschau und der Server das endgültige Blatt, gibt es **zwei Zeichner — und +zwei Zeichner driften auseinander**. Genau davor schützt sich der heutige Code bereits: +`berechnung.py` wird von Oberfläche UND PDF benutzt, *„damit Bildschirm und Ausdruck garantiert +identisch sind"*. + +Eine Vorschau, die nicht dem entspricht, was hinausgeht, ist **schlimmer als keine** — sie erzeugt +Vertrauen, das nicht gerechtfertigt ist. (Und die Vorschau war seine ausdrückliche Begründung für +das Ganze: *„sonst haben die den storno-aufwand"*.) + +→ **`pdf_renderer.py` und `berechnung.py` bleiben EINE gemeinsame Bibliothek**, die an zwei Orten +ausgeführt wird. Kein zweiter Code. + +### ⚠️ Korrektur: Paperless archiviert, es VERSCHICKT NICHT + +Paperless-ngx nimmt Dokumente entgegen, erkennt Text, verschlagwortet und lagert ein. **Rechnungen +an Kunden mailen kann es nicht.** Der Versand per SMTP gehört auf den Server / in die API. + +Die Ablage unter der Kundennummer geht dagegen sauber — Paperless kennt Korrespondenten und +Schlagworte, die Kundennummer passt in beides. + +### ⛔ Verworfen: PDF auf dem Client zeichnen und hochladen + +Andreas hatte es erwogen (*„ob das schlau ist, weiß ich nicht"*). **Nein**, aus einem Grund: + +Zeichnet der Client den endgültigen Beleg, entscheidet der Client, was darauf steht — und der +Server archiviert blind, was ankommt. Er könnte nicht mehr garantieren, dass zur Nummer, die er +vergeben hat, auch das Blatt gehört, das er ablegt. Eine ältere oder veränderte Fassung des Tools +erzeugte Dokumente, die niemand prüft. + +**Der Beleg entsteht dort, wo auch die Nummer entsteht.** Dazu käme: jede künftige Oberfläche +(Tablet, Browser) müsste den ganzen Renderer mitschleppen. + +Die **Vorschau** bleibt lokal — sie ist kein Beleg: ohne Nummer, mit Wasserzeichen, wie heute. + +### ⭐ Kundenstamm offline: ein ZWISCHENSPEICHER, kein Sync + +Seine Frage: *„wenn ein kunde eine kundennummer bekommt, wie willst die wiederfinden? musst die api +auch abfragen dafür — heißt, ohne internet wird da eh nichts. sonst musst wieder alles lokal und +online speichern, um das schnell zu halten, und dann nur eine art rsync raufpacken."* + +**Der Instinkt stimmt — nur ist es kein Sync, sondern ein Spiegel.** Der Unterschied ist keine +Wortklauberei: + +| | bedeutet | Folge | +|---|---|---| +| **Sync** | beide Seiten dürfen ändern | braucht Konfliktauflösung — wer gewinnt bei doppelter Änderung? | +| **Spiegel** | nur der Server besitzt die Daten, der Client hält eine Lesekopie | **keine Konflikte**, nur ein Stand, der mal älter ist | + +**Und die Datenmenge passt:** Kunden sind wenige und ändern sich selten — anders als Rechnungen. +Den ganzen Kundenstamm lokal zu halten kostet nichts und macht die Suche sofort schnell, auch ohne +Netz. + +**Sein Wort dafür: „eine Art Zwischenspeicher"** — und das ist ehrlicher als „Spiegel", weil es +schon sagt, dass er veralten darf. + +**Zwei Dinge braucht er, sonst wird er selbst zur Fehlerquelle:** + +1. **Er muss sein Alter zeigen.** Klein in der Oberfläche: *„Kundendaten vom 06.09.2026, 15:40"*. + Sonst sucht jemand einen Kunden, findet ihn nicht, legt ihn neu an — dabei war er nur seit + gestern nicht heruntergeladen. **Ein Zwischenspeicher, dem man sein Alter nicht ansieht, wird + irgendwann für die Wahrheit gehalten.** +2. **Er darf nie Quelle für etwas sein, das geschrieben wird.** Lesen und Suchen: ja. Eine Nummer + daraus vergeben: nie. Aufgefrischt wird beim Start (wenn Netz da ist) und auf Knopfdruck. + +**Dieselbe Regel wie bei den Rechnungsnummern:** Ein offline angelegter Kunde bekommt seine Nummer +**erst vom Server** — bis dahin ist er ein Entwurf. Sonst vergeben zwei Tablets dieselbe +Kundennummer. + +### Folge 2: der Client wird schlank + +Das Tablet beim Kunden braucht kein PDF-Werkzeug, keine Steuerlogik, keinen Nummernkreis. Ein Weg +hinein, mehrere hinaus. + +### Der Preis + +**Ohne Server geht nichts außer Entwürfen** (siehe Punkt 1). Bei FiveM ist das dasselbe — nur hängt +hier ein Beleg dran, den jemand zehn Jahre aufbewahren muss. + +### Sicherheit, weil „Schlüssel zu allem" wörtlich zutrifft + +Wenn ein Schlüssel alles öffnet, muss er: **je Installation** vergeben sein (nicht einer für alle), +**widerrufbar**, **nur über TLS** unterwegs, und die API muss **Missbrauch begrenzen** +(Rate-Limit). Sonst ist ein einziger abhandengekommener Schlüssel der Zugang zu allen Gästedaten. + +## 5. Offene Entscheidungen + +- Wer betreibt den Server, und für wen? (nur Bartl, oder mehrere Betriebe) +- Ein Mandant je Datenbank oder ein gemeinsames Schema mit Mandantenspalte? +- Was passiert mit dem lokalen Betrieb — bleibt SQLite als Offline-Fassung, oder wird alles zentral? +- Wie kommt der Bestand in die MySQL, ohne dass Nummern doppelt vergeben werden? + +--- + +**Nichts davon beginnen, bevor die Punkte 1 bis 3 entschieden sind** — sie bestimmen den Aufbau, +nicht die Ausstattung. diff --git a/Diagnose.spec b/Diagnose.spec new file mode 100644 index 0000000..d030d6f --- /dev/null +++ b/Diagnose.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + # Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start + # nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt + # das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders + # aus als hier. + datas=[('assets/programm.ico', 'assets'), + ('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'), + ('assets/fonts', 'assets/fonts'), + # Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe + # nur ihren Text, aber der Anordnen-Griff fehlt. + ('assets/icons', 'assets/icons')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Diagnose', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + icon='assets/programm.ico', + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/Installer.iss b/Installer.iss new file mode 100644 index 0000000..86f6ce7 --- /dev/null +++ b/Installer.iss @@ -0,0 +1,95 @@ +; Installer fuer das Rechnungstool (Inno Setup 6) +; +; ENTSCHEIDUNGEN, DIE HIER STECKEN - und warum +; -------------------------------------------- +; 1. INSTALLATION PRO BENUTZER nach %LOCALAPPDATA%\Programs, NICHT nach +; "Program Files". Grund: der Updater muss die EXE ablegen duerfen. In +; Program Files darf ein normaler Benutzer nicht schreiben - jedes Update +; wuerde an fehlenden Rechten scheitern oder eine Administrator-Abfrage +; erzwingen. Chrome, VS Code, Discord, Teams und Fusion 360 machen es +; genauso, aus demselben Grund. +; +; 2. DIE VERKNUEPFUNG ZEIGT AUF DEN UPDATER, nicht auf das Programm. So schaut +; jeder Start zuerst nach einer neuen Fassung. Der Updater startet danach +; die eigentliche EXE - die ist dabei noch gar nicht in Benutzung und laesst +; sich deshalb problemlos ersetzen. +; +; 3. KEINE DATEN ANFASSEN. Der Installer legt keine Rechnungsordner an, kein +; Journal, kein Nummernbuch - und beim Deinstallieren wird davon nichts +; entfernt. Das Programm schreibt aufbewahrungspflichtige Belege +; (§ 147 AO, zehn Jahre). Ein Deinstallieren darf sie nie mitnehmen. +; +; 4. Der Eintrag unter "Apps & Features" entsteht von selbst - Inno Setup legt +; den Registry-Schluessel unter HKCU an. Da muss niemand von Hand hinein. + +#define Name "Rechnungstool" +#define Herausgeber "ravokk" +#define ExeUpdater "Updater.exe" +#define Webseite "https://git.pcore.de/TheMockTv/rechnungstool" + +[Setup] +AppId={{7F3A9C2E-51D4-4B86-9A17-2C0E8B4D6A31} +AppName={#Name} +AppVersion={#Version} +AppVerName={#Name} {#Version} +AppPublisher={#Herausgeber} +AppPublisherURL={#Webseite} +AppSupportURL={#Webseite}/issues +AppUpdatesURL={#Webseite}/releases + +; --- pro Benutzer, ohne Adminrechte --- +PrivilegesRequired=lowest +; Der Zielordner ist NICHT waehlbar. Wer hier "Programme" auswaehlen +; koennte, braeche sich den Updater: dort darf ein normaler Benutzer +; nicht schreiben. Chrome, Teams und Discord blenden diese Seite aus +; demselben Grund aus. In "Programme" gehoert hoechstens eine +; Verknuepfung - und die liegt ohnehin im Startmenue. +DisableDirPage=yes +DefaultDirName={localappdata}\Programs\{#Name} +DefaultGroupName={#Name} +DisableProgramGroupPage=yes +UsePreviousAppDir=yes + +OutputDir=setup +OutputBaseFilename=Rechnungstool-Setup-{#Version} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +; Das Programmsymbol - auch fuer den Installer selbst und den +; Eintrag unter "Apps & Features". +SetupIconFile=assets\programm.ico +UninstallDisplayIcon={app}\{#ExeProgramm} +ArchitecturesInstallIn64BitMode=x64compatible + +; Erprobungsfassung - das steht auch im Programm selbst. +AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr. + +[Languages] +Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \ + GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked + +[Files] +; Der Updater - das ist, was der Benutzer startet. +Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion +; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran, +; was installiert ist, und legt neue Fassungen daneben statt darueber. +Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm. +Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}" +Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}" +Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \ + Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Nur die Buchfuehrung des Updaters. +; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein +; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben. +Type: files; Name: "{app}\installiert.json" diff --git a/Rechnungstool.spec b/Rechnungstool.spec new file mode 100644 index 0000000..57d1265 --- /dev/null +++ b/Rechnungstool.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + # Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start + # nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt + # das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders + # aus als hier. + datas=[('assets/programm.ico', 'assets'), + ('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'), + ('assets/fonts', 'assets/fonts'), + # Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe + # nur ihren Text, aber der Anordnen-Griff fehlt. + ('assets/icons', 'assets/icons')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Rechnungstool', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + icon='assets/programm.ico', + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/Updater.spec b/Updater.spec new file mode 100644 index 0000000..a82ec2a --- /dev/null +++ b/Updater.spec @@ -0,0 +1,24 @@ +# -*- mode: python ; coding: utf-8 -*- +# Der Updater ist der Starter: winzige Konsolen-EXE, die vor dem Programm laeuft. +a = Analysis( + ['updater.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['version'], + hookspath=[], + runtime_hooks=[], + excludes=['tkinter', 'reportlab', 'PIL', 'pypdf', 'pypdfium2', 'numpy'], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, a.scripts, a.binaries, a.datas, [], + name='Updater', + debug=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + icon='assets/programm.ico', +) diff --git a/app.py b/app.py index b833b73..ade214f 100644 --- a/app.py +++ b/app.py @@ -32,6 +32,7 @@ import config import berechnung import gemeinsam import hinweis +import wasserzeichen import ordnerwahl import pdf_renderer from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern @@ -63,6 +64,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, super().__init__() self.cfg = config.load_config() self.title(config.PROGRAMM) + self._fenstersymbol() # Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht hoehe = max(560, min(840, self.winfo_screenheight() - 90)) self.geometry(f"1060x{hoehe}") @@ -118,11 +120,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum. self.protocol("WM_DELETE_WINDOW", self._beenden) - # after_idle statt after(0): laeuft, sobald Tk nichts mehr zu zeichnen hat - - # also nachdem das Hauptfenster steht. Bei after(0) haengt der modale Dialog - # an einem noch ungezeichneten Fenster und wirkt wie eingefroren; eine feste - # Wartezeit dagegen erreicht ein Pruefstand nie, der keine Schleife dreht. - self.after_idle(self._hinweis_pruefen) + # Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH + # gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten + # EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog + # sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein + # haengendes Programm (06.09.2026, am Installer aufgefallen). + self.after(50, self._hinweis_wenn_sichtbar) # ---------------------------------------------------------------- Menue @@ -243,6 +246,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, self.flaeche = flaeche.Arbeitsflaeche(self, farben=self) self.flaeche.pack(side="top", fill="both", expand=True, padx=10, pady=(4, 0)) + # Das Wasserzeichen liegt HINTER der Arbeitsflaeche, unten rechts. Es + # wird mit place() gesetzt, damit es die Anordnung der Kaesten nicht + # anfasst - die darf der Benutzer ja selbst verschieben. lower() legt + # es unter alles andere. + self._wasserzeichen_setzen() def _anordnen_umschalten(self): self.flaeche.anordnen_starten() @@ -611,6 +619,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, def _build_summen(self): # Statuszeile ganz unten status = ttk.Frame(self, padding=(12, 2)) + # Das Programmlogo neben der Marke - klein, immer sichtbar. Es steht + # rechts bei "powered by ravokk", weil dort die Kennung des Werkzeugs + # sitzt und nicht die des Kunden. + self._kopf_bild = wasserzeichen.kopf(18, master=self) + if self._kopf_bild is not None: + ttk.Label(status, image=self._kopf_bild).pack(side="right", padx=(0, 6)) status.pack(side="bottom", fill="x") ttk.Label(status, text=config.MARKE, foreground=self.col_muted, font=(theme.SCHRIFT_NAME, 8)).pack(side="right") @@ -644,6 +658,40 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # --------------------------------------------------- Gemeinsames Buch # ------------------------------------------------- Erprobungshinweis + + def _wasserzeichen_setzen(self, groesse=240): + """Das Programmlogo blass hinter die Arbeitsflaeche legen.""" + bild = wasserzeichen.hole(groesse, 0.06, master=self) + if bild is None: + return + # Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk + # zeigt eine leere Flaeche (der klassische PhotoImage-Fehler). + self._wz_bild = bild + self._wz = tk.Label(self.flaeche, image=bild, borderwidth=0, + background=self.col_bg) + self._wz.place(relx=1.0, rely=1.0, anchor='se', x=-12, y=-12) + self._wz.lower() + + def _fenstersymbol(self): + """Das Programmsymbol auf das Fenster legen.""" + return wasserzeichen.symbol_setzen(self) + print("[symbol] assets/programm.ico nicht gefunden") + return False + + def _hinweis_wenn_sichtbar(self, versuche=0): + """Wartet, bis das Fenster gezeichnet ist, und fragt dann. + + winfo_viewable() sagt, ob das Fenster tatsaechlich auf dem Schirm + ist. Solange nicht, wird in kurzen Abstaenden nachgesehen. Nach + rund zwei Sekunden wird trotzdem gefragt - im Pruefstand ist das + Fenster absichtlich nicht sichtbar, und dort soll es trotzdem + laufen. + """ + if not self.winfo_viewable() and versuche < 40: + self.after(50, lambda: self._hinweis_wenn_sichtbar(versuche + 1)) + return + self._hinweis_pruefen() + def _hinweis_pruefen(self): """Hinweis auf die Erprobungsfassung. Ablehnen beendet das Programm. @@ -1821,7 +1869,11 @@ if __name__ == "__main__": if not fenster_nach_vorn(laufende_pid_lesen()): _r = tk.Tk() _r.withdraw() - self.melden_fehler( + # Hier gibt es kein self - das Fenster wird ja gerade NICHT gebaut. + # Deshalb die schlichte Windows-Meldung statt des eigenen Dialogs. + # (Frueher stand hier self.melden_fehler und ist beim Start eines + # zweiten Fensters mit NameError abgestuerzt.) + messagebox.showerror( "Rechnungstool läuft bereits", """Das Rechnungstool ist schon geöffnet, das Fenster ließ sich aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln. diff --git a/assets/programm.ico b/assets/programm.ico new file mode 100644 index 0000000..cae16c9 Binary files /dev/null and b/assets/programm.ico differ diff --git a/assets/programm_logo.png b/assets/programm_logo.png new file mode 100644 index 0000000..6086c87 Binary files /dev/null and b/assets/programm_logo.png differ diff --git a/assets/programm_logo.svg b/assets/programm_logo.svg new file mode 100644 index 0000000..f16f814 --- /dev/null +++ b/assets/programm_logo.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bauen.py b/bauen.py new file mode 100644 index 0000000..2220498 --- /dev/null +++ b/bauen.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""Der ganze Weg von der Quelle zur Setup-Datei - an EINER Stelle. + +WARUM DAS HIER STEHT +-------------------- +Bis hierher lag der Bau in einer Folge von Befehlen, die jemand von Hand +eintippen musste: PyInstaller fuers Programm, PyInstaller fuer den Updater, +umbenennen, Inno Setup mit den richtigen Schaltern. Wer einen Schritt +vergisst, liefert eine Setup-Datei aus, die eine alte EXE enthaelt - und +niemand sieht es ihr an. Genau das ist am 06.09.2026 passiert (der Installer +schrieb ueber eine laufende, gesperrte EXE hinweg und meldete trotzdem Erfolg). + +Ab jetzt ist der Bau ein Befehl: + + python bauen.py + +WAS ER TUT +---------- +1. Die Nummer kommt aus version.py - nicht von der Kommandozeile. So kann sie + nicht auseinanderlaufen mit dem, was das Programm ueber sich selbst sagt. +2. Programm und Updater werden gebaut. +3. Die Programm-EXE bekommt die Nummer in den Namen. Der Updater erkennt daran, + welche Fassungen installiert sind, und legt neue DANEBEN statt darueber - + deshalb kann er auch dann austauschen, wenn Windows die alte Datei sperrt. +4. Inno Setup baut die Setup-Datei. +5. Zum Schluss stehen die SHA256-Summen da. Die gehoeren in den Release-Text: + nur damit laesst sich spaeter nachweisen, dass die heruntergeladene Datei + genau die gebaute ist. +""" +import hashlib +import os +import shutil +import subprocess +import sys + +HIER = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HIER) + +import version # noqa: E402 + +# Inno Setup liegt bei ihm unter LOCALAPPDATA (Installation ohne Adminrechte). +ISCC_ORTE = [ + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Inno Setup 6", "ISCC.exe"), + r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe", + r"C:\Program Files\Inno Setup 6\ISCC.exe", +] + + +def summe(pfad): + h = hashlib.sha256() + with open(pfad, "rb") as f: + for brocken in iter(lambda: f.read(1 << 20), b""): + h.update(brocken) + return h.hexdigest() + + +def lauf(befehl, was): + print(f"--- {was}") + e = subprocess.run(befehl, cwd=HIER, capture_output=True, text=True, + encoding="utf-8", errors="replace") + if e.returncode != 0: + print(e.stdout[-3000:]) + print(e.stderr[-3000:]) + raise SystemExit(f"{was} fehlgeschlagen (Rueckgabe {e.returncode})") + + +def iscc(): + for p in ISCC_ORTE: + if p and os.path.exists(p): + return p + raise SystemExit("ISCC.exe nicht gefunden - Inno Setup 6 fehlt") + + +def main(): + stamm, endung = os.path.splitext(version.EXE_NAME) + mit_nummer = f"{stamm}-{version.VERSION}{endung}" + + # Die Bauordner werden geleert. Ein PyInstaller-Lauf ueber einen alten + # Bauordner uebernimmt sonst Reste, die es in der Quelle nicht mehr gibt - + # und in setup/ laege noch die Datei der VORIGEN Nummer herum. Beim + # Ausliefern greift man dann leicht zur falschen. + for ordner in ("build", "dist", "setup"): + shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True) + + lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", f"{stamm}.spec"], + f"{stamm} {version.VERSION}") + lauf([sys.executable, "-m", "PyInstaller", "--noconfirm", "Updater.spec"], + "Updater") + + quelle = os.path.join(HIER, "dist", version.EXE_NAME) + ziel = os.path.join(HIER, "dist", mit_nummer) + os.replace(quelle, ziel) + + lauf([iscc(), f"/DVersion={version.VERSION}", f"/DExeProgramm={mit_nummer}", + "Installer.iss"], "Setup") + + print() + print(f"FERTIG | {stamm} {version.VERSION}") + for name in sorted(os.listdir(os.path.join(HIER, "dist"))): + p = os.path.join(HIER, "dist", name) + print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}") + setup = os.path.join(HIER, "setup") + for name in sorted(os.listdir(setup)) if os.path.isdir(setup) else []: + p = os.path.join(setup, name) + print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}") + + +if __name__ == "__main__": + main() diff --git a/pdf_renderer.py b/pdf_renderer.py index 9797acc..a72f2a7 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab). +PDF-Renderer fuer die Rechnung (reportlab). render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF im Layout der Excel-Vorlage: diff --git a/pruef_hinweis.py b/pruef_hinweis.py index 6558969..09eb11c 100644 --- a/pruef_hinweis.py +++ b/pruef_hinweis.py @@ -22,6 +22,7 @@ Geprueft wird die ganze Strecke, weil jeder Teil fuer sich wertlos waere: """ import json import os +import time import shutil import sys import tempfile @@ -89,6 +90,25 @@ messagebox.showwarning = lambda *a, **k: None messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") +def pumpen(fenster, bedingung, sekunden=6): + """Ereignisschleife drehen, bis die Bedingung stimmt. + + Der Hinweis wartet, bis das Fenster gezeichnet ist, und meldet sich per + after(). Ein einzelnes update() erreicht das nicht - hier wird die Schleife + gedreht wie mainloop() es taete. Abgekuerzt wird nichts. + """ + ende = time.time() + sekunden + while time.time() < ende: + try: + fenster.update() + except Exception: # noqa: BLE001 - Fenster kann zwischendurch weg sein + return bedingung() + if bedingung(): + return True + time.sleep(0.02) + return bedingung() + + def gelesen(): return json.load(open(config.CONFIG_PATH, encoding="utf-8")) @@ -96,7 +116,7 @@ def gelesen(): # ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt ----------------------- antworten["Erprobungsfassung"] = False f = A.RechnungsApp() -f.update() # laesst das after(0) laufen -> Hinweis kommt +pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen)) treffer = [t for t in gesehen if "Erprobungsfassung" in t[0]] pruefe(bool(treffer), "der Hinweis kommt beim Start") @@ -129,7 +149,7 @@ pruefe(not cfg.get("hinweis_zeit"), "nach Nein steht nichts in der Konfiguration gesehen.clear() antworten["Erprobungsfassung"] = True f = A.RechnungsApp() -f.update() +pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen)) pruefe(any("Erprobungsfassung" in t[0] for t in gesehen), "beim naechsten Start wird wieder gefragt") @@ -147,7 +167,7 @@ pruefe("gelesen und zur kenntnis genommen" in sig.lower(), gesehen.clear() f.destroy() f = A.RechnungsApp() -f.update() +pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen), sekunden=2) pruefe(not any("Erprobungsfassung" in t[0] for t in gesehen), "nach dem Ja wird nicht noch einmal gefragt") @@ -156,7 +176,7 @@ gesehen.clear() f.destroy() H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden f = A.RechnungsApp() -f.update() +pumpen(f, lambda: any("Erprobungsfassung" in t[0] for t in gesehen)) pruefe(any("Erprobungsfassung" in t[0] for t in gesehen), "bei geaenderter Fassung wird erneut gefragt") diff --git a/pruef_selbstbezug.py b/pruef_selbstbezug.py new file mode 100644 index 0000000..d8a6e84 --- /dev/null +++ b/pruef_selbstbezug.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: `self` darf nur dort stehen, wo es auch eines gibt. + +DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026) +-------------------------------------------- +Nach dem Installieren meldete das Programm beim Start: + + File "app.py", line 1824, in + NameError: name 'self' is not defined + +Im `if __name__ == "__main__"`-Block stand `self.melden_fehler(...)`. Dort gibt +es kein `self` - das Fenster wird ja gerade NICHT gebaut. Beim Umbau auf die +eigenen Meldungsfenster war die Zeile mitgezogen worden. + +WARUM DAS KEINE SYNTAXPRUEFUNG FINDET +------------------------------------- +`ast.parse` und `py_compile` pruefen die FORM, nicht die Bedeutung. Ein +undefinierter Name faellt erst auf, wenn die Zeile ausgefuehrt wird - und diese +hier laeuft nur, wenn ein ZWEITES Fenster gestartet wird und sich das erste +nicht nach vorn holen laesst. Also fast nie. Genau deshalb ist sie durch alle +Pruefstaende gekommen. + +WAS HIER GEPRUEFT WIRD +---------------------- +Jede Verwendung des Namens `self` muss in einer Funktion stehen, die `self` auch +als ersten Parameter hat. Alles andere ist ein Ueberbleibsel aus einer Methode. + +Geprueft werden beide Programme - der Fehler war in einem, haette aber genauso +im anderen stecken koennen. +""" +import ast +import io +import os +import sys + +ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"] + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kod = sys.stdout.encoding or "utf-8" + print(zeile.encode(kod, "replace").decode(kod)) + if not bedingung: + fehler.append(text) + + +def fundstellen(quelltext): + """Alle Zeilen, in denen `self` steht, ohne dass es eines gibt.""" + baum = ast.parse(quelltext) + treffer = [] + + def erster_parameter(knoten): + args = knoten.args + alle = list(args.posonlyargs) + list(args.args) + return alle[0].arg if alle else None + + def durchgehen(knoten, hat_self): + for kind in ast.iter_child_nodes(knoten): + if isinstance(kind, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Eine verschachtelte Funktion in einer Methode darf `self` aus + # der umgebenden Methode benutzen - das ist ein Abschluss und + # voellig richtig. Verfuegbar ist es also, sobald IRGENDEINE + # umgebende Funktion es als ersten Parameter hat. + durchgehen(kind, hat_self or erster_parameter(kind) == "self") + elif isinstance(kind, ast.Lambda): + durchgehen(kind, hat_self) + else: + if isinstance(kind, ast.Name) and kind.id == "self" and not hat_self: + treffer.append(kind.lineno) + durchgehen(kind, hat_self) + + durchgehen(baum, False) + return sorted(set(treffer)) + + +# ---- 1. Die Pruefung muss den bekannten Fehler FINDEN ------------------- +# Sonst prueft sie nichts und wiegt uns in Sicherheit. +kaputt = ( + "class A:\n" + " def m(self):\n" + " self.x = 1\n" + "\n" + 'if __name__ == "__main__":\n' + " self.melden_fehler('geht nicht')\n" +) +gefunden = fundstellen(kaputt) +pruefe(gefunden == [6], + f"die Pruefung findet den bekannten Fehler in Zeile 6 (gefunden: {gefunden})") + +sauber = ( + "class A:\n" + " def m(self):\n" + " self.x = 1\n" + " f = lambda: self.x\n" + " return f\n" +) +pruefe(fundstellen(sauber) == [], + "und meldet sauberen Code nicht faelschlich") + +# ---- 2. Beide Programme durchgehen ------------------------------------- +for ordner in ORDNER: + for name in sorted(os.listdir(ordner)): + if not name.endswith(".py"): + continue + pfad = os.path.join(ordner, name) + try: + quelle = io.open(pfad, encoding="utf-8").read() + stellen = fundstellen(quelle) + except (OSError, SyntaxError) as e: + pruefe(False, f"{name} liess sich nicht lesen ({e})") + continue + if stellen: + pruefe(False, f"{os.path.basename(ordner)}/{name}: `self` ohne self " + f"in Zeile(n) {stellen}") + +pruefe(True, f"{len(ORDNER)} Programme durchgegangen") + +if fehler: + print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") + sys.exit(1) +print("GRUEN | Nirgends steht `self`, wo es keines gibt") diff --git a/pruef_start_exe.py b/pruef_start_exe.py new file mode 100644 index 0000000..04f02fb --- /dev/null +++ b/pruef_start_exe.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: kommt bei der GEBAUTEN EXE wirklich ein Fenster? + +DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026) +-------------------------------------------- +Nach dem Installieren lief das Programm - im Taskmanager stand es da, es +verbrauchte Rechenzeit - und auf dem Bildschirm war NICHTS. Ursache: der +Erprobungshinweis ging auf, bevor das Hauptfenster gezeichnet war. Der modale +Dialog sperrte das Hauptfenster, und beide blieben unsichtbar. + +WARUM DAS KEIN ANDERER PRUEFSTAND FINDET +---------------------------------------- +Alle uebrigen Pruefstaende fahren das Programm aus der QUELLE. Dort ist das +Fenster sofort gezeichnet, der Fehler tritt nicht auf. Er gehoert zur gepackten +EXE - also muss genau die gestartet werden. + +Und "der Prozess laeuft" beweist nichts: er lief ja. Geprueft wird deshalb, ob +Windows ein Fenster dieses Programms als SICHTBAR fuehrt. + +ZWEI FALLEN, die hier schon zugeschnappt sind: + * Eine PyInstaller-Onefile-EXE ist zweistufig. Der gestartete Prozess packt + nur aus; das Fenster gehoert dem KIND. Wer nur die eigene PID betrachtet, + sieht nie ein Fenster. + * terminate() beendet nur den Auspacker. Das Kind laeuft weiter - und dessen + Einzelinstanz-Sperre haelt danach jeden weiteren Start ab. Es muss die + ganze Sippe beendet werden. +""" +import ctypes +import os +import subprocess +import sys +import time +from ctypes import wintypes + +HIER = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HIER) + +import version # noqa: E402 + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kod = sys.stdout.encoding or "utf-8" + print(zeile.encode(kod, "replace").decode(kod)) + if not bedingung: + fehler.append(text) + + +stamm, endung = os.path.splitext(version.EXE_NAME) +EXE = os.path.join(HIER, "dist", f"{stamm}-{version.VERSION}{endung}") + +if not os.path.exists(EXE): + print(f"UEBERSPRUNGEN | {os.path.basename(EXE)} ist nicht gebaut " + f"(vorher: python bauen.py)") + sys.exit(0) + +u = ctypes.windll.user32 +p = subprocess.Popen([EXE]) +print(f" (gestartet: {os.path.basename(EXE)}, PID {p.pid})") + +RUECKRUF = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) +gefunden = [] +verwandte = {p.pid} + + +def sippe(): + alle = {p.pid} + try: + e = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object " + f"{{ $_.ParentProcessId -eq {p.pid} }} | ForEach-Object {{ $_.ProcessId }}"], + capture_output=True, text=True, timeout=20) + for zeile in e.stdout.split(): + if zeile.strip().isdigit(): + alle.add(int(zeile.strip())) + except Exception: # noqa: BLE001 - dann bleibt es bei der eigenen PID + pass + return alle + + +def je_fenster(hwnd, _): + pid = wintypes.DWORD() + u.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value not in verwandte: + return True + laenge = u.GetWindowTextLengthW(hwnd) + puffer = ctypes.create_unicode_buffer(laenge + 1) + u.GetWindowTextW(hwnd, puffer, laenge + 1) + r = wintypes.RECT() + u.GetWindowRect(hwnd, ctypes.byref(r)) + gefunden.append((puffer.value, bool(u.IsWindowVisible(hwnd)), + r.right - r.left, r.bottom - r.top)) + return True + + +ende = time.time() + 60 +sichtbar = False +vorzeitig_weg = False +while time.time() < ende and not sichtbar: + if p.poll() is not None: + vorzeitig_weg = True + break + verwandte = sippe() + gefunden.clear() + u.EnumWindows(RUECKRUF(je_fenster), 0) + # Ein "Fenster" von wenigen Pixeln ist Verwaltung (IME, Notifier), kein + # Programmfenster. Deshalb die Groesse mitpruefen. + sichtbar = any(s and b > 300 and h > 300 for _, s, b, h in gefunden) + time.sleep(0.5) + +for titel, s, b, h in gefunden: + print(f" {titel!r:42s} sichtbar={s} {b}x{h}") + +pruefe(not vorzeitig_weg, + f"das Programm bleibt am Leben (Rueckgabe {p.returncode})" + if vorzeitig_weg else "das Programm bleibt am Leben") +if vorzeitig_weg and p.returncode == 0: + print(" (Rueckgabe 0 heisst meist: es laeuft schon eine Fassung. " + "Erst beenden, dann noch einmal.)") + +pruefe(sichtbar, "ein sichtbares Programmfenster ist da") +pruefe(any(str(t).startswith(stamm) and s for t, s, _, _ in gefunden), + f"und es traegt den Programmnamen ({stamm})") + +subprocess.run(["taskkill", "/PID", str(p.pid), "/T", "/F"], capture_output=True) +try: + p.wait(10) +except subprocess.TimeoutExpired: + p.kill() + +if fehler: + print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") + sys.exit(1) +print("GRUEN | Die gebaute EXE zeigt ein Fenster") diff --git a/pruef_updater.py b/pruef_updater.py index e1bebef..227bc3c 100644 --- a/pruef_updater.py +++ b/pruef_updater.py @@ -196,6 +196,31 @@ pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe", pruefe(not any("Wird geladen" in m for m in meldungen), f"und nichts noch einmal geladen ({meldungen})") +# 8. Ordner nicht beschreibbar -> kein Update, aber das Programm startet +# Sein Einwand: "was ist, wenn ein user den ordner aendert?" Liegt die +# Installation dann in einem geschuetzten Ordner, darf das Update scheitern - +# aber NIE der Start. Ein fehlgeschlagenes Update, nach dem gar nichts mehr +# geht, waere schlimmer als kein Update. +meldungen.clear() +gesperrt = os.path.join(tmp, "gesperrt") +os.makedirs(gesperrt) +with open(os.path.join(gesperrt, "Rechnungstool-1.6.0.exe"), "wb") as f: + f.write(b"INSTALLIERT") + +echt_beschreibbar = U.beschreibbar +U.beschreibbar = lambda _o: False # so, als waere es "Programme" +try: + start = U.durchlauf(gesperrt, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) +finally: + U.beschreibbar = echt_beschreibbar + +pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", + "geschuetzter Ordner: die vorhandene Fassung wird trotzdem gestartet") +pruefe(any("nicht geschrieben werden" in m for m in meldungen), + f"und es wird gesagt, warum ({meldungen})") +pruefe(not os.path.exists(os.path.join(gesperrt, "Rechnungstool-1.9.0.exe")), + "es wurde nichts abgelegt") + shutil.rmtree(tmp, ignore_errors=True) if fehler: diff --git a/updater.py b/updater.py index f06b06c..5db2447 100644 --- a/updater.py +++ b/updater.py @@ -129,6 +129,22 @@ def summe_aus_release(release, exe_name): # --------------------------------------------------------------------------- # Laden und pruefen # --------------------------------------------------------------------------- +def beschreibbar(ordner): + """Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten. + + Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein + beschreibbarer - erst der Versuch zeigt es. + """ + probe = os.path.join(ordner, ".schreibprobe") + try: + with open(probe, "w", encoding="ascii") as f: + f.write("x") + os.remove(probe) + return True + except OSError: + return False + + def sha256(pfad): h = hashlib.sha256() with open(pfad, "rb") as f: @@ -309,6 +325,16 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None): sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.") return ziel + # Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die + # Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das + # Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm + # startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass + # gar nichts mehr geht. + if not beschreibbar(ordner): + sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.") + sagen("Es wird die vorhandene Fassung gestartet.") + return hier_pfad + sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...") zwischen = tempfile.mkdtemp(prefix="update_") geladen = os.path.join(zwischen, exe_name) @@ -332,7 +358,12 @@ def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None): return hier_pfad # Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort. - shutil.move(geladen, ziel) + try: + shutil.move(geladen, ziel) + except OSError as e: # noqa: BLE001 + sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).") + sagen("Es wird die vorhandene Fassung gestartet.") + return hier_pfad buch_schreiben(ordner, {"version": dort.lstrip("v"), "datei": os.path.basename(ziel), "sha256": erwartete_summe, diff --git a/version.py b/version.py index 4da2e80..acce0ab 100644 --- a/version.py +++ b/version.py @@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier. Regel ----- Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das -"v": Tag `v1.6.0` -> VERSION "1.6.0". Wer das eine ohne das andere aendert, +"v": Tag `v1.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert, baut dem Updater eine Falle. """ -VERSION = "1.6.0" +VERSION = "1.7.0" # Zu welchem Repository dieses Programm gehoert - der Updater sucht dort. REPOSITORY = "TheMockTv/rechnungstool" diff --git a/wasserzeichen.py b/wasserzeichen.py new file mode 100644 index 0000000..935ba0a --- /dev/null +++ b/wasserzeichen.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""Das Programmlogo - als Fenstersymbol und als blasses Wasserzeichen. + +WOHER DAS BILD KOMMT +-------------------- +Die Quelle ist die SVG aus dem Entwurf (`assets/programm_logo.svg`). Sie wird +BEIM BAUEN einmal in 512 Pixel gerendert (`assets/programm_logo.png`) und in +eine `programm.ico` fuer Windows umgesetzt. Hier zur Laufzeit wird nur noch +skaliert. + +Warum nicht direkt aus der SVG zeichnen? Weil dafuer `svglib` und `lxml` mit in +die EXE muessten - und bis 512 Pixel ist das Ergebnis pixelgenau dasselbe. Wird +das Wasserzeichen je groesser gebraucht, gehoert das umgestellt. + +Warum ueberhaupt eine ICO? Weil Windows das Symbol einer EXE als Ressource in +der Datei erwartet, und dieses Format ist ICO. Das gilt fuer jede Sprache, nicht +nur fuer Python - eine SVG kann Windows dort nicht verwenden. + +Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie +hinueber. +""" + +import os +import sys +import tkinter as tk + +_lager = {} # (fenster, groesse, deckkraft) -> PhotoImage + + +def _ordner(): + """Alle Orte, an denen die Bilder liegen koennen - Quelle wie EXE.""" + hier = os.path.dirname(os.path.abspath(__file__)) + aus_exe = getattr(sys, "_MEIPASS", "") + neben_exe = os.path.dirname(os.path.abspath(sys.executable)) \ + if getattr(sys, "frozen", False) else "" + return [p for p in (aus_exe, neben_exe, hier) if p] + + +def pfad(name): + """Voller Pfad zu einer Datei in assets/ - oder ''.""" + for basis in _ordner(): + p = os.path.join(basis, "assets", name) + if os.path.exists(p): + return p + return "" + + +def symbol_setzen(fenster, melden=print): + """Das Programmsymbol auf ein Fenster legen. True bei Erfolg. + + Die EXE traegt es ohnehin - das Fenster aber nicht von selbst. Ohne das + steht in der Titelleiste das nackte Tk-Symbol, obwohl die Datei ein eigenes + hat. + """ + p = pfad("programm.ico") + if not p: + melden("[symbol] assets/programm.ico nicht gefunden") + return False + try: + fenster.iconbitmap(default=p) + return True + except Exception as e: # noqa: BLE001 - ohne Symbol laeuft es weiter + melden(f"[symbol] konnte nicht gesetzt werden: {e}") + return False + + +def hole(groesse=240, deckkraft=0.06, master=None, melden=print): + """Das Logo blass als PhotoImage - oder None. + + `deckkraft` 0..1, klein halten. Ein Wasserzeichen, das man LIEST, stoert + beim Arbeiten; eines, das man ahnt, gehoert zum Programm. + + `master` MUSS mitgegeben werden, wenn mehr als ein Tk-Fenster laeuft: + sonst gehoert das Bild dem falschen Fenster und Tk meldet spaeter, es + kenne es nicht. + """ + fenster = master if master is not None else tk._default_root + if fenster is None: + return None + schluessel = (id(fenster), groesse, round(deckkraft, 3)) + if schluessel in _lager: + return _lager[schluessel] + p = pfad("programm_logo.png") + if not p: + melden("[wasserzeichen] assets/programm_logo.png nicht gefunden") + return None + try: + from PIL import Image, ImageTk + bild = Image.open(p).convert("RGBA").resize((groesse, groesse), Image.LANCZOS) + bild.putalpha(bild.getchannel("A").point(lambda w: int(w * deckkraft))) + fertig = ImageTk.PhotoImage(bild, master=fenster) + except Exception as e: # noqa: BLE001 - ein Wasserzeichen darf nichts blockieren + melden(f"[wasserzeichen] nicht ladbar: {e}") + return None + _lager[schluessel] = fertig + return fertig + + +def kopf(hoehe=28, master=None, melden=print): + """Das Logo fuer die Kopfzeile - volle Deckkraft, kleine Hoehe. + + Getrennt von hole(): dort geht es um ein blasses Wasserzeichen, hier um ein + Erkennungszeichen. Zwei Zwecke, zwei Funktionen - sonst haette eine davon + einen Schalter, den man beim Lesen nachschlagen muss. + """ + return hole(hoehe, 1.0, master=master, melden=melden)