diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e3fff1e..0000000 --- a/.gitattributes +++ /dev/null @@ -1,23 +0,0 @@ -# Zeilenenden festlegen, damit sie nicht von der Maschine abhaengen. -# -# Neun Dateien liegen in beiden Programmen gleich und werden von Hand -# hinueberkopiert; pruef_gemeinsam_automatisch.py prueft das byteweise. -# Ohne diese Festlegung entscheidet core.autocrlf jeder Maschine einzeln, -# wie eine Datei im Arbeitsverzeichnis aussieht - und dann meldet die -# Pruefung einen Unterschied, den es im Repository gar nicht gibt. -# Genau so stand es am 07.09.2026 bei hinweis.py: gleicher Inhalt, die -# eine Fassung CRLF, die andere LF. -* text=auto -*.py text eol=crlf -*.iss text eol=crlf -*.md text eol=crlf -*.json text eol=crlf - -# Binaeres nie anfassen. -*.png binary -*.ico binary -*.svg text eol=crlf -*.ttf binary -*.exe binary -*.pdf binary -*.sqlite3 binary diff --git a/.gitignore b/.gitignore index a21aa42..3833f58 100644 --- a/.gitignore +++ b/.gitignore @@ -9,18 +9,3 @@ _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 deleted file mode 100644 index c520033..0000000 --- a/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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_BEWEISKETTE_PRUEFBAR.md b/AUFGABE_BEWEISKETTE_PRUEFBAR.md deleted file mode 100644 index 426faf7..0000000 --- a/AUFGABE_BEWEISKETTE_PRUEFBAR.md +++ /dev/null @@ -1,139 +0,0 @@ -# Beweiskette: von Anfang bis Ende prüfbar - -Seine Vorgabe vom 06.09.2026, wörtlich: - -> „ich will für den prüfer sichtbar machen: ich wollte das so. die software dokumentiert das, -> und damit wird es nachvollziehbar, weil es einen checkmark nun gibt in der sqlite" -> — „das soll alles von anfang bis ende prüfbar sein" -> — „damit denke ich überfüllen wir sogar das gobd, sogar mehr, weil es nun eine beweiskette -> darstellt" - -**Grundgedanke:** GoBD verlangt Nachvollziehbarkeit und Unveränderbarkeit — aber **keinen -dokumentierten Freigabe-Entscheid**. Den gibt es jetzt zusätzlich. Damit die Kette trägt, muss -jedes Glied für sich prüfbar sein und keines allein tragen. - ---- - -## Schritt 1 — Freigabe vor dem Beleg ✅ ERLEDIGT (Commit 95a9063) - -- Rückfrage vor dem Schreiben, Text nennt § 14 UStG, § 14 Abs. 4 Nr. 4 UStG, § 147 Abs. 3 AO, - § 146 Abs. 4 AO; Angebot bekommt einen eigenen Text (ausdrücklich **keine** Rechnung). -- Storno/Korrektur und Berichtigung behalten ihre eigene Rückfrage — nicht verdoppelt. -- Spalte `freigabe` (ISO-Zeitstempel) in `nummern` im gemeinsamen Nummernbuch, mit - ALTER-TABLE-Migration. **Alte Zeilen bleiben leer** — dort wurde nie gefragt. -- `pruef_freigabe.py` prüft beide Richtungen. Alle 16 Prüfstände grün. - ---- - -## Schritt 2 — Freigabe zusätzlich in die PDF-Metadaten ⬜ OFFEN - -**Warum das nötig ist:** `gemeinsam.py` sagt über die SQLite selbst: *„Die Datei ist ein REGISTER, -keine zweite Buchhaltung: die Wahrheit bleiben die Rechnungs-PDFs. Geht das Buch verloren, fehlt -keine Rechnung — es muss nur neu gefüllt werden."* Ein solcher Neuaufbau kennt die -Freigabe-Zeitstempel **nicht**. Damit hinge die Beweiskette an der schwächsten Datei. - -**Zu tun:** `freigabe` in `pdf_renderer._kenndaten()` aufnehmen (die maschinenlesbaren Kenndaten im -`/Subject`, die das Steuerjournal ohnehin liest). Dann steht der Zeitstempel an zwei Stellen und -übersteht den Verlust des Registers. - -**Prüfstand:** `pruef_freigabe.py` erweitern — nach dem Ja muss `bestand.kenndaten_lesen(pdf)` den -Zeitstempel liefern, und er muss mit dem in der SQLite übereinstimmen. - ---- - -## Schritt 3 — Steuerprüfungs-Ausdruck im Beherbergungssteuer-Tool ⬜ OFFEN - -Sein Wortlaut: *„wir müssen im kassenbuch, was das beherbergungstool ist, einen button in die -einstellungen machen: steuerprüfungs-ausdruck. damit bekommt der prüfer einen ausdruck aus der db -und kann damit das in der tabelle prüfen auf richtigkeit, oder halt wenn der typ das als -papierversion hat."* - -**Zu tun:** Knopf in den Einstellungen, der aus der Datenbank ein PDF erzeugt, das ein Prüfer gegen -die Belege halten kann. - -**Vor dem Bauen zu klären:** -- Welcher Zeitraum? (Jahr, Monat, frei wählbar) -- Welche Spalten? Mindestens: Nummer, Datum, Art, Beträge, Vorgang/Storno-Bezug, **Freigabe** -- Wie wird gezeigt, dass nichts fehlt? Lückenlosigkeit der Nummern ist der Kern jeder Prüfung — - Lücken müssen **sichtbar** sein, nicht stillschweigend übersprungen. -- Summen je Zeitraum, damit der Prüfer gegenrechnen kann. -- Kennzeichnung als Auswertung, **nicht** als Beleg (sonst dasselbe Problem wie beim Amt-Blatt). - -⚠️ **Nicht verwechseln:** Der bestehende „Bericht fürs Amt" ist die **Steueranmeldung** nach -kommunaler Satzung. Der Prüfungs-Ausdruck ist etwas anderes: eine Auswertung für die -Betriebsprüfung. Beide bleiben nebeneinander bestehen. - ---- - -## Schritt 4 — Updater als Starter ⬜ OFFEN - -Sein Bild (06.09.2026): *„der updater schaut beim starten erst: ist auf dem git link eine neue -version mit exe? wenn ja, erst laden, ersetzen und dann starten."* Dazu: *„einmal prüfen auf -vollständigkeit."* - -**Warum das der richtige Aufbau ist:** Der Updater ist das, was der Nutzer startet. Er läuft -**bevor** das Programm läuft — damit gibt es das übliche Problem gar nicht, dass eine laufende EXE -sich nicht selbst ersetzen kann. - -**Ablauf:** -1. Beim Start die Release-Angaben vom Repository holen. -2. Ist die dortige Version neuer als die installierte → Datei in einen **Zwischenordner** laden. -3. **Vollständigkeit prüfen** — Größe und Prüfsumme gegen die Angabe des Releases. Erst wenn beides - stimmt, wird ersetzt. Ein halb geladener Download darf die Installation nie erreichen. -4. Alte Fassung als **Rückfallebene** behalten; scheitert der Start, zurücktauschen. -5. Programm starten. - -**⚠️ Zwei Voraussetzungen fehlen noch:** -- **Es gibt keine Versionsnummer im Programm.** Ohne die kann kein Updater vergleichen. Muss zuerst - eingeführt werden — an einer Stelle, aus der auch die EXE-Metadaten und der Info-Dialog lesen. -- **Sind die Repositories öffentlich?** Wenn nicht, braucht der Updater einen Zugangstoken — und - ein Token, das in einer verteilten EXE steckt, ist kein Geheimnis mehr. Dann müssten die Releases - öffentlich sein oder über eine andere Stelle ausgeliefert werden. - -**Und Andreas' Ansage zu den Releases:** *„die alten als experimental taggen, nicht als stabil — -weil die sind nicht stabil."* - ---- - -## Schritt 5 — Pcore-Design im Steuerrechnungstool ⬜ OFFEN - -Sein Wunsch: *„aber bitte mit unserm theme style."* - -**Befund:** Das Steuerrechnungstool hat **kein `theme.py`**. Es benutzt nackte ttk-Widgets mit -Segoe UI. Das Rechnungstool hat das ganze Pcore/Ravokk-Design — Farben, Roboto, Glass-Buttons, -eigene Meldungsfenster (`MeldungMixin`), hell und dunkel. - -**Zu tun:** `theme.py` herüberziehen und die Oberfläche darauf umstellen. Das ist ein eigener Bau, -kein Nebenbei — Menüs, Tabellen, Dialoge, Statusleiste. Vorher planen. - ---- - -## ✅ Erledigter Altfehler — `pruef_wache` (Steuerrechnungstool) **07.09.2026 behoben** - -**Nicht von den Arbeiten am 06.09.2026 verursacht** — bei der Ausgangsmessung vor dem Umbau bereits -rot, nur mit anderem Text. - -`pruef_wache.py` erwartete nach einem Push des Rechnungstools in der Statuszeile den Hinweis -**„Rechnungstool meldet"**. Tatsächlich stand dort das Ergebnis des anschließenden Einlesens. - -**Die Frage war:** Fehler im Programm oder veraltete Erwartung im Prüfstand? - -**Die Antwort: Fehler im Programm.** Sichtbar wurde das, weil der Prüfstand *mal grün und mal rot* -lief — je nachdem, wann er hinsah. Ein Prüfstand, der würfelt, ist nicht der eigentliche Befund, -sondern sein Symptom: die Meldung stand nur Sekundenbruchteile da, danach überschrieb sie das -Einlese-Ergebnis. Der Benutzer sah **nie**, dass eine Rechnung hereingekommen war. - -**Behoben:** `melde()` kennt jetzt einen `vorrang` in Sekunden. Eine Meldung mit Vorrang lässt sich -in dieser Zeit nicht von einer gewöhnlichen überschreiben; die Meldung des Rechnungstools bekommt -sechs Sekunden. Dreimal hintereinander stabil grün. - -⚠️ **Zum Merken:** Erst die Warteschleife im Prüfstand („warten, bis der Text dasteht" statt einmal -hinsehen) machte den Fehler *reproduzierbar* — und drehte ihn von zufällig grün auf konstant rot. -Ein wackelnder Prüfstand ist keine Nebensache, die man wegdrückt: er zeigt auf eine echte Stelle. - ---- - -## Reihenfolge - -Schritt 2 zuerst — er schließt eine Lücke in dem, was gerade gebaut wurde, und ist klein. -Schritt 3 danach, mit eigenem Plan, weil dort Entscheidungen offen sind. diff --git a/AUFGABE_KUNDENSTAMM_UND_WEB.md b/AUFGABE_KUNDENSTAMM_UND_WEB.md deleted file mode 100644 index 1c2dbda..0000000 --- a/AUFGABE_KUNDENSTAMM_UND_WEB.md +++ /dev/null @@ -1,83 +0,0 @@ -# Rechnungstool — Kundenstamm und Weboberflaeche (offen, aufgenommen 06.09.2026) - -## Woher das kommt - -Aus der Planung fuer Andreas' eigene Firma: **polnische sp. z o.o., Verkauf ausschliesslich B2B, -Kunden ueberwiegend in Deutschland**. Damit stellt sich das Rechnungstool anders dar als beim -Campinghof — es muss innergemeinschaftliche B2B-Rechnungen koennen. - -Sein Wort: *"da fehlt dann die kundennummer noch mit drin ... ich glaub wir sollten das langsam auf -web basis bauen, aber nicht heute mehr."* - -## 1. Kundenstamm (fehlt bisher ganz) - -Es gibt keinen Kundendatensatz. Fuer B2B-Rechnungen ins EU-Ausland ist er Pflicht, nicht Komfort. - -Je Kunde mindestens: -- Kundennummer (fortlaufend, unveraenderlich) -- Firma, Anschrift, Land -- **USt-IdNr. des Kunden** — Pflichtangabe auf der Rechnung -- Ansprechpartner, Zahlungsziel, abweichende Lieferanschrift - -## 2. Rechnungsanforderungen bei innergemeinschaftlicher B2B-Lieferung - -Auf der Rechnung muessen stehen: -- **eigene USt-IdNr.** UND **USt-IdNr. des Kunden** (beide!) -- Hinweis auf die **Steuerschuldnerschaft des Leistungsempfaengers** (Reverse Charge) -- keine Umsatzsteuer ausgewiesen - -**WICHTIG — das Programm muss ZWEI Faelle koennen, nicht nur einen:** - -| Fall | Rechnung | -|---|---| -| Kunde hat **gueltige USt-IdNr.** | ohne Umsatzsteuer, beide IdNr. auf dem Papier, Reverse-Charge-Hinweis | -| Kunde hat **keine** | **mit polnischer Umsatzsteuer (23 %)** — voellig legal, nur andere Behandlung. **Der Auftrag ist NICHT verloren.** | - -(Andreas' Sorge am 06.09.2026: *"dann kaufen die bei mir nicht"* — trifft nicht zu. Die USt-IdNr. -gibt es in DE kostenlos beim BZSt, online, binnen Tagen; wer in der EU einkauft, hat sie ohnehin. -Ohne Nummer wird schlicht mit polnischer Steuer fakturiert. Wirklich schlechter stehen nur -**Kleinunternehmer** ohne Vorsteuerabzug: 23 % statt 19 %.) - -Dahinter haengen drei Nachweispflichten, an denen die Steuerfreiheit tatsaechlich haengt und die das -Programm mitfuehren sollte: -1. **Pruefung der USt-IdNr.** ueber das EU-Portal, qualifiziert, **mit Beleg und Datum je Auftrag** - speichern (ungueltige Nummer = er schuldet die Steuer selbst) -2. **Nachweis des Grenzuebertritts** — Frachtpapier oder **Gelangensbestaetigung**, dem Vorgang - zuordenbar ablegen -3. **Zusammenfassende Meldung** — Auswertung je Zeitraum, Kunde + Betrag, exportierbar - -Das sind genau die Stellen, an denen Betriebspruefer zuerst nachsehen. Gehoert von Anfang an rein, -nicht nachtraeglich. - -## ⚠️ FUND 06.09.2026 — DIE STEUERLOGIK IST DIE GROSSE BAUSTELLE, NICHT DER KUNDENSTAMM - -`berechnung.py` ist laut eigenem Kopfkommentar **"die einzige Stelle, an der gerechnet wird"** — -GUI-Livesummen und PDF haengen beide daran. Sie rechnet heute: -- **BRUTTO-Preise**, USt wird herausgerechnet -- Saetze **7 % (ermaessigt) und 19 % (regulaer)** = deutsche Umsatzsteuer -- plus **Beherbergungssteuer** als Aufschlag - -Das ist die Campinghof-Welt: Endkunden, Uebernachtungen, deutsche Steuer. - -**Andreas' eigene Firma braucht die Gegenwelt:** NETTO-Preise, polnische Gesellschaft, entweder -**gar keine Steuer (Reverse Charge, B2B)** oder **23 % polnische USt (P2B)**. - -→ **Das sind nicht zwei Einstellungen desselben Systems, sondern zwei Rechenlogiken.** - -**Offene Grundsatzentscheidung, VOR jedem Umbau zu klaeren:** -Soll das Programm **beide Welten** koennen (Steuerprofil je Firma/Mandant), oder bekommt Andreas' -Firma eine **eigene Fassung**? Solange das offen ist, nicht an `berechnung.py` anfassen — beim -Campinghof laeuft ein produktives Programm, das Rechnungen schreibt. - -**Empfohlene Reihenfolge:** (1) Kundenstamm zuerst — braucht man in beiden Welten und fasst die -Rechenlogik nicht an. (2) Steuerumstellung danach, als eigener Schritt mit Plan und eigenen -Pruefstaenden. - -## 3. Weboberflaeche - -Wunsch: das Tool langsam **auf Webbasis** umbauen. Noch nicht begonnen, kein Termin, ausdruecklich -**nicht heute**. Vor dem Umbau planen (Regel: Plan vor Neubau), nicht nebenbei anfangen. - -## Status - -**Offen. Nicht begonnen.** Erst ansprechen, wenn er das Thema selbst wieder aufmacht. diff --git a/AUFGABE_ONLINE_API_MYSQL.md b/AUFGABE_ONLINE_API_MYSQL.md deleted file mode 100644 index 31ca040..0000000 --- a/AUFGABE_ONLINE_API_MYSQL.md +++ /dev/null @@ -1,417 +0,0 @@ -# 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/AUFGABE_PREISVORSCHLAG.md b/AUFGABE_PREISVORSCHLAG.md deleted file mode 100644 index 82bbe9c..0000000 --- a/AUFGABE_PREISVORSCHLAG.md +++ /dev/null @@ -1,129 +0,0 @@ -# Preisvorschlag beim Rechnungschreiben - -**Stand 07.09.2026 — recherchiert, noch nicht gebaut.** Kein Auftrag zum Loslegen; -das hier hält fest, was herausgekommen ist, damit es nicht noch einmal -recherchiert werden muss. - -## Woher der Wunsch kommt - -Sein Freund (Handwerk) berichtet: *„wenn er ein heizungsventiel auf die rechnung -macht dann schlägt das tool ihm ein preis vor"*, Abweichung nur zwei bis drei -Euro. Zuerst hieß es, das sei MyHammer. - -## Was es tatsächlich ist - -**Es ist mit hoher Wahrscheinlichkeit nicht MyHammer.** Ein Heizungsventil ist -ein *Artikel* mit Artikelnummer, kein Gewerk. Schlägt eine Software dazu einen -Preis vor, kommt der aus der **Großhandelsanbindung** — in der SHK-Branche seit -Jahrzehnten Standard. MyHammer ist dann die App daneben für die Auftragssuche. - -⚠️ **Offen:** Welches Programm hat der Freund beim Rechnungschreiben offen? Das -klärt die Frage in einem Satz. Danach fragen, bevor irgendetwas gebaut wird. - -Die zwei bis drei Euro passen zu dieser Erklärung: das ist die Differenz zwischen -einem alten Preisstand und dem tagesaktuellen Großhandelspreis. - -### Was MyHammer wirklich hat — und warum es nichts nützt - -- **Keine öffentliche API.** Keine Entwicklerdokumentation. Was an Schnittstellen - existiert, sind Einzelanbindungen an CRM-Systeme für *Leads* (400–1.200 € - einmalig), nicht für Preise. -- Das öffentliche **Preisradar** (394 Ratgeberseiten) trägt am Fuß den Satz: - *„Es handelt sich um Schätzungen, die auf Marktbeobachtung und Preisen auf - MyHammer basieren. Sie wurden mit Hilfe von KI zusammengetragen."* Spannen wie - „Tapete entfernen: 3 bis 15 Euro pro m²". **Als Rechnungsgrundlage untauglich** — - man würde KI-Schätzungen abrechnen. -- Automatisierte Abrufe beantwortet die Seite mit **HTTP 403**. - -**Ein Crawler ist damit dreifach erledigt:** der Inhalt taugt nicht, sie sperren -technisch, und die Übernahme wesentlicher Teile einer fremden Datenbank fällt -unter § 87b UrhG. Nicht weiterverfolgen. - -## Der Weg, der funktioniert — zwei Stufen - -### Stufe 1: DATANORM einlesen ← hier anfangen - -Der Handwerker lädt bei **seinem** Großhändler die DATANORM-Datei herunter; die -bekommt jeder Kunde per Mail oder aus dem Shop. Textdateien mit festen Satzarten: - -| Satz | Inhalt | -|---|---| -| A / B | Artikelstamm (Nummer, Kurztext, Preis, Einheit) | -| T | Langtext | -| P | Preisänderung auf bestehende Artikel | -| Z | Rabatt- und Zuschlagsstaffeln | -| D | Maße | - -Einlesen in die SQLite, danach schlägt das Tool beim Tippen Artikelnummer, Text -und Preis vor. - -**Warum das der erste Schritt ist:** Es läuft **offline** — passt zu dem, was für -die Tablets im Außendienst gedacht ist ([[AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL]]). -Es kostet nichts, braucht keinen Vertrag und keine Verbandsmitgliedschaft. Und die -Daten gehören dem Betrieb: sein Lieferant gibt sie ihm für seinen Gebrauch. Kein -Crawling, keine fremden Daten. - -⚠️ **Einschränkung, ehrlich:** Die offizielle DATANORM-Spezifikation ist *nicht* -frei verfügbar. Die Satzstruktur ist aber in mehreren Softwarehandbüchern -öffentlich beschrieben (mohr-it, es2000, EULANDA) — das reicht zum Bauen, aber -man baut gegen eine Beschreibung, nicht gegen eine Norm. Also: gegen echte -Dateien mehrerer Großhändler prüfen, nicht gegen eine selbst gebaute Musterdatei -([[feedback_selbst_arrangierte_messung_beweist_nichts]]). - -### Stufe 2: Open Masterdata für Tagespreise - -Webservice, Abruf einzelner Artikel über **GTIN** oder **Hersteller + Artikelnummer** -oder Großhandelsnummer. Liefert Grunddaten, Preise, Logistikdaten, Texte, -Dokumente. Synchron für einzelne Artikel, asynchron für Listen. Nachfolger von -DATANORM, entwickelt von ITEK. - -- Herausgeber ist ein Arbeitskreis aus **BVBS, DG Haustechnik, ZVSHK und VEG**, - jährliche Aktualisierung. -- **Die technische Doku liegt in einem geschlossenen Bereich** — für - Verbandsmitglieder kostenlos, für andere gegen Gebühr, Zugang über ein - Kontaktformular. Das ist die Hürde: hier müsste jemand Mitglied sein oder zahlen. -- Freischaltung läuft je Betrieb über **seinen** Großhändler; nicht jeder - Großhändler macht mit. - -### Was ausdrücklich NICHT gebraucht wird - -**Sirados, BKI, DBD** liefern Kalkulationsdaten für *Leistungen* („Heizkörper- -ventil austauschen: 0,5 Std. + Material"), nicht für Artikel. Kostenpflichtig, -Sirados je nach Paket **200 bis 2.000 €**, Übergabe über GAEB. Für die hier -beschriebene Aufgabe der falsche Hebel — das wäre ein eigenes Thema, wenn es -einmal um Angebotskalkulation statt um Rechnungsschreiben geht. - -## Die zweite Hälfte: die eigene Historie - -Material ist nur der eine Teil. Der andere ist die **Arbeitszeit**, und dafür -braucht es niemanden von außen: ein Betrieb rechnet immer wieder dieselben 20 bis -50 Leistungen ab, mit seinen Sätzen, in seiner Gegend. Diese Daten liegen bereits -im Nummernbuch. - - Zimmer streichen 25 m² — zuletzt 385 € (12.08.), - Mittel der letzten 6: 392 €, Spanne 370–410 € - -Das ist belastbarer als jede Fremdschätzung, kostet nichts, und niemand kann es -abdrehen. MyHammer mittelt über alle Betriebe in Deutschland — hier geht es um -*diesen* Betrieb. - -**Zusammen ergibt das den ganzen Preisvorschlag:** Material aus DATANORM bzw. OMD -(fremde Daten, tagesaktuell) plus Arbeitszeit aus der eigenen Rechnungshistorie -(eigene Daten, betriebsgenau). Genau so machen es die großen Programme auch. - -## Reihenfolge, wenn es losgeht - -1. Den Freund fragen, welches Programm er benutzt — klärt die Ausgangsfrage. -2. Vorschlag aus der **eigenen Historie** (braucht keine fremde Quelle, die Daten - sind da). Kleinster Schritt mit sofortigem Nutzen. -3. **DATANORM-Leser**, geprüft gegen echte Dateien mehrerer Großhändler. -4. Erst danach überlegen, ob Open Masterdata den Zugang wert ist. - -## Quellen - -- MyHammer Preisradar: https://www.my-hammer.de/preisradar -- Open Masterdata (ZVSHK): https://www.open-datapool.de -- Open Masterdata (ITEK): https://itek.de/wissen/verzeichnis-branchenstandards/open-masterdata -- DATANORM-Satzarten: https://www.mohr-it.de/Hilfe/DatanormStudio/datanormstruktur.html -- SIRADOS: https://www.sirados.de/handwerker -- § 87b UrhG: https://www.gesetze-im-internet.de/urhg/__87b.html diff --git a/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md b/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md deleted file mode 100644 index 38903bb..0000000 --- a/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md +++ /dev/null @@ -1,107 +0,0 @@ -# Rechnungstool: rechtssicher, gestaltbar, mit Vorschau - -**Stand 05.09.2026 — in Arbeit.** Programm `C:\claude\rechnungstool`, -Repo `git.pcore.de/TheMockTv/rechnungstool` (Pre-Tag `pre/rechtssicher-bilder`). - -Seine Ansage: *„wir bauen den rechnungs ersteller so um das der nun richtig -rechtlich sicher ist und die kopf und fuss zeile mit eigenen bildern import"* — -und im Lauf des Umbaus dazu: *„das die firmen das designen können"*, -*„eine vorschau butten wäre gut … sonst haben die den storno auf wand"*, -*„das mit rechnung an das amt kommt raus, das ist nicht § conform"*. - -## 1. Bilder und Farbe gehören der Firma (fertig) - -Bis jetzt war das Blatt auf EINEN Betrieb gebaut: Camping-Grün fest im Code, -das Logo ein Pfad in der config. - -- **`bilder.py`** (neu): ein gewähltes Bild wird in den Ordner `bilder/` neben - die `config.json` **kopiert**, gemerkt wird nur `bilder/logo.png`. - Der Grund: ein Pfad auf den Desktop ist tot, sobald dort jemand aufräumt — - und das fällt erst beim Drucken auf. Gleicher Name, anderes Bild → `-2`, - nie überschreiben. -- **Kopfbild** (Logo, oben links) und **Fußbild** (schmaler Streifen unten) - sind austauschbar. Das Layout bleibt, wie es war. -- **Firmenname, Anschrift, Bank, Steuernummer bleiben gezeichnet.** Kein Bild - kann sie ersetzen — die Steuernummer ist Pflichtangabe - (§ 14 Abs. 4 Nr. 2 UStG), und was in einem Bild steht, kann niemand prüfen. - (Ein Haken „Angaben stehen im Bild" war gebaut und ist auf seine Ansage hin - wieder rausgeflogen.) -- **Farbe der Rechnung**: EIN Wert (`design.akzent`), Hex-Feld **und** - Farbwähler in den Einstellungen. Die helle Fläche im Tabellenkopf wird daraus - gemischt — niemand muss zwei Farben aufeinander abstimmen. Eine unlesbare - Farbe fällt still auf die Vorgabe zurück, statt das Schreiben der Rechnung zu - verhindern. - -Prüfstand: **`pruef_bilder.py`**. - -## 2. Vorschau-Knopf (fertig) - -Seine Begründung: *„sonst haben die den storno auf wand"* — eine ausgestellte -Rechnung lässt sich nur noch über Storno oder Berichtigung geradeziehen. - -Der Knopf zeigt das fertige Blatt, **bevor** es eine Rechnung wird: - -- verbraucht **keine** Rechnungsnummer, trägt nichts ins Nummernbuch, -- landet **nicht** im Rechnungsordner (Temp-Ordner), -- trägt quer über jeder Seite **„VORSCHAU – keine Rechnung"**, -- prüft **keine** Pflichtfelder — man will ja gerade sehen, was noch fehlt. - -Prüfstand: **`pruef_vorschau.py`** (u. a.: die echte Rechnung danach trägt die -Marke NICHT). - -## 3. Das Blatt „für das Amt" ist entfallen - -Bis 05.09.2026 hing an jeder Rechnung mit Beherbergungssteuer ein zweites Blatt -mit **derselben Rechnungsnummer**, aber ohne die Steuer — also mit einem -**anderen Gesamtbetrag**. - -Seine Ansage: *„das mit rechnung an das amt kommt raus … wenn so was in den -büchern von einem unternehmen drin ist, dann ist das ein genickbruch, wenn der -prüfer kommt"*. Vorgeschichte: Bartl wollte es so, weil es sonst seine -Steuerberaterin durcheinanderbringe; der Widerspruch stand schon damals. - -Nachgeschlagen: mehrere Blätter zum selben Umsatz mit **gleicher** Nummer sind -für sich noch kein § 14c-Fall — aber unterschiedliche Endbeträge erwecken den -Eindruck, es werde über **verschiedene Umsätze** abgerechnet. Genau das fällt -bei einer Prüfung auf. - -**Was an die Stelle tritt:** nichts im Rechnungstool. Das Steuerjournal liest -die Kenndaten (`/Subject`) der PDF und rechnet die Bemessungsgrundlage selbst -aus; ans Amt geht eine **Summenanmeldung**, keine Rechnungskopie mit Gastnamen. -Vorher geprüft: kein Programm liest die zweite Seite — `pruef_montage.py` -belegt weiter, dass das Journal die Datei einliest. - -Mit dem Blatt entfielen: `_AmtMarke`, der Parameter `gesamtrechnung_betont` und -der doppelte Renderlauf (der existierte nur, um zu wissen, auf welcher Seite das -Amt-Blatt beginnt). Der Renderer ist dadurch deutlich kürzer. - -## 4. Prüfstände nehmen keine Maus mehr - -Sein Fund mitten im Lauf: *„pack doch die maus nicht immer auf das programm, -weil ich kann dir sonst nichts schreiben"*. - -- `config.TESTLAUF` steht an **einer** Stelle (`--test` / `CAMPINGHOF_TEST=1`). -- Hauptfenster und **jeder** Dialog werden im Prüflauf durchsichtig gemacht und - aus dem Bild geschoben, und `grab_set` fällt weg. -- ⚠️ **Nicht `withdraw()`**: damit baut ein Fenster seine Dialoge nicht mehr - richtig auf — vier Prüfstände wurden davon rot und hätten eine Bedienung - geprüft, die es so nicht gibt. -- **`pruefsteuerung.py`** (neu): Meldungen wandern ins Protokoll, Ja/Nein-Fragen - in einen Fahrplan (`antwortet("Hausnummer", True)`). Eine **unangemeldete** - Frage bricht den Lauf ab — stillschweigend „ja" zu sagen würde genau die Fälle - übergehen, wegen denen es die Prüfung gibt. - -## Offen - -- **Betriebsart abfragen** (Campingplatz / Handwerker / Unternehmen) — sein - Zielbau: *„nicht jeder braucht das so wie der camping typ"*. Handwerker und - Unternehmen brauchen **freie Rechnungszeilen** statt des festen Katalogs; das - ist ein neuer Eingabemodus, kein Schalter. -- **§ 14-Lücke Leistungszeitpunkt**: ohne An-/Abreise (z. B. nur Gasfüllung) - steht heute kein Leistungsdatum auf dem Blatt — § 14 Abs. 4 Nr. 6 UStG. -- **Zahlungsziel** einstellbar. -- **E-Rechnung (ZUGFeRD)**: Pflicht ab 01.01.2028 für B2B — trifft die - Montage-Rechnung. Bibliothek `factur-x` ist geprüft und kann XML erzeugen - (Profil EN 16931), prüfen (XSD + Schematron) und einbetten (PDF/A-3). -- **GoBD**: PDFs nach dem Schreiben schreibgeschützt; Aufbewahrung seit BEG IV - 8 statt 10 Jahre (§ 147 Abs. 3 AO). diff --git a/Diagnose.spec b/Diagnose.spec deleted file mode 100644 index d030d6f..0000000 --- a/Diagnose.spec +++ /dev/null @@ -1,48 +0,0 @@ -# -*- 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 deleted file mode 100644 index dcfb2d5..0000000 --- a/Installer.iss +++ /dev/null @@ -1,127 +0,0 @@ -; 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 -; EIN Verzeichnis fuer beides - seine Ansage am 07.09.2026: -; "es gibt ravokk als main verzeichnis, da liegt config und sqlite -; drin, und dann gibt es den ordner rechnungstool und steuertool" -; -; %LOCALAPPDATA%\ravokk\ -; campinghof-nummernbuch.sqlite3 <- gemeinsam, beide schauen hierher -; Rechnungstool\ <- Programm + Updater -; Steuerrechnungstool\ <- Programm + Updater -; -; Jedes Programm in seinem eigenen Unterordner: die beiden Updater heissen -; gleich und wuerden sich sonst gegenseitig ueberschreiben. -; -; Das Nummernbuch liegt eine Ebene DARUEBER und gehoert keinem der beiden. -; Ein Deinstallieren nimmt es deshalb nicht mit - und wer nur eines der -; Programme installiert, legt es trotzdem am richtigen Ort an -; (gemeinsam.standard_ordner). -DefaultDirName={localappdata}\ravokk\{#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] -; Kein "unchecked": der Haken ist vorgesetzt. Er hatte nach dem -; Installieren kein Symbol auf dem Desktop und musste das Programm -; suchen (07.09.2026). Wer es nicht will, nimmt den Haken heraus. -Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \ - GroupDescription: "Zusätzliche Verknüpfungen:" - -[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. -; IconFilename ausdruecklich: ohne das erbt die Verknuepfung das Symbol vom -; UPDATER, und Windows zeigte an ihrer Stelle das nackte Standardsymbol. -Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}" -Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}" -Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; IconFilename: "{app}\{#ExeProgramm}"; Tasks: desktopicon - -[Run] -Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \ - Flags: nowait postinstall skipifsilent - -[UninstallDelete] -; Was der INSTALLER gelegt hat, raeumt Inno Setup von selbst weg. Hier -; steht, was der UPDATER angelegt hat - davon weiss der Installer nichts: -; -.exe jede geladene Fassung -; installiert.json die Buchfuehrung des Updaters -; Ohne diese Zeilen bleibt der Ordner voller alter EXEs stehen. -; -; NICHT geloescht werden Rechnungen, Journal und Nummernbuch. Die liegen -; ohnehin woanders (%LOCALAPPDATA%/ravokk) und muessen zehn Jahre bleiben -; (§ 147 AO). Deshalb steht hier auch kein 'Type: filesandordirs' auf -; {app} - das waere die bequeme, aber falsche Zeile. -Type: files; Name: "{app}\installiert.json" -Type: files; Name: "{app}\{#Name}-*.exe" -Type: files; Name: "{app}\Updater.exe" -; Zum Schluss den Ordner selbst - aber NUR, wenn er leer ist. -Type: dirifempty; Name: "{app}" \ No newline at end of file diff --git a/README.md b/README.md index 389fec8..5ed8d4a 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,5 @@ # Rechnungstool Campinghof Bartl -> ### Umbenennung, 06.09.2026 -> -> Die Repositories heissen jetzt: -> -> | bisher | jetzt | -> |---|---| -> | `beherbergungssteuer` | **`Steuerrechnungstool`** - https://git.pcore.de/TheMockTv/Steuerrechnungstool | -> | `rechnungstool` | unveraendert - https://git.pcore.de/TheMockTv/rechnungstool | -> -> Grund: „Beherbergungssteuer“ beschrieb nur noch einen Teil dessen, was das Programm tut. -> Alte Links leitet Forgejo weiter, bestehende Klone laufen weiter - das `origin` sollte -> trotzdem umgestellt werden: -> -> ``` -> git remote set-url origin https://git.pcore.de/TheMockTv/Steuerrechnungstool.git -> ``` -> -> **Fehler und Wünsche** bitte als Issue im jeweiligen Repository melden - die Adresse steht -> auch im Programm unter *Hilfe → Info* und im Hinweis beim Start. -> -> ⚠️ Der **lokale Ordner** heißt weiterhin `C:\claude\beherbergungssteuer`. Der Pfad steht fest -> verdrahtet in mehreren Prüfständen und in den Spec-Dateien - das Umbenennen ist ein -> eigener Schritt. - - Desktop-Programm (Python/Tkinter), das die alte Excel-Rechnungsvorlage ersetzt: Kundendaten + Anzahl eingeben → druckfertige **PDF**-Rechnung im Campinghof-Layout. diff --git a/Rechnungstool.spec b/Rechnungstool.spec deleted file mode 100644 index 57d1265..0000000 --- a/Rechnungstool.spec +++ /dev/null @@ -1,48 +0,0 @@ -# -*- 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 deleted file mode 100644 index a82ec2a..0000000 --- a/Updater.spec +++ /dev/null @@ -1,24 +0,0 @@ -# -*- 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/api.py b/api.py deleted file mode 100644 index 196de45..0000000 --- a/api.py +++ /dev/null @@ -1,359 +0,0 @@ -# -*- coding: utf-8 -*- -"""Mini-API zwischen Rechnungstool und Steuerjournal. - -WARUM ------ -Beide Programme stehen nebeneinander offen. Schreibt das Rechnungstool eine -Rechnung, soll sie im Journal SOFORT auftauchen - nicht erst beim naechsten -Einlesen und nicht nach einer Wartezeit. - -Seine Ansage dazu: *"machst ein kleinen py server der hin und her pingt mit -einem push vom rechnungstool: eh schau mal in die sqlite"* und *"die muessen ja -sehen das die beiden auf sind"*. - -WIE ---- -Jedes Programm macht beim Start einen winzigen Server auf **127.0.0.1** mit -einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es -in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen -will, schlaegt dort Port und PID nach, prueft die PID und ruft an. - -Zwei Nachrichten reichen dafuer: - - {"was": "ping"} -> {"antwort": "hallo, bin noch da"} - {"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach) - -Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die -Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur -wegzunehmen: - - {"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}} - -DER SCHLUESSEL --------------- -Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten -Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im -gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner -lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand. -Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu -verraten, ob der Name ueberhaupt existiert. - -`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw. -"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres -Steuerjournal nicht mehr anklopfen. - -WAS EINE AKTION DARF --------------------- -Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im -Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur -Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen, -Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in -einer Buchhaltung nichts zu suchen. - -Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die -Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht -durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim -naechsten Nachsehen trotzdem. - -SICHERHEIT ----------- -Gebunden wird ausschliesslich auf 127.0.0.1 - von aussen ist da nichts zu -erreichen, und Windows fragt bei Loopback nicht nach der Firewall. Angenommen -werden nur die zwei Woerter oben; alles andere wird verworfen. Die API fuehrt -keine Befehle aus und uebertraegt keine Daten. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber. -""" - -import json -import logging -import os -import queue -import secrets -import socket -import socketserver -import sys -import threading - -log = logging.getLogger("api") - -ANTWORT_HALLO = "hallo, bin noch da" -SCHLUESSEL_DATEI = "api_schluessel.txt" - - -def schluessel_holen(ordner): - """Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen. - - Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als - Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine - Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das - sich jemand merken muesste. - """ - if not ordner: - return "" - pfad = os.path.join(ordner, SCHLUESSEL_DATEI) - try: - if os.path.exists(pfad): - wert = open(pfad, encoding="utf-8").read().strip() - if wert: - return wert - wert = secrets.token_hex(32) - with open(pfad, "w", encoding="utf-8") as f: - f.write(wert) - log.info("neuer API-Schluessel angelegt: %s", pfad) - return wert - except OSError as e: - log.warning("API-Schluessel nicht nutzbar (%s)", e) - return "" - - -class _Handler(socketserver.StreamRequestHandler): - timeout = 3 - - def handle(self): - # Nur der eigene Rechner darf reden - gebunden ist ohnehin nur Loopback. - if self.client_address[0] not in ("127.0.0.1", "::1"): - return - try: - zeile = self.rfile.readline(4096).decode("utf-8", "replace").strip() - except OSError: - return - if not zeile: - return - try: - nachricht = json.loads(zeile) - was = str(nachricht.get("was", "")) - except (ValueError, AttributeError): - nachricht, was = {}, "" - if was == "ping": - antwort = ANTWORT_HALLO - elif was == "schau_nach": - antwort = "ok" - self.server.melden() # Fenster benachrichtigen (threadsicher) - elif was == "aktion": - antwort = self._aktion(nachricht) - else: - antwort = "haeh?" - try: - self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8")) - except OSError: - # Der Rufer hat die Verbindung schon zugemacht - das ist der - # Normalfall bei "schau_nach", wenn das andere Programm beendet - # wurde, waehrend die Antwort unterwegs war. Die Arbeit ist - # getan; nur die Empfangsbestaetigung geht ins Leere. - pass - - - def _aktion(self, nachricht): - """Eine Bedienung ausfuehren - nur mit gueltigem Schluessel.""" - erwartet = getattr(self.server, "schluessel", "") - gegeben = str(nachricht.get("schluessel", "")) - # compare_digest statt "==": ein Vergleich, der bei der ersten falschen - # Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag. - if not erwartet or not secrets.compare_digest(erwartet, gegeben): - log.warning("Aktion ohne gueltigen Schluessel abgewiesen") - return "kein zutritt" - if self.server.aktion is None: - return "keine bedienung moeglich" - name = str(nachricht.get("name", "")) - daten = nachricht.get("daten") or {} - log.info("Aktion ueber die API: %s", name) - try: - return self.server.aktion(name, daten) - except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben - log.warning("Aktion %r fehlgeschlagen: %s", name, e) - return {"fehler": str(e)} - - -class _Server(socketserver.ThreadingTCPServer): - allow_reuse_address = True - daemon_threads = True - - def __init__(self, adresse, bei_push, aktion=None, schluessel=""): - self.melden = bei_push - self.aktion = aktion - self.schluessel = schluessel - super().__init__(adresse, _Handler) - - -class MiniApi: - """Der kleine Server eines Programms. - - `bei_push` wird aus einem FREMDEN Faden gerufen - Tkinter darf man von dort - nicht anfassen. Der Aufrufer legt die Meldung deshalb in eine Warteschlange - und holt sie im Fenster mit after() ab. - """ - - def __init__(self, programm, bei_push, aktion=None): - self.programm = programm - self.bei_push = bei_push - self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung - self.server = None - self.faden = None - self.port = 0 - self.schluessel = "" - - def starten(self, buch, ordner=None): - """Server aufmachen und im Telefonbuch (SQLite) eintragen. - - Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er - fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen - Schluessel und damit auch keine Bedienung von aussen. - """ - if self.server is not None: - return self.port - if ordner is None and buch is not None: - ordner = os.path.dirname(getattr(buch, "pfad", "") or "") - self.schluessel = schluessel_holen(ordner) - try: - self.server = _Server(("127.0.0.1", 0), self.bei_push, - aktion=self.aktion, schluessel=self.schluessel) - except OSError as e: - log.warning("API konnte nicht starten: %s", e) - self.server = None - return 0 - self.port = self.server.server_address[1] - self.faden = threading.Thread(target=self.server.serve_forever, - name=f"api-{self.programm}", daemon=True) - self.faden.start() - log.info("API von %s laeuft auf 127.0.0.1:%s", self.programm, self.port) - self.eintragen(buch) - return self.port - - def eintragen(self, buch): - """Port und PID ins Telefonbuch. Ohne Buch gibt es keine API-Auskunft.""" - if buch is None or not self.port: - return - try: - buch.anmelden(self.programm, os.getpid(), - os.path.basename(sys.executable), self.port) - except Exception as e: # noqa: BLE001 - log.warning("Eintrag ins Telefonbuch fehlgeschlagen: %s", e) - - def stoppen(self, buch=None): - if buch is not None: - try: - buch.abmelden(self.programm) - except Exception: # noqa: BLE001 - beim Schliessen nichts erzwingen - pass - if self.server is not None: - try: - self.server.shutdown() - self.server.server_close() - except Exception: # noqa: BLE001 - pass - self.server = None - self.port = 0 - - -def _reden(port, was, zeitgrenze=1.5, **rest): - """Eine Zeile hinschicken und die Antwort lesen. None = niemand da.""" - nachricht = {"was": was} - nachricht.update(rest) - try: - with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s: - s.settimeout(zeitgrenze) - s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8")) - roh = s.makefile("r", encoding="utf-8").readline() - return json.loads(roh).get("antwort", "") - except (OSError, ValueError): - return None - - -def erreichbar(buch, programm): - """Meldet sich das andere Programm? ("hallo, bin noch da") - - Zuerst die PID - die beantwortet Windows und kostet nichts. Erst wenn die - stimmt, wird ueberhaupt angerufen. - """ - if buch is None: - return False - try: - eintrag = buch.wer_laeuft(programm) - except Exception as e: # noqa: BLE001 - log.debug("Telefonbuch nicht lesbar: %s", e) - return False - if not eintrag or not eintrag.get("port"): - return False - return _reden(eintrag["port"], "ping") == ANTWORT_HALLO - - -def anklopfen(buch, programm): - """Push: "schau mal in die sqlite". True, wenn es angekommen ist.""" - if buch is None: - return False - try: - eintrag = buch.wer_laeuft(programm) - except Exception as e: # noqa: BLE001 - log.debug("Telefonbuch nicht lesbar: %s", e) - return False - if not eintrag or not eintrag.get("port"): - return False - return _reden(eintrag["port"], "schau_nach") == "ok" - - -def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0): - """Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt. - - Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster - bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen. - - Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den - lesen darf, darf bedienen. - """ - if buch is None: - return None - try: - eintrag = buch.wer_laeuft(programm) - except Exception as e: # noqa: BLE001 - log.debug("Telefonbuch nicht lesbar: %s", e) - return None - if not eintrag or not eintrag.get("port"): - return None - if ordner is None: - ordner = os.path.dirname(getattr(buch, "pfad", "") or "") - return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze, - schluessel=schluessel_holen(ordner), name=name, - daten=daten or {}) - - -class Warteschlange: - """Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck. - - Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht - aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das - Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus - und legt die Antwort zurueck. - - Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die - Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer - Verbindung, die nie antwortet. - """ - - def __init__(self): - self.eingang = queue.Queue() - - def annehmen(self, name, daten, zeitgrenze=12.0): - """Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten.""" - fertig = threading.Event() - auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None} - self.eingang.put(auftrag) - if not fertig.wait(zeitgrenze): - return {"fehler": "das Fenster hat nicht geantwortet"} - return auftrag["antwort"] - - def abarbeiten(self, ausfuehren): - """Im Fensterfaden gerufen: alles erledigen, was eingegangen ist.""" - while True: - try: - auftrag = self.eingang.get_nowait() - except queue.Empty: - return - try: - auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"]) - except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen - log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e) - auftrag["antwort"] = {"fehler": str(e)} - finally: - auftrag["fertig"].set() diff --git a/app.py b/app.py index c777e88..8b05b7f 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Rechnungstool (powered by ravokk) - Desktop-GUI (Tkinter). +Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter). Bedienung: 1. Kundenadresse + An-/Abreise eingeben @@ -20,84 +20,32 @@ Aufgeteilt in: """ import os -import tempfile import sys import datetime -import queue import tkinter as tk from tkinter import ttk, messagebox -import api import config -import menueleiste 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 -from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer, - rechnungen_im_ordner, - vorhandene_rechnungen) +from bestand import naechste_freie_nummer, vorhandene_rechnungen from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, laufende_pid_lesen) -import flaeche -import icons -import reiter -import theme -from theme import MeldungMixin, ThemeMixin +from theme import ThemeMixin from korrektur import KorrekturMixin from einstellungen import EinstellungenMixin -# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein -# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den -# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente). -# python app.py --test oder CAMPINGHOF_TEST=1 -# Der Pruefschalter steht in config (dort als Funktion, siehe Kommentar). Hier -# nur noch der Startwert fuer alles, was ihn schon beim Laden braucht. -TESTLAUF = config.testlauf() - - -class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): +class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def __init__(self): super().__init__() self.cfg = config.load_config() - self.title(config.PROGRAMM) - self._fenstersymbol() - # Fenstergroesse an den Bildschirm anpassen, damit nichts unter den - # Rand rutscht. Die BREITE ist gemessen, nicht geraten: die - # Leistungstabelle braucht 743 Pixel fuer ihre sieben Spalten, dazu - # Bildlaufleiste, Rahmen und die Formularseite links - zusammen rund - # 1180. Bei den frueheren 1060 waren "E.-Preis" und "Gesamt" - # abgeschnitten (sein Befund am 06.09.2026: die Formularbloecke - # "verdrengen die posten block"). Passt das nicht auf den Bildschirm, - # wird so breit wie moeglich aufgemacht. + self.title("Rechnungstool - " + self.cfg["firma"].get("name", "Campinghof")) + # Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht hoehe = max(560, min(840, self.winfo_screenheight() - 90)) - breite = max(1000, min(1180, self.winfo_screenwidth() - 60)) - self.geometry(f"{breite}x{hoehe}") - # Die Mindestbreite ist keine Geschmacksfrage: unterhalb von 1060 - # Pixeln passen die sieben Spalten der Leistungstabelle selbst dann - # nicht mehr nebeneinander, wenn die Textspalte auf ihrer - # Mindestbreite steht - "E.-Preis" und "Gesamt" waeren dann nicht mehr - # zu sehen und auch nicht zu erreichen. Frueher stand hier 900. - self.minsize(1080, 540) - if config.testlauf(): - # Ein Pruefstand bedient das Programm, waehrend nebenan jemand - # arbeitet. Das Fenster geht deshalb NEBEN dem Bildschirm auf (auf - # dem zweiten Monitor, wenn einer da ist) - sichtbar, aber nicht im - # Weg. Unsichtbar machen waere falsch: dann sieht niemand mehr, was - # der Pruefstand eigentlich tut. - self.geometry(theme.pruefplatz() - or f"+{self.winfo_screenwidth() + 20}+40") - try: - # Kein Eintrag in der Taskleiste: das Fenster liegt neben - # dem Bildschirm, und ein Eintrag, den man anklickt, ohne - # dass etwas erscheint, ist schlimmer als gar keiner. - self.attributes("-toolwindow", True) - except tk.TclError: - pass + self.geometry(f"1060x{hoehe}") + self.minsize(900, 540) self.anzahl_vars = {} # nr -> StringVar self.personen_vars = {} # nr -> StringVar @@ -105,28 +53,15 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss. # Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette. self.storno_stand = None - # Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner - # gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar. - self.buch = None - # Die API kann das Fenster auch BEDIENEN (nur mit Schluessel, siehe - # api.py). Die Warteschlange bringt die Aktion aus dem Netzfaden - # hierher - Tkinter darf nur aus seinem eigenen Faden angefasst werden. - self._api_schlange = api.Warteschlange() - self.api = api.MiniApi("rechnungstool", self._push_empfangen, - aktion=self._api_schlange.annehmen) - self.after(150, self._api_pumpe) - self._push_kasten = queue.Queue() # Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV): # die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung. self.berichtigung_stand = None self._apply_theme() # Farben nach Windows hell/dunkel self._build_menu() + self._build_kopf() self._build_summen() # untere Leiste (Summen + Buttons) zuerst -> bleibt immer sichtbar - self._build_flaeche() # Werkzeugleiste + verschiebbare Kaesten - self._build_kopf() # Rechnungsdaten + Kunde als Kaesten - self._build_positionen() # Leistungen als Kasten, fuellt den Rest - self.flaeche.anordnen() + self._build_positionen() # Positionsliste fuellt den Rest und scrollt self._neue_nummer_vorschlagen() self.recalc() @@ -135,21 +70,19 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum. self.protocol("WM_DELETE_WINDOW", self._beenden) - # 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) + # Titelleiste dunkel (nach dem Mappen des Fensters) + self.after(60, lambda: self._dark_titlebar(self)) + # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) + self.after(250, self._frage_speicherort_falls_noetig) # ---------------------------------------------------------------- Menue def _build_menu(self): # Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform # (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben). + self._open_pop = None self._menus = { "Datei": [ - ("Vorschau ansehen", self.vorschau_zeigen), ("PDF erstellen", self.pdf_erstellen), ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), @@ -159,92 +92,82 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, "Einstellungen": [ ("Steuersätze…", self.dlg_steuern), ("Leistungskatalog / Preise…", self.dlg_katalog), - ("Montage-Rechnung…", self.dlg_montage), ("Firmendaten, Logo & Texte…", self.dlg_firma), ("Rechnungsnummer…", self.dlg_nummer), ("Speicherort der Rechnungen…", self.dlg_speicherort), - ("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam), - ("-", None), - ("Darstellung…", self.dlg_darstellung), ], "Hilfe": [("Info", self._info)], } - self.menue = menueleiste.Menueleiste(self, self._menus, farben=self) + bar = tk.Frame(self, bg=self.col_bg) + bar.pack(side="top", fill="x") + for text, items in self._menus.items(): + lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3) + lbl.pack(side="left") + lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_sel)) + lbl.bind("", lambda e, w=lbl: w.configure(bg=self.col_bg)) + lbl.bind("", lambda e, b=lbl, it=items: self._menu_popup(b, it)) + ttk.Separator(self, orient="horizontal").pack(side="top", fill="x") # bind statt bind_all: sonst feuert Strg+P auch aus einem offenen # Dialog heraus und schreibt im Formular dahinter eine Rechnung. self.bind("", lambda e: self.pdf_erstellen()) - def _build_flaeche(self): - """Die Kaesten und der Knopf, mit dem man sie verschieben darf. + def _menu_popup(self, anchor, items): + if self._open_pop: # offenes Menu zu -> Toggle + self._close_pop() + return + pop = tk.Toplevel(self) + pop.overrideredirect(True) + pop.configure(bg=self.col_line) # 1px Rahmen = col_line statt weiss + inner = tk.Frame(pop, bg=self.col_field) + inner.pack(padx=1, pady=1) + for label, cmd in items: + if label == "-": + tk.Frame(inner, bg=self.col_line, height=1).pack(fill="x", pady=3) + continue + it = tk.Label(inner, text=label, bg=self.col_field, fg=self.col_fg, + anchor="w", padx=16, pady=5) + it.pack(fill="x") + it.bind("", lambda e, w=it: w.configure(bg=self.col_sel)) + it.bind("", lambda e, w=it: w.configure(bg=self.col_field)) + it.bind("", lambda e, c=cmd: self._run_menu(c)) + pop.update_idletasks() + pop.geometry(f"+{anchor.winfo_rootx()}+{anchor.winfo_rooty() + anchor.winfo_height()}") + self._open_pop = pop + pop.grab_set() + pop.bind("", self._maybe_close_pop, add="+") + pop.bind("", lambda e: self._close_pop()) + self._dark_titlebar(pop) - Wie bei OPNsense: erst "Anordnen" druecken, dann schieben, dann - speichern. Ohne diesen Schritt sitzt alles fest - beim taeglichen - Arbeiten soll nichts verrutschen. - """ - leiste = ttk.Frame(self, padding=(16, 8, 16, 0)) - leiste.pack(side="top", fill="x") + def _maybe_close_pop(self, event): + pop = self._open_pop + if not pop: + return + if (event.x_root < pop.winfo_rootx() or event.x_root > pop.winfo_rootx() + pop.winfo_width() + or event.y_root < pop.winfo_rooty() or event.y_root > pop.winfo_rooty() + pop.winfo_height()): + self._close_pop() - # Das Programmlogo oben LINKS und gross. Es stand zuerst klein (18 px) - # unten in der Statuszeile - sein Befund am 07.09.2026: "ist mir zu - # klein geht unter mach es doch gros oben links weil da ist eh platz". - # Hier ist wirklich Platz: die Leiste traegt sonst nur den Knopf - # "Anordnen", und der sitzt rechts. - self._kopf_bild = wasserzeichen.kopf( - 34, master=self, dunkel=getattr(self, "_dark_mode", False)) - if self._kopf_bild is not None: - ttk.Label(leiste, image=self._kopf_bild).pack(side="left") + def _run_menu(self, cmd): + self._close_pop() + if cmd: + cmd() - self.werkzeuge = ttk.Frame(leiste) - self.werkzeuge.pack(side="right") - - self._ic_anordnen = icons.hole("dashboard_customize", 16, self.col_muted, master=self) - self._ic_speichern = icons.hole("check", 16, self.col_accent_hell, master=self) - self._ic_verwerfen = icons.hole("close", 16, self.col_muted, master=self) - - self.knopf_anordnen = ttk.Button(self.werkzeuge, text=" Anordnen", - image=self._ic_anordnen, compound="left", - command=self._anordnen_umschalten) - self.knopf_anordnen.pack(side="left") - self.knopf_speichern = ttk.Button(self.werkzeuge, text=" Speichern", - image=self._ic_speichern, compound="left", - style="Haupt.TButton", - command=self._anordnen_speichern) - self.knopf_verwerfen = ttk.Button(self.werkzeuge, text=" Verwerfen", - image=self._ic_verwerfen, compound="left", - command=self._anordnen_verwerfen) - - 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() - self.knopf_anordnen.pack_forget() - self.knopf_verwerfen.pack(side="left", padx=(0, 6)) - self.knopf_speichern.pack(side="left") - - def _anordnen_fertig(self): - self.knopf_speichern.pack_forget() - self.knopf_verwerfen.pack_forget() - self.knopf_anordnen.pack(side="left") - - def _anordnen_speichern(self): - self.flaeche.anordnen_speichern() - self._anordnen_fertig() - - def _anordnen_verwerfen(self): - self.flaeche.anordnen_verwerfen() - self._anordnen_fertig() + def _close_pop(self): + if getattr(self, "_open_pop", None): + try: + self._open_pop.grab_release() + self._open_pop.destroy() + except tk.TclError: + pass + self._open_pop = None # ---------------------------------------------------------------- Kopf def _build_kopf(self): - # Beide Bloecke sind Kaesten der Arbeitsflaeche - sie lassen sich - # verschieben, deshalb platziert sie niemand mehr von Hand. - kunde = self.flaeche.kasten("kunde", "Rechnung an (Kunde)") + top = ttk.Frame(self, padding=(12, 10, 12, 4)) + top.pack(fill="x") + + # Kunde (links) + kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8) + kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) self.k_anrede = tk.StringVar() self.k_vorname = tk.StringVar() self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal @@ -260,26 +183,22 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, ("PLZ / Ort *", self.k_plzort), ] for i, (lbl, var) in enumerate(felder): - ttk.Label(kunde, text=lbl, style="Karte.TLabel").grid( - row=i, column=0, sticky="w", pady=4) + ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2) if var is self.k_anrede: # Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen. - w = ttk.Combobox(kunde, textvariable=var, width=22, values=ANREDEN) + w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN) else: - # 22 Zeichen: das laengste, was hier realistisch steht, ist eine - # Strasse mit Hausnummer ("Bornaer Chaussee 36" = 19). Breiter - # gemacht hat das Feld nur die linke Spalte aufgeblaeht und der - # Leistungstabelle den Platz genommen. Getippt werden kann - # weiterhin beliebig viel - das Feld scrollt mit. - w = ttk.Entry(kunde, textvariable=var, width=22) + w = ttk.Entry(kunde, textvariable=var, width=38) w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören " "auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).", - style="KarteMuted.TLabel", wraplength=250, justify="left").grid( + foreground=self.col_muted, wraplength=330, justify="left").grid( row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0)) kunde.columnconfigure(1, weight=1) - info = self.flaeche.kasten("daten", "Rechnungsdaten") + # Rechnungsinfos (rechts) + info = ttk.LabelFrame(top, text="Rechnungsdaten", padding=8) + info.grid(row=0, column=1, sticky="nsew") self.r_nummer = tk.StringVar() self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y")) self.r_anreise = tk.StringVar() @@ -288,26 +207,13 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, rows = [ ("Rechnungsnummer", self.r_nummer), ("Rechnungsdatum", self.r_datum), - ("Anreise", self.r_anreise), - ("Abreise", self.r_abreise), + ("Anreise (TT.MM.JJJJ)", self.r_anreise), + ("Abreise (TT.MM.JJJJ)", self.r_abreise), ] date_vars = (self.r_datum, self.r_anreise, self.r_abreise) - self.datum_labels = {} for i, (lbl, var) in enumerate(rows): - beschriftung = ttk.Label(info, text=lbl, style="Karte.TLabel") - beschriftung.grid(row=i, column=0, sticky="w", pady=4) - if var is self.r_anreise: - self.datum_labels["von"] = beschriftung - elif var is self.r_abreise: - self.datum_labels["bis"] = beschriftung - # 12 Zeichen reichen fuer "TT.MM.JJJJ" und jede Rechnungsnummer. - # Die Form stand frueher in der Beschriftung ("Anreise - # (TT.MM.JJJJ)") und hat den ganzen Block breit gemacht - Breite, - # die der Leistungstabelle rechts fehlte. Sie steht jetzt einmal - # als graue Zeile unter dem Block. Als Platzhalter IM Feld waere - # sie gefaehrlich: ein nicht geloeschter Platzhalter stuende als - # Anreisedatum auf der Rechnung. - ent = ttk.Entry(info, textvariable=var, width=12) + ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2) + ent = ttk.Entry(info, textvariable=var, width=18) ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) if var is self.r_nummer: # Waehrend eines Stornos wird das Feld gesperrt - die Nummern @@ -315,27 +221,20 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, self.nummer_entry = ent if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende ent.bind("", self._auto_datum_evt) - self.naechte_label = ttk.Label(info, text="Nächte", style="Karte.TLabel") - self.naechte_label.grid(row=4, column=0, sticky="w", pady=4) - ttk.Label(info, textvariable=self.r_naechte, style="Karte.TLabel", - font=(theme.SCHRIFT_NAME, 11, "bold")).grid( + ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2) + ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid( row=4, column=1, sticky="w", padx=(8, 0)) - ttk.Label(info, text="Datum als TT.MM.JJJJ – die Punkte setzt das " - "Programm beim Tippen.", - style="KarteMuted.TLabel", wraplength=250, - justify="left").grid(row=5, column=0, columnspan=2, - sticky="w", pady=(8, 0)) # Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt. self.storno_hinweis = tk.StringVar(value="") - ttk.Label(info, textvariable=self.storno_hinweis, style="Karte.TLabel", - foreground=self.col_gefahr, - font=(theme.SCHRIFT_NAME, 9, "bold"), wraplength=250, justify="left").grid( - row=6, column=0, columnspan=2, sticky="w", pady=(6, 0)) + ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020", + font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid( + row=5, column=0, columnspan=2, sticky="w", pady=(6, 0)) info.columnconfigure(1, weight=1) self.r_anreise.trace_add("write", lambda *a: self._update_naechte()) self.r_abreise.trace_add("write", lambda *a: self._update_naechte()) - + top.columnconfigure(0, weight=1) + top.columnconfigure(1, weight=0) def _auto_datum_evt(self, event): """Punkte automatisch setzen, Cursor ans Ende. Nach Tag+Monat das aktuelle @@ -346,7 +245,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, e = event.widget s = e.get() d = "".join(c for c in s if c.isdigit())[:8] - d = self._datum_ziffern_pruefen(d) if len(d) < 2: out = d elif len(d) == 2: @@ -364,29 +262,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, if len(d) == 4: # Tag+Monat fertig, Jahr automatisch -> direkt ins nächste Feld e.tk_focusNext().focus() - @staticmethod - def _datum_ziffern_pruefen(d): - """Unmögliche Tage und Monate gar nicht erst entstehen lassen. - - Den 32. gibt es nicht und den 13. Monat auch nicht - das gehört beim - Tippen abgefangen, nicht erst beim Erstellen der Rechnung. Zwei Regeln: - - * Eine 4 bis 9 an erster Stelle kann nur „04.… 09." heißen (kein Tag - fängt so an), ebenso eine 2 bis 9 an der ersten Monatsstelle. Die Null - wird ergänzt, statt den Anschlag zu schlucken. - * Was danach immer noch unmöglich wäre (00, 32.., 13..), fällt weg: die - Ziffer erscheint nicht, der Rest bleibt stehen. - """ - if d and d[0] > "3": # 4 -> 04 - d = "0" + d - if len(d) >= 2 and not 1 <= int(d[:2]) <= 31: - d = d[:1] # 35, 00 -> die zweite Ziffer weg - if len(d) >= 3 and d[2] > "1": # 3 -> 03 (Monat) - d = d[:2] + "0" + d[2:] - if len(d) >= 4 and not 1 <= int(d[2:4]) <= 12: - d = d[:3] # 13, 00 -> die zweite Ziffer weg - return d[:8] - def _update_naechte(self): a = parse_date(self.r_anreise.get()) b = parse_date(self.r_abreise.get()) @@ -400,12 +275,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # (Titel, Ausrichtung, Mindestbreite px). col 1 (Leistung) bekommt das Gewicht. COLS = [ ("Pos.", "center", 38), - # Die Textspalte hat als einzige Gewicht - sie bekommt allen freien - # Platz. Ihre Mindestbreite ist deshalb bewusst klein: bei einem engen - # Fenster soll sie schrumpfen und umbrechen, statt die Preisspalten - # rechts aus dem Bild zu schieben. Bei den frueheren 330 tat sie genau - # das - "E.-Preis" und "Gesamt" waren nicht mehr zu sehen. - ("Leistung", "w", 200), + ("Leistung", "w", 330), ("USt %", "center", 50), ("Anzahl\nTage/Menge", "center", 80), ("Pers.", "center", 56), @@ -414,41 +284,16 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, ] def _build_positionen(self): - # Zwei Sorten Rechnung, zwei Reiter: die Campingrechnung mit dem - # Leistungskatalog und die Montage-Rechnung mit der Monatsmiete. Der - # Kopf (Kunde, Nummer, Datum, Zeitraum) und die Summenleiste gelten fuer - # beide - nur die Mitte wechselt. So tippt niemand den Kunden zweimal. - # Eigene Reiterleiste statt ttk.Notebook: unter clam liess sich der - # ruhende Reiter nicht dunkel bekommen, er sah aus wie der offene. - # Nach aussen verhaelt sie sich wie ein Notebook (siehe reiter.py). - leistungen = self.flaeche.kasten("leistungen", "Leistungen") - self.blaetter = reiter.Reiterleiste(leistungen, farben=self) - self.blaetter.pack(side="top", fill="both", expand=True) - self.blaetter.bind("<>", lambda _e: self._blatt_gewechselt()) + wrap = ttk.LabelFrame(self, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4)) + wrap.pack(side="top", fill="both", expand=True, padx=12, pady=4) - camping = ttk.Frame(self.blaetter.bereich, padding=(2, 6)) - self.blaetter.add(camping, text=" Campingrechnung ") - montage_tab = ttk.Frame(self.blaetter.bereich, padding=(14, 14)) - self.blaetter.add(montage_tab, text=" Montage ") - self._build_montage(montage_tab) - - wrap = ttk.LabelFrame(camping, text="LEISTUNGEN – ANZAHL (UND GGF. PERSONEN) EINTRAGEN", - padding=(12, 8), style="Karte.TLabelframe") - wrap.pack(side="top", fill="both", expand=True) - - canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_card) + canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_bg) sb = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview) self.pos_frame = ttk.Frame(canvas) self._pos_win = canvas.create_window((0, 0), window=self.pos_frame, anchor="nw") self.pos_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) # pos_frame auf Canvas-Breite ziehen (minus Luft rechts zum Scrollbalken) - def breite_gesetzt(e): - canvas.itemconfig(self._pos_win, width=e.width - 18) - # after_idle, weil die Spaltenbreite erst NACH dem Neuverteilen - # feststeht - direkt im Ereignis gemessen kaeme der alte Wert. - self.after_idle(self._umbruch_nachziehen) - - canvas.bind("", breite_gesetzt) + canvas.bind("", lambda e: canvas.itemconfig(self._pos_win, width=e.width - 18)) canvas.configure(yscrollcommand=sb.set) canvas.pack(side="left", fill="both", expand=True, pady=(4, 0)) sb.pack(side="right", fill="y") @@ -460,141 +305,17 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, self._render_positionszeilen() - # ------------------------------------------------------- Darstellung - def _oberflaeche_neu(self): - """Die ganze Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel. - - WARUM NEU BAUEN UND NICHT UMFAERBEN - ----------------------------------- - Die Farben stehen an ueber sechzig Stellen fest in den Widgets - (tk.Label mit bg=..., Streifen der Leistungsliste, Kopfzeilen der - Kaesten). Sie einzeln nachzuziehen hiesse, ein Verzeichnis aller - eingefaerbten Widgets zu fuehren - und wer beim naechsten neuen Widget - vergisst, es einzutragen, hat einen hellen Fleck im dunklen Fenster, - den niemand mehr findet. Neu bauen kann nichts vergessen. - - WAS DABEI ERHALTEN BLEIBT - ------------------------- - Alles Eingetippte. Ein Themenwechsel darf keine halb getippte Rechnung - wegwerfen. Um die Tk-Variablen kuemmert sich variablen_sichern() im - ThemeMixin - dort steht auch, warum ALLE gesichert werden und nicht - eine gepflegte Auswahl. - - Hier stehen nur die Dinge, die es sonst nirgends gibt: die Mengen und - Personenzahlen der Leistungszeilen (sie haengen an Woerterbuechern, - nicht am Fenster) und das Blatt, das gerade vorn war. - """ - gesichert = self.variablen_sichern() - mengen = {nr: v.get() for nr, v in self.anzahl_vars.items()} - personen = {nr: v.get() for nr, v in self.personen_vars.items()} - blatt = None - try: - blatt = self.blaetter.index(self.blaetter.select()) - except Exception: # noqa: BLE001 - dann eben das erste Blatt - pass - - self.kinder_loeschen() - self.flaeche = None - self._text_zellen = [] - - self._apply_theme() - self._build_menu() - self._build_summen() - self._build_flaeche() - self._build_kopf() - self._build_positionen() - self.flaeche.anordnen() - - self.variablen_zuruecksetzen(gesichert) - for nr, wert in mengen.items(): - if nr in self.anzahl_vars: - self.anzahl_vars[nr].set(wert) - for nr, wert in personen.items(): - if nr in self.personen_vars: - self.personen_vars[nr].set(wert) - if blatt is not None: - try: - self.blaetter.select(blatt) - except Exception: # noqa: BLE001 - pass - self._buch_anzeigen() - self.recalc() - - # Der Dialog selbst steht im ThemeMixin (theme.py) - er sah in beiden - # Programmen gleich aus. Verschieden ist nur der Hinweis darin und die - # Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. - DARSTELLUNG_HINWEIS = ( - "Gilt nur für das Programmfenster. Die Farbe der Rechnung steht unter " - "Einstellungen → Firmendaten – das Blatt gehört dem Kunden, nicht dem " - "Programm.") - - def _cfg_speichern(self): - """Hier ist die Konfiguration eine config.json und will geschrieben sein.""" - config.save_config(self.cfg) - - def _umbruch_nachziehen(self, runde=0, vorige_breite=None): - """Den Textumbruch der Leistungsspalte auf ihre echte Breite setzen. - - Die Spalte ist die einzige mit Gewicht - sie bekommt allen freien - Platz und schrumpft bei engem Fenster bis auf ihre Mindestbreite. Ein - fest eingetragener Umbruch passt dann nicht mehr: er bricht zu spaet, - der Text laeuft ueber die Spalte hinaus und wird abgeschnitten. Genau - das war nach dem Umbau am 06.09.2026 bei Position 1 zu sehen - ("... inkl. Pkw, Stro"). - - Es braucht mehrere Runden, und zwar aus einem Grund, der beim ersten - Versuch Zeit gekostet hat: beim ersten Messen steht die Spalte noch auf - ihrer MINDESTbreite, weil die Labels noch mit dem alten Umbruch - vermessen sind. Setzt man danach nur einmal, bleibt der Text auf der - Mindestbreite umgebrochen, obwohl die Spalte laengst breiter ist - der - Text stand dann dreizeilig in einer Spalte, in die er zweizeilig - gepasst haette. Also wird gemessen, gesetzt und noch einmal gemessen, - bis sich nichts mehr aendert. - - Abgebrochen wird, wenn zwei Messungen dieselbe SPALTENBREITE ergeben - - nicht, wenn sich der Umbruch nicht geaendert hat. Der Unterschied ist - entscheidend und hat beim ersten Versuch eine Runde gekostet: die - Spalte kann breiter werden, ohne dass ich etwas geaendert habe (sie - bekommt den freien Platz des Fensters). Wer dann abbricht, weil sein - eigener Wert gleich blieb, laesst den Umbruch auf dem alten Stand - stehen - beim Vergroessern bricht der Text unnoetig um, beim - Verkleinern laeuft er aus der Spalte heraus. Die Rundengrenze ist der - zweite Riegel gegen ein Aufschaukeln. - """ - if not getattr(self, "_text_zellen", None): - return - kasten = self.pos_frame.grid_bbox(column=1, row=0) - if not kasten or kasten[2] < 40: - return - breite = kasten[2] - 14 # padx=6 auf beiden Seiten, etwas Luft - for lbl in self._text_zellen: - try: - if int(lbl.cget("wraplength")) != breite: - lbl.configure(wraplength=breite) - except tk.TclError: # Zeile wurde zwischendurch neu gebaut - pass - if runde < 5 and breite != vorige_breite: - self.after_idle( - lambda: self._umbruch_nachziehen(runde + 1, breite)) - def _render_positionszeilen(self): for w in self.pos_frame.winfo_children(): w.destroy() - self._text_zellen = [] self.anzahl_vars.clear() self.personen_vars.clear() self.gesamt_lbls.clear() # Kopfzeile (gleiches Grid wie die Daten) - # Kopfzeile: derselbe durchgehende Streifen wie die Datenzeilen, sonst - # steht der zweizeilige "Anzahl Tage/Menge" als Kasten heraus. - kopfgrund = tk.Frame(self.pos_frame, bg=self.col_card) - kopfgrund.grid(row=0, column=0, columnspan=len(self.COLS), sticky="nsew") for i, (title, anchor, _) in enumerate(self.COLS): - tk.Label(self.pos_frame, text=title.upper(), anchor=anchor, - bg=self.col_card, fg=self.col_accent, - font=(theme.SCHRIFT_NAME, 9, "bold"), padx=6, pady=6).grid( - row=0, column=i, sticky="nsew") + ttk.Label(self.pos_frame, text=title, anchor=anchor, + font=("Segoe UI", 9, "bold")).grid(row=0, column=i, padx=3, pady=(0, 3), sticky="ew") ttk.Separator(self.pos_frame, orient="horizontal").grid( row=1, column=0, columnspan=len(self.COLS), sticky="ew", pady=(0, 2)) @@ -608,147 +329,28 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, av.trace_add("write", lambda *a: self.recalc()) pv.trace_add("write", lambda *a: self.recalc()) - # Eine Zeile ist EIN durchgehender Streifen, kein Satz Kaestchen: - # der Hintergrund liegt ueber alle Spalten, die Zellen sitzen darin - # und fuellen die volle Zeilenhoehe (sticky="nsew"). Sonst klafften - # bei der zweizeiligen Position 1 Luecken ueber und unter den - # einzeiligen Zellen daneben. - grund = self.col_zebra if idx % 2 else self.col_card - streifen = tk.Frame(self.pos_frame, bg=grund) - streifen.grid(row=r, column=0, columnspan=len(self.COLS), sticky="nsew") - - def zelle(text, spalte, anker="center", umbruch=0): - lbl = tk.Label(self.pos_frame, text=text, bg=grund, fg=self.col_fg, - font=theme.SCHRIFT, anchor=anker, justify="left", - padx=6, pady=6) - if umbruch: - lbl.configure(wraplength=umbruch) - lbl.grid(row=r, column=spalte, sticky="nsew") - return lbl - - zelle(str(nr), 0) - # Der Umbruch wird nicht fest gesetzt: die Leistungsspalte ist die - # einzige, die mitwaechst, und ein fester Wert schnitt den Text ab, - # sobald die Spalte schmaler wurde als er. _umbruch_nachziehen() - # holt ihn nach jeder Groessenaenderung. - self._text_zellen.append( - zelle(pos["leistung"], 1, anker="w", umbruch=200)) - zelle(str(pos["satz"]), 2) - ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center", - style="Liste.TEntry").grid( - row=r, column=3, padx=6, pady=4) + ttk.Label(self.pos_frame, text=str(nr), anchor="center").grid(row=r, column=0, padx=3, pady=2, sticky="ew") + ttk.Label(self.pos_frame, text=pos["leistung"], anchor="w", wraplength=320, + justify="left").grid(row=r, column=1, padx=3, pady=2, sticky="ew") + ttk.Label(self.pos_frame, text=str(pos["satz"]), anchor="center").grid(row=r, column=2, padx=3, pady=2, sticky="ew") + ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center").grid(row=r, column=3, padx=3, pady=2) # Personen-Feld NUR bei "pro Person"-Positionen anzeigen if pos["pro_person"]: - ttk.Entry(self.pos_frame, textvariable=pv, width=6, justify="center", - style="Liste.TEntry").grid( - row=r, column=4, padx=6, pady=4) - zelle(berechnung.eur_kurz(pos["preis"]), 5, anker="e") - g = zelle("0,00", 6, anker="e") + ttk.Entry(self.pos_frame, textvariable=pv, width=6, justify="center").grid(row=r, column=4, padx=3, pady=2) + ttk.Label(self.pos_frame, text=berechnung.eur_kurz(pos["preis"]), anchor="e").grid(row=r, column=5, padx=3, pady=2, sticky="ew") + g = ttk.Label(self.pos_frame, text="0,00", anchor="e") + g.grid(row=r, column=6, padx=3, pady=2, sticky="ew") self.gesamt_lbls[nr] = g - # ------------------------------------------------------------ Montage - def _build_montage(self, eltern): - """Reiter fuer die Monatsmiete eines Montagearbeiters. - - Eine Zeile, ein Monatspreis, ein Zeitraum - keine Nachtpreise. Der - Zeitraum kommt aus denselben beiden Feldern oben rechts, die bei der - Campingrechnung An- und Abreise heissen; die Naechte daraus meldet das - Steuerjournal spaeter ans Amt. - """ - self.m_monate = tk.StringVar(value="1") - self.m_monate.trace_add("write", lambda *a: self.recalc()) - - ttk.Label(eltern, text="Monatsmiete für einen Montagearbeiter", - font=(theme.SCHRIFT_NAME, 11, "bold")).grid(row=0, column=0, columnspan=4, sticky="w") - ttk.Label(eltern, wraplength=760, justify="left", foreground=self.col_muted, - text="Den Zeitraum trägst du oben rechts ein (dieselben Felder wie Anreise/Abreise). " - "Bezeichnung, Steuersatz und Monatspreis stehen unter " - "Einstellungen → Montage-Rechnung.").grid( - row=1, column=0, columnspan=4, sticky="w", pady=(2, 12)) - - ttk.Label(eltern, text="Monate:").grid(row=2, column=0, sticky="w", pady=3) - ttk.Entry(eltern, textvariable=self.m_monate, width=8, justify="center").grid( - row=2, column=1, sticky="w", padx=(8, 24), pady=3) - ttk.Label(eltern, text="Monatspreis (brutto):").grid(row=2, column=2, sticky="w", pady=3) - self.m_preis = ttk.Label(eltern, text="", font=(theme.SCHRIFT_NAME, 10, "bold")) - self.m_preis.grid(row=2, column=3, sticky="w", padx=(8, 0), pady=3) - - ttk.Separator(eltern, orient="horizontal").grid( - row=3, column=0, columnspan=4, sticky="ew", pady=12) - - ttk.Label(eltern, text="So steht es auf der Rechnung:", - font=(theme.SCHRIFT_NAME, 9, "bold")).grid(row=4, column=0, columnspan=4, sticky="w") - self.m_text = ttk.Label(eltern, text="", wraplength=760, justify="left") - self.m_text.grid(row=5, column=0, columnspan=4, sticky="w", pady=(4, 10)) - self.m_zahlen = ttk.Label(eltern, text="", font=(theme.SCHRIFT_NAME, 10)) - self.m_zahlen.grid(row=6, column=0, columnspan=4, sticky="w") - eltern.columnconfigure(3, weight=1) - - def ist_montage(self): - """Liegt der Montage-Reiter oben?""" - try: - return self.blaetter.index("current") == 1 - except (AttributeError, tk.TclError): - return False - - def _blatt_gewechselt(self): - """Reiterwechsel: die zwei Datumsfelder heissen hier anders, und gerechnet - wird ab jetzt die andere Sorte Rechnung.""" - if not hasattr(self, "datum_labels") or not hasattr(self, "m_monate"): - return - if self.ist_montage(): - self.datum_labels["von"].configure(text="Zeitraum von (TT.MM.JJJJ)") - self.datum_labels["bis"].configure(text="Zeitraum bis (TT.MM.JJJJ)") - self.naechte_label.configure(text="Nächte im Zeitraum") - else: - self.datum_labels["von"].configure(text="Anreise (TT.MM.JJJJ)") - self.datum_labels["bis"].configure(text="Abreise (TT.MM.JJJJ)") - self.naechte_label.configure(text="Nächte") - self.recalc() - - def _montage_text(self): - """Die eine Zeile, die auf dem Blatt steht - Wortlaut wie bei Bartl.""" - m = self.cfg.get("montage", {}) - satz = int(parse_num(str(m.get("satz", 7)))) - text = f"{m.get('leistung', 'Miete')} inkl. {satz} % MwSt." - von = self.r_anreise.get().strip() - bis = self.r_abreise.get().strip() - if von and bis: - text += f" für den Zeitraum {von} – {bis}" - elif von: - text += f" ab {von}" - monate = parse_num(self.m_monate.get()) - if monate and abs(monate - 1) > 0.001: - anz = berechnung.eur_kurz(monate).replace(",00", "") - text += f" ({anz} Monate × {berechnung.eur(float(m.get('preis', 0) or 0))})" - return text - - def _montage_katalog(self): - """Ein-Zeilen-Katalog fuer den gemeinsamen Rechenkern. - - Auch die Montage-Rechnung geht durch berechnung.berechne() - es bleibt - EINE Rechenstelle, und die Beherbergungssteuer kommt genauso obendrauf - wie bei jeder Campingrechnung. - """ - m = self.cfg.get("montage", {}) - return [{"nr": 1, "leistung": self._montage_text(), - "satz": int(parse_num(str(m.get("satz", 7)))), - "preis": float(m.get("preis", 0) or 0), "pro_person": False}] - # ------------------------------------------------------------- Summen def _build_summen(self): # Statuszeile ganz unten status = ttk.Frame(self, padding=(12, 2)) status.pack(side="bottom", fill="x") - ttk.Label(status, text=config.MARKE, foreground=self.col_muted, - font=(theme.SCHRIFT_NAME, 8)).pack(side="right") - # Steht der gemeinsame Ordner? Das gehoert sichtbar hin - ohne ihn kennt - # das Programm die Nummern des Steuerjournals nicht. - self.buch_lbl = ttk.Label(status, text="", foreground=self.col_muted, - font=(theme.SCHRIFT_NAME, 8)) - self.buch_lbl.pack(side="left") + ttk.Label(status, text="powered by ravokk", foreground=self.col_muted, + font=("Segoe UI", 8)).pack(side="right") - f = ttk.Frame(self, padding=(16, 12, 16, 14)) + f = ttk.Frame(self, padding=(12, 6, 12, 10)) f.pack(side="bottom", fill="x") ttk.Separator(self, orient="horizontal").pack(side="bottom", fill="x") @@ -758,475 +360,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, links = ttk.Frame(f) links.pack(side="left") - # "PDF erstellen" ist der Knopf, um den es geht - der traegt die - # Akzentfarbe. Storno greift in eine ausgestellte Rechnung ein und ist - # deshalb rot markiert, nicht weil er gefaehrlich waere, sondern damit - # niemand ihn versehentlich statt "PDF erstellen" trifft. - ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, - style="Haupt.TButton").pack(side="left", padx=(0, 6)) - ttk.Button(links, text="Vorschau", command=self.vorschau_zeigen).pack(side="left", padx=6) + ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) ttk.Button(links, text="Storno", command=self.dlg_storno, - style="Gefahr.TButton").pack(side="left", padx=6) - ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen).pack(side="left", padx=6) - ttk.Button(links, text="Zurücksetzen", command=self.reset_formular).pack(side="left", padx=6) - - # --------------------------------------------------- Gemeinsames Buch - # ------------------------------------------------- Erprobungshinweis - - def _wasserzeichen_setzen(self, groesse=240): - """Das Programmlogo blass hinter die Arbeitsflaeche legen.""" - bild = wasserzeichen.hole(groesse, 0.06, master=self, - dunkel=getattr(self, "_dark_mode", False)) - 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) - - 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. - - Erst wenn er bestaetigt ist, faehrt der Rest des Starts an - sonst - stuenden beim Ablehnen noch Timer in der Warteschlange, die auf ein - Fenster zugreifen, das es nicht mehr gibt. - """ - self.update_idletasks() # Fenster fertig zeichnen lassen - if not hinweis.angenommen(self.cfg): - print(f"[hinweis] Kenntnisnahme noch offen (Fassung {hinweis.FASSUNG})") - if not self.frage(hinweis.TITEL, hinweis.text("rechnungstool"), - ja="Ja, gelesen", nein="Nein, beenden", default="no"): - print("[hinweis] abgelehnt - Programm wird beendet") - self.destroy() - return - zeile = hinweis.vermerken(self.cfg) - try: - config.save_config(self.cfg) - except OSError as e: # noqa: BLE001 - print("[hinweis] konnte nicht gespeichert werden:", e) - print("[hinweis]", zeile) - self._start_fortsetzen() - - def _start_fortsetzen(self): - """Der uebrige Start - laeuft erst nach der Kenntnisnahme an.""" - self.after(0, lambda: self._dark_titlebar(self)) - # Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist) - self.after(200, self._frage_speicherort_falls_noetig) - # ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch. - self.after(350, self._frage_gemeinsam_falls_noetig) - # ... und danach einmalig nach dem Stand der Rechnungsnummer. - self.after(550, self._frage_nummer_falls_noetig) - self.after(450, self._push_abholen) - - def _buch_oeffnen(self): - """Nummernbuch aus dem eingestellten Ordner - oder None.""" - ordner = str(self.cfg.get("gemeinsam_ordner", "") or "") - if not ordner: - self.buch = None - return - taugt, grund = gemeinsam.ordner_taugt(ordner) - if not taugt: - self.buch = None - print("[nummernbuch] nicht erreichbar:", grund) - return - self.buch = gemeinsam.Nummernbuch(ordner) - # Auch das Rechnungstool macht seinen kleinen Server auf: die beiden - # sollen sich GEGENSEITIG sehen, nicht nur einer den anderen. - self.api.starten(self.buch) - - def _frage_gemeinsam_falls_noetig(self): - """Beim ersten Start nach dem gemeinsamen Ordner fragen. - - Wer abbricht, kann weiterarbeiten - dann steht aber in der Fussleiste, - dass die Nummern des Steuerjournals unbekannt sind. Beim naechsten Start - wird wieder gefragt. - """ - self._buch_oeffnen() - if self.buch is None and not config.testlauf(): - # Kein Dialog mehr beim ersten Start: der Ordner steht fest und - # ist in beiden Programmen derselbe (gemeinsam.standard_ordner). - # Gefragt wurde frueher - und wer in den zwei Programmen zwei - # verschiedene Pfade eintippte, merkte es erst, wenn eine - # Rechnungsnummer zum zweiten Mal vergeben war. - ordner = gemeinsam.standard_anlegen() - if ordner: - self.cfg["gemeinsam_ordner"] = ordner - config.save_config(self.cfg) - self._buch_oeffnen() - if self.buch is None: - # Nur wenn selbst das nicht geht, wird gefragt. - self.dlg_gemeinsam(erststart=True) - self._buch_anzeigen() - - def dlg_gemeinsam(self, erststart=False): - # Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen: - # den kennen beide Programme, er wird mitgesichert, und er ueberlebt - # ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt - # dafuer NICHT mehr - seit dem Installer sind das zwei getrennte. - ordner = ordnerwahl.ordner_abfragen( - self, "Rechnungstool", - str(self.cfg.get("gemeinsam_ordner", "") or "") - or str(self.cfg.get("output_dir", "") or "")) - if not ordner: - if erststart: - self.melden_warnung( - "Ohne gemeinsamen Ordner", - "Es wurde kein gemeinsamer Ordner gewählt.\n\n" - "Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen " - "PDFs - eine Buchung, die nur im Steuerjournal steht, kann eine " - "Nummer ein zweites Mal bekommen.\n\n" - "Nachholen: Einstellungen -> Gemeinsamer Ordner (Nummernbuch).") - return - self.cfg["gemeinsam_ordner"] = ordner - config.save_config(self.cfg) - self._buch_oeffnen() - self._nachtragen() - self._buch_anzeigen() - self._neue_nummer_vorschlagen() - - def _buch_anzeigen(self): - """Fussleiste: welcher Ordner, wie viele Nummern - oder die Warnung.""" - if not hasattr(self, "buch_lbl"): - return - if self.buch is None: - self.buch_lbl.configure( - text="kein gemeinsamer Ordner - Nummern des Steuerjournals unbekannt", - foreground="#c04040") - return - try: - anzahl = len(self.buch.nummern()) - except Exception as e: # noqa: BLE001 - self.buch_lbl.configure(text=f"Nummernbuch nicht lesbar: {e}", - foreground="#c04040") - return - self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern", - foreground=self.col_muted) - - def _frage_nummer_falls_noetig(self): - """Beim ersten Start einmal nach dem Stand der Rechnungsnummer fragen. - - Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er - einmal nennen koennen, sonst faengt das Programm bei 001 an und vergibt - Nummern ein zweites Mal. Danach ist der Zaehler zu. - """ - # Im Pruflauf bleibt dieser Dialog zu - er wartet auf einen Klick, den dort - # niemand macht. Ein Pruefstand, der GENAU IHN pruefen will, setzt - # `_erststart_zeigen = True` (dasselbe Muster wie `_dialog_zeigen` beim - # Angebot). So bleiben alle anderen Meldungen weiterhin automatisch - # beantwortet - vorher musste dafuer der ganze Pruefschalter fallen, und - # dann hing der Lauf in der naechsten Meldung. - if (config.testlauf() and not getattr(self, "_erststart_zeigen", False)) or self.cfg.get("nummer_eingerichtet"): - return - jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year) - gefunden = self.hoechster_stand(jahr) - vorschlag = max(gefunden, int(self.cfg.get("rechnung_zaehler", 0) or 0)) - - d = tk.Toplevel(self) - d.title("Rechnungsnummer einrichten") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - wert = tk.StringVar(value=str(vorschlag)) - fertig = {"ok": False} - - rahmen = ttk.Frame(d, padding=18) - rahmen.pack(fill="both", expand=True) - ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?", - font=(theme.SCHRIFT_NAME, 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w") - ttk.Label(rahmen, wraplength=620, justify="left", text=( - "Zwei Wege - such dir einen aus." + chr(10) + chr(10) + - "Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine " - "ausgestellte Rechnung wird nur noch über Storno oder Berichtigung " - "korrigiert, und jede Nummer gibt es nur einmal " - "(§ 14 Abs. 4 Nr. 4 UStG).")).grid( - row=1, column=0, columnspan=2, sticky="w", pady=(8, 12)) - - if gefunden: - tk.Label(rahmen, wraplength=620, justify="left", fg="#1f5c3d", - font=(theme.SCHRIFT_NAME, 9, "bold"), - text=f"Im Rechnungsordner und im Nummernbuch steht schon " - f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid( - row=2, column=0, columnspan=2, sticky="w", pady=(0, 10)) - - # Zwei Wege statt einer Zahl: neu anfangen oder die eigene Zaehlung - # weiterfuehren. Die Frage "trag die zuletzt vergebene Nummer ein" holt - # niemanden ab, der bei 0 anfaengt - der hat gar keine. - weg = tk.StringVar(value="weiter" if gefunden else "null") - zeile = ttk.Frame(rahmen) - # Das Eingabefeld ist ein Kind DIESER Zeile - ein Widget muss dort - # zuhause sein, wo es gepackt wird, sonst mischt Tk grid und pack im - # selben Rahmen und bricht ab. - eingabe = ttk.Entry(zeile, textvariable=wert, width=10, justify="center") - - def weg_gewaehlt(*_): - eingabe.configure(state="normal" if weg.get() == "weiter" else "disabled") - - ttk.Radiobutton(rahmen, variable=weg, value="null", command=weg_gewaehlt, - text=f"Bei 0 anfangen - die erste Rechnung wird {jahr}-001").grid( - row=3, column=0, columnspan=2, sticky="w", pady=(0, 4)) - zeile.grid(row=4, column=0, columnspan=2, sticky="w") - ttk.Radiobutton(zeile, variable=weg, value="weiter", command=weg_gewaehlt, - text=f"Meine Zählung weiterführen - zuletzt vergeben: {jahr} -").pack( - side="left") - eingabe.pack(side="left", padx=(8, 0)) - weg_gewaehlt() - hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000", - font=(theme.SCHRIFT_NAME, 9)) - hinweis.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) - - def uebernehmen(): - if weg.get() == "null": - zahl = 0 - else: - try: - zahl = int(parse_num(wert.get())) - except (TypeError, ValueError): - zahl = -1 - if zahl < 0: - hinweis.configure(text="Bitte die zuletzt vergebene Nummer eintragen.") - return - # Unter das, was schon vergeben ist, darf niemand - egal auf welchem - # Weg. Sonst gaebe es eine Nummer zweimal (§ 14 Abs. 4 Nr. 4 UStG). - if zahl < gefunden: - hinweis.configure( - text=f"Schon vergeben ist {jahr}-{gefunden:03d} - darunter geht es nicht." - if weg.get() == "weiter" else - f"Bei 0 anfangen geht nicht mehr: {jahr}-{gefunden:03d} ist schon vergeben.") - return - if not self.frage( - "Sind Sie sicher?", - f"Die nächste Rechnung bekommt die Nummer {jahr}-{zahl + 1:03d}.\n\n" - "Das lässt sich danach nicht mehr ändern.\n\n" - "Ja = so übernehmen\n" - "Nein = zurück zur Eingabe", icon="question", default="yes", parent=d): - return - self.cfg["rechnung_jahr"] = jahr - self.cfg["rechnung_zaehler"] = zahl - self.cfg["nummer_eingerichtet"] = True - try: - config.save_config(self.cfg) - except OSError as e: # noqa: BLE001 - self.melden_warnung("Nicht gespeichert", - f"Der Stand ließ sich nicht speichern:\n{e}", parent=d) - return - fertig["ok"] = True - d.destroy() - - knoepfe = ttk.Frame(rahmen) - knoepfe.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(16, 0)) - ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right") - ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left") - - d.protocol("WM_DELETE_WINDOW", d.destroy) - d.bind("", lambda _e: d.destroy()) - d.update_idletasks() - theme.mittig(d, self) - d.grab_set() - self.wait_window(d) - if fertig["ok"]: - self._neue_nummer_vorschlagen() - - def hoechster_stand(self, jahr=None): - """Hoechster JE VERGEBENER Zaehler dieses Jahres - aus beiden Quellen. - - Der Zaehler in der config allein taugt nicht als Wahrheit: er laesst - sich in den Einstellungen zuruecksetzen. Deshalb zaehlt, was WIRKLICH - vergeben ist - die PDFs im Ordner und das gemeinsame Nummernbuch (das - kennt auch die Nummern aus dem Altbestand, zu denen es keine PDF gibt). - """ - jahr = int(jahr or self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year) - hoch = hoechster_zaehler_im_ordner(config.ensure_output_dir(self.cfg), jahr) - if self.buch is not None: - try: - hoch = max(hoch, self.buch.hoechster_zaehler(jahr)) - except Exception as e: # noqa: BLE001 - ohne Buch bleibt der Ordner - print("[nummernbuch] Hoechststand nicht lesbar:", e) - return hoch - - def _zaehler_absichern(self): - """Einen zurueckgedrehten Zaehler stillschweigend wieder anheben. - - Nicht als Vorwurf - es reicht ein alter Rechner, eine wiederhergestellte - config oder ein Jahreswechsel von Hand. Vergeben ist vergeben. - """ - jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year) - if jahr != datetime.date.today().year: - return - hoch = self.hoechster_stand(jahr) - if hoch > int(self.cfg.get("rechnung_zaehler", 0) or 0): - print(f"[nummern] Zaehler stand auf {self.cfg.get('rechnung_zaehler')}, " - f"vergeben ist bis {hoch} - angehoben") - self.cfg["rechnung_zaehler"] = hoch - try: - config.save_config(self.cfg) - except OSError as e: # noqa: BLE001 - print("[nummern] Zaehler nicht gespeichert:", e) - - def _belegte_nummern(self): - """Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge - leer - eine Rechnung muss auch dann geschrieben werden koennen.""" - if self.buch is None: - return set() - try: - return self.buch.nummern() - except Exception as e: # noqa: BLE001 - print("[nummernbuch] Lesen fehlgeschlagen:", e) - return set() - - def _ins_buch(self, nummer, kopf, rechnung, pdf_pfad=""): - """Eine geschriebene Rechnung ins gemeinsame Buch eintragen.""" - if self.buch is None or not nummer: - return - aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None - datum = parse_date(str(kopf.get("datum", ""))) - try: - jahr = int(str(nummer).split("-")[0]) - except (ValueError, IndexError): - jahr = datum.year if datum else datetime.date.today().year - try: - self.buch.eintragen( - nummer, - jahr=jahr, - datum=datum.isoformat() if datum else "", - nachname=str(kopf.get("nachname", "") or ""), - vorname=str(kopf.get("vorname", "") or ""), - naechte=int(parse_num(str(kopf.get("naechte") or 0)) or 0), - zwischensumme=berechnung.runde(rechnung.brutto), - steuer_satz=float(aufschlag.satz) if aufschlag else 0.0, - steuer_betrag=berechnung.runde(aufschlag.betrag) if aufschlag else 0.0, - gesamt=berechnung.runde(rechnung.gesamt), - art=str(kopf.get("art", "rechnung") or "rechnung"), - vorgang=str(kopf.get("vorgang", "") or ""), - storno_zu=str(kopf.get("storno_zu", "") or ""), - ersetzt=str(kopf.get("ersetzt", "") or ""), - berichtigt_zu=str(kopf.get("berichtigt_zu", "") or ""), - vorlage=str(kopf.get("vorlage", "") or ""), - quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad, - # Zeitstempel der ausdruecklichen Freigabe. Leer heisst: nicht - # gefragt (Beleg aus einer Fassung vor dem 06.09.2026 oder ueber - # einen Weg ohne Rueckfrage, z. B. Storno/Berichtigung mit eigener). - freigabe=str(kopf.get("freigabe", "") or "")) - except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben - print("[nummernbuch] Eintragen fehlgeschlagen:", e) - self.melden_warnung( - "Nummernbuch nicht erreichbar", - "Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame " - f"Nummernbuch eingetragen werden:\n{e}\n\n" - "Das Steuerjournal sieht sie trotzdem - es liest die PDFs.") - - def _journal_benachrichtigen(self, text): - """Dem laufenden Steuerjournal Bescheid sagen. - - Erst nachsehen, ob es ueberhaupt offen ist (es legt einen Herzschlag im - gemeinsamen Ordner ab), dann eine Zeile in den Briefkasten. Das Journal - liest die Rechnung daraufhin von selbst ein. - - Ist es zu, passiert nichts - es liest den Ordner beim naechsten Start - ohnehin. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle. - """ - if self.buch is None: - return - try: - if not self.buch.laeuft("journal"): - print("[melden] Steuerjournal laeuft nicht - es liest die Rechnung " - "beim naechsten Start") - return - # Erst die Meldung in die SQLite (das ist die Wahrheit), dann - # anklopfen. Geht der Anruf nicht durch, findet das Journal sie - # beim naechsten Nachsehen trotzdem. - self.buch.melden("rechnungstool", text) - self.buch.aufraeumen() - gehoert = api.anklopfen(self.buch, "journal") - print(f"[melden] ans Steuerjournal: {text}" - + (" (angeklopft)" if gehoert else " (in der SQLite, nicht erreicht)")) - except Exception as e: # noqa: BLE001 - die Rechnung ist schon geschrieben - print("[melden] fehlgeschlagen:", e) - - def _push_empfangen(self): - """Aus dem API-Faden - hier nur einen Zettel hinterlegen.""" - self._push_kasten.put(1) - - def _push_abholen(self): - """Im Fenster-Faden: hat das Journal angeklopft, Nummernvorschlag frisch holen.""" - try: - geklopft = False - while True: - try: - self._push_kasten.get_nowait() - geklopft = True - except queue.Empty: - break - if geklopft: - self._buch_anzeigen() - self._neue_nummer_vorschlagen() - except Exception as e: # noqa: BLE001 - print("[push]", e) - finally: - self._wieder(250, self._push_abholen) - - def _nachtragen(self): - """Alles, was schon im Rechnungsordner liegt, ins Buch schreiben. - - Laeuft beim Einrichten des Ordners einmal durch - sonst faengt das Buch - bei null an und kennt die bisherigen Nummern nicht. - """ - if self.buch is None: - return - anzahl = 0 - for meta in rechnungen_im_ordner(config.ensure_output_dir(self.cfg)): - nummer = str(meta.get("rechnungsnummer") or "") - if not nummer: - continue - datum = parse_date(str(meta.get("datum", ""))) - try: - jahr = int(nummer.split("-")[0]) - except (ValueError, IndexError): - jahr = datum.year if datum else 0 - try: - self.buch.eintragen( - nummer, jahr=jahr, - datum=datum.isoformat() if datum else "", - nachname=str(meta.get("nachname", "") or ""), - vorname=str(meta.get("vorname", "") or ""), - naechte=int(parse_num(str(meta.get("naechte") or 0)) or 0), - zwischensumme=float(meta.get("zwischensumme") or 0), - steuer_satz=float(meta.get("steuer_satz") or 0), - steuer_betrag=float(meta.get("steuer_betrag") or 0), - gesamt=float(meta.get("gesamt") or 0), - art=str(meta.get("art", "rechnung") or "rechnung"), - vorgang=str(meta.get("vorgang", "") or ""), - storno_zu=str(meta.get("storno_zu", "") or ""), - ersetzt=str(meta.get("ersetzt", "") or ""), - berichtigt_zu=str(meta.get("berichtigt_zu", "") or ""), - vorlage=str(meta.get("vorlage", "") or ""), - quelle="rechnungstool", herkunft="pdf") - anzahl += 1 - except Exception as e: # noqa: BLE001 - print("[nummernbuch] Nachtragen fehlgeschlagen:", e) - break - if anzahl: - print(f"[nummernbuch] {anzahl} vorhandene Rechnungen nachgetragen") + width=12).pack(side="left", padx=4) + ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen, + width=14).pack(side="left", padx=4) + ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4) # --------------------------------------------------------- Berechnung def _eingaben(self): @@ -1235,29 +374,11 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, e[nr] = {"anzahl": parse_num(av.get()), "personen": parse_num(self.personen_vars[nr].get())} return e - def _recalc_montage(self): - """Montage-Reiter durchrechnen und seine Anzeige nachziehen.""" - m = self.cfg.get("montage", {}) - r = berechnung.berechne(self._montage_katalog(), - {1: {"anzahl": parse_num(self.m_monate.get()), "personen": 0}}, - self.cfg) - self.m_preis.configure(text=berechnung.eur(float(m.get("preis", 0) or 0))) - self.m_text.configure(text=self._montage_text()) - netto = sum(z.netto for z in r.zeilen) - self.m_zahlen.configure( - text=f"Netto {berechnung.eur(netto)} " - f"USt. {berechnung.eur(r.brutto - netto)} " - f"Brutto {berechnung.eur(r.brutto)}") - return r - def recalc(self): - if self.ist_montage(): - r = self._recalc_montage() - else: - r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg) - aktiv = {z.nr: z.brutto for z in r.zeilen} - for nr, lbl in self.gesamt_lbls.items(): - lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0))) + r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg) + aktiv = {z.nr: z.brutto for z in r.zeilen} + for nr, lbl in self.gesamt_lbls.items(): + lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0))) # Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene # USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag. @@ -1275,8 +396,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, rows.append(("Gesamtbetrag:", berechnung.eur(r.gesamt), True)) for i, (lbl, val, fett) in enumerate(rows): - font = ((theme.SCHRIFT_NAME, 15, "bold") if fett - else (theme.SCHRIFT_NAME, 10)) + font = ("Segoe UI", 12, "bold") if fett else ("Segoe UI", 10) pady = (5, 0) if fett else (1, 0) ttk.Label(self.summen_box, text=lbl, font=font).grid(row=i, column=0, sticky="e", padx=6, pady=pady) vkw = {"font": font, "width": 14, "anchor": "e"} @@ -1296,12 +416,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, """ if self.storno_stand or self.berichtigung_stand: return - # Erst sicherstellen, dass der Zaehler nicht hinter dem steht, was - # schon vergeben ist - sonst schlaegt das Programm eine Nummer vor, - # die es ein zweites Mal vergeben wuerde. - self._zaehler_absichern() - frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg, - self._belegte_nummern()) + frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg) if frei: self.r_nummer.set(frei) @@ -1319,241 +434,92 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, "strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()}) - def _datumsfelder_pruefen(self): - """Welche Datumsfelder sind unlesbar? Leere Felder sind in Ordnung. - - Geprueft wird mit parse_date - also gegen den echten Kalender. Die - Tipp-Hilfe verhindert nur 32. und 13., den 31.02. faengt erst das hier. - """ - felder = [("Rechnungsdatum", self.r_datum.get().strip())] - if self.ist_montage(): - felder += [("Zeitraum von", self.r_anreise.get().strip()), - ("Zeitraum bis", self.r_abreise.get().strip())] - else: - felder += [("Anreise", self.r_anreise.get().strip()), - ("Abreise", self.r_abreise.get().strip())] - return [f"{name}: „{wert}“ ist kein Datum" - for name, wert in felder if wert and not parse_date(wert)] - - def _art_waehlen(self, nummer, betrag, wer): - """Popup: Rechnung oder Angebot? Gibt "rechnung", "angebot" oder None. - - Ersetzt die fruehere Sicherheitsfrage - EIN Fenster statt zwei. Die - Warnung, dass eine ausgestellte Rechnung nicht mehr zu aendern ist, - steht weiter drin: sie gilt genau fuer diesen einen Knopf. - """ - # Im Testlauf gibt es niemanden, der klickt: dann gilt der uebliche Weg - # (Rechnung). Der Pruefstand, der GENAU DIESES Fenster pruefen will, - # setzt _dialog_zeigen und drueckt die Knoepfe wirklich. - if config.testlauf() and not getattr(self, "_dialog_zeigen", False): - # _testwahl: was der Pruefstand im Fenster geklickt haette. - # "rechnung" (Vorgabe), "angebot" oder None fuer Abbrechen. - return getattr(self, "_testwahl", "rechnung") - d = tk.Toplevel(self) - d.title("Was soll erstellt werden?") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - wahl = {"art": None} - - rahmen = ttk.Frame(d, padding=18) - rahmen.pack(fill="both", expand=True) - ttk.Label(rahmen, text=f"{wer or 'ohne Namen'} – {betrag}", - font=(theme.SCHRIFT_NAME, 12, "bold")).grid(row=0, column=0, sticky="w") - - ttk.Label(rahmen, wraplength=560, justify="left", text=( - f"RECHNUNG {nummer} – der Beleg. Danach lässt sie sich nicht mehr " - "ändern: korrigiert wird nur über Storno oder Berichtigung, und jede " - "Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4 UStG). Das Steuerjournal " - "bucht sie und meldet die Übernachtungen ans Amt.")).grid( - row=1, column=0, sticky="w", pady=(10, 8)) - ttk.Label(rahmen, wraplength=560, justify="left", text=( - "ANGEBOT – unverbindliche Kostenaufstellung vorab (§ 145 BGB). Keine " - "Rechnung im Sinne des § 14 UStG, kein Vorsteuerabzug, nicht zu zahlen. " - "Es verbraucht KEINE Rechnungsnummer (eigene Reihe) und wird nirgends " - f"gebucht – die nächste Rechnung bleibt {nummer}.")).grid( - row=2, column=0, sticky="w", pady=(0, 14)) - - def nimm(art): - wahl["art"] = art - d.destroy() - - knoepfe = ttk.Frame(rahmen) - knoepfe.grid(row=3, column=0, sticky="ew") - ttk.Button(knoepfe, text=f"Rechnung {nummer} erstellen", width=28, - command=lambda: nimm("rechnung")).pack(side="left") - ttk.Button(knoepfe, text="Angebot erstellen", width=20, - command=lambda: nimm("angebot")).pack(side="left", padx=8) - ttk.Button(knoepfe, text="Abbrechen", width=14, - command=d.destroy).pack(side="right") - - d.protocol("WM_DELETE_WINDOW", d.destroy) - d.bind("", lambda _e: d.destroy()) - d.update_idletasks() - theme.mittig(d, self) - d.grab_set() - self.wait_window(d) - return wahl["art"] - - def _angebot_ordner(self): - """Unterordner "Angebote" neben den Rechnungen. - - Angebote sind keine Buchhaltung - im Rechnungsordner haetten sie nur - zwischen den Belegen gelegen. Aufzubewahren sind sie trotzdem: ein - abgesandtes Angebot ist ein Handelsbrief (§ 257 HGB). Das Steuerjournal - liest den Ordner mit, ueberspringt sie aber (art="angebot"). - """ - ordner = os.path.join(config.ensure_output_dir(self.cfg), "Angebote") - try: - os.makedirs(ordner, exist_ok=True) - except OSError as e: # noqa: BLE001 - dann eben neben die Rechnungen - print("[angebot] Unterordner nicht anlegbar:", e) - return config.ensure_output_dir(self.cfg) - return ordner - - def _naechstes_angebot(self): - """JJJJ-Annn aus der EIGENEN Reihe. Der Jahreswechsel faengt bei A001 an. - - Wichtig: hier wird der Rechnungszaehler nicht angefasst. Ein Angebot - darf keine Rechnungsnummer verbrauchen - sonst muesste man die Nummern - wieder von Hand aendern koennen, und genau daher kamen die doppelten. - """ - jahr = datetime.date.today().year - zaehler = int(self.cfg.get("angebot_zaehler", 0) or 0) - if int(self.cfg.get("angebot_jahr", 0) or 0) != jahr: - zaehler = 0 - ordner = self._angebot_ordner() - try: - vorhanden = os.listdir(ordner) - except OSError: - vorhanden = [] - for _ in range(1000): - zaehler += 1 - nummer = f"{jahr}-A{zaehler:03d}" - if not any(d.startswith(f"Angebot_{nummer}") for d in vorhanden): - return jahr, zaehler, nummer - return jahr, zaehler, f"{jahr}-A{zaehler:03d}" - - # ------------------------------------------------- Bedienung ueber die API - def _wieder(self, ms, was): - """Einen sich selbst planenden Timer neu setzen - nur solange es das - Fenster noch gibt. - - Ohne das melden die beiden Dauertimer beim Beenden - `invalid command name ..._api_pumpe`: Tk hat das Fenster samt Callback - schon abgeraeumt, der eingeplante Aufruf laeuft aber noch. Das - stuerzt nichts ab, landet aber als Fehler im Log - und ein Log, in dem - beim normalen Beenden immer zwei Fehler stehen, liest irgendwann - niemand mehr. - """ - try: - if self.winfo_exists(): - self.after(ms, was) - except tk.TclError: - pass # Fenster ist weg - dann gibt es nichts mehr zu tun - - def _api_pumpe(self): - """Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus. - - Laeuft, solange das Fenster steht. Tkinter darf nur aus seinem eigenen - Faden angefasst werden - der API-Server lauscht in einem anderen. - """ - try: - self._api_schlange.abarbeiten(self._api_aktion) - finally: - self._wieder(150, self._api_pumpe) - - def _api_aktion(self, name, daten): - """Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py). - - Nachschlagetabelle statt if-Kette: ein neuer Knopf ist EIN Eintrag. - - Was einen BELEG schreibt, geht nur im Testlauf. Eine Fernbedienung, die - im laufenden Betrieb echte Rechnungen ausstellt, hat in einer - Buchhaltung nichts zu suchen - eine ausgestellte Rechnung bekommt man - nur ueber einen Storno wieder weg. - """ - schreibt_beleg = {"rechnung", "angebot"} - tabelle = { - "hilfe": lambda _d: {"kann": ["hilfe", "zustand", "setze", "zuruecksetzen", - "vorschau", "rechnung", "angebot"]}, - "zustand": lambda _d: self._api_zustand(), - "setze": self._api_setzen, - "zuruecksetzen": lambda _d: (self.reset_formular(), {"ok": True})[1], - "vorschau": lambda d: {"pfad": self.vorschau_zeigen( - oeffnen=bool(d.get("oeffnen", False)))}, - "rechnung": lambda _d: (self.pdf_erstellen(), self._api_zustand())[1], - "angebot": lambda _d: (self.pdf_erstellen(angebot=True), self._api_zustand())[1], - } - if name not in tabelle: - return {"fehler": "kenne ich nicht: " + str(name), "kann": sorted(tabelle)} - if name in schreibt_beleg and not config.testlauf(): - return {"fehler": "schreibt einen Beleg - geht nur im Testlauf"} - return tabelle[name](daten or {}) - - def _api_zustand(self): - """Was steht gerade im Formular? Zum Nachsehen, ohne Maus.""" + def pdf_erstellen(self): r = self.recalc() - return { - "reiter": "montage" if self.ist_montage() else "camping", - "nummer": self.r_nummer.get(), - "datum": self.r_datum.get(), - "anreise": self.r_anreise.get(), - "abreise": self.r_abreise.get(), - "naechte": self.r_naechte.get(), - "kunde": {"anrede": self.k_anrede.get(), "vorname": self.k_vorname.get(), - "nachname": self.k_name.get(), "zusatz": self.k_zusatz.get(), - "strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()}, - "positionen": [{"nr": z.nr, "leistung": z.leistung, "anzahl": z.anzahl, - "personen": z.personen, "brutto": z.brutto} for z in r.zeilen], - "gesamt": r.gesamt, - } + if not r.zeilen: + messagebox.showwarning("Keine Positionen", + "Es wurde keine Leistung mit Anzahl > 0 eingetragen.") + return + nummer = self.r_nummer.get().strip() + if not nummer: + messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.") + return + # Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung + # (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben. + maengel = self.kunde_pruefen() + if maengel: + messagebox.showwarning( + "Kundenangaben fehlen", + "So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) + + "\n\nName und vollständige Anschrift des Kunden gehören auf jede " + "Rechnung (§ 14 Abs. 4 Nr. 1 UStG).") + return + # Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne + # (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer. + if not ziffern(self.k_strasse.get()): + if not messagebox.askyesno( + "Hausnummer fehlt", + f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n" + "Trotzdem so auf die Rechnung schreiben?"): + return - def _api_setzen(self, daten): - """Felder fuellen, wie es sonst die Tastatur taete.""" - felder = { - "anrede": self.k_anrede, "vorname": self.k_vorname, "nachname": self.k_name, - "zusatz": self.k_zusatz, "strasse": self.k_strasse, "plzort": self.k_plzort, - "nummer": self.r_nummer, "datum": self.r_datum, - "anreise": self.r_anreise, "abreise": self.r_abreise, - } - for schluessel, wert in (daten.get("kunde") or {}).items(): - if schluessel in felder: - felder[schluessel].set(str(wert)) - for schluessel in ("nummer", "datum", "anreise", "abreise"): - if schluessel in daten: - felder[schluessel].set(str(daten[schluessel])) - for eintrag in (daten.get("positionen") or []): - nr = int(eintrag.get("nr", 0)) - if nr in self.anzahl_vars: - self.anzahl_vars[nr].set(str(eintrag.get("anzahl", ""))) - if nr in self.personen_vars and "personen" in eintrag: - self.personen_vars[nr].set(str(eintrag["personen"])) - self.update() - return self._api_zustand() + # Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht + # mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder + # eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum + # Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin + # schon, darum hier nur bei einer ganz normalen Rechnung. + if not self.storno_stand and not self.berichtigung_stand: + wer = " ".join(t for t in (self.k_vorname.get().strip(), + self.k_name.get().strip()) if t) + if not messagebox.askyesno( + "Rechnung endgültig erstellen?", + f"Rechnung {nummer} für {wer or 'ohne Namen'} über " + f"{berechnung.eur(r.gesamt)}.\n\n" + "Sind alle Angaben richtig? Danach lässt sich die Rechnung " + "nicht mehr ändern.\n\n" + "Eine ausgestellte Rechnung darf weder überschrieben noch " + "gelöscht werden. Korrigiert wird sie nur noch über " + "„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche " + "Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt " + "es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).", + icon="question", default="yes"): + return - def _kopf_basis(self, nummer, r): - """Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau. - - Steht nur hier: sonst tippt die Vorschau denselben Adressblock ein - zweites Mal ab, und beim naechsten neuen Feld zeigt sie ein Blatt, das - es so gar nicht gibt. - - Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; - der Nachname geht zusaetzlich getrennt mit, damit das Steuerjournal ihn - nicht aus dem Adressblock raten muss. - """ + # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; + # der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn + # nicht aus dem Adressblock raten muss. nachname = self.k_name.get().strip() vorname = self.k_vorname.get().strip() namenszeile = " ".join(t for t in (vorname, nachname) if t) - return { + kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(), + self.k_strasse.get(), self.k_plzort.get()] + # Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung. + # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder + # geloescht und es bleibt allein die urspruengliche Rechnung stehen. + stand = self.storno_stand + if stand: + if not messagebox.askyesno( + "Storno wirklich durchführen?", + f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, " + f"und {nummer} wird die berichtigte Rechnung.\n\n" + "Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie " + "ihm zugegangen ist.\n\n" + "Ja = Storno behalten und berichtigte Rechnung erstellen\n" + f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}", + icon="warning", default="yes"): + self._storno_verwerfen("vom Benutzer verworfen") + messagebox.showinfo( + "Storno verworfen", + f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.") + return + + kopf = { "rechnungsnummer": nummer, "datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"), "anreise": self.r_anreise.get().strip(), "abreise": self.r_abreise.get().strip(), "naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "", - "kunde_zeilen": [self.k_anrede.get(), namenszeile, self.k_zusatz.get(), - self.k_strasse.get(), self.k_plzort.get()], + "kunde_zeilen": kunde_zeilen, "nachname": nachname, "vorname": vorname, # Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter @@ -1565,146 +531,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, "positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen} for z in r.zeilen], } - - def vorschau_zeigen(self, oeffnen=True): - """Zeigt das Blatt, BEVOR es eine Rechnung wird. - - Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno - geradeziehen - Papier, Porto und ein Weg zum Gast. Deshalb dieser Knopf: - ansehen, aendern, ansehen. Die Vorschau - - - verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch, - - landet NICHT im Rechnungsordner, sondern im Temp-Ordner, - - traegt quer ueber jede Seite "VORSCHAU - keine Rechnung". - - Gepruefte Pflichtfelder gibt es hier bewusst nicht: man will ja gerade - sehen, was noch fehlt. - """ - montage = self.ist_montage() - r = self.recalc() - if not r.zeilen: - self.melden_info( - "Noch nichts einzutragen", - "Bitte die Anzahl der Monate eintragen." if montage else - "Trage erst bei einer Leistung eine Anzahl ein - dann zeigt die " - "Vorschau, wie die Rechnung aussehen wird.") - return - kopf = self._kopf_basis(self.r_nummer.get().strip() or "(noch keine Nummer)", r) - if montage: - kopf["vorlage"] = "montage" - kopf["montage_text"] = self._montage_text() - pfad = os.path.join(tempfile.gettempdir(), "Vorschau - so wird die Rechnung.pdf") - try: - pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True) - print(f"[vorschau] geschrieben -> {pfad}") - if oeffnen: - self._oeffne_datei(pfad) - return pfad - except OSError as e: - self.melden_fehler("Vorschau nicht möglich", - f"Die Vorschau konnte nicht geöffnet werden:\n{e}") - return "" - - def pdf_erstellen(self, angebot=False): - montage = self.ist_montage() - r = self.recalc() - if not r.zeilen: - self.melden_warnung( - "Keine Positionen", - "Bitte die Anzahl der Monate eintragen." if montage else - "Es wurde keine Leistung mit Anzahl > 0 eingetragen.") - return - if montage and not (self.r_anreise.get().strip() and self.r_abreise.get().strip()): - # Ohne Zeitraum steht auf dem Blatt nicht, wofuer gezahlt wird - und - # das Steuerjournal haette keine Naechte fuer die Meldung ans Amt. - self.melden_warnung( - "Zeitraum fehlt", - "Bitte oben rechts den Zeitraum eintragen (von / bis).\n\n" - "Er steht auf der Rechnung und ist die Grundlage für die " - "Übernachtungen, die ans Amt gemeldet werden.") - return - nummer = self.r_nummer.get().strip() - if not nummer: - self.melden_warnung("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.") - return - # Den 31.02. gibt es auch dann nicht, wenn Tag und Monat einzeln moeglich - # sind. Ein falsches Datum auf einer ausgestellten Rechnung laesst sich nur - # noch ueber eine Berichtigung geradeziehen - also hier abfangen. - kaputt = self._datumsfelder_pruefen() - if kaputt: - self.melden_warnung( - "Datum nicht lesbar", - "So kann die Rechnung nicht geschrieben werden:" + chr(10) + chr(10) + - "- " + (chr(10) + "- ").join(kaputt) + chr(10) + chr(10) + - "Erwartet wird ein Datum wie 12.08.2026.") - return - # Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung - # (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben. - maengel = self.kunde_pruefen() - if maengel: - self.melden_warnung( - "Kundenangaben fehlen", - "So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) + - "\n\nName und vollständige Anschrift des Kunden gehören auf jede " - "Rechnung (§ 14 Abs. 4 Nr. 1 UStG).") - return - # Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne - # (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer. - if not ziffern(self.k_strasse.get()): - if not self.frage( - "Hausnummer fehlt", - f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n" - "Trotzdem so auf die Rechnung schreiben?"): - return - - # Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht - # mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder - # eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum - # Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin - # schon, darum hier nur bei einer ganz normalen Rechnung. - # EIN Knopf - gewaehlt wird hier: Rechnung oder Proforma. Waehrend - # eines Stornos oder einer Berichtigung gibt es nichts zu waehlen. - if not self.storno_stand and not self.berichtigung_stand: - wer = " ".join(t for t in (self.k_vorname.get().strip(), - self.k_name.get().strip()) if t) - wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer) - if wahl is None: - return - angebot = wahl == "angebot" - if angebot: - # EIGENE Reihe: Nummernfeld und Rechnungszaehler bleiben - # unangetastet, die naechste Rechnung behaelt ihre Nummer. - a_jahr, a_zaehler, nummer = self._naechstes_angebot() - - # Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung. - # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder - # geloescht und es bleibt allein die urspruengliche Rechnung stehen. - stand = self.storno_stand - if stand: - if not self.frage( - "Storno wirklich durchführen?", - f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, " - f"und {nummer} wird die berichtigte Rechnung.\n\n" - "Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie " - "ihm zugegangen ist.\n\n" - "Ja = Storno behalten und berichtigte Rechnung erstellen\n" - f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}", - icon="warning", default="yes"): - self._storno_verwerfen("vom Benutzer verworfen") - self.melden_info( - "Storno verworfen", - f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.") - return - - kopf = self._kopf_basis(nummer, r) - if montage: - # Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung - # dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer. - kopf["vorlage"] = "montage" - kopf["montage_text"] = self._montage_text() - if angebot: - kopf["art"] = "angebot" - kopf["rechnungsnummer"] = nummer if stand: kopf["art"] = "korrektur" kopf["vorgang"] = stand.get("vorgang") or stand["zu"] @@ -1719,7 +545,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, kopf["vorgang"] = ber.get("vorgang") or ber["zu"] kopf["berichtigt_zu"] = ber["zu"] kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y") - if not self.frage( + if not messagebox.askyesno( "Berichtigte Rechnung erstellen?", f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes " "Blatt geschrieben.\n\n" @@ -1734,7 +560,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, pfad = os.path.join( out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}" f"_{safe_filename(ber['zu'])}.pdf") - if os.path.exists(pfad) and not self.frage( + if os.path.exists(pfad) and not messagebox.askyesno( "Berichtigung von heute ersetzen?", f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n" f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"): @@ -1742,25 +568,14 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, try: pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - self.melden_fehler("Fehler beim Erstellen", + messagebox.showerror("Fehler beim Erstellen", f"Berichtigung konnte nicht erstellt werden:\n{e}") print("[berichtigung] FEHLER:", e) return print(f"[berichtigung] erstellt -> {pfad}") - # Das Blatt verbraucht keine Nummer und bucht nichts - im Buch wird - # nur der berichtigte Name der Rechnung nachgezogen. Wuerde man hier - # den ganzen Eintrag schreiben, stuenden dort die Nullen des Blattes. - if self.buch is not None: - try: - self.buch.eintragen(ber["zu"], - nachname=kopf.get("nachname", ""), - vorname=kopf.get("vorname", ""), - berichtigt_zu=ber["zu"]) - except Exception as e: # noqa: BLE001 - print("[nummernbuch] Berichtigung nicht eingetragen:", e) self.berichtigung_stand = None self._storno_anzeigen() - if self.frage("Fertig", + if messagebox.askyesno("Fertig", f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n" "Jetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) @@ -1772,21 +587,16 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, # Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede # Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer. vorhanden = vorhandene_rechnungen(out_dir, nummer) - # Auch das gemeinsame Nummernbuch zaehlt als vergeben: dort stehen die - # Nummern des Steuerjournals, zu denen es hier gar keine PDF gibt. - im_buch = nummer in self._belegte_nummern() - if vorhanden or im_buch: + if vorhanden: liste = "\n".join("- " + os.path.basename(x) for x in vorhanden) - if im_buch and not vorhanden: - liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)" - frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) + frei = naechste_freie_nummer(out_dir, self.cfg) if not frei: - self.melden_fehler( + messagebox.showerror( "Rechnungsnummer schon vergeben", f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" "Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.") return - if not self.frage( + if not messagebox.askyesno( "Rechnungsnummer schon vergeben", f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" "Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben " @@ -1798,102 +608,24 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, self.r_nummer.set(nummer) kopf["rechnungsnummer"] = nummer - # ---- Freigabe durch den Benutzer, BEVOR ein Beleg entsteht -------------- - # Ein Beleg ist kein Ausdruck, den man wegwerfen kann: die Nummer ist - # danach verbraucht (§ 14 Abs. 4 Nr. 4 UStG), das Blatt ist aufzubewahren - # (§ 147 Abs. 3 AO) und darf nicht mehr geaendert werden (§ 146 Abs. 4 AO). - # Deshalb wird einmal ausdruecklich gefragt, was gleich entsteht. - # - # Storno/Korrektur und Berichtigung haben ihre eigene Rueckfrage weiter - # oben - die wird nicht verdoppelt (ein Schritt pro Fenster). - if not stand and not ber: - if angebot: - titel = "Angebot jetzt erstellen?" - text = (f"Das Angebot {nummer} ist unverbindlich und ist KEINE Rechnung.\n\n" - "Es weist keine Steuer aus, verbraucht keine " - "Rechnungsnummer und wird nirgends gebucht.\n\n" - "Jetzt erstellen?") - else: - titel = "Rechnung jetzt erstellen?" - text = (f"Die Rechnung {nummer} wird als Rechnung im Sinne des " - "§ 14 UStG ausgestellt.\n\n" - "Die Nummer ist damit vergeben und darf kein zweites Mal " - "verwendet werden (§ 14 Abs. 4 Nr. 4 UStG). Das Blatt ist " - "zehn Jahre aufzubewahren (§ 147 Abs. 3 AO) und darf " - "nachträglich nicht geändert werden (§ 146 Abs. 4 AO). " - "Eine falsche Rechnung wird nicht überschrieben, sondern " - "storniert.\n\n" - "Jetzt erstellen?") - print(f"[freigabe] frage: {titel} (Nummer {nummer})") - if not self.frage(titel, text, default="yes"): - print(f"[freigabe] abgelehnt - kein Beleg, Nummer {nummer} bleibt frei") - return - kopf["freigabe"] = datetime.datetime.now().isoformat(timespec="seconds") - print(f"[freigabe] erteilt fuer {nummer} um {kopf["freigabe"]}") - - if angebot: - name = f"Angebot_{safe_filename(nummer)}" - if self.k_name.get().strip(): - name += "_" + safe_filename(self.k_name.get().strip()) - pfad = os.path.join(self._angebot_ordner(), name + ".pdf") - try: - pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) - except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - self.melden_fehler( - "Fehler beim Erstellen", - f"Angebot konnte nicht erstellt werden:\n{e}") - print("[angebot] FEHLER:", e) - return - # NUR der Angebots-Zaehler wandert weiter. Rechnungsnummer, deren - # Zaehler, das gemeinsame Nummernbuch und die Meldung ans - # Steuerjournal bleiben unberuehrt - gebucht wird ein Angebot - # nirgends. - self.cfg["angebot_jahr"] = a_jahr - self.cfg["angebot_zaehler"] = a_zaehler - try: - config.save_config(self.cfg) - except OSError as e: # noqa: BLE001 - print("[angebot] Zaehler nicht gespeichert:", e) - print(f"[angebot] erstellt -> {pfad}") - if self.frage( - "Fertig", f"Angebot erstellt:\n{pfad}\n\n" - "Jetzt öffnen (zum Drucken)?"): - self._oeffne_datei(pfad) - return - - # Der Dateiname faengt weiter mit "Rechnung_" an - daran erkennt - # bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das - # "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht. fname = f"Rechnung_{safe_filename(nummer)}" if self.k_name.get().strip(): fname += "_" + safe_filename(self.k_name.get().strip()) - if montage: - fname += "_Montage" pfad = os.path.join(out_dir, fname + ".pdf") try: - # EIN Blatt: die Rechnung fuer den Gast. Ein zweites Blatt "fuers Amt" - # gibt es seit dem 05.09.2026 nicht mehr - siehe pdf_renderer.render_rechnung. + # Ein PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - self.melden_fehler("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}") + messagebox.showerror("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}") print("[pdf] FEHLER:", e) return - # Ins gemeinsame Nummernbuch, damit das Steuerjournal die Nummer kennt - - # auch wenn es die PDF (noch) nicht eingelesen hat. - self._ins_buch(nummer, kopf, r, pfad) - self._buch_anzeigen() - self._journal_benachrichtigen(f"Rechnung {nummer} erstellt") - # Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN self._zaehler_aus_nummer_speichern(nummer) print(f"[pdf] erstellt -> {pfad}") - # Kein Seitenhinweis mehr: die Rechnung ist EIN Blatt. Der frühere Zusatz - # "Seite 2 = Beherbergungssteuer fürs Amt" beschrieb ein Blatt, das es - # seit dem 05.09.2026 nicht mehr gibt. - hinweis = "" + hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else "" storno_pfad = None if stand: # Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen. @@ -1903,7 +635,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} " f"({os.path.basename(storno_pfad)}).\n" "Beide Blätter gehören zum Gast.") + hinweis - if self.frage("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): + if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) if storno_pfad: self._oeffne_datei(storno_pfad) @@ -1928,7 +660,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben # Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben # ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt. - self.melden_warnung( + messagebox.showwarning( "Rechnungsnummer nicht gespeichert", f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n" f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, " @@ -1938,7 +670,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, def _beenden(self): """Schliessen - mit Rückfrage, wenn ein Storno noch offen ist.""" if self.storno_stand: - if not self.frage( + if not messagebox.askyesno( "Storno ist noch offen", f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte " @@ -1946,17 +678,13 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, "Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, " "warum.\n\nTrotzdem beenden?", icon="warning", default="no"): return - self.api.stoppen(self.buch) self.destroy() def reset_formular(self): - # Der Montage-Reiter hat eine eigene Eingabe - die gehoert mit zurueck. - if hasattr(self, "m_monate"): - self.m_monate.set("1") # Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die # neu eingetippten Daten plötzlich zum Storno der fremden Rechnung. if self.storno_stand: - if not self.frage( + if not messagebox.askyesno( "Storno verwerfen?", f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"{self.storno_stand['zu']} liegt noch bereit.\n\n" @@ -1986,16 +714,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, def _oeffne_datei(self, pfad): - """Datei oder Ordner im Betrachter oeffnen. Im Pruflauf: NICHT. - - Ein Pruefstand schreibt Dutzende Rechnungen. Wuerde jede davon den - PDF-Betrachter aufmachen, hat der Mensch am Rechner am Ende dreissig - Fenster offen, die er selbst wegklicken darf. Gemeldet wird es trotzdem, - damit im Protokoll steht, was zu sehen gewesen waere. - """ - if config.testlauf(): - print(f"[oeffnen] im Pruflauf nicht geoeffnet: {pfad}") - return False try: if sys.platform.startswith("win"): os.startfile(pfad) # noqa: S606 @@ -2004,7 +722,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin, else: os.system(f'xdg-open "{pfad}"') except Exception as e: # noqa: BLE001 - self.melden_info("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") + messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") if __name__ == "__main__": @@ -2015,10 +733,6 @@ if __name__ == "__main__": if not fenster_nach_vorn(laufende_pid_lesen()): _r = tk.Tk() _r.withdraw() - # 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 diff --git a/assets/fonts/Roboto-Italic.ttf b/assets/fonts/Roboto-Italic.ttf deleted file mode 100644 index a122c13..0000000 Binary files a/assets/fonts/Roboto-Italic.ttf and /dev/null differ diff --git a/assets/fonts/Roboto.ttf b/assets/fonts/Roboto.ttf deleted file mode 100644 index 5522a36..0000000 Binary files a/assets/fonts/Roboto.ttf and /dev/null differ diff --git a/assets/icons/check.png b/assets/icons/check.png deleted file mode 100644 index 7dcb1a5..0000000 Binary files a/assets/icons/check.png and /dev/null differ diff --git a/assets/icons/close.png b/assets/icons/close.png deleted file mode 100644 index ca051d4..0000000 Binary files a/assets/icons/close.png and /dev/null differ diff --git a/assets/icons/dashboard_customize.png b/assets/icons/dashboard_customize.png deleted file mode 100644 index 80f2a65..0000000 Binary files a/assets/icons/dashboard_customize.png and /dev/null differ diff --git a/assets/icons/drag_indicator.png b/assets/icons/drag_indicator.png deleted file mode 100644 index 1b060c1..0000000 Binary files a/assets/icons/drag_indicator.png and /dev/null differ diff --git a/assets/icons/save.png b/assets/icons/save.png deleted file mode 100644 index bffa440..0000000 Binary files a/assets/icons/save.png and /dev/null differ diff --git a/assets/programm.ico b/assets/programm.ico deleted file mode 100644 index f3778ff..0000000 Binary files a/assets/programm.ico and /dev/null differ diff --git a/assets/programm_logo.png b/assets/programm_logo.png deleted file mode 100644 index 6086c87..0000000 Binary files a/assets/programm_logo.png and /dev/null differ diff --git a/assets/programm_logo.svg b/assets/programm_logo.svg deleted file mode 100644 index f16f814..0000000 --- a/assets/programm_logo.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/bauen.py b/bauen.py deleted file mode 100644 index f3aa6dc..0000000 --- a/bauen.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- 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", "release"): - shutil.rmtree(os.path.join(HIER, ordner), ignore_errors=True) - - # Das Windows-Symbol aus dem Logo bauen: helle Kachel mit runden Ecken, - # Zeichen darauf, in allen Groessen von 16 bis 256. Es wird HIER erzeugt - # und nicht zur Laufzeit - ein installiertes Programm soll nicht in seinen - # eigenen Ordner schreiben muessen. - sys.path.insert(0, HIER) - import wasserzeichen - ico = wasserzeichen.symbol_bauen( - os.path.join(HIER, "assets", "programm.ico")) - print(f"--- Symbol: {ico or 'FEHLT'}") - - 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") - - # ---- release/ : genau das, was ins Release gehoert --------------------- - # Der Anhang, den der Updater sucht, heisst OHNE Nummer - so steht es in - # version.EXE_NAME, und danach sucht passendes_release(). Haengt man ihn - # mit Nummer an, findet der Updater nie ein Update und meldet stumm - # "aktuell". Die Nummer kommt erst beim Ablegen dazu (updater.dateiname). - # - # Daneben die Pruefsumme als eigene Datei: ohne sie prueft der Updater nur - # die GROESSE. Eine Datei, die unterwegs verfaelscht wurde, hat aber genau - # die richtige Groesse. - freigabe = os.path.join(HIER, "release") - os.makedirs(freigabe, exist_ok=True) - shutil.copy2(ziel, os.path.join(freigabe, version.EXE_NAME)) - with open(os.path.join(freigabe, version.EXE_NAME + ".sha256"), - "w", encoding="ascii") as f: - f.write(summe(ziel) + chr(10)) - shutil.copy2(os.path.join(HIER, "dist", "Updater.exe"), - os.path.join(freigabe, "Updater.exe")) - for name in os.listdir(os.path.join(HIER, "setup")): - shutil.copy2(os.path.join(HIER, "setup", name), - os.path.join(freigabe, name)) - - print() - print(f"FERTIG | {stamm} {version.VERSION}") - print() - print(" release/ - genau diese Dateien gehoeren ans Release:") - for name in sorted(os.listdir(freigabe)): - p = os.path.join(freigabe, name) - print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}") - - -if __name__ == "__main__": - main() diff --git a/bestand.py b/bestand.py index abfd31e..baac192 100644 --- a/bestand.py +++ b/bestand.py @@ -8,7 +8,6 @@ Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen. import datetime import json import os -import re import config from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename @@ -169,37 +168,14 @@ def rechnungen_im_ordner(ordner): gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden -def hoechster_zaehler_im_ordner(ordner, jahr): - """Groesster Zaehler dieses Jahres, den die PDFs im Ordner hergeben. - - Gelesen wird der DATEINAME (Rechnung_2026-442_..., Storno_2026-443_...) - - das geht ohne jede PDF zu oeffnen und reicht: das Programm schreibt die - Nummer immer in den Namen. Was jemand von Hand umbenannt hat, faellt hier - durch; dafuer gibt es das gemeinsame Nummernbuch als zweite Quelle. - """ - hoch = 0 - muster = re.compile(rf"(?:Rechnung|Storno)_{int(jahr)}-(\d+)", re.IGNORECASE) - try: - namen = os.listdir(ordner) - except OSError: - return 0 - for name in namen: - if not name.lower().endswith(".pdf"): - continue - m = muster.search(name) - if m: - hoch = max(hoch, int(m.group(1))) - return hoch - - -def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None): +def naechste_freie_nummer_nach(ordner, cfg, vergeben): """Erste freie Nummer NACH - fuer die Rechnung, die auf ein Storno folgt. Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch einmal. """ - kandidat = naechste_freie_nummer(ordner, cfg, belegt) + kandidat = naechste_freie_nummer(ordner, cfg) if kandidat and kandidat != vergeben: return kandidat ersatz = dict(cfg) @@ -208,21 +184,15 @@ def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None): ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z) except (ValueError, AttributeError): return kandidat - return naechste_freie_nummer(ordner, ersatz, belegt) + return naechste_freie_nummer(ordner, ersatz) -def naechste_freie_nummer(ordner, cfg, belegt=None): - """Erste Nummer JJJJ-NNN, die weder im Ordner noch im Nummernbuch steht. +def naechste_freie_nummer(ordner, cfg): + """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind. - - `belegt` sind zusaetzlich vergebene Nummern - die aus dem gemeinsamen - Nummernbuch. Ohne sie kennt das Rechnungstool nur die eigenen PDFs und - vergibt eine Nummer aus dem Steuerjournal (Altbestand aus der Excel-Mappe, - zu dem es keine PDF gibt) ein zweites Mal. """ - belegt = set(belegt or ()) jahr = cfg.get("rechnung_jahr", datetime.date.today().year) z = cfg.get("rechnung_zaehler", 0) # Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends @@ -233,6 +203,6 @@ def naechste_freie_nummer(ordner, cfg, belegt=None): for _ in range(1000): z += 1 kandidat = config.format_rechnungsnummer(jahr, z) - if kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat): + if not vorhandene_rechnungen(ordner, kandidat): return kandidat return None diff --git a/bilder.py b/bilder.py deleted file mode 100644 index 535eed5..0000000 --- a/bilder.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -"""Bilder fuer Kopf- und Fusszeile der Rechnung: ablegen, wiederfinden, laden. - -Warum eine eigene Datei: Kopfbild und Fussbild brauchen dieselben drei Schritte -(uebernehmen -> aufloesen -> laden), und der PDF-Renderer, der Einstellungsdialog -und die Pruefstaende brauchen sie alle. Stuende die Logik im Renderer, muesste der -Dialog sie nachbauen - und beim naechsten Bild laufen die beiden auseinander. - -⭐ Der wichtigste Teil ist `uebernehmen`: das gewaehlte Bild wird in den Ordner -`bilder/` neben der config.json KOPIERT, statt nur seinen Pfad zu merken. Sonst -zeigt die Einstellung auf den Desktop des Nutzers - und sobald er dort aufraeumt, -faellt das Logo still von der Rechnung. Gemerkt wird nur der kurze Name -("bilder/logo.png"), damit die Einstellung auch nach einem Umzug des Ordners passt. -""" -import os -import shutil - -import config - -# Erlaubte Formate. reportlab liest PNG/JPG/GIF direkt; SVG kann es nicht - -# deshalb steht es hier bewusst NICHT. -FORMATE = (".png", ".jpg", ".jpeg", ".gif") -ORDNER = "bilder" - - -def bilder_dir(): - """Ordner fuer die uebernommenen Bilder (neben der config.json). Wird angelegt.""" - d = os.path.join(config.BASE_DIR, ORDNER) - os.makedirs(d, exist_ok=True) - return d - - -def uebernehmen(quelle): - """Bild in den Bilderordner kopieren -> gibt den zu speichernden Pfad zurueck. - - Liegt die Datei schon im Bilderordner, wird nichts kopiert. Ein Name, der - bereits vergeben ist, bekommt eine Nummer angehaengt - ein bestehendes Bild - wird NIE ueberschrieben, sonst waere das alte Logo mit einem Klick weg. - """ - quelle = (quelle or "").strip() - if not quelle: - return "" - if not os.path.exists(quelle): - print(f"[bilder] Quelle gibt es nicht: {quelle}") - return "" - ziel_dir = bilder_dir() - name = os.path.basename(quelle) - if os.path.dirname(os.path.abspath(quelle)) == os.path.abspath(ziel_dir): - return f"{ORDNER}/{name}" - stamm, endung = os.path.splitext(name) - ziel = os.path.join(ziel_dir, name) - n = 2 - while os.path.exists(ziel) and not _gleich(quelle, ziel): - ziel = os.path.join(ziel_dir, f"{stamm}-{n}{endung}") - n += 1 - try: - shutil.copyfile(quelle, ziel) - print(f"[bilder] uebernommen: {quelle} -> {ziel}") - except OSError as e: - print(f"[bilder] FEHLER beim Kopieren ({e}) - merke den Originalpfad") - return quelle - return f"{ORDNER}/{os.path.basename(ziel)}" - - -def _gleich(a, b): - """Zwei Dateien byte-gleich? Verhindert 'logo-2.png' beim zweiten Waehlen desselben Bilds.""" - try: - if os.path.getsize(a) != os.path.getsize(b): - return False - with open(a, "rb") as fa, open(b, "rb") as fb: - return fa.read() == fb.read() - except OSError: - return False - - -def aufloesen(pfad): - """Gespeicherten Pfad -> existierender absoluter Pfad, sonst "". - - Ein relativer Pfad wird erst neben dem Programm gesucht (BASE_DIR, dort liegt - der Bilderordner), dann im gebuendelten Ressourcenordner der .exe - so findet - die exe ihr mitgeliefertes Standardlogo, der Nutzer aber sein eigenes. - """ - pfad = (pfad or "").strip() - if not pfad: - return "" - if os.path.isabs(pfad): - return pfad if os.path.exists(pfad) else "" - for basis in (config.BASE_DIR, config.RESOURCE_DIR): - kandidat = os.path.join(basis, pfad) - if os.path.exists(kandidat): - return kandidat - return "" - - -def laden(pfad): - """(ImageReader, breite, hoehe) oder (None, 0, 0). Meldet, warum es nicht ging.""" - voll = aufloesen(pfad) - if not voll: - if (pfad or "").strip(): - print(f"[bilder] nicht gefunden: {pfad}") - return None, 0, 0 - try: - from reportlab.lib.utils import ImageReader - img = ImageReader(voll) - iw, ih = img.getSize() - return img, iw, ih - except Exception as e: # noqa: BLE001 - print(f"[bilder] '{voll}' laesst sich nicht lesen: {e}") - return None, 0, 0 - - -def einpassen(iw, ih, box_w, box_h): - """Groesse im Kasten, Seitenverhaeltnis bleibt. Nie groesser als das Original.""" - if not iw or not ih: - return 0, 0 - skala = min(box_w / iw, box_h / ih) - return iw * skala, ih * skala diff --git a/config.py b/config.py index 3ae12a8..218280d 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Konfiguration fuer das Rechnungstool (powered by ravokk). +Konfiguration fuer das Rechnungstool Campinghof Bartl. Haelt die Standardwerte (Firmendaten, Steuersaetze, Leistungskatalog, Rechnungsnummer-Zaehler) und kuemmert sich um Laden/Speichern der config.json. @@ -42,15 +42,9 @@ def _daten_ordner(): Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern. - 1. der Ordner der Firma: %LOCALAPPDATA%/ravokk - 2. geht das nicht, neben der EXE ein Unterordner "Daten" - 3. und zuletzt der alte eigene Ordner unter LOCALAPPDATA - - Warum unter "ravokk" (07.09.2026): dort liegen beide Programme und das - gemeinsame Nummernbuch beieinander - seine Ansage, *"ravokk ist meine - firma und die programme gehoeren zu der firma"*. Und weil die Daten damit - NEBEN dem Programmordner liegen statt darin, raeumt ein Deinstallieren nur - das Programm weg und laesst die Belege stehen (§ 147 AO). + 1. neben der EXE ein Unterordner "Daten" + 2. geht das nicht (EXE liegt z.B. unter "Programme"), dann + der Ordner Rechnungstool unter LOCALAPPDATA Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt @@ -58,68 +52,25 @@ def _daten_ordner(): """ if not getattr(sys, "frozen", False): return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist - import gemeinsam - heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - # NEBEN dem Programm, nicht darin: das Programm liegt in - # ravokk/Rechnungstool, die Daten eine Ebene darueber in ravokk. Genau so - # hat er es beschrieben - "da liegt config und sqlite drin, und dann gibt - # es den ordner rechnungstool und steuertool". Nur dadurch kann ein - # Deinstallieren den Programmordner restlos leeren, ohne die Belege - # anzufassen (§ 147 AO). - # Gerechnet wird der Pfad nur an EINER Stelle - siehe dort. - kandidat = gemeinsam.standard_ordner() + kandidat = os.path.join(EXE_DIR, "Daten") if not _beschreibbar(kandidat): - kandidat = os.path.join(EXE_DIR, "Daten") + kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool") if not _beschreibbar(kandidat): - kandidat = os.path.join(heim, "Rechnungstool") - if not _beschreibbar(kandidat): - return EXE_DIR # letzter Ausweg: wie frueher - # Was an einem frueheren Ort liegt, zieht mit um - sonst faengt das - # Programm ohne Firmendaten, Katalog und Zaehler neu an. + return EXE_DIR # letzter Ausweg: wie frueher for name in ("config.json",): - neu_ = os.path.join(kandidat, name) - for alt in (os.path.join(EXE_DIR, "Daten", name), - os.path.join(EXE_DIR, name), - os.path.join(heim, "Rechnungstool", name)): - if os.path.exists(alt) and not os.path.exists(neu_): - try: - os.replace(alt, neu_) - print(f"[config] {name} aus {alt} übernommen") - break - except OSError as e: - # Nicht abbrechen - der naechste Ort wird noch probiert, - # und zur Not faengt das Programm eben neu an. Aber sagen, - # dass es schiefging: der Erfolgsfall schreibt eine Zeile, - # und wer sie sucht und nicht findet, wuesste sonst nicht, - # ob nichts da war oder ob die Uebernahme misslang. Es geht - # um Firmendaten, Leistungskatalog und Rechnungszaehler. - print(f"[config] {name} aus {alt} NICHT übernommen: {e}") + alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name) + if os.path.exists(alt) and not os.path.exists(neu_): + try: + os.replace(alt, neu_) + print(f"[config] {name} in den Datenordner übernommen") + except OSError: + pass return kandidat -# Das Programm heisst Rechnungstool und kommt von ravokk. Der Firmenname des -# KUNDEN gehoert nicht in den Fenstertitel - das Werkzeug steht bei jedem -# Betrieb, der es einsetzt, und traegt seinen eigenen Namen. -PROGRAMM = "Rechnungstool" -MARKE = "powered by ravokk" - BASE_DIR = _daten_ordner() CONFIG_PATH = os.path.join(BASE_DIR, "config.json") -# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es -# aber nicht: kein Fenster mitten auf dem Bildschirm, kein Dialog, der Maus und -# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet. -# -# ⚠️ Das ist eine FUNKTION, keine Konstante. Ein Pruefstand importiert config -# haeufig, bevor er CAMPINGHOF_TEST setzt - ein einmal berechneter Wert stuende -# dann fuer den ganzen Lauf auf False, und das Programm oeffnete echte Dialoge, -# auf die niemand klickt. Genau daran hing pruef_api eine Runde lang. -def testlauf(): - return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1" - - -TESTLAUF = testlauf() # Startwert; wer spaeter fragt, nimmt testlauf() - # Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl). # satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung) # preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt) @@ -145,18 +96,6 @@ DEFAULT_KATALOG = [ {"nr": 17, "leistung": "Gasfüllung 11 Kg (zuzüglich Pfand 35 €)", "satz": 19, "preis": 31.5, "pro_person": False}, ] -# Die Montage-Rechnung ist eine eigene Sorte Beleg (eigener Reiter im Fenster): -# ein Monteur mietet monatsweise, nicht naechteweise. Deshalb steht sie NICHT im -# Leistungskatalog - sie hat eine einzige Zeile mit einem Monatspreis, und im -# Katalog wuerde sie zwischen den Nachtpreisen nur Unheil stiften. -# preis = BRUTTO je Monat (wie im Katalog), die USt wird herausgerechnet. -# Die Beherbergungssteuer kommt wie ueberall obendrauf (500 + 5 % = 525). -DEFAULT_MONTAGE = { - "leistung": "Miete für Montagearbeiter", - "satz": 7, - "preis": 500.0, -} - # Firmen- und Fusszeilen-Daten – Platzhalter. # Die echten Daten stehen in der config.json neben dem Programm (nicht im Repo) # und werden im Menü "Einstellungen -> Firmendaten & Texte" gepflegt. @@ -173,11 +112,6 @@ DEFAULT_FIRMA = { "email": "E-Mail kontakt@example.de", "telefon": "Telefon +49 (0) 000 0000000", "logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner) - # Bild fuer die Fusszeile: ein schmaler Streifen UNTER den Fusstexten. Firma, - # Anschrift, Bank und Steuernummer bleiben immer gezeichnet - sie kommen aus - # den Einstellungen und duerfen nicht in einem Bild verschwinden, das niemand - # nachlesen kann. Leer = die Rechnung sieht aus wie bisher. - "fussbild": "", } # Festtexte unten auf der Rechnung (aus der Vorlage). @@ -189,27 +123,10 @@ DEFAULT_TEXTE = { "hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *", } -# Aussehen der Rechnung. EINE Farbe - die helle Flaeche im Tabellenkopf wird im -# Renderer daraus gemischt. Jede Firma faerbt ihr Blatt selbst; ein Handwerker -# will kein Camping-Gruen. -DEFAULT_DESIGN = { - "akzent": "#1f5c3d", -} - DEFAULT_CONFIG = { "firma": DEFAULT_FIRMA, - "design": DEFAULT_DESIGN, "texte": DEFAULT_TEXTE, "katalog": DEFAULT_KATALOG, - "montage": DEFAULT_MONTAGE, - # Das Angebot hat eine EIGENE Reihe (2026-A001): es darf keine - # Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung. - # Einmal beim ersten Start gesetzt (er kommt aus einer Excel-Tabelle mit - # laufender Nummer). Danach steht der Zaehler fest - geaendert wird eine - # Rechnung nur noch ueber Storno oder Berichtigung. - "nummer_eingerichtet": False, - "angebot_jahr": 0, - "angebot_zaehler": 0, # Alle Steuern als EINE benannte Liste. # - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet. # - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt. diff --git a/einstellungen.py b/einstellungen.py index f63155d..200aa7c 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -8,12 +8,9 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. import datetime import os import tkinter as tk -from tkinter import ttk, messagebox, filedialog, colorchooser +from tkinter import ttk, messagebox, filedialog import berechnung -import hinweis -import theme -import bilder import config from helfer import parse_num @@ -25,7 +22,7 @@ class EinstellungenMixin: if (self.cfg.get("output_dir") or "").strip(): return vorschlag = config.default_output_dir() - self.melden_info( + messagebox.showinfo( "Speicherort der Rechnungen", "Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n" f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)") @@ -42,7 +39,7 @@ class EinstellungenMixin: self.cfg["output_dir"] = d config.save_config(self.cfg) config.ensure_output_dir(self.cfg) - self.melden_info("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") + messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") def _info(self): d = tk.Toplevel(self) @@ -52,24 +49,13 @@ class EinstellungenMixin: self._style_dialog(d) frm = ttk.Frame(d, padding=18) frm.pack(fill="both", expand=True) - ttk.Label(frm, text=config.PROGRAMM, font=(theme.SCHRIFT_NAME, 11, "bold"), + ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), foreground=self.col_accent).pack(anchor="w") ttk.Label(frm, justify="left", text="Daten eingeben, PDF erstellen, drucken.\n" "Steuersätze, Preise und Firmendaten lassen sich\n" "im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12)) - # Der Erprobungshinweis steht hier dauerhaft - mit der Signaturzeile - # darunter. Sonst waere die Kenntnisnahme ein Klick, den spaeter - # niemand mehr nachlesen kann. - ttk.Separator(frm, orient="horizontal").pack(fill="x", pady=(4, 10)) - ttk.Label(frm, justify="left", wraplength=430, - text=hinweis.text("rechnungstool")).pack(anchor="w") - _sig = hinweis.signatur(self.cfg) - if _sig: - ttk.Label(frm, justify="left", wraplength=430, foreground=self.col_accent, - text=_sig).pack(anchor="w", pady=(10, 0)) - ttk.Separator(frm, orient="horizontal").pack(fill="x", pady=(10, 10)) - ttk.Label(frm, text=config.MARKE, foreground=self.col_muted).pack(anchor="w") + ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0)) d.bind("", lambda e: d.destroy()) d.bind("", lambda e: d.destroy()) @@ -87,7 +73,7 @@ class EinstellungenMixin: frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) - ttk.Label(frm, wraplength=580, justify="left", font=(theme.SCHRIFT_NAME, 9, "bold"), + ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses " "Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei " "'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den " @@ -105,7 +91,7 @@ class EinstellungenMixin: for w in liste.winfo_children(): w.destroy() for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]): - ttk.Label(liste, text=t, font=(theme.SCHRIFT_NAME, 9, "bold")).grid( + ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid( row=0, column=c, padx=4, pady=(0, 3), sticky="w") for i, row in enumerate(arbeit, start=1): ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2) @@ -133,7 +119,7 @@ class EinstellungenMixin: "bezeichnung": row["bez"].get().strip() or "USt", "extra_blatt": bool(row["extra"].get())}) if not neu: - self.melden_warnung("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) + messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) return self.cfg["steuersaetze"] = neu config.save_config(self.cfg) @@ -146,70 +132,6 @@ class EinstellungenMixin: ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") - def dlg_montage(self): - """Bezeichnung, Steuersatz und Monatspreis der Montage-Rechnung. - - Eigener Dialog und NICHT im Leistungskatalog: die Montage-Rechnung ist - eine Monatsmiete, im Katalog stuende sie zwischen lauter Nachtpreisen und - wuerde auf jeder Campingrechnung mit angeboten. - """ - d = tk.Toplevel(self) - d.title("Montage-Rechnung") - d.transient(self) - d.grab_set() - self._style_dialog(d) - - m = self.cfg.get("montage", {}) - v_leistung = tk.StringVar(value=str(m.get("leistung", ""))) - v_satz = tk.StringVar(value=str(int(parse_num(str(m.get("satz", 7)))))) - v_preis = tk.StringVar(value=berechnung.eur_kurz(m.get("preis", 0))) - - rahmen = ttk.Frame(d, padding=12) - rahmen.pack(fill="both", expand=True) - ttk.Label(rahmen, wraplength=520, justify="left", - text="So steht die eine Zeile auf dem Montage-Blatt. Der Preis ist ein " - "BRUTTO-Monatspreis wie im Leistungskatalog - die Umsatzsteuer wird " - "herausgerechnet, die Beherbergungssteuer kommt obendrauf.").grid( - row=0, column=0, columnspan=2, sticky="w", pady=(0, 10)) - - saetze = [s2 for s2 in self.cfg.get("steuersaetze", []) if not s2.get("extra_blatt")] - zeilen = [("Bezeichnung", v_leistung), ("Umsatzsteuer %", v_satz), - ("Monatspreis brutto (EUR)", v_preis)] - for i, (lbl, var) in enumerate(zeilen, start=1): - ttk.Label(rahmen, text=lbl + ":").grid(row=i, column=0, sticky="w", pady=3) - if var is v_satz: - w = ttk.Combobox(rahmen, textvariable=var, width=10, state="readonly", - values=[str(x.get("satz")) for x in saetze] or ["7", "19"]) - else: - w = ttk.Entry(rahmen, textvariable=var, width=44) - w.grid(row=i, column=1, sticky="ew", padx=(10, 0), pady=3) - rahmen.columnconfigure(1, weight=1) - - def speichern(): - name = v_leistung.get().strip() - preis = parse_num(v_preis.get()) - if not name: - self.melden_warnung("Bezeichnung fehlt", - "Bitte eine Bezeichnung eintragen.", parent=d) - return - if preis <= 0: - self.melden_warnung("Preis fehlt", - "Bitte einen Monatspreis groesser als 0 eintragen.", - parent=d) - return - self.cfg["montage"] = {"leistung": name, - "satz": int(parse_num(v_satz.get())), - "preis": preis} - config.save_config(self.cfg) - self.recalc() - d.destroy() - - knoepfe = ttk.Frame(rahmen) - knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="e", pady=(14, 0)) - ttk.Button(knoepfe, text="Abbrechen", command=d.destroy).pack(side="right", padx=4) - ttk.Button(knoepfe, text="Speichern", command=speichern).pack(side="right", padx=4) - d.bind("", lambda _e: d.destroy()) - def dlg_katalog(self): d = tk.Toplevel(self) d.title("Leistungskatalog / Preise") @@ -260,7 +182,7 @@ class EinstellungenMixin: for w in inner.winfo_children(): w.destroy() for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]): - ttk.Label(inner, text=t, font=(theme.SCHRIFT_NAME, 9, "bold")).grid( + ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid( row=0, column=c, padx=4, pady=(0, 4), sticky="w") for i, row in enumerate(arbeit, start=1): ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1) @@ -301,7 +223,7 @@ class EinstellungenMixin: "pro_person": bool(row["pro_person"].get()), }) if not neu: - self.melden_warnung("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) + messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) return self.cfg["katalog"] = neu config.save_config(self.cfg) @@ -347,10 +269,7 @@ class EinstellungenMixin: d.transient(self) d.grab_set() self._style_dialog(d) - breite = min(900, max(700, d.winfo_screenwidth() - 200)) - hoehe = min(760, max(560, d.winfo_screenheight() - 140)) - d.geometry(f"{breite}x{hoehe}") - d.minsize(700, 560) + d.geometry("640x640") frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) @@ -365,129 +284,57 @@ class EinstellungenMixin: for i, (key, lbl) in enumerate(felder): ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2) v = tk.StringVar(value=self.cfg["firma"].get(key, "")) - ttk.Entry(frm, textvariable=v, width=44).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) vars_[key] = v frm.columnconfigure(1, weight=1) base = len(felder) - # Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten). - # Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen - - # sonst laufen sie beim naechsten Umbau auseinander. - bild_vars = {} + # Logo (Bilddatei) mit Durchsuchen / Entfernen + ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) + logo_row = ttk.Frame(frm) + logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) + v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) + ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) - def bild_zeile(zeile, key, beschriftung): - ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2) - kasten = ttk.Frame(frm) - kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2) - v = tk.StringVar(value=self.cfg["firma"].get(key, "")) - reihe = ttk.Frame(kasten) - reihe.pack(fill="x") - ttk.Entry(reihe, textvariable=v).pack(side="left", fill="x", expand=True) + def logo_waehlen(): + p = filedialog.askopenfilename( + title="Logo auswählen", + filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) + if p: + v_logo.set(p) - def waehlen(): - p = filedialog.askopenfilename( - title=beschriftung + " auswählen", - filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")], - parent=d) - if p: - # Das Bild wird in den Ordner "bilder" neben das Programm - # KOPIERT. Der Pfad zum Desktop waere morgen tot - und das - # faellt erst beim Drucken auf. - v.set(bilder.uebernehmen(p)) + ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) + ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) - ttk.Button(reihe, text="Durchsuchen…", command=waehlen).pack(side="left", padx=(6, 0)) - ttk.Button(reihe, text="Entfernen", command=lambda: v.set("")).pack(side="left", padx=(4, 0)) - bild_vars[key] = v - - # Ausgetauscht wird nur das BILD. Firmenname, Anschrift, Bank und - # Steuernummer stehen weiter gezeichnet auf dem Blatt - sie kommen aus den - # Feldern darueber und duerfen in keinem Bild verschwinden. - bild_zeile(base, "logo", "Bild oben (Logo)") - bild_zeile(base + 1, "fussbild", "Bild unten (Streifen)") - - # Farbe der Rechnung: Ueberschriften, Linie unter dem Kopf, Tabellenkopf. - # EINE Farbe - die helle Flaeche wird im Renderer daraus gemischt. - ttk.Label(frm, text="Farbe der Rechnung").grid(row=base + 2, column=0, sticky="w", pady=2) - farb_reihe = ttk.Frame(frm) - farb_reihe.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) - v_farbe = tk.StringVar(value=(self.cfg.get("design", {}) or {}).get( - "akzent", config.DEFAULT_DESIGN["akzent"])) - probe = tk.Label(farb_reihe, width=6, relief="solid", borderwidth=1) - - def probe_zeigen(*_): - wert = v_farbe.get().strip() - try: - probe.configure(background=wert) - except tk.TclError: - # Unlesbare Farbe: das Feld bleibt grau, gespeichert wird sie nicht. - probe.configure(background=self.col_field) - - def farbe_waehlen(): - _, hexwert = colorchooser.askcolor(color=v_farbe.get().strip() or None, - title="Farbe der Rechnung", parent=d) - if hexwert: - v_farbe.set(hexwert) - - probe.pack(side="left") - ttk.Entry(farb_reihe, textvariable=v_farbe, width=12).pack(side="left", padx=(6, 0)) - ttk.Button(farb_reihe, text="Wählen…", command=farbe_waehlen).pack(side="left", padx=(6, 0)) - ttk.Button(farb_reihe, text="Zurücksetzen", - command=lambda: v_farbe.set(config.DEFAULT_DESIGN["akzent"])).pack(side="left", padx=(4, 0)) - v_farbe.trace_add("write", probe_zeigen) - probe_zeigen() - - ttk.Label(frm, text="Danktext 1").grid(row=base + 3, column=0, sticky="nw", pady=2) - t1 = tk.Text(frm, width=44, height=3, wrap="word", **self.feld_optionen(mehrzeilig=True)) + ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) + t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t1.insert("1.0", self.cfg["texte"].get("dank1", "")) - t1.grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Danktext 2").grid(row=base + 4, column=0, sticky="nw", pady=2) - t2 = tk.Text(frm, width=44, height=3, wrap="word", **self.feld_optionen(mehrzeilig=True)) + t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) + t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t2.insert("1.0", self.cfg["texte"].get("dank2", "")) - t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Hinweiszeile").grid(row=base + 5, column=0, sticky="w", pady=2) + t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) - ttk.Entry(frm, textvariable=vh, width=44).grid(row=base + 5, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) def speichern(): for key, v in vars_.items(): self.cfg["firma"][key] = v.get() - for key, v in bild_vars.items(): - wert = v.get() - self.cfg["firma"][key] = wert.strip() if isinstance(wert, str) else bool(wert) - self.cfg.setdefault("design", {})["akzent"] = v_farbe.get().strip() or config.DEFAULT_DESIGN["akzent"] + self.cfg["firma"]["logo"] = v_logo.get().strip() self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() self.cfg["texte"]["hinweis"] = vh.get().strip() config.save_config(self.cfg) - self.title(config.PROGRAMM) + self.title("Rechnungstool - " + self.cfg["firma"].get("name", "")) d.destroy() btns = ttk.Frame(frm) - btns.grid(row=base + 6, column=0, columnspan=2, pady=(12, 0), sticky="e") + btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") def dlg_nummer(self): - # Einmal eingerichtet, dann zu: eine ausgestellte Rechnung wird nur noch - # ueber Storno oder Berichtigung korrigiert (§ 14 Abs. 4 Nr. 4 UStG, - # § 146 Abs. 4 AO). Angezeigt wird der Stand trotzdem - man will ja - # nachsehen koennen. - if self.cfg.get("nummer_eingerichtet"): - jahr = self.cfg.get("rechnung_jahr") - zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0) - hoch = self.hoechster_stand(jahr) - self.melden_info( - "Rechnungsnummer", - f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n" - f"Die nächste Rechnung bekommt {jahr}-{max(zaehler, hoch) + 1:03d}.\n\n" - "Der Zähler wurde einmal eingerichtet und lässt sich nicht mehr ändern. " - "Eine ausgestellte Rechnung wird über „Storno“ oder „Berichtigen“ " - "korrigiert – jede Nummer darf es nur einmal geben " - "(§ 14 Abs. 4 Nr. 4 UStG).") - return - return self._dlg_nummer_einrichten() - - def _dlg_nummer_einrichten(self): d = tk.Toplevel(self) d.title("Rechnungsnummer") d.transient(self) @@ -501,39 +348,14 @@ class EinstellungenMixin: ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n" "vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.", justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) - hoch_jetzt = self.hoechster_stand() - ttk.Label(frm, foreground=self.col_muted, justify="left", - text=f"Schon vergeben ist bis {self.cfg.get('rechnung_jahr')}-" - f"{hoch_jetzt:03d} (aus den PDFs im Ordner und dem gemeinsamen\n" - "Nummernbuch). Dahinter zurück geht nicht – nach vorn schon.").grid( - row=4, column=0, columnspan=2, sticky="w", pady=(10, 0)) ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3) ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3) ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3) def speichern(): - jahr = int(parse_num(v_jahr.get())) - zaehler = int(parse_num(v_z.get())) - # Zurueck geht nicht: was vergeben ist, ist vergeben. Geprueft wird - # gegen die PDFs im Ordner UND das gemeinsame Nummernbuch - der - # Zaehler in der config allein waere ja gerade das, was hier - # geaendert wird. Vorwaerts ist erlaubt: eine lueckenlose Folge - # verlangt das Gesetz nicht, jede Nummer aber nur einmal - # (§ 14 Abs. 4 Nr. 4 UStG). - hoch = self.hoechster_stand(jahr) - if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch: - self.melden_warnung( - "Nummer schon vergeben", - f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n" - f"Der Zähler kann nicht auf {zaehler} zurückgesetzt werden – sonst " - "gäbe es eine Nummer zweimal, und jede Rechnungsnummer darf nur " - "einmal vergeben werden (§ 14 Abs. 4 Nr. 4 UStG).\n\n" - "Nach vorn ist er frei: Lücken sind erlaubt.", parent=d) - v_z.set(str(hoch)) - return - self.cfg["rechnung_jahr"] = jahr - self.cfg["rechnung_zaehler"] = zaehler + self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) + self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) config.save_config(self.cfg) self._neue_nummer_vorschlagen() d.destroy() diff --git a/einzelinstanz.py b/einzelinstanz.py index 3311efa..cdd10cc 100644 --- a/einzelinstanz.py +++ b/einzelinstanz.py @@ -41,10 +41,6 @@ def eigene_pid_merken(): with open(PID_DATEI, "w", encoding="ascii") as f: f.write(str(os.getpid())) except OSError: - # Der Merkzettel ist eine Bequemlichkeit, keine Bedingung: ohne ihn - # findet eine zweite Instanz das laufende Fenster nicht und meldet - # nur, dass das Programm schon laeuft. Daran darf der Start nicht - # scheitern - der Ordner kann schreibgeschuetzt sein. pass def laufende_pid_lesen(): """PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar.""" diff --git a/flaeche.py b/flaeche.py deleted file mode 100644 index fe850c1..0000000 --- a/flaeche.py +++ /dev/null @@ -1,308 +0,0 @@ -# -*- coding: utf-8 -*- -"""Arbeitsflaeche: die Kaesten des Fensters lassen sich verschieben. - -Seine Ansage: *"rechnungsdaten oben links und darunter rechnung an kunde und den -tabellen kasten rechts - oder noch besser dnd einbauen, das man die kaesten -selber schieben kann"*. Beides ist hier drin: die genannte Anordnung ist die -Vorgabe, und wer sie anders haben will, zieht die Kaesten um. - -WIE ES FUNKTIONIERT -------------------- -Wie bei OPNsense: normalerweise sitzt alles fest. Ein kleiner Knopf oben rechts -schaltet das ANORDNEN ein - dann zeigen die Kaesten einen Griff, lassen sich -ziehen, und daneben stehen "Speichern" und "Verwerfen". Erst mit Speichern -steht die neue Anordnung; Verwerfen holt die alte zurueck. - -So kann beim taeglichen Arbeiten nichts verrutschen - man muss das Verschieben -erst absichtlich einschalten. - -Die Flaeche ist ein Raster aus zwei Spalten. Jeder Kasten sitzt in einer Zelle -und kann sich ueber beide Zeilen strecken. Gezogen wird am KOPF des Kastens; -der Kasten unter dem Zeiger wird hervorgehoben, beim Loslassen tauschen die -beiden ihre Plaetze. - -Getauscht wird, nicht "irgendwo abgelegt": ein freies Verschieben auf Pixel -braucht Regeln fuer Ueberlappung, Groesse und leere Zellen - und am Ende steht -das Fenster schief. Ein Tausch ist in einem Griff erledigt und kann nichts -kaputt machen. - -Die Anordnung steht in der config (`fenster_layout`) und ueberlebt den Neustart. -""" -import tkinter as tk -from tkinter import ttk - -import config - -# Vorgabe: seine Anordnung. Je Kasten (Spalte, Zeile, Zeilen hoch). -STANDARD = { - "daten": (0, 0, 1), - "kunde": (0, 1, 1), - "leistungen": (1, 0, 2), -} - - -class Arbeitsflaeche(ttk.Frame): - # Wie breit die beiden Spalten mindestens sein muessen. Die Formularseite - # ist gemessen, nicht geschaetzt: das breiteste, was dort steht, ist die - # Beschriftung "Nachname / Firma *" neben einem 22 Zeichen breiten Feld. - MIN_FORMULAR = 330 - MIN_LISTE = 520 - - def __init__(self, master, farben=None, beim_umbauen=None): - super().__init__(master) - self.f = farben if farben is not None else master - self.beim_umbauen = beim_umbauen # wird nach jedem Tausch gerufen - self.kaesten = {} # name -> dict(rahmen, kopf, titel) - self.layout = {} - self._vorher = {} # Stand vor dem Anordnen - self.anordnen_an = False # Verschieben eingeschaltet? - self._zieht = None - self._ziel = None - # Die Gewichte setzt `anordnen()` - sie haengen davon ab, WO die lange - # Liste gerade steht. - - # ---- Aufbau ----------------------------------------------------------- - def kasten(self, name, titel): - """Legt einen Kasten an und gibt seinen Inhaltsrahmen zurueck.""" - aussen = ttk.Frame(self, style="Karte.TFrame") - kopf = tk.Label(aussen, text=titel.upper(), anchor="w", cursor="fleur", - bg=self.f.col_card, fg=self.f.col_accent, - font=(self._schrift(), 9, "bold"), padx=12, pady=8) - kopf.pack(side="top", fill="x") - inhalt = ttk.Frame(aussen, style="Karte.TFrame", padding=(12, 0, 12, 12)) - inhalt.pack(side="top", fill="both", expand=True) - - kopf.bind("", lambda e, n=name: self._greifen(n)) - kopf.bind("", lambda e, n=name: self._ziehen(e, n)) - kopf.bind("", lambda e, n=name: self._loslassen(n)) - self.kaesten[name] = {"aussen": aussen, "kopf": kopf, "inhalt": inhalt, - "titel": titel} - return inhalt - - def anordnen(self, layout=None): - """Kaesten ins Raster setzen. Ohne Angabe: was in der config steht.""" - self.layout = dict(layout or self._gespeichert()) - for name, kasten in self.kaesten.items(): - spalte, zeile, hoch = self.layout.get(name, STANDARD.get(name, (0, 0, 1))) - kasten["aussen"].grid(row=zeile, column=spalte, rowspan=hoch, - sticky="nsew", padx=6, pady=6) - self._platz_verteilen() - - def _platz_verteilen(self): - """Der freie Platz gehoert dem Kasten mit der langen Liste. - - Feste Gewichte an festen Zellen gehen nicht: die Liste wandert beim - Verschieben, und wer das Gewicht an einer festen Zelle laesst, - schneidet danach den Kundenkasten unten ab - genau das ist beim ersten - Versuch passiert. - - WARUM NICHT MEHR `uniform` (06.09.2026) - --------------------------------------- - Vorher standen beide Spalten in einer uniform-Gruppe mit den Gewichten - 4 und 3. Das teilt die Breite STRENG in diesem Verhaeltnis auf - bei - 1040 Pixeln also 594 zu 446, ganz gleich, was in den Kaesten steht. - Sein Befund: *"die namen und datums bloecke koennen schmaler werden - sind zu breit und verdrengen die posten block"*. Er hatte recht, aber - an den Feldbreiten zu drehen half nichts: die Spalte war so breit, weil - das Gewicht es vorschrieb, nicht weil der Inhalt es brauchte. - - Jetzt bekommen die Formularkaesten nur, was sie brauchen (feste - Mindestbreite, kein Gewicht), und der ganze uebrige Platz geht an die - Liste. Wird das Fenster groesser, waechst allein die Tabelle - genau - dort nuetzt die Breite, waehrend ein Adressfeld von 600 Pixeln nichts - besser macht. - """ - for z in range(2): - self.rowconfigure(z, weight=0, minsize=0) - for sp in range(2): - self.columnconfigure(sp, weight=0, uniform="", minsize=0) - lang = self.layout.get("leistungen") - if not lang: - for sp in range(2): - self.columnconfigure(sp, weight=1) - return - spalte, zeile, hoch = lang - for z in range(zeile, zeile + hoch): - self.rowconfigure(z, weight=1) - # Die Liste nimmt allen freien Platz ... - self.columnconfigure(spalte, weight=1, minsize=self.MIN_LISTE) - # ... die Formularseite genau so viel, wie ihr breitestes Feld braucht. - self.columnconfigure(1 - spalte, weight=0, minsize=self.MIN_FORMULAR) - # Steht die Liste nur in EINER Zeile, braucht die andere trotzdem Luft - # fuer ihren Inhalt - sonst wird sie zusammengequetscht. - if hoch == 1: - self.rowconfigure(1 - zeile, weight=1, minsize=170) - - def zuruecksetzen(self): - self.anordnen(STANDARD) - self._speichern() - self._koepfe_zeigen() - - # ---- Ziehen ----------------------------------------------------------- - # ---- Anordnen ein- und ausschalten ------------------------------------ - def anordnen_starten(self): - self._vorher = dict(self.layout) - self.anordnen_an = True - self._koepfe_zeigen() - - def anordnen_speichern(self): - self.anordnen_an = False - self._speichern() - self._koepfe_zeigen() - - def anordnen_verwerfen(self): - self.anordnen_an = False - if self._vorher: - self.anordnen(self._vorher) - self._koepfe_zeigen() - - def _koepfe_zeigen(self): - """Im Anordnen-Modus tragen die Koepfe einen Griff und einen Zeiger.""" - import icons - for kasten in self.kaesten.values(): - kopf = kasten["kopf"] - if self.anordnen_an: - bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self) - kopf.configure(image=bild, compound="left", cursor="fleur", - bg=self.f.col_sel) - kopf.bild = bild # sonst raeumt Python das Bild weg - else: - kopf.configure(image="", compound="none", cursor="", - bg=self.f.col_card) - kopf.bild = None - - def _greifen(self, name): - if not self.anordnen_an: - return - self._zieht = name - self.kaesten[name]["kopf"].configure(bg=self.f.col_sel) - # Der gegriffene Kasten wird blass - man sieht, WAS man in der Hand hat. - self.kaesten[name]["aussen"].configure(style="Gegriffen.TFrame") - self._etikett_zeigen(name) - - def _ziehen(self, ereignis, name): - if self._zieht is None or not self.anordnen_an: - return - self._etikett_bewegen() - ziel = self._kasten_unter(ereignis) - if ziel == self._ziel: - return - self._marke(self._ziel, False) - self._ziel = ziel if ziel != name else None - self._marke(self._ziel, True) - - def _loslassen(self, name): - if self._zieht is None: - return - self._etikett_weg() - self.kaesten[name]["aussen"].configure(style="Karte.TFrame") - self.kaesten[name]["kopf"].configure(bg=self.f.col_sel) - self._marke(self._ziel, False) - ziel, self._zieht, self._ziel = self._ziel, None, None - if ziel and ziel != name: - # Getauscht wird sofort sichtbar - gespeichert erst mit dem Knopf. - self.layout[name], self.layout[ziel] = self.layout[ziel], self.layout[name] - self.anordnen(self.layout) - self._koepfe_zeigen() - if self.beim_umbauen: - self.beim_umbauen() - - # ---- Das Etikett am Zeiger ------------------------------------------- - def _etikett_zeigen(self, name): - """Ein kleines Schild haengt am Zeiger, solange gezogen wird. - - Ohne das sieht man nur, WOHIN es geht, aber nicht, dass man ueberhaupt - etwas in der Hand hat - es wirkte "statisch". Ein Widget selbst kann in - Tk nicht mitwandern; ein randloses Fensterchen kann es. - """ - import icons - self._etikett_weg() - try: - schild = tk.Toplevel(self) - schild.overrideredirect(True) - schild.attributes("-topmost", True) - try: - schild.attributes("-alpha", 0.85) - except tk.TclError: - pass - rahmen = tk.Frame(schild, bg=self.f.col_accent, padx=1, pady=1) - rahmen.pack() - bild = icons.hole("drag_indicator", 16, self.f.col_accent, master=self) - inhalt = tk.Label(rahmen, text=" " + self.kaesten[name]["titel"], - image=bild, compound="left", - bg=self.f.col_card, fg=self.f.col_accent, - font=(self._schrift(), 10, "bold"), padx=12, pady=8) - inhalt.bild = bild - inhalt.pack() - self._etikett = schild - self._etikett_bewegen() - except tk.TclError as e: - print("[flaeche] Etikett nicht moeglich:", e) - self._etikett = None - - def _etikett_bewegen(self): - schild = getattr(self, "_etikett", None) - if schild is None: - return - try: - schild.geometry(f"+{self.winfo_pointerx() + 14}+{self.winfo_pointery() + 14}") - except tk.TclError: - self._etikett = None - - def _etikett_weg(self): - schild = getattr(self, "_etikett", None) - if schild is not None: - try: - schild.destroy() - except tk.TclError: - pass - self._etikett = None - - def _marke(self, name, an): - if not name: - return - self.kaesten[name]["kopf"].configure( - fg=self.f.col_accent_hell if an else self.f.col_accent) - self.kaesten[name]["aussen"].configure( - style="Ziel.TFrame" if an else "Karte.TFrame") - - def _kasten_unter(self, ereignis): - """Welcher Kasten liegt unter dem Zeiger?""" - x = self.winfo_pointerx() - self.winfo_rootx() - y = self.winfo_pointery() - self.winfo_rooty() - for name, kasten in self.kaesten.items(): - w = kasten["aussen"] - lx, ly = w.winfo_x(), w.winfo_y() - if lx <= x <= lx + w.winfo_width() and ly <= y <= ly + w.winfo_height(): - return name - return None - - # ---- Merken ----------------------------------------------------------- - def _schrift(self): - import theme - return theme.SCHRIFT_NAME - - def _gespeichert(self): - cfg = getattr(self.f, "cfg", {}) or {} - roh = cfg.get("fenster_layout") or {} - aus = {} - for name in self.kaesten: - wert = roh.get(name) - # Nur uebernehmen, was auch wirklich passt - eine von Hand - # verstellte config darf das Fenster nicht zerlegen. - if (isinstance(wert, (list, tuple)) and len(wert) == 3 - and all(isinstance(z, int) for z in wert)): - aus[name] = tuple(wert) - else: - aus[name] = STANDARD.get(name, (0, 0, 1)) - return aus - - def _speichern(self): - cfg = getattr(self.f, "cfg", None) - if cfg is None: - return - cfg["fenster_layout"] = {n: list(v) for n, v in self.layout.items()} - try: - config.save_config(cfg) - except OSError as e: - print("[flaeche] Anordnung nicht gespeichert:", e) diff --git a/gemeinsam.py b/gemeinsam.py deleted file mode 100644 index 7fc329e..0000000 --- a/gemeinsam.py +++ /dev/null @@ -1,399 +0,0 @@ -# -*- coding: utf-8 -*- -"""Das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal. - -WARUM ES DAS GIBT ------------------ -Bis hierher wusste jedes Programm nur, was in SEINEM Ordner lag. Das -Rechnungstool sucht die naechste freie Nummer in den PDFs im Rechnungsordner - -im Steuerjournal stehen aber auch Buchungen, zu denen es gar keine PDF gibt: -der Altbestand aus der Excel-Mappe und die frueher von Hand eingetragenen -Zeilen. Deren Nummern kannte das Rechnungstool nicht und hat sie ein zweites -Mal vergeben. Jede Rechnungsnummer darf es aber nur einmal geben -(Paragraph 14 Abs. 4 Nr. 4 UStG). - -Deshalb fuehren beide Programme ab jetzt EIN gemeinsames Buch: eine -SQLite-Datei in einem Ordner, den beide kennen. Wer eine Nummer vergibt oder -eine Buchung anlegt, traegt sie dort ein; wer eine neue Nummer sucht, schaut -zuerst hier nach. - -Die Datei ist ein REGISTER, keine zweite Buchhaltung: die Wahrheit bleiben die -Rechnungs-PDFs. Geht das Buch verloren, fehlt keine Rechnung - es muss nur neu -gefuellt werden (beide Programme tun das beim naechsten Einlesen von selbst). - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber - sonst schreiben zwei Fassungen in dieselbe Datei. -""" - -import logging -import os -import sqlite3 -import sys -import time - -log = logging.getLogger("gemeinsam") - -DATEINAME = "campinghof-nummernbuch.sqlite3" - -SCHEMA = """ -CREATE TABLE IF NOT EXISTS nummern ( - nummer TEXT PRIMARY KEY, -- '2026-442', immer die volle Nummer - jahr INTEGER NOT NULL DEFAULT 0, - datum TEXT NOT NULL DEFAULT '', -- ISO JJJJ-MM-TT - nachname TEXT NOT NULL DEFAULT '', - vorname TEXT NOT NULL DEFAULT '', - naechte INTEGER NOT NULL DEFAULT 0, - zwischensumme REAL NOT NULL DEFAULT 0, - steuer_satz REAL NOT NULL DEFAULT 0, - steuer_betrag REAL NOT NULL DEFAULT 0, - gesamt REAL NOT NULL DEFAULT 0, - art TEXT NOT NULL DEFAULT 'rechnung', -- rechnung|storno|korrektur|berichtigung - vorgang TEXT NOT NULL DEFAULT '', -- Klammer um Rechnung+Storno+Korrektur - storno_zu TEXT NOT NULL DEFAULT '', -- welche Rechnung hebt dieser Storno auf - ersetzt TEXT NOT NULL DEFAULT '', -- welche Rechnung ersetzt diese hier - berichtigt_zu TEXT NOT NULL DEFAULT '', -- zu welcher Rechnung gehoert das Blatt - vorlage TEXT NOT NULL DEFAULT '', -- '' oder 'montage' - quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal - herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell - pdf_pfad TEXT NOT NULL DEFAULT '', - freigabe TEXT NOT NULL DEFAULT '', -- ISO-Zeitstempel der - -- ausdruecklichen Freigabe - -- durch den Betreiber - geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel -); -CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr); - --- Wer gerade laeuft. Eine Zeile je Programm, mit PID und EXE-Namen: ob es --- wirklich noch laeuft, beantwortet Windows (prozess_laeuft), nicht ein --- Zeitstempel. Ein abgestuerztes Programm hinterlaesst hier nur eine --- Karteileiche, die beim naechsten Nachsehen weggeraeumt wird. -CREATE TABLE IF NOT EXISTS laeuft ( - programm TEXT PRIMARY KEY, -- 'journal' | 'rechnungstool' - pid INTEGER NOT NULL DEFAULT 0, - exe TEXT NOT NULL DEFAULT '', - port INTEGER NOT NULL DEFAULT 0, -- wo seine Mini-API lauscht (127.0.0.1) - seit TEXT NOT NULL DEFAULT '' -); - --- Der Briefkasten. Das Rechnungstool legt hier ab, dass es eine Rechnung --- geschrieben hat; das Journal liest die neuen Zeilen und liest die PDFs nach. -CREATE TABLE IF NOT EXISTS nachrichten ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - zeit TEXT NOT NULL DEFAULT '', - absender TEXT NOT NULL DEFAULT '', - text TEXT NOT NULL DEFAULT '' -); -""" - -FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme", - "steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu", - "ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad", - "freigabe") - - -def standard_ordner(): - """Wo das Nummernbuch liegt, wenn niemand etwas anderes einstellt. - - WARUM ES DIESEN FESTEN ORT GIBT (07.09.2026) - -------------------------------------------- - Vorher fragte jedes Programm beim ersten Start nach einem Ordner - und der - Nutzer musste in BEIDEN denselben eintragen. Sein Einwand, wortwoertlich: - - "wie soll das rechnungstool und steuertool wissen wo es ist, das muss - im ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an - das andere ran" - - Er hat recht: die beiden sind ueber genau zwei Dinge verbunden - diese - SQLite-Datei und die kleine API, die "hier ist etwas Neues" ruft. Wenn - schon der Ort geraten werden muss, ist die Verbindung nur so gut wie die - Aufmerksamkeit dessen, der zweimal denselben Pfad tippt. Ein falscher - Buchstabe faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal - vergeben wurde. - - Deshalb: ein fester Ort, den beide von selbst kennen. Gefragt wird nur - noch, wer ihn ausdruecklich verschieben will (Menue -> Gemeinsamer Ordner) - - etwa auf ein Netzlaufwerk, wenn die Programme auf zwei Rechnern laufen. - - LOCALAPPDATA und nicht "Dokumente": dort liegen die Programmdaten ohnehin, - und niemand raeumt den Ordner versehentlich auf. Das Buch ist ein - REGISTER, kein Beleg - geht es verloren, fehlt keine Rechnung, es wird beim - naechsten Einlesen neu gefuellt. - """ - heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - return os.path.join(heim, "ravokk") - - -def standard_anlegen(): - """Den festen Ordner anlegen und zurueckgeben - oder '' wenn das nicht geht.""" - ordner = standard_ordner() - try: - os.makedirs(ordner, exist_ok=True) - return ordner - except OSError as e: - log.warning("Gemeinsamer Ordner %s liess sich nicht anlegen: %s", ordner, e) - return "" - - -def pfad_zur_datei(ordner): - """Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner.""" - return os.path.join(ordner or "", DATEINAME) - - -def ordner_taugt(ordner): - """Kann in diesem Ordner wirklich gearbeitet werden? Wird ausprobiert, - nicht geraten - ein nicht verbundenes Netzlaufwerk sieht sonst gut aus, - bis die erste Rechnung geschrieben wird.""" - if not ordner or not os.path.isdir(ordner): - return False, "Den Ordner gibt es nicht." - probe = os.path.join(ordner, ".schreibprobe") - try: - with open(probe, "w", encoding="ascii") as f: - f.write("x") - os.remove(probe) - except OSError as e: - return False, f"In den Ordner kann nicht geschrieben werden ({e.strerror or e})." - return True, "" - - -# --------------------------------------------------------------------------- -# Briefkasten und Herzschlag: das Rechnungstool sagt dem Steuerjournal Bescheid -# -# Kein Programm durchsucht dauernd den Rechnungsordner. Stattdessen legt das -# LAUFENDE Steuerjournal einen Herzschlag im gemeinsamen Ordner ab, und das -# Rechnungstool schaut nach dem Schreiben einer Rechnung nur kurz hinein: laeuft -# es, wirft es eine Nachricht in den Briefkasten. Das Journal sieht genau EINE -# Datei nach (os.stat) und liest die Rechnung dann von selbst ein. -# -# Ist das Journal zu, passiert nichts - es liest den Ordner beim naechsten Start -# ohnehin komplett. Die Nachricht ist eine Anklopfhilfe, keine Datenquelle. -def prozess_laeuft(pid, name=""): - """Laeuft unter dieser Prozessnummer wirklich noch etwas? - - Die Antwort kommt von Windows, nicht aus einer Uhrzeit und nicht aus einer - Fenstersuche. PIDs werden wiederverwendet - deshalb wird zusaetzlich der - Name der EXE verglichen, wenn einer eingetragen wurde. Sonst haette ein - fremder Prozess mit derselben Nummer das andere Programm "am Leben" - gehalten. - """ - if not pid: - return False - if not sys.platform.startswith("win"): - try: - os.kill(int(pid), 0) - return True - except (OSError, ValueError): - return False - import ctypes - from ctypes import wintypes - PROCESS_QUERY_LIMITED = 0x1000 - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - k32.OpenProcess.restype = wintypes.HANDLE - griff = k32.OpenProcess(PROCESS_QUERY_LIMITED, False, int(pid)) - if not griff: - return False - try: - if not name: - return True - puffer = ctypes.create_unicode_buffer(4096) - laenge = wintypes.DWORD(len(puffer)) - if not k32.QueryFullProcessImageNameW(griff, 0, puffer, ctypes.byref(laenge)): - return False - return os.path.basename(puffer.value).lower() == str(name).lower() - finally: - k32.CloseHandle(griff) - - -def datei_stand(ordner): - """(Aenderungszeit, Groesse) der gemeinsamen Datei - oder None. - - Damit sieht ein Programm mit EINEM os.stat, ob sich im Buch etwas getan hat, - ohne dafuer die Datenbank zu oeffnen. - """ - try: - st = os.stat(pfad_zur_datei(ordner)) - except OSError: - return None - return st.st_mtime, st.st_size - - -def journal_laeuft(ordner): - """Ist das Steuerjournal gerade offen?""" - if not ordner or not os.path.exists(pfad_zur_datei(ordner)): - return False - try: - return Nummernbuch(ordner).laeuft("journal") - except Exception as e: # noqa: BLE001 - eine Auskunft darf nichts umwerfen - log.debug("Anmeldung nicht lesbar: %s", e) - return False - - -class Nummernbuch: - """Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder - geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand, - und auf einem Netzlaufwerk gibt das gesperrte Dateien.""" - - def __init__(self, ordner): - self.ordner = ordner - self.pfad = pfad_zur_datei(ordner) - - def _verbindung(self): - con = sqlite3.connect(self.pfad, timeout=10) - con.row_factory = sqlite3.Row - con.executescript(SCHEMA) - # Spalte nachziehen: CREATE TABLE IF NOT EXISTS aendert eine bestehende - # Tabelle nicht, und ein Buch aus der Fassung ohne API hat sie noch nicht. - spalten = {z["name"] for z in con.execute("PRAGMA table_info('laeuft')")} - if spalten and "port" not in spalten: - con.execute("ALTER TABLE laeuft ADD COLUMN port INTEGER NOT NULL DEFAULT 0") - # Ebenso die Freigabe-Spalte: ein Buch aus der Fassung vor dem 06.09.2026 - # kennt sie noch nicht. Alte Zeilen bleiben leer - dort wurde nie gefragt, - # und ein nachtraeglich erfundener Zeitstempel waere schlimmer als keiner. - spalten_n = {z["name"] for z in con.execute("PRAGMA table_info('nummern')")} - if spalten_n and "freigabe" not in spalten_n: - con.execute("ALTER TABLE nummern ADD COLUMN freigabe TEXT NOT NULL DEFAULT ''") - return con - - # ---- Lesen ------------------------------------------------------------- - def nummern(self, jahr=None): - """Alle vergebenen Nummern (als Menge), auf Wunsch nur eines Jahres.""" - with self._verbindung() as con: - if jahr: - zeilen = con.execute("SELECT nummer FROM nummern WHERE jahr=?", (int(jahr),)) - else: - zeilen = con.execute("SELECT nummer FROM nummern") - return {z["nummer"] for z in zeilen} - - def hoechster_zaehler(self, jahr): - """Groesster Zaehler dieses Jahres im Buch (0, wenn nichts drinsteht). - - Aus '2026-442' wird 442. Nummern anderer Reihen (Angebote 2026-A001) - zaehlen NICHT mit - die haben mit der Rechnungsnummer nichts zu tun. - """ - hoch = 0 - for nummer in self.nummern(jahr): - teil = str(nummer).split("-")[-1] - if teil.isdigit(): - hoch = max(hoch, int(teil)) - return hoch - - def eintrag(self, nummer): - with self._verbindung() as con: - z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone() - return dict(z) if z else None - - def alle(self): - with self._verbindung() as con: - return [dict(z) for z in con.execute("SELECT * FROM nummern ORDER BY nummer")] - - def belegt(self, nummer): - return self.eintrag(nummer) is not None - - # ---- Schreiben --------------------------------------------------------- - def eintragen(self, nummer, **werte): - """Nummer eintragen oder ihre Angaben auffrischen. - - Eine schon eingetragene Nummer wird NICHT als Fehler behandelt: beide - Programme sehen dieselbe Rechnung (das Rechnungstool beim Schreiben, - das Journal beim Einlesen der PDF) und sollen sich gegenseitig - ergaenzen duerfen. - """ - import datetime - nummer = str(nummer or "").strip() - if not nummer: - return False - daten = {f: werte.get(f) for f in FELDER if werte.get(f) is not None} - daten["geaendert"] = datetime.datetime.now().isoformat(timespec="seconds") - spalten = ["nummer"] + list(daten) - platzhalter = ",".join("?" * len(spalten)) - setzen = ",".join(f"{k}=excluded.{k}" for k in daten) - with self._verbindung() as con: - con.execute( - f"INSERT INTO nummern ({','.join(spalten)}) VALUES ({platzhalter}) " - f"ON CONFLICT(nummer) DO UPDATE SET {setzen}", - [nummer] + list(daten.values())) - log.info("Nummernbuch: %s eingetragen (%s)", nummer, daten.get("quelle", "")) - return True - - def storniert(self): - """Nummern, die durch einen Storno aufgehoben sind.""" - with self._verbindung() as con: - return {z["storno_zu"] for z in - con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")} - - # ---- Wer laeuft, und der Briefkasten ----------------------------------- - def anmelden(self, programm, pid, exe, port=0): - """Eintragen: dieses Programm ist offen - und wo seine API lauscht. - - Die Tabelle ist das Telefonbuch der beiden Programme: PID (laeuft es - ueberhaupt?) und Port (wo erreiche ich es?). - """ - import datetime - with self._verbindung() as con: - con.execute( - "INSERT INTO laeuft (programm, pid, exe, port, seit) VALUES (?,?,?,?,?) " - "ON CONFLICT(programm) DO UPDATE SET pid=excluded.pid, exe=excluded.exe, " - "port=excluded.port, seit=excluded.seit", - (str(programm), int(pid), str(exe), int(port or 0), - datetime.datetime.now().isoformat(timespec="seconds"))) - log.info("angemeldet: %s (PID %s, Port %s)", programm, pid, port) - - def wer_laeuft(self, programm): - """Telefonbuch-Eintrag eines laufenden Programms - oder None. - - Geprueft wird die PID (Windows antwortet), nicht ein Zeitstempel. Eine - Karteileiche wird gleich weggeraeumt. - """ - with self._verbindung() as con: - z = con.execute("SELECT programm, pid, exe, port FROM laeuft WHERE programm=?", - (str(programm),)).fetchone() - if not z: - return None - if not prozess_laeuft(z["pid"], z["exe"]): - self.abmelden(programm) - return None - return dict(z) - - def abmelden(self, programm): - with self._verbindung() as con: - con.execute("DELETE FROM laeuft WHERE programm=?", (str(programm),)) - - def laeuft(self, programm): - """Laeuft dieses Programm noch? Karteileichen werden weggeraeumt.""" - return self.wer_laeuft(programm) is not None - - def melden(self, absender, text): - """Eine Zeile in den Briefkasten legen.""" - import datetime - with self._verbindung() as con: - con.execute("INSERT INTO nachrichten (zeit, absender, text) VALUES (?,?,?)", - (datetime.datetime.now().isoformat(timespec="seconds"), - str(absender), str(text))) - log.info("Nachricht von %s: %s", absender, text) - return True - - def letzte_nachricht(self): - """Hoechste Nachrichten-Nummer (0, wenn der Kasten leer ist).""" - with self._verbindung() as con: - z = con.execute("SELECT MAX(id) AS m FROM nachrichten").fetchone() - return int(z["m"] or 0) - - def nachrichten_ab(self, seit_id): - """Alle Nachrichten NACH dieser Nummer: (Liste von Texten, neue Nummer).""" - with self._verbindung() as con: - zeilen = list(con.execute( - "SELECT id, absender, text FROM nachrichten WHERE id > ? ORDER BY id", - (int(seit_id or 0),))) - if not zeilen: - return [], int(seit_id or 0) - return [z["text"] for z in zeilen], int(zeilen[-1]["id"]) - - def aufraeumen(self, behalten=200): - """Alte Nachrichten wegwerfen - der Briefkasten ist kein Archiv.""" - with self._verbindung() as con: - con.execute("DELETE FROM nachrichten WHERE id <= " - "(SELECT MAX(id) FROM nachrichten) - ?", (int(behalten),)) - - def entfernen(self, nummer): - """Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck.""" - with self._verbindung() as con: - con.execute("DELETE FROM nummern WHERE nummer=?", (str(nummer),)) - log.info("Nummernbuch: %s entfernt", nummer) diff --git a/hinweis.py b/hinweis.py deleted file mode 100644 index 170b3ee..0000000 --- a/hinweis.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -"""Der Hinweis auf die Erprobungsfassung - und die Kenntnisnahme des Nutzers. - -WARUM ES DAS GIBT ------------------ -Beide Programme schreiben Belege, die steuerlich zaehlen. Solange sie erprobt -werden, muss der Betreiber wissen, woran er ist: dass es eine Erprobungsfassung -ist, dass fuer falsch ausgestellte Belege nicht gehaftet wird, und dass er jeden -Beleg vor dem Herausgeben SELBST ansehen muss - dafuer gibt es die Vorschau. - -Sein Wortlaut (06.09.2026): - - "bei jedem oeffnen, solange wir die testen, ein popup, dass dies eine - experimentelle software ist und ich keine haftung fuer irgendwelche falsch - ausgestellten uebernehme. der user muss selbst pruefen ueber den vorschau - button auf beiden tools, ob das alles seine richtigkeit hat. wenn nicht, - auf dem link eine issue erstellen. und das alles auch noch mal in die info - mit signatur: user hat dies gelesen und zur kenntnis genommen. wenn er nein - drueckt oder ablehnt, schliesst sich die software und geht beim naechsten - start wieder mit dem selben auf, bis er auf ja drueckt." - -WIE ES WIRKT ------------- -* Beim Start kommt der Hinweis, BEVOR gearbeitet werden kann. -* "Nein" beendet das Programm. Es wird nichts geschrieben, nichts gespeichert. -* "Ja" wird mit Benutzername, Zeitpunkt und Fassung in der Konfiguration - vermerkt - das ist die Signatur. Danach kommt der Hinweis nicht mehr. -* Aendert sich der TEXT (also FASSUNG), wird erneut gefragt. Eine Zustimmung - gilt fuer das, was dagestanden hat, nicht fuer alles Kuenftige. -* Unter "Info" steht derselbe Text dauerhaft, mit der Signaturzeile darunter. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber - sonst steht in zwei Programmen zweierlei. -""" - -import datetime -import getpass - -# Fassung des HINWEISTEXTES, nicht des Programms. Wird der Text geaendert, -# gehoert dieses Datum mitgeaendert - dann wird die Kenntnisnahme neu eingeholt. -FASSUNG = "2026-09-06" - -# Wo Fehler gemeldet werden. Je Programm eine eigene Adresse. -# -# Das frueher "Beherbergungssteuer" genannte Programm heisst seit dem -# 06.09.2026 STEUERRECHNUNGSTOOL - so wie sein Repository. Der lokale Ordner -# heisst noch beherbergungssteuer: der Pfad steht in mehreren Pruefstaenden und -# in den Spec-Dateien fest, das ist ein eigener Schritt. -ISSUES = { - "rechnungstool": "https://git.pcore.de/TheMockTv/rechnungstool", - "steuerrechnungstool": "https://git.pcore.de/TheMockTv/Steuerrechnungstool", -} - -TITEL = "Erprobungsfassung – bitte lesen" - - -def text(programm): - """Der Hinweistext. `programm` ist ein Schluessel aus ISSUES.""" - url = ISSUES.get(programm, "") - return ( - "Diese Software ist eine ERPROBUNGSFASSUNG.\n\n" - "Sie wird noch getestet. Für Belege, die falsch ausgestellt werden, " - "wird keine Haftung übernommen.\n\n" - "Prüfen Sie jeden Beleg selbst, bevor Sie ihn herausgeben oder " - "verbuchen. Nutzen Sie dafür die Vorschau – in diesem Programm und im " - "jeweils anderen. Stimmt etwas nicht, melden Sie es bitte als Fehler " - "unter:\n\n" - f" {url}\n\n" - "Mit „Ja\" bestätigen Sie, dass Sie diesen Hinweis gelesen und zur " - "Kenntnis genommen haben.\n" - "Mit „Nein\" wird das Programm beendet; der Hinweis erscheint beim " - "nächsten Start erneut." - ) - - -def _stand(cfg): - return (str(cfg.get("hinweis_fassung", "") or ""), - str(cfg.get("hinweis_zeit", "") or ""), - str(cfg.get("hinweis_benutzer", "") or "")) - - -def angenommen(cfg): - """Hat der Nutzer GENAU DIESE Fassung schon zur Kenntnis genommen?""" - fassung, zeit, benutzer = _stand(cfg) - return fassung == FASSUNG and bool(zeit) and bool(benutzer) - - -def vermerken(cfg): - """Die Kenntnisnahme festhalten. Gibt die Signaturzeile zurueck.""" - cfg["hinweis_fassung"] = FASSUNG - cfg["hinweis_zeit"] = datetime.datetime.now().isoformat(timespec="seconds") - try: - cfg["hinweis_benutzer"] = getpass.getuser() - except Exception: # noqa: BLE001 - ohne Benutzernamen ist es kein Grund zu scheitern - cfg["hinweis_benutzer"] = "unbekannt" - return signatur(cfg) - - -def signatur(cfg): - """Die Zeile fuer den Info-Dialog - oder '' , wenn nichts vorliegt.""" - fassung, zeit, benutzer = _stand(cfg) - if not (fassung and zeit and benutzer): - return "" - try: - wann = datetime.datetime.fromisoformat(zeit).strftime("%d.%m.%Y um %H:%M") - except ValueError: - wann = zeit - return (f"Gelesen und zur Kenntnis genommen von {benutzer} " - f"am {wann} (Fassung {fassung}).") diff --git a/icons.py b/icons.py deleted file mode 100644 index 3cd0c7b..0000000 --- a/icons.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- -"""Icons aus dem Hub-Vorrat (Material Symbols), eingefaerbt zur Laufzeit. - -Die SVGs liegen auf dem Hub (`M:/projekte/material-symbols`) und wurden einmal -nach PNG gewandelt - als weisse Silhouette mit Transparenz. Hier bekommen sie -ihre Farbe: dieselbe Datei taugt fuer die Akzentfarbe, fuer gedaempfte Schrift -und fuer das helle Thema, ohne dass jemand drei Fassungen pflegt. - -Ohne Pillow oder ohne Datei gibt es einfach kein Bild zurueck - der Aufrufer -zeigt dann seinen Text. Ein fehlendes Icon darf kein Fenster verhindern. -""" -import os -import tkinter as tk - -import config - -# ⚠️ Ein PhotoImage gehoert zu GENAU EINEM Tk-Fenster. Wird ein zweites -# aufgemacht (ein Pruefstand faehrt Journal und Rechnungstool nacheinander), -# zeigt ein weitergereichtes Bild ins Leere: 'image "pyimage1" doesn't exist'. -# Deshalb steht das Fenster mit im Schluessel. -_zwischenlager = {} # (fenster, name, groesse, farbe) -> PhotoImage - - -def _pfad(name): - for basis in (config.BASE_DIR, config.RESOURCE_DIR, - os.path.dirname(os.path.abspath(__file__))): - p = os.path.join(basis, "assets", "icons", name + ".png") - if os.path.exists(p): - return p - return "" - - -def hole(name, groesse=18, farbe="#e8e8ea", master=None): - """Icon als PhotoImage - oder None, wenn es nicht geht. - - `master` ist das Widget, in dem das Bild landet. Es MUSS mitgegeben werden, - wenn mehr als ein Tk-Fenster laeuft: `tk._default_root` zeigt dann auf das - zuerst geoeffnete, und das Bild gehoert dem falschen Fenster. - """ - fenster = master if master is not None else tk._default_root - if fenster is None: - return None - schluessel = (id(fenster), name, groesse, farbe) - if schluessel in _zwischenlager: - return _zwischenlager[schluessel] - pfad = _pfad(name) - if not pfad: - print(f"[icons] nicht gefunden: {name}") - return None - try: - from PIL import Image, ImageTk - bild = Image.open(pfad).convert("RGBA") - bild = bild.resize((groesse, groesse), Image.LANCZOS) - # Die Silhouette steckt im Alphakanal - die Farbe kommt hier drueber. - flaeche = Image.new("RGBA", bild.size, farbe) - flaeche.putalpha(bild.getchannel("A")) - fertig = ImageTk.PhotoImage(flaeche, master=fenster) - except Exception as e: # noqa: BLE001 - ein Icon darf nichts blockieren - print(f"[icons] {name} nicht ladbar: {e}") - return None - _zwischenlager[schluessel] = fertig - return fertig diff --git a/korrektur.py b/korrektur.py index 9b5f894..77038df 100644 --- a/korrektur.py +++ b/korrektur.py @@ -23,13 +23,11 @@ import tkinter as tk from tkinter import ttk, messagebox import berechnung -import theme import config import pdf_renderer -import berechnung from bestand import (kundendaten_ermitteln, naechste_freie_nummer, naechste_freie_nummer_nach, rechnungen_im_ordner) -from helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename +from helfer import kunde_maengel, nummer_normalisieren, safe_filename from storno import storno_rechnung_bauen @@ -69,7 +67,7 @@ class KorrekturMixin: self.r_datum.set(str(meta.get("datum") or self.r_datum.get())) self._storno_anzeigen() hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else "" - self.melden_info( + messagebox.showinfo( "Rechnung geladen", f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " @@ -93,7 +91,7 @@ class KorrekturMixin: # Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner, # der Zähler ist zurückgedreht und die Rechnung gilt als storniert, # obwohl der Nutzer sie behalten wollte. - self.melden_fehler( + messagebox.showerror( "Storno konnte nicht gelöscht werden", f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n" "Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen " @@ -104,17 +102,10 @@ class KorrekturMixin: try: config.save_config(self.cfg) except OSError as e: # noqa: BLE001 - self.melden_warnung( + messagebox.showwarning( "Zähler nicht gespeichert", f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n" "Bitte im Menü unter „Rechnungsnummer…“ prüfen.") - # Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst - # ueberspringt das Rechnungstool sie fuer immer. - if self.buch is not None and stand.get("nummer"): - try: - self.buch.entfernen(stand["nummer"]) - except Exception as e: # noqa: BLE001 - print("[nummernbuch] Storno nicht ausgetragen:", e) self.storno_stand = None self._storno_anzeigen() self._neue_nummer_vorschlagen() @@ -137,14 +128,14 @@ class KorrekturMixin: """ storno = modus == "storno" if self.storno_stand: - self.melden_info( + messagebox.showinfo( "Storno liegt schon bereit", f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"{self.storno_stand['zu']} ist noch offen.\n\n" "Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.") return if self.berichtigung_stand: - self.melden_info( + messagebox.showinfo( "Berichtigung läuft schon", f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen " "im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.") @@ -162,7 +153,7 @@ class KorrekturMixin: if d.get("art") not in ("storno", "berichtigung") and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] if not offen: - self.melden_warnung( + messagebox.showwarning( "Nichts zu korrigieren", f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.") return @@ -204,9 +195,9 @@ class KorrekturMixin: # wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit # "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist. self._auswahl = tk.Listbox(frm, height=4, exportselection=False, - selectbackground=self.col_sel, - selectforeground=self.col_fg, - font=("Consolas", 9), **self.feld_optionen(einfuegemarke=False)) + bg=self.col_field, fg=self.col_fg, + selectbackground=self.col_sel, selectforeground=self.col_fg, + relief="flat", font=("Consolas", 9)) self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0)) self._auswahl.grid_remove() self._kandidaten = [] @@ -304,239 +295,12 @@ class KorrekturMixin: frm.columnconfigure(1, weight=1) - @staticmethod - def _passt_zusammen(alt, neu): - """Passt die bleibende Rechnung inhaltlich zu der, die storniert wird? - - Die Nummer allein sagt nichts - sie koennte auf einen ganz anderen Gast - zeigen. Verglichen werden Name, Zeitraum und Betrag, und zwar getrennt, - damit die Meldung sagt, WAS abweicht. - - Gibt (schwer, leicht) zurueck: schwer = darf nicht durchgehen (anderer - Gast), leicht = Hinweise, die der Nutzer bestaetigen muss. - """ - def wort(x): - return " ".join(str(x or "").lower().split()) - - schwer, leicht = [], [] - if wort(alt.get("nachname")) != wort(neu.get("nachname")): - schwer.append(f"anderer Gast: {alt.get('nachname') or '?'} / " - f"{neu.get('nachname') or '?'}") - for feld, name in (("anreise", "Anreise"), ("abreise", "Abreise")): - a, b = wort(alt.get(feld)), wort(neu.get(feld)) - if a and b and a != b: - leicht.append(f"{name} {alt.get(feld)} / {neu.get(feld)}") - try: - ga, gb = float(alt.get("gesamt") or 0), float(neu.get("gesamt") or 0) - except (TypeError, ValueError): - ga = gb = 0.0 - if abs(ga - gb) > 0.005: - leicht.append(f"Betrag {berechnung.eur(ga)} / {berechnung.eur(gb)}") - return schwer, leicht - - def _storno_folge_waehlen(self, out_dir, alt_nummer, alt_meta=None): - """Nach der Frage WELCHE Rechnung kommt die Frage, wie es weitergeht. - - Zwei Schritte in einem Fenster, so wie er es beschrieben hat: erst A - oder B, und erst wenn B gedrueckt ist, kommt das Nummernfeld. - - A - es folgt eine neue Rechnung (der bisherige Weg) - B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN - C - nur Storno: es folgt nichts und es gibt keine andere Rechnung - - Sein Fall: mit dem alten Programm wurde bei einem Fehler einfach eine - NEUE Rechnung geschrieben. Jetzt liegt dieselbe Leistung mehrfach im - Ordner, und die Ueberzaehligen muessen auf die Rechnung verweisen, die - BLEIBT - sonst wartet der Gast auf eine Rechnung, die nie kommt. - - C kam am 04.09.2026 dazu (*"des wegen sollte man auch eine version c mit - only storno machen weil das gibt es auch"*): die Leistung wird gar nicht - abgerechnet - Buchung geplatzt, Rechnung komplett irrtuemlich. Ohne C - muesste man A waehlen und die versprochene neue Rechnung bliebe ewig - offen, oder B und muesste auf eine Rechnung verweisen, die es nicht gibt. - - Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | ("nur", None) - | None (Abbruch). - """ - # Im Testlauf klickt niemand: dann gilt der uebliche Weg. Der Pruefstand, - # der GENAU DIESES Fenster prueft, setzt _storno_dialog_zeigen. - from app import TESTLAUF - if TESTLAUF and not getattr(self, "_storno_dialog_zeigen", False): - return getattr(self, "_folgewahl", ("neu", None)) - - d = tk.Toplevel(self) - d.title("Wie geht es nach dem Storno weiter?") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - wahl = {"art": None, "nummer": ""} - gefunden = {"meta": None, "schwer": [], "leicht": []} - eingabe = tk.StringVar() - - rahmen = ttk.Frame(d, padding=18) - rahmen.pack(fill="both", expand=True) - ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}", - font=(theme.SCHRIFT_NAME, 13, "bold")).pack(anchor="w") - - # ---- Schritt 1: A oder B ------------------------------------------ - schritt1 = ttk.Frame(rahmen) - schritt1.pack(fill="x", pady=(8, 0)) - ttk.Label(schritt1, wraplength=620, justify="left", text=( - "Auf dem Storno-Blatt muss stehen, wie die Leistung stattdessen abgerechnet " - "wird – sonst weiß der Gast nicht, woran er ist.")).pack(anchor="w", pady=(0, 12)) - ttk.Label(schritt1, text="A – es folgt eine neue Rechnung", - font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") - ttk.Label(schritt1, wraplength=620, justify="left", text=( - "Der übliche Weg: die Rechnung war falsch und wird nach dem Storno richtig neu " - "geschrieben. Das Formular bleibt danach offen.")).pack(anchor="w", pady=(2, 10)) - ttk.Label(schritt1, text="B – die Leistung ist schon abgerechnet", - font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") - ttk.Label(schritt1, wraplength=620, justify="left", text=( - "Für Doppelte: derselbe Aufenthalt steht mehrfach im Ordner. Im nächsten Schritt " - "trägst du die Nummer der Rechnung ein, die bleibt.")).pack(anchor="w", pady=(2, 10)) - ttk.Label(schritt1, text="C – nur Storno", - font=(theme.SCHRIFT_NAME, 10, "bold")).pack(anchor="w") - ttk.Label(schritt1, wraplength=620, justify="left", text=( - "Die Leistung wird gar nicht abgerechnet: Buchung geplatzt oder die Rechnung " - "war komplett irrtümlich. Es folgt keine neue Rechnung und es gibt auch keine " - "andere, auf die verwiesen wird.")).pack(anchor="w", pady=(2, 0)) - - # ---- Schritt 2: die Nummer (erst nach B) -------------------------- - schritt2 = ttk.Frame(rahmen) - ttk.Label(schritt2, wraplength=620, justify="left", text=( - "Welche Rechnung bleibt? Sie muss im Rechnungsordner liegen und denselben " - "Aufenthalt abrechnen – das Programm sieht nach.")).pack(anchor="w", pady=(8, 10)) - zeile = ttk.Frame(schritt2) - zeile.pack(anchor="w") - ttk.Label(zeile, text="Bleibende Rechnung:").pack(side="left") - feld = ttk.Entry(zeile, textvariable=eingabe, width=16, justify="center") - feld.pack(side="left", padx=(8, 0)) - stand = tk.Label(schritt2, text="", wraplength=620, justify="left", - font=(theme.SCHRIFT_NAME, 9)) - stand.pack(anchor="w", pady=(8, 0)) - - def suchen(*_a): - """Gibt es die Nummer - und passt der INHALT? Antwort aus dem Ordner.""" - nr = nummer_normalisieren(eingabe.get()) - gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = None, [], [] - if not nr: - stand.configure(text="", fg=self.col_muted) - return None - if nr == alt_nummer: - stand.configure(text="Das ist die Rechnung, die gerade storniert wird.", - fg="#a00000") - return None - treffer = [m for m in rechnungen_im_ordner(out_dir) - if str(m.get("rechnungsnummer")) == nr and m.get("art") != "storno"] - if not treffer: - stand.configure(text=f"Zu {nr} liegt keine Rechnung im Ordner.", fg="#a00000") - return None - m = treffer[0] - wer = " ".join(t for t in (str(m.get("vorname") or ""), - str(m.get("nachname") or "")) if t) - grund = (f"{nr} vom {m.get('datum','')} – {wer or 'ohne Namen'}, " - f"{berechnung.eur(m.get('gesamt') or 0)}") - schwer, leicht = self._passt_zusammen(alt_meta or {}, m) - gefunden["meta"], gefunden["schwer"], gefunden["leicht"] = m, schwer, leicht - if schwer: - stand.configure(text=grund + " — " + "; ".join(schwer) - + ". So kann das Storno nicht darauf verweisen.", - fg="#a00000") - return None - if leicht: - stand.configure(text=grund + " — weicht ab: " + "; ".join(leicht), - fg="#a06000") - return nr - stand.configure(text=grund + " — gleicher Gast, gleicher Zeitraum, gleicher Betrag.", - fg="#1f5c3d") - return nr - - eingabe.trace_add("write", suchen) - - # ---- Knopfleiste, wechselt mit dem Schritt ------------------------ - knoepfe = ttk.Frame(rahmen) - knoepfe.pack(fill="x", pady=(16, 0)) - - def nimm_neu(): - wahl["art"] = "neu" - d.destroy() - - def nimm_nur(): - wahl["art"] = "nur" - d.destroy() - - def uebernehmen(): - nr = suchen() - if not nr: - if not eingabe.get().strip(): - stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.", - fg="#a00000") - return - if gefunden["leicht"] and not self.frage( - "Weicht ab – trotzdem?", - f"Rechnung {nr} weicht ab: " + "; ".join(gefunden["leicht"]) + - ". Das Storno würde trotzdem darauf verweisen. Ist das dieselbe " - "Leistung?", icon="warning", default="no", parent=d): - return - wahl["art"], wahl["nummer"] = "vorhanden", nr - d.destroy() - - def zu_schritt2(): - schritt1.pack_forget() - schritt2.pack(fill="x") - for w in knoepfe.winfo_children(): - w.destroy() - ttk.Button(knoepfe, text="Übernehmen", width=16, - command=uebernehmen).pack(side="right") - ttk.Button(knoepfe, text="Zurück", width=12, command=zu_schritt1).pack(side="left") - ttk.Button(knoepfe, text="Abbrechen", width=14, - command=d.destroy).pack(side="right", padx=(0, 8)) - d.update_idletasks() - feld.focus_set() - - def zu_schritt1(): - schritt2.pack_forget() - schritt1.pack(fill="x", pady=(8, 0)) - for w in knoepfe.winfo_children(): - w.destroy() - ttk.Button(knoepfe, text="A: neue Rechnung folgt", width=24, - command=nimm_neu).pack(side="left") - ttk.Button(knoepfe, text="B: schon abgerechnet", width=22, - command=zu_schritt2).pack(side="left", padx=8) - ttk.Button(knoepfe, text="C: nur Storno", width=16, - command=nimm_nur).pack(side="left") - ttk.Button(knoepfe, text="Abbrechen", width=14, - command=d.destroy).pack(side="right") - - zu_schritt1() - d.protocol("WM_DELETE_WINDOW", d.destroy) - d.bind("", lambda _e: d.destroy()) - d.update_idletasks() - theme.mittig(d, self) - d.grab_set() - self.wait_window(d) - if wahl["art"] == "vorhanden": - return "vorhanden", wahl["nummer"] - if wahl["art"] in ("neu", "nur"): - return wahl["art"], None - return None - def _storno_anlegen(self, meta, out_dir): """Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung.""" alt_nummer = str(meta.get("rechnungsnummer", "")) - # Zuerst die Frage, die frueher gefehlt hat: kommt eine neue Rechnung, oder - # ist die Leistung schon unter einer anderen Nummer abgerechnet? Beim - # alten Programm wurde bei einem Fehler einfach neu geschrieben - deshalb - # liegt dieselbe Leistung mehrfach im Ordner, und die Ueberzaehligen - # muessen auf die BLEIBENDE Rechnung verweisen. - weiter = self._storno_folge_waehlen(out_dir, alt_nummer, meta) - if weiter is None: - return - art_folge, vorhandene = weiter - - nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) + nummer = naechste_freie_nummer(out_dir, self.cfg) if not nummer: - self.melden_fehler("Keine freie Nummer", + messagebox.showerror("Keine freie Nummer", "Es wurde keine freie Rechnungsnummer gefunden.") return @@ -546,18 +310,7 @@ class KorrekturMixin: # die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit # die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt # werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt. - if art_folge == "vorhanden": - # Es kommt keine neue Rechnung - das Storno verweist auf die, - # die bleibt. Es wird also auch keine Nummer dafuer reserviert. - folge_nummer = vorhandene - elif art_folge == "nur": - # Nur Storno: es folgt nichts, es gibt auch nichts, worauf zu - # verweisen waere. Keine Nummer reservieren - sonst reisst die - # fortlaufende Nummernfolge eine Luecke (Paragraph 14 Abs. 4 Nr. 4 UStG). - folge_nummer = "" - else: - folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, - self._belegte_nummern()) + folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer) # Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in # deren Kenndaten fehlt, wird aus ihrem Adressblock geholt. kunde, woher = kundendaten_ermitteln(meta) @@ -571,7 +324,7 @@ class KorrekturMixin: # kann das Programm nichts erfinden; dann muss der Nutzer entscheiden. maengel = kunde_maengel(kunde) if maengel: - if not self.frage( + if not messagebox.askyesno( "Angaben zum Kunden unvollständig", f"In Rechnung {alt_nummer} steht nicht alles:\n\n- " + "\n- ".join(maengel) + @@ -580,17 +333,10 @@ class KorrekturMixin: icon="warning", default="no"): return - # Der Storno ist das Spiegelbild der Rechnung - auch bei den - # UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der - # aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten - # sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie - # doppelt gemeldet (4 - 0 + 4 = 8 statt 4). - alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0) kopf = { "rechnungsnummer": nummer, "datum": heute, - "anreise": "", "abreise": "", - "naechte": -abs(alt_naechte) if alt_naechte else "", + "anreise": "", "abreise": "", "naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": kunde.get("nachname", ""), "vorname": kunde.get("vorname", ""), @@ -603,12 +349,6 @@ class KorrekturMixin: # alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette # aus dem Storno allein lesbar ist. "korrektur_nummer": folge_nummer or "", - # Sagt dem Blatt, ob die Nummer eine BLEIBENDE Rechnung meint - # ("bereits abgerechnet mit") oder eine, die noch kommt. - "ersatz_vorhanden": art_folge == "vorhanden", - # Sagt dem Blatt: es kommt NICHTS nach. Ohne das stuende dort der - # Satz von der "berichtigten Rechnung", die es nie geben wird. - "nur_storno": art_folge == "nur", "storno_datum": str(meta.get("datum") or ""), "kunde": kunde, } @@ -619,14 +359,12 @@ class KorrekturMixin: try: pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - self.melden_fehler("Fehler beim Erstellen", + messagebox.showerror("Fehler beim Erstellen", f"Storno-PDF konnte nicht erstellt werden:\n{e}") print("[storno] FEHLER:", e) return - # Nur wenn eine neue Rechnung folgt, bleibt ein Vorgang offen. Bei - # "schon abgerechnet" ist mit dem Storno alles getan. - self.storno_stand = None if art_folge in ("vorhanden", "nur") else { + self.storno_stand = { "pfad": pfad, "nummer": nummer, "zu": alt_nummer, "vorgang": str(meta.get("vorgang") or alt_nummer), "zu_datum": str(meta.get("datum") or ""), @@ -635,24 +373,6 @@ class KorrekturMixin: } self._zaehler_aus_nummer_speichern(nummer) print(f"[storno] erstellt -> {pfad}") - # Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch, - # sonst vergibt sie das andere Programm noch einmal, und das Journal - # erfaehrt nicht, dass die Rechnung aufgehoben ist. - self._ins_buch(nummer, kopf, r, pfad) - self._buch_anzeigen() - self._journal_benachrichtigen(f"Storno {nummer} zu Rechnung {alt_nummer} erstellt") - - if art_folge == "vorhanden": - self._storno_anzeigen() - self._neue_nummer_vorschlagen() - if self.frage( - "Storno erstellt", - f"Storno {nummer} hebt Rechnung {alt_nummer} auf.\n\n" - f"Die Leistung bleibt mit Rechnung {folge_nummer} abgerechnet – es " - "kommt keine neue Rechnung dazu.\n\n" - "Das Blatt gehört zum Gast.\n\nJetzt öffnen (zum Drucken)?"): - self._oeffne_datei(pfad) - return fehlend = self._formular_aus_meta(meta) self.r_nummer.set(folge_nummer or "") @@ -662,7 +382,7 @@ class KorrekturMixin: if fehlend: hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – " "ohne vollständige Anschrift wird die Rechnung nicht geschrieben.") - self.melden_info( + messagebox.showinfo( "Storno erstellt", f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n" f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und " @@ -685,28 +405,14 @@ class KorrekturMixin: self.r_abreise.set(str(meta.get("abreise", "") or "")) self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) - # Eine Montage-Rechnung gehoert in ihren Reiter: ihre Position 1 ist die - # Monatsmiete, im Campingformular waere es Position 1 des Katalogs - # (Wohnwagen pro Nacht) - der Storno haette stillschweigend etwas - # anderes berichtigt. - montage = str(meta.get("vorlage", "") or "") == "montage" - try: - self.blaetter.select(1 if montage else 0) - except (AttributeError, tk.TclError): - pass - for nr, var in self.anzahl_vars.items(): var.set("") self.personen_vars[nr].set("") - if montage: - erste = (meta.get("positionen") or [{}])[0] - self.m_monate.set(str(erste.get("anzahl", "1") or "1")) - else: - for p in meta.get("positionen") or []: - nr = p.get("nr") - if nr in self.anzahl_vars: - self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) - self.personen_vars[nr].set(str(p.get("personen", "") or "")) + for p in meta.get("positionen") or []: + nr = p.get("nr") + if nr in self.anzahl_vars: + self.anzahl_vars[nr].set(str(p.get("anzahl", "") or "")) + self.personen_vars[nr].set(str(p.get("personen", "") or "")) self.recalc() hinweise = [] diff --git a/menueleiste.py b/menueleiste.py deleted file mode 100644 index 7b0f578..0000000 --- a/menueleiste.py +++ /dev/null @@ -1,169 +0,0 @@ -# -*- coding: utf-8 -*- -"""Eine Menueleiste, die sich einfaerben laesst. - -WARUM NICHT DAS MENUE VON TK ----------------------------- -`tk.Menu` ist unter Windows kein Tk-Widget, sondern ein Menue des -Betriebssystems. Es nimmt Farben zwar entgegen, zeichnet aber trotzdem im -Systemgrau - ein weisser Balken ueber einem dunklen Fenster. Sein Befund am -07.09.2026 zum Steuerrechnungstool: *"nur die einstellunden hilfe leiste die -ist noch hell"*. - -Diese Leiste ist deshalb aus gewoehnlichen Labels gebaut: eine Zeile mit den -Titeln, und beim Klick geht ein randloses Toplevel als Klappliste auf. Beides -traegt die Farben des Programms. - -WAS SIE KANN UND WAS NICHT --------------------------- -Sie kann, was diese beiden Programme brauchen: Titel, Eintraege, Trennlinien. -Sie kann bewusst NICHT: Untermenues, Tastenkuerzel neben dem Eintrag, -Ankreuzfelder. Wer das braucht, baut es hier ein - nicht daneben. - -BENUTZUNG - - self.menue = menueleiste.Menueleiste(self, farben=self, menues={ - "Datei": [("Beenden", self._beenden)], - "Hilfe": [("Info", self._info)], - }) - -`farben` ist das Fenster mit den col_*-Feldern (das ThemeMixin). Ein "-" als -Beschriftung ergibt eine Trennlinie. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber. -""" -import theme -import tkinter as tk -from tkinter import ttk - - -class Menueleiste(tk.Frame): - def __init__(self, master, menues, farben=None): - self.f = farben if farben is not None else master - super().__init__(master, bg=self.f.col_bg) - self.menues = menues - self._offen = None - self._anker = None # welcher Titel gerade offen ist - self._titel = {} # Label -> Eintraege, fuer den Wechsel im Klick - self.pack(side="top", fill="x") - - for titel, eintraege in menues.items(): - lbl = tk.Label(self, text=titel, bg=self.f.col_bg, fg=self.f.col_fg, - padx=8, pady=3) - lbl.pack(side="left") - self._titel[lbl] = eintraege - lbl.bind("", lambda e, w=lbl: w.configure(bg=self.f.col_sel)) - lbl.bind("", lambda e, w=lbl: w.configure(bg=self.f.col_bg)) - lbl.bind("", - lambda e, b=lbl, it=eintraege: self.aufklappen(b, it)) - - ttk.Separator(master, orient="horizontal").pack(side="top", fill="x") - - # ---------------------------------------------------------------- Klappen - def aufklappen(self, anker, eintraege): - if self._offen: # zweiter Klick schliesst wieder - self.zuklappen() - return - pop = tk.Toplevel(self) - pop.overrideredirect(True) - # Der Rahmen ist der Hintergrund des Toplevels, einen Pixel breit - - # sonst zeichnet Windows einen weissen Rand um die Liste. - pop.configure(bg=self.f.col_line) - innen = tk.Frame(pop, bg=self.f.col_field) - innen.pack(padx=1, pady=1) - - for beschriftung, befehl in eintraege: - if beschriftung == "-": - tk.Frame(innen, bg=self.f.col_line, height=1).pack(fill="x", pady=3) - continue - eintrag = tk.Label(innen, text=beschriftung, bg=self.f.col_field, - fg=self.f.col_fg, anchor="w", padx=16, pady=5) - eintrag.pack(fill="x") - eintrag.bind("", - lambda e, w=eintrag: w.configure(bg=self.f.col_sel)) - eintrag.bind("", - lambda e, w=eintrag: w.configure(bg=self.f.col_field)) - eintrag.bind("", lambda e, c=befehl: self._ausfuehren(c)) - - pop.update_idletasks() - # Auf den sichtbaren Bereich begrenzen, wie jede andere Position im - # Programm auch. Steht das Fenster am unteren oder rechten Rand, lief - # die Liste sonst hinaus und war nicht mehr zu treffen. - x, y = theme.auf_bildschirm( - anker.winfo_rootx(), anker.winfo_rooty() + anker.winfo_height(), - pop.winfo_reqwidth(), pop.winfo_reqheight()) - pop.geometry(f"+{x}+{y}") - self._offen = pop - self._anker = anker - pop.grab_set() - pop.bind("", self._klick_ausserhalb, add="+") - pop.bind("", lambda e: self.zuklappen()) - faerben = getattr(self.f, "_dark_titlebar", None) - if faerben: - faerben(pop) - - def _klick_ausserhalb(self, ereignis): - """Klick neben die Liste: schliessen - und auf einem anderen Titel - gleich dessen Liste aufklappen. - - WARUM DAS HIER STEHT UND NICHT IM LABEL (07.09.2026) - ---------------------------------------------------- - Solange eine Liste offen ist, haelt ihr `grab_set` alle Mausklicks der - Anwendung fest. Die ``-Bindung des Menuetitels feuert dann - gar nicht - der Klick landet hier. Vorher endete er damit beim blossen - Zuklappen, und man musste ein zweites Mal klicken, um von "Datei" zu - "Hilfe" zu kommen. Das native tk.Menu, das diese Leiste ersetzt hat, - wechselte schon beim blossen Ueberfahren; zwei Klicks waren also eine - Verschlechterung gegenueber dem, was vorher da war. - - Ein Klick auf DENSELBEN Titel schliesst weiterhin nur - sonst koennte - man eine Liste nie ueber ihren Titel wieder loswerden. - """ - pop = self._offen - if not pop: - return - if (pop.winfo_rootx() <= ereignis.x_root - <= pop.winfo_rootx() + pop.winfo_width() - and pop.winfo_rooty() <= ereignis.y_root - <= pop.winfo_rooty() + pop.winfo_height()): - return # der Klick lag in der Liste - vorher = self._anker - self.zuklappen() - getroffen = self._titel_bei(ereignis.x_root, ereignis.y_root) - if getroffen is not None and getroffen is not vorher: - self.aufklappen(getroffen, self._titel[getroffen]) - - def _titel_bei(self, x_root, y_root): - """Welcher Menuetitel an dieser Bildschirmstelle liegt - oder None. - - Nachgerechnet statt gefragt: `winfo_containing()` waere kuerzer, gibt - aber nichts zurueck, sobald das Fenster einer ANDEREN Anwendung ueber - dem Punkt liegt. Beim Bedienen ist das nie der Fall - beim Pruefen - schon, und dort meldete es dann einen Fehler, den es nicht gibt - (07.09.2026, zwei Pruefstaende liefen gleichzeitig auf demselben - Bildschirmplatz). Wo die eigenen Titel liegen, weiss diese Leiste - selbst; dafuer muss sie das Fenstersystem nicht fragen. - """ - for lbl in self._titel: - x, y = lbl.winfo_rootx(), lbl.winfo_rooty() - if (x <= x_root <= x + lbl.winfo_width() - and y <= y_root <= y + lbl.winfo_height()): - return lbl - return None - - def _ausfuehren(self, befehl): - # Erst zuklappen, dann ausfuehren: sonst haelt der grab_set der Liste - # einen Dialog fest, der aus dem Eintrag heraus aufgeht. - self.zuklappen() - if befehl: - befehl() - - def zuklappen(self): - if self._offen: - try: - self._offen.grab_release() - self._offen.destroy() - except tk.TclError: - pass - self._offen = None - self._anker = None diff --git a/ordnerwahl.py b/ordnerwahl.py deleted file mode 100644 index fa9b5a9..0000000 --- a/ordnerwahl.py +++ /dev/null @@ -1,223 +0,0 @@ -# -*- coding: utf-8 -*- -"""Den gemeinsamen Ordner abfragen - in beiden Programmen gleich. - -Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch -(siehe gemeinsam.py). Damit sie sich finden, muss BEIDEN derselbe Ordner -genannt werden - deshalb fragt jedes Programm beim ersten Start danach und -sagt ausdruecklich dazu, dass es derselbe sein muss. - -⚠ NICHT der Ordner, in dem das Programm liegt. Frueher stand das so im Text -("am besten der Ordner, in dem die Programme liegen") und der Knopf hiess -sogar "Install-Ordner auswaehlen". Seit es einen Installer gibt, geht das gar -nicht mehr: die beiden Programme liegen in ZWEI getrennten Ordnern unter -%LOCALAPPDATA%/Programs, und ein Deinstallieren wuerde das Nummernbuch -mitnehmen. Vorgeschlagen wird deshalb der Ordner, in dem auch die Rechnungen -liegen - den kennen beide Programme ohnehin, und er wird mitgesichert. - -Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal -bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine -Rechnungsnummer zum zweiten Mal vergeben wurde. - -Wer jetzt keinen Ordner hat, kommt mit "Spaeter einrichten" weiter - das -Programm laeuft dann ohne das gemeinsame Buch und sagt das in der Fussleiste. - -⚠ Das Fenster bekommt seine Groesse ausdruecklich gesetzt. Ohne das war es zu -schmal, der Knopf zum Auswaehlen lag ausserhalb - und der Dialog war nicht zu -bedienen (gefunden am 03.09.2026, bevor es jemand benutzen musste). - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber. -""" - -import os -import tkinter as tk -from tkinter import ttk, filedialog, messagebox - -import theme - -import gemeinsam - -BREITE = 780 - - -class OrdnerWahl(tk.Toplevel): - """Modaler Dialog. Nach dem Schliessen steht das Ergebnis in `.ergebnis` - (gewaehlter Ordner) oder es ist None (spaeter/abgebrochen).""" - - def __init__(self, master, programm: str, vorgabe: str = ""): - super().__init__(master) - self.ergebnis = None - self.title("Gemeinsamer Ordner für das Nummernbuch") - self.transient(master) - # Dieses Fenster stand als einziges noch in Weiss da: es kam nie durch - # _style_dialog. Farben holt es sich jetzt vom Hauptfenster, damit es - # nicht wieder auseinanderlaeuft, wenn dort etwas geaendert wird. - self._bg = getattr(master, "col_bg", "#0d0e10") - self._fg = getattr(master, "col_fg", "#e8e8ea") - self._warn = getattr(master, "col_gefahr", "#e85a5a") - self._gut = getattr(master, "col_accent", "#06C6A4") - self._muted = getattr(master, "col_muted", "#8a8e96") - if hasattr(master, "_style_dialog"): - master._style_dialog(self) - else: - self.configure(bg=self._bg) - self.pfad = tk.StringVar(value=vorgabe or "") - - rahmen = ttk.Frame(self, padding=18) - rahmen.pack(fill="both", expand=True) - rahmen.columnconfigure(0, weight=1) - breite = BREITE - 60 - - ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen", - font=(theme.SCHRIFT_NAME, 13, "bold")).grid(row=0, column=0, sticky="w") - ttk.Label(rahmen, wraplength=breite, justify="left", text=( - "Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht " - "jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der " - "es gar keine PDF gibt.\n\n" - "Ohne dieses Buch kennt jedes Programm nur seine eigenen Nummern und vergibt " - "eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben " - "(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12)) - - tk.Label(rahmen, wraplength=breite, justify="left", fg=self._warn, bg=self._bg, - font=(theme.SCHRIFT_NAME, 10, "bold"), - text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten " - "der Ordner, in dem auch die Rechnungen liegen.").grid( - row=2, column=0, sticky="w", pady=(0, 14)) - - wahl = ttk.Frame(rahmen) - wahl.grid(row=3, column=0, sticky="ew") - wahl.columnconfigure(0, weight=1) - self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly") - self.feld.grid(row=0, column=0, sticky="ew") - # "Install-Ordner" hiess der Knopf frueher - das war schon immer - # falsch und ist es seit dem Installer erst recht: hier geht es NICHT - # um den Ordner, in dem das Programm liegt, sondern um den, in dem die - # beiden Programme ihr gemeinsames Nummernbuch fuehren. Sein Befund am - # 07.09.2026: "gibt es noch das popup install ordner auswaehlen, das - # stimmt ja nicht". - ttk.Button(wahl, text="Ordner auswählen…", command=self.waehlen, - width=22).grid(row=0, column=1, sticky="w", padx=(10, 0)) - - self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left", - bg=self._bg, - font=(theme.SCHRIFT_NAME, 9)) - self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0)) - - knoepfe = ttk.Frame(rahmen) - knoepfe.grid(row=5, column=0, sticky="ew", pady=(18, 0)) - self.ok = ttk.Button(knoepfe, text="OK", command=self.uebernehmen, width=14) - self.ok.pack(side="right") - ttk.Button(knoepfe, text="Später einrichten", command=self.spaeter, - width=18).pack(side="left") - - self._stand_zeigen() - self.protocol("WM_DELETE_WINDOW", self.spaeter) - self.bind("", lambda _e: self.spaeter()) - - # Groesse ausdruecklich setzen: sonst schneidet Tk das Fenster an der - # zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar. - self.update_idletasks() - hoehe = max(self.winfo_reqheight(), 300) - self.geometry(f"{BREITE}x{hoehe}") - theme.mittig(self, master) - self.minsize(BREITE, hoehe) - self.grab_set() - self.lift() - self.focus_force() - - # ------------------------------------------------------------------ Weg - def waehlen(self): - # Der Griff (grab_set) muss waehrend des Windows-Ordnerdialogs los sein: - # sonst kann der native Dialog hinter dem gesperrten Fenster landen und - # nimmt keine Eingabe an - man klickt und es passiert nichts. - try: - self.grab_release() - except tk.TclError: - pass - try: - ordner = filedialog.askdirectory( - title="Gemeinsamen Ordner wählen (in beiden Programmen derselbe!)", - initialdir=self.pfad.get() or os.path.expanduser("~"), parent=self) - finally: - try: - self.grab_set() - except tk.TclError: - pass - if ordner: - self.pfad.set(os.path.abspath(ordner)) - self._stand_zeigen() - - def _stand_zeigen(self): - """Sagt, was in dem gewaehlten Ordner schon liegt. - - Ein bereits vorhandenes Nummernbuch ist das beste Zeichen, dass es der - richtige Ordner ist - dann hat das andere Programm ihn schon. - """ - ordner = self.pfad.get().strip() - if not ordner: - self.stand.configure(text="Noch kein Ordner gewählt.", fg=self._warn) - self.ok.state(["disabled"]) - return - taugt, grund = gemeinsam.ordner_taugt(ordner) - if not taugt: - self.stand.configure(text=grund, fg=self._warn) - self.ok.state(["disabled"]) - return - self.ok.state(["!disabled"]) - if os.path.exists(gemeinsam.pfad_zur_datei(ordner)): - try: - anzahl = len(gemeinsam.Nummernbuch(ordner).nummern()) - except Exception: # noqa: BLE001 - anzahl = "?" - self.stand.configure( - text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – " - "das ist der Ordner des anderen Programms. Genau richtig.", - fg=self._gut) - else: - self.stand.configure( - text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im " - "anderen Programm denselben Ordner.", fg=self._muted) - - def _melden(self, titel, text): - """Meldung im Theme des Hauptfensters - sonst wie bisher.""" - eltern = self.master - if hasattr(eltern, "melden_warnung"): - return eltern.melden_warnung(titel, text) - return messagebox.showwarning(titel, text, parent=self) - - def _frage(self, titel, text): - eltern = self.master - if hasattr(eltern, "frage"): - return eltern.frage(titel, text) - return messagebox.askyesno(titel, text, parent=self) - - - def uebernehmen(self): - ordner = self.pfad.get().strip() - taugt, grund = gemeinsam.ordner_taugt(ordner) - if not taugt: - self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.") - return - if not self._frage( - "Sind Sie sicher?", - f"Gemeinsamer Ordner:\n\n{ordner}\n\n" - "Steht im anderen Programm derselbe Ordner? Nur dann kennen beide " - "dieselben Rechnungsnummern.\n\n" - "Ja = Ordner übernehmen\n" - "Nein = zurück zur Auswahl"): - # Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt. - self.feld.focus_set() - return - self.ergebnis = ordner - self.destroy() - - def spaeter(self): - self.ergebnis = None - self.destroy() - - -def ordner_abfragen(fenster, programm, vorgabe=""): - """Dialog zeigen und den gewaehlten Ordner zurueckgeben (oder None).""" - d = OrdnerWahl(fenster, programm, vorgabe) - fenster.wait_window(d) - return d.ergebnis diff --git a/pdf_renderer.py b/pdf_renderer.py index a72f2a7..bfc69ff 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -PDF-Renderer fuer die Rechnung (reportlab). +PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab). render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF im Layout der Excel-Vorlage: @@ -10,66 +10,47 @@ im Layout der Excel-Vorlage: """ import os +import io import json from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm +from reportlab.lib.utils import ImageReader from reportlab.lib import colors from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import ( - SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak, + Flowable, ) -import bilder import config from berechnung import eur, eur_kurz, runde -# ---- Farben / Masse --------------------------------------------------------- -# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die -# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein -# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle -# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben -# aufeinander abstimmen muss. -AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping) +class _AmtMarke(Flowable): + """Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt. -ACCENT = colors.HexColor(AKZENT_VORGABE) + Nur so kann die Fusszeile die Kundenseite vom Amt-Blatt unterscheiden - sie wird + gezeichnet, bevor der Seiteninhalt steht, und weiss von sich aus nichts davon. + """ + + def __init__(self, merker): + super().__init__() + self._merker = merker + + def wrap(self, *_): + return (0, 0) + + def draw(self): + self._merker["seite"] = self.canv.getPageNumber() + +# ---- Farben / Masse --------------------------------------------------------- +ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping) GREY_BG = colors.HexColor("#eef1ee") LINE = colors.HexColor("#9aa39c") DARK = colors.HexColor("#222222") - -def _farbe(wert, vorgabe): - """'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck. - - Eine kaputte Farbe in der config.json darf niemals das Schreiben der - Rechnung verhindern - dann steht der Gast an der Schranke. - """ - try: - return colors.HexColor(str(wert).strip()) - except (ValueError, AttributeError): - if str(wert).strip(): - print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}") - return colors.HexColor(vorgabe) - - -def _design_anwenden(cfg): - """Firmenfarbe uebernehmen, bevor die Stile gebaut werden. - - Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen. - Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei - Rechnungen gleichzeitig. - """ - global ACCENT, GREY_BG - design = (cfg or {}).get("design") or {} - ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE) - # Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder - # gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss. - GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red), - 1 - 0.08 * (1 - ACCENT.green), - 1 - 0.08 * (1 - ACCENT.blue)) - MARGIN_L = 18 * mm MARGIN_R = 18 * mm MARGIN_TOP = 40 * mm # Platz fuer den Kopf @@ -119,46 +100,36 @@ def _styles(): # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- -def _vorschau_marke(canvas): - """Schraeg ueber das Blatt: das hier ist KEINE Rechnung. - - Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder - weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette - der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner - Buchhaltung steht. - """ - canvas.saveState() - canvas.setFont("Helvetica-Bold", 62) - canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22)) - canvas.translate(PAGE_W / 2, PAGE_H / 2) - canvas.rotate(38) - canvas.drawCentredString(0, 0, "VORSCHAU") - canvas.setFont("Helvetica-Bold", 15) - canvas.drawCentredString(0, -22 * mm, "keine Rechnung") - canvas.restoreState() - - -def _kopf_fuss(canvas, doc, cfg, vorschau=False): - """Kopf und Fuss einer Seite.""" +def _kopf_fuss(canvas, doc, cfg, amt=False): + """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile).""" firma = cfg["firma"] canvas.saveState() - if vorschau: - _vorschau_marke(canvas) # --- Kopf --- - # Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts - # daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte - # kommen aus den Einstellungen und bleiben gezeichnet. - img, iw, ih = bilder.laden(firma.get("logo")) - line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher) + logo = (firma.get("logo") or "").strip() + # relativen Pfad (z.B. "assets/logo.png") portabel aufloesen: + # erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe) + if logo and not os.path.isabs(logo): + for _base in (config.BASE_DIR, config.RESOURCE_DIR): + _cand = os.path.join(_base, logo) + if os.path.exists(_cand): + logo = _cand + break logo_ok = False - if img: - dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links - ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand - canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh, - preserveAspectRatio=True, mask="auto") - logo_ok = True - line_y = ly - 3 * mm + if logo and os.path.exists(logo): + try: + img = ImageReader(logo) + iw, ih = img.getSize() + box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links + scale = min(box_w / iw, box_h / ih) + dw, dh = iw * scale, ih * scale + ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand + canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh, + preserveAspectRatio=True, mask="auto") + logo_ok = True + line_y = ly - 3 * mm + except Exception as e: # noqa: BLE001 + print("[pdf] Logo konnte nicht geladen werden:", e) if logo_ok: # Firmenname + Untertitel rechtsbuendig neben dem Logo @@ -184,8 +155,13 @@ def _kopf_fuss(canvas, doc, cfg, vorschau=False): canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) # --- Fusszeile --- - # Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der - # Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG). + # + # STRIKTE TRENNUNG Kunde / Amt: + # Kundenseite : voller Briefkopf-Fuss - Bankverbindung und Steuernummer MUESSEN + # drauf, das ist die Rechnung, die der Gast bezahlt. + # Amt-Blatt : KEINE Bankverbindung, KEINE Steuernummer. Das Amt zieht nichts + # ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und + # die Kontaktdaten fuer Rueckfragen. fy = MARGIN_BOT - 4 * mm canvas.setStrokeColor(LINE) canvas.setLineWidth(0.6) @@ -196,17 +172,9 @@ def _kopf_fuss(canvas, doc, cfg, vorschau=False): col_m = MARGIN_L + CONTENT_W * 0.40 col_r = PAGE_W - MARGIN_R - fuss_img, fiw, fih = bilder.laden(firma.get("fussbild")) - if fuss_img: - # Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind - - # Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal - # welches Bild jemand einstellt. - dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm) - canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm, - width=dw, height=dh, preserveAspectRatio=True, mask="auto") - - rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")] - rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")] + rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")] + rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""), + firma.get("steuernummer", "")] rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")] for i, t in enumerate(rows_l): @@ -220,17 +188,90 @@ def _kopf_fuss(canvas, doc, cfg, vorschau=False): # ---- Hauptfunktion ---------------------------------------------------------- -def _positionstabelle(story, st, rechnung, ohne_ust=False): - """Die gewohnte Positionstabelle der Campingrechnung. +def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): + """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte). - ohne_ust=True laesst die Spalte mit dem Steuersatz weg - auf einer - Proforma steht bewusst kein Steuersatz und kein Steuerbetrag - (§ 14c UStG: wer Steuer ausweist, schuldet sie).""" + gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor + (fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite. + """ + # Absenderzeile (klein, unterstrichen) + story.append(Paragraph( + f"{cfg['firma'].get('absender','')}", + ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK))) + story.append(Spacer(1, 5 * mm)) + + # Kundenadresse (links) | Rechnungsinfos (rechts) + kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])] + for z in kopf.get("kunde_zeilen", []): + if str(z).strip(): + kunde_flow.append(Paragraph(str(z), st["addr"])) + if len(kunde_flow) == 1: + kunde_flow.append(Paragraph(" ", st["addr"])) + + art = kopf.get("art", "rechnung") + info_rows = [ + ("Stornonummer:" if art == "storno" else "Rechnungsnummer:", + kopf.get("rechnungsnummer", "")), + ("Rechnungsdatum:", kopf.get("datum", "")), + ] + # Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum. + if art == "storno" and kopf.get("storno_zu"): + # Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen + # Spalte um und der Bezug sieht aus wie zwei halbe Angaben. + info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", ""))) + if kopf.get("storno_datum"): + info_rows.append(("Rechnung vom:", kopf["storno_datum"])) + # Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die + # ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer. + if kopf.get("korrektur_nummer"): + info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"])) + elif art == "korrektur" and kopf.get("ersetzt"): + info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) + elif art == "berichtigung": + # Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der + # Berichtigung, damit man die Reihenfolge sieht. + if kopf.get("berichtigt_am"): + info_rows.append(("Berichtigt am:", kopf["berichtigt_am"])) + if kopf.get("anreise"): + info_rows.append(("Anreise:", kopf.get("anreise", ""))) + if kopf.get("abreise"): + info_rows.append(("Abreise:", kopf.get("abreise", ""))) + if kopf.get("naechte") not in (None, "", 0, "0"): + info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte")) + info_tbl = Table( + [[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows], + colWidths=[39 * mm, 25 * mm]) + info_tbl.setStyle(TableStyle([ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 1), + ])) + + head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm]) + head.setStyle(TableStyle([ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ])) + story.append(head) + story.append(Spacer(1, 7 * mm)) + + # Ueberschrift (auf dem Amt-Blatt mit Zusatz) + # Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht + # in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist. + # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg + # eindeutig als Aufhebung erkennbar ist. + titel = {"storno": "Stornorechnung", + "berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht") + titel += " (für das Amt)" if gesamtrechnung_betont else "" + story.append(Paragraph(titel, st["h"])) + + # Positionstabelle col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm] data = [[ Paragraph("Pos.", st["th"]), Paragraph("Leistung", st["th_l"]), - Paragraph("" if ohne_ust else "USt %", st["th"]), + Paragraph("USt %", st["th"]), Paragraph("Anzahl
Tage/Menge", st["th"]), Paragraph("Pers.", st["th"]), Paragraph("E.-Preis", st["th"]), @@ -241,7 +282,7 @@ def _positionstabelle(story, st, rechnung, ohne_ust=False): data.append([ Paragraph(str(z.nr), st["cell_c"]), Paragraph(z.leistung, st["leistung"]), - Paragraph("" if ohne_ust else str(z.satz), st["cell_c"]), + Paragraph(str(z.satz), st["cell_c"]), Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]), Paragraph(pers, st["cell_c"]), Paragraph(eur_kurz(z.preis), st["cell_r"]), @@ -264,177 +305,31 @@ def _positionstabelle(story, st, rechnung, ohne_ust=False): ("RIGHTPADDING", (0, 0), (-1, -1), 3), ])) story.append(items) - - -def _montage_zeile(story, st, rechnung, kopf): - """Das Montage-Blatt: eine Textzeile, darunter Netto / USt / Brutto. - - Bartl schreibt dieses Blatt seit Jahren von Hand ("Miete fuer Montagearbeiter - inkl. 7 % MwSt. fuer den Monat August 2026"), darum hat es keine Tabelle mit - Anzahl und Einzelpreis. Gerechnet wird trotzdem im selben Rechenkern - die - Zahlen kommen aus rechnung.zeilen, nicht aus dem Text. - """ - text = kopf.get("montage_text") or "" - if not text: - text = ", ".join(z.leistung for z in rechnung.zeilen) - story.append(Paragraph(text, st["leistung"])) - story.append(Spacer(1, 3 * mm)) - - netto = sum(z.netto for z in rechnung.zeilen) - ust = rechnung.brutto - netto - dreier = Table( - [[Paragraph(f"Netto {eur(netto)}", st["cell_c"]), - Paragraph(f"USt. {eur(ust)}", st["cell_c"]), - Paragraph(f"Brutto {eur(rechnung.brutto)}", st["cell_c"])]], - colWidths=[CONTENT_W / 3.0] * 3) - dreier.setStyle(TableStyle([ - ("BACKGROUND", (0, 0), (-1, -1), GREY_BG), - ("LINEBELOW", (0, 0), (-1, -1), 0.8, ACCENT), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("TOPPADDING", (0, 0), (-1, -1), 5), - ("BOTTOMPADDING", (0, 0), (-1, -1), 5), - ])) - story.append(dreier) - - -def _seite_inhalt(story, st, cfg, rechnung, kopf): - """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).""" - # Absenderzeile (klein, unterstrichen) - story.append(Paragraph( - f"{cfg['firma'].get('absender','')}", - ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK))) - story.append(Spacer(1, 5 * mm)) - - # Kundenadresse (links) | Rechnungsinfos (rechts) - kunde_flow = [Paragraph("Angebot an:" if kopf.get("art") == "angebot" - else "Rechnung an:", st["addr_label"])] - for z in kopf.get("kunde_zeilen", []): - if str(z).strip(): - kunde_flow.append(Paragraph(str(z), st["addr"])) - if len(kunde_flow) == 1: - kunde_flow.append(Paragraph(" ", st["addr"])) - - art = kopf.get("art", "rechnung") - # Montage-Rechnung: eigenes Blatt fuer die Monatsmiete eines Monteurs. - # Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der - # einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat. - montage = kopf.get("vorlage") == "montage" - angebot = art == "angebot" - info_rows = [ - ("Stornonummer:" if art == "storno" else - "Angebotsnummer:" if angebot else "Rechnungsnummer:", - kopf.get("rechnungsnummer", "")), - # Auf dem Angebot faellt das Wort "Rechnung" ueberall weg: es soll gar - # nicht erst der Eindruck entstehen, der Gast haette schon eine. - ("Datum:" if angebot else "Rechnungsdatum:", kopf.get("datum", "")), - ] - # Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum. - if art == "storno" and kopf.get("storno_zu"): - # Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen - # Spalte um und der Bezug sieht aus wie zwei halbe Angaben. - info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", ""))) - if kopf.get("storno_datum"): - info_rows.append(("Rechnung vom:", kopf["storno_datum"])) - # Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die - # ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer. - if kopf.get("korrektur_nummer"): - # "Neue Rechnung" war missverstaendlich - man liest es als die Nummer - # DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt - - # oder, bei einer doppelt geschriebenen Leistung, die BLEIBENDE. - info_rows.append( - ("Schon abgerechnet:" if kopf.get("ersatz_vorhanden") - else "Berichtigte Rechnung:", kopf["korrektur_nummer"])) - elif art == "korrektur" and kopf.get("ersetzt"): - info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", ""))) - elif art == "berichtigung": - # Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der - # Berichtigung, damit man die Reihenfolge sieht. - if kopf.get("berichtigt_am"): - info_rows.append(("Berichtigt am:", kopf["berichtigt_am"])) - if not montage: - if kopf.get("anreise"): - info_rows.append(("Anreise:", kopf.get("anreise", ""))) - if kopf.get("abreise"): - info_rows.append(("Abreise:", kopf.get("abreise", ""))) - if kopf.get("naechte") not in (None, "", 0, "0"): - # Auf dem Storno stehen die Naechte negativ in den Kenndaten (das - # Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4 - # Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird. - try: - anzahl = int(float(str(kopf.get("naechte")).replace(",", "."))) - except (TypeError, ValueError): - anzahl = 0 - if anzahl < 0: - info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)")) - else: - info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte")) - info_tbl = Table( - [[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows], - colWidths=[39 * mm, 25 * mm]) - info_tbl.setStyle(TableStyle([ - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("TOPPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 1), - ])) - - head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm]) - head.setStyle(TableStyle([ - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("LEFTPADDING", (0, 0), (-1, -1), 0), - ("RIGHTPADDING", (0, 0), (-1, -1), 0), - ])) - story.append(head) - story.append(Spacer(1, 7 * mm)) - - # Ueberschrift. - # Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg - # eindeutig als Aufhebung erkennbar ist. - titel = {"storno": "Stornorechnung", - "berichtigung": "Berichtigte Rechnung", - "angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht") - story.append(Paragraph(titel, st["h"])) - if angebot: - # Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine - - # und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine - # werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf - # dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG). - story.append(Paragraph( - "Dies ist ein unverbindliches Angebot und keine Rechnung im " - "Sinne des § 14 UStG: es berechtigt nicht zum Vorsteuerabzug und ist " - "nicht zu bezahlen. Alle Beträge in Euro inkl. gesetzlicher " - "Umsatzsteuer; ausgewiesen wird " - "sie erst in der Rechnung nach dem Aufenthalt.", st["dank"])) - story.append(Spacer(1, 3 * mm)) - - if montage: - _montage_zeile(story, st, rechnung, kopf) - else: - _positionstabelle(story, st, rechnung, ohne_ust=angebot) story.append(Spacer(1, 3 * mm)) # ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto) # -> zzgl. Beherbergungssteuer -> Gesamtbetrag ---- - # Auf dem Angebot steht weder Netto noch Umsatzsteuer: ausgewiesen wird - # sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer - # und darf bleiben - der Gast soll sehen, was er zahlen wird. - summen_rows = [] if angebot else [[ + summen_rows = [[ Paragraph("Nettobetrag:", st["info_l"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), ]] - for b in ([] if angebot else rechnung.bloecke): + for b in rechnung.bloecke: name = b.bezeichnung or "USt" summen_rows.append([ Paragraph(f"{name} {b.satz} %:", st["info_l"]), Paragraph(eur(b.ust), st["cell_r"]), ]) - # Auf der Rechnung MUSS die Beherbergungssteuer ausgewiesen sein und im - # Gesamtbetrag stecken - das ist der Betrag, den der Gast zahlt. Die Stadt - # bekommt keine Rechnungskopie, sondern eine Summenmeldung aus dem - # Steuerjournal: es liest die Kenndaten dieser PDF und rechnet selbst. - # Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer - # noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich - # zahlt; aufgeschluesselt wird erst in der Rechnung. - if rechnung.aufschlaege and not angebot: + # Auf dem Amt-Blatt taucht die Beherbergungssteuer NIRGENDS auf - weder als eigene + # Zeile, noch versteckt im Gesamtbetrag. Das Amt bekommt die Rechnung so, wie sie + # ohne die Steuer aussaehe: Netto, Umsatzsteuern, Gesamt. Genau dieser Gesamtbetrag + # ist die Bemessungsgrundlage, aus der es die Steuer selbst errechnet. + # + # Deshalb entfaellt hier auch die Zwischensumme: sie waere betragsgleich mit dem + # Gesamtbetrag und wuerde nur zweimal dasselbe zeigen. + # + # Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen + # sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt. + if rechnung.aufschlaege and not gesamtrechnung_betont: summen_rows.append([ Paragraph("Zwischensumme:", st["info_l"]), Paragraph(eur(rechnung.brutto), st["cell_r"]), @@ -446,11 +341,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf): Paragraph(eur(a.betrag), st["cell_r"]), ]) - endsumme = rechnung.gesamt + endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt summen_rows.append([ - Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else - "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", - st["total_l"]), + Paragraph("Gesamtbetrag:", st["total_l"]), Paragraph(eur(endsumme), st["total_v"]), ]) style_cmds = [ @@ -465,13 +358,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf): summen.setStyle(TableStyle(style_cmds)) story.append(summen) - # Hinweis zur Beherbergungssteuer. - if angebot: - story.append(Spacer(1, 1.5 * mm)) - story.append(Paragraph( - "In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt " - "werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"])) - else: + # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt + # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos. + if not gesamtrechnung_betont: for a in rechnung.aufschlaege: if not a.bezeichnung: continue @@ -489,84 +378,53 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf): story.append(Spacer(1, 8 * mm)) - # ---- Schlusstexte ------------------------------------------------------- - texte = cfg.get("texte", {}) - block = [] - if art == "storno": - # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird - # nichts geschuldet, sondern aufgehoben. - bezug = kopf.get("storno_zu", "") - if kopf.get("storno_datum"): - bezug += f" vom {kopf['storno_datum']}" - folgt = kopf.get("korrektur_nummer") or "" - if kopf.get("ersatz_vorhanden") and folgt: - # Doppelt geschriebene Leistung: es kommt nichts nach, sie ist - # bereits abgerechnet. Der Satz muss das sagen - sonst wartet der - # Gast auf eine Rechnung, die nie kommt. + # ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- + if not gesamtrechnung_betont: + texte = cfg.get("texte", {}) + block = [] + if art == "storno": + # Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird + # nichts geschuldet, sondern aufgehoben. + bezug = kopf.get("storno_zu", "") + if kopf.get("storno_datum"): + bezug += f" vom {kopf['storno_datum']}" + folgt = kopf.get("korrektur_nummer") or "" + kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} " + f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " + f"→ {folgt}).") if folgt else "" block.append(Paragraph( f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " - f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und " - f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine " - "weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder " - "verrechnet.", st["dank"])) - story.append(KeepTogether(block)) - return - if kopf.get("nur_storno"): - # Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet. - # Hier darf NICHT von einer berichtigten Rechnung die Rede sein - - # es kommt keine, und der Gast wartet sonst darauf. + "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " + "Rechnung verrechnet." + kette, st["dank"])) + elif art == "berichtigung": + # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung + # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 + # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht + # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und + # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim + # Aussteller. Also bleibt der Satz drauf. block.append(Paragraph( - f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " - "Die Leistung wird nicht abgerechnet – es folgt keine weitere " - "Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"])) - story.append(KeepTogether(block)) - return - kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} " - f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} " - f"→ {folgt}).") if folgt else "" - block.append(Paragraph( - f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. " - "Bereits gezahlte Beträge werden erstattet oder mit der berichtigten " - "Rechnung verrechnet." + kette, st["dank"])) - elif art == "berichtigung": - # Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung - # ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4 - # Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht - # (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und - # gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim - # Aussteller. Also bleibt der Satz drauf. - block.append(Paragraph( - f"Dieses Blatt berichtigt die Angaben zur Rechnung " - f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " - "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind " - "unverändert – es handelt sich nicht um eine zusätzliche " - "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält " - "ihre Nummer.", st["dank"])) - if texte.get("dank1"): - block.append(Paragraph(texte["dank1"], st["dank"])) - elif angebot: - # Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt - # noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die - # Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen. - block.append(Paragraph( - "Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und " - "keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren " - "wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen " - "stehen wir gern zur Verfügung.", - st["dank"])) - else: - if art == "korrektur" and kopf.get("ersetzt"): - hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" - if kopf.get("storno_nummer"): - hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde" - block.append(Paragraph(hinweis + ".", st["dank"])) - if texte.get("dank1"): - block.append(Paragraph(texte["dank1"], st["dank"])) - if texte.get("dank2"): - block.append(Paragraph(texte["dank2"], st["dank"])) - if texte.get("hinweis"): - block.append(Paragraph(texte["hinweis"], st["hinweis"])) - story.append(KeepTogether(block)) + f"Dieses Blatt berichtigt die Angaben zur Rechnung " + f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach " + "§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind " + "unverändert – es handelt sich nicht um eine zusätzliche " + "Leistung und nicht um eine zweite Rechnung. Die Rechnung behält " + "ihre Nummer.", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) + else: + if art == "korrektur" and kopf.get("ersetzt"): + hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}" + if kopf.get("storno_nummer"): + hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde" + block.append(Paragraph(hinweis + ".", st["dank"])) + if texte.get("dank1"): + block.append(Paragraph(texte["dank1"], st["dank"])) + if texte.get("dank2"): + block.append(Paragraph(texte["dank2"], st["dank"])) + if texte.get("hinweis"): + block.append(Paragraph(texte["hinweis"], st["hinweis"])) + story.append(KeepTogether(block)) def _kenndaten(rechnung, kopf): @@ -594,22 +452,6 @@ def _kenndaten(rechnung, kopf): Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). """ aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None - if kopf.get("art") == "angebot": - # Ein Angebot ist kein Umsatz - es traegt Nullen. Ein Programm, das - # "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie - # abgerechnet wurde. - return json.dumps({ - "quelle": "rechnungstool", "version": 3, "art": "angebot", - "rechnungsnummer": kopf.get("rechnungsnummer", ""), - "datum": kopf.get("datum", ""), - "nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""), - "naechte": kopf.get("naechte", ""), - "zwischensumme": 0.0, "gesamt": 0.0, - "steuer_satz": 0.0, "steuer_betrag": 0.0, - "netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [], - "kunde": kopf.get("kunde", {}), - "anreise": kopf.get("anreise", ""), "abreise": kopf.get("abreise", ""), - }, ensure_ascii=False) if kopf.get("art") == "berichtigung": # Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber # KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art" @@ -657,8 +499,6 @@ def _kenndaten(rechnung, kopf): "vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""), "storno_zu": kopf.get("storno_zu", ""), "korrektur_nummer": kopf.get("korrektur_nummer", ""), - "ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")), - "nur_storno": bool(kopf.get("nur_storno")), "storno_datum": kopf.get("storno_datum", ""), "ersetzt": kopf.get("ersetzt", ""), "storno_nummer": kopf.get("storno_nummer", ""), @@ -667,55 +507,56 @@ def _kenndaten(rechnung, kopf): "kunde": kopf.get("kunde", {}), "positionen": kopf.get("positionen", []), } - # Montage-Rechnung: welches Formular sie geschrieben hat und der Text der - # einen Zeile. "art" bleibt "rechnung" - fuer das Steuerjournal ist sie eine - # ganz normale Buchung (Miete mit Beherbergungssteuer), nur eben monatsweise. - # Damit kann das Formular sie spaeter wieder laden und der Storno-Weg - # erkennt, in welchen Reiter sie gehoert. - if kopf.get("vorlage"): - daten["vorlage"] = kopf["vorlage"] - daten["montage_text"] = kopf.get("montage_text", "") return json.dumps(daten, ensure_ascii=False) -def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False): +def render_rechnung(pfad, cfg, rechnung, kopf): """ pfad : Zielpfad der PDF cfg : geladene Konfiguration rechnung : berechnung.Rechnung kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen - EIN Blatt: die Rechnung fuer den Gast. - - Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben - Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen - Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter - zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber - verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen. - - Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten - (subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die - Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit - Gastnamen. + EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt): + Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss). + Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt), + keine Zwischensumme, keine Bankverbindung, keine Steuernummer. """ - _design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit st = _styles() - doc = SimpleDocTemplate( - pfad, pagesize=A4, - leftMargin=MARGIN_L, rightMargin=MARGIN_R, - topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=({"storno": "Stornorechnung", - "angebot": "Angebot"}.get(kopf.get("art"), "Rechnung")) - + f" {kopf.get('rechnungsnummer','')}", - author=cfg["firma"].get("name", ""), - subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal - ) - story = [] - _seite_inhalt(story, st, cfg, rechnung, kopf) + def bauen(ziel, amt_seite): + doc = SimpleDocTemplate( + ziel, pagesize=A4, + leftMargin=MARGIN_L, rightMargin=MARGIN_R, + topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, + title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung") + + f" {kopf.get('rechnungsnummer','')}", + author=cfg["firma"].get("name", ""), + subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal + ) + story = [] + _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde + if rechnung.aufschlaege: + story.append(PageBreak()) + story.append(_AmtMarke(marke)) + _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt - def seite(c, d): - _kopf_fuss(c, d, cfg, vorschau=vorschau) + def seite(c, d): + _kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite)) - doc.build(story, onFirstPage=seite, onLaterPages=seite) + doc.build(story, onFirstPage=seite, onLaterPages=seite) + + # Zwei Durchlaeufe, und zwar aus einem konkreten Grund: + # + # Kopf und Fuss werden VOR dem Seiteninhalt gezeichnet - beim Zeichnen weiss die + # Seite also noch nicht, ob auf ihr gleich die Kunden- oder die Amts-Seite landet. + # Auf "Seite 2 ist das Amt" zu wetten, waere falsch, sobald die Kundenrechnung + # laenger als eine Seite wird: dann stuende die Bankverbindung ploetzlich auf dem + # Amt-Blatt und fehlte beim Kunden. + # + # Darum: erster Lauf ins Nichts, nur um zu erfahren, auf welcher Seite das + # Amt-Blatt beginnt. Zweiter Lauf schreibt die echte Datei. + marke = {} + bauen(io.BytesIO(), None) + bauen(pfad, marke.get("seite")) return pfad diff --git a/pruef_angebot.py b/pruef_angebot.py deleted file mode 100644 index 2349b3d..0000000 --- a/pruef_angebot.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: das unverbindliche Angebot. - -Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Angebot - *"musst -nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst -muessen wir wieder die nummern aenderbar machen und dann macht er wieder -fehler"*. - -Genau das ist hier die Hauptpruefung: das Angebot laeuft in einer EIGENEN -Reihe (2026-A001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt. - -Dazu die Rechtslage (nachgeschlagen am 03.09.2026): - * keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach - § 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen - * kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer - ausweist, schuldet sie) - * deutlich kennzeichnen, sonst kann das Finanzamt das Blatt als echte Rechnung - werten (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung ist gleichgueltig). "Angebot" - ist im Camping- und Hotelgeschaeft der uebliche Begriff, "Kostenanschlag" - gehoert ins Werkvertragsrecht (§ 632 Abs. 3, § 649 BGB). - -Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -JOURNAL = r"C:\claude\beherbergungssteuer" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - -os.environ["CAMPINGHOF_TEST"] = "1" -tmp = tempfile.mkdtemp(prefix="angebot_pruef_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -import app as A # noqa: E402 -import bestand as BE # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -antworten = {"Fertig": False, "Hausnummer": True} - - -def askyesno(titel, text, **kw): - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True -messagebox.askyesno = askyesno -messagebox.showinfo = lambda *a, **k: None -warnungen = [] -messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") - -f = A.RechnungsApp() -f.cfg["output_dir"] = ordner -# Dieser Pruefstand will das ECHTE Popup sehen und seine Knoepfe druecken. -f._dialog_zeigen = True - - -def knoepfe_von(fenster): - """Alle Knopf-Widgets eines Fensters, egal wie tief.""" - aus = [] - - def gehe(w): - for k in w.winfo_children(): - if k.winfo_class() in ("TButton", "Button"): - aus.append(k) - gehe(k) - - gehe(fenster) - return aus - - -def klicke_im_popup(teil): - """Wartet auf das Popup und drueckt den Knopf, dessen Text `teil` enthaelt.""" - def versuch(rest=40): - tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] - if tops: - for k in knoepfe_von(tops[-1]): - if teil.lower() in str(k.cget("text")).lower(): - k.invoke() - return - if rest: - f.after(100, lambda: versuch(rest - 1)) - f.after(200, versuch) - - -def kunde(vorname, nachname): - f.k_vorname.set(vorname) - f.k_name.set(nachname) - f.k_strasse.set("Musterweg 1") - f.k_plzort.set("12345 Musterstadt") - f.r_anreise.set("01.08.2026") - f.r_abreise.set("04.08.2026") - f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("3") - f.update() - - -# ---- 1. Der eine Knopf und das Popup -------------------------------------- -knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)] -pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})") -pruefe(not any("Angebot" in t for t in knopf_texte), - "und kein zweiter Knopf fuers Angebot - gewaehlt wird im Popup") - -kunde("Anna", "Vorab") -nummer_vorher = f.r_nummer.get() -zaehler_vorher = f.cfg.get("rechnung_zaehler") -pruefe(nummer_vorher == "2026-001", f"vorgeschlagen ist 2026-001 ({nummer_vorher})") - -# ---- 2. Abbrechen schreibt nichts ---------------------------------------- -klicke_im_popup("Abbrechen") -f.pdf_erstellen() -pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})") - -# ---- 3. Angebot waehlen -------------------------------------------------- -klicke_im_popup("Angebot") -f.pdf_erstellen() -pruefe(sorted(os.listdir(ordner)) == ["Angebote"], - f"im Rechnungsordner liegt nur der Unterordner ({sorted(os.listdir(ordner))})") -angebote = os.path.join(ordner, "Angebote") -dateien = sorted(os.listdir(angebote)) -pruefe(dateien == ["Angebot_2026-A001_Vorab.pdf"], - f"das Angebot liegt im Unterordner Angebote ({dateien})") - -# ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt -------------- -pruefe(f.r_nummer.get() == nummer_vorher, - f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})") -pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, - f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})") -pruefe(f.cfg.get("angebot_zaehler") == 1, - f"nur der Angebots-Zaehler ist weitergelaufen ({f.cfg.get('angebot_zaehler')})") - -# ---- 5. Was auf dem Blatt steht ----------------------------------------- -from pypdf import PdfReader # noqa: E402 - -pfad = os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf") -seiten = PdfReader(pfad).pages -pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})") -text = (seiten[0].extract_text() or "").replace("\n", " ") -pruefe("Unverbindliches Angebot" in text, "es steht deutlich Unverbindliches Angebot drauf") -pruefe("Rechnung" not in text.split("Unverbindliches Angebot")[0], - "und das Wort Rechnung steht nicht in der Ueberschrift") -pruefe("keine Rechnung im Sinne des § 14 UStG" in text, - "mit dem Satz, dass es keine Rechnung nach § 14 UStG ist") -pruefe("Angebotsnummer" in text, "die Nummer heisst Angebotsnummer") -pruefe("Rechnungsnummer" not in text, - "das Wort Rechnungsnummer kommt gar nicht mehr vor") -pruefe("2026-A001" in text and "2026-001" not in text, - "nur die Angebots-Nummer steht drauf, keine Rechnungsnummer") -pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text, - "kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)") -pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text, - "auch die Beherbergungssteuer steht nicht drauf - gar keine Steuer wird ausgewiesen") -pruefe("alle Steuern und Abgaben enthalten" in text, - "dafuer der Hinweis, dass alles im Betrag enthalten ist") -pruefe("Voraussichtlicher Gesamtbetrag" in text, - "der Betrag heisst voraussichtlich, nicht Rechnungsbetrag") - -meta = BE.kenndaten_lesen(pfad) or {} -pruefe(meta.get("art") == "angebot", f"art = angebot ({meta.get('art')})") -pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0, - f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})") - -# ---- 6. Danach die echte Rechnung: sie bekommt 2026-001 ---------------- -antworten["Storno"] = True -klicke_im_popup("Rechnung 2026-001 erstellen") -f.pdf_erstellen() -pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")), - f"die Rechnung liegt bei den Rechnungen ({sorted(os.listdir(ordner))})") -pruefe(f.cfg.get("rechnung_zaehler") == 1, - f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})") - -# ---- 7. Das zweite Angebot zaehlt in seiner Reihe weiter --------------- -f.reset_formular() -kunde("Bernd", "Zweitvorab") -klicke_im_popup("Angebot") -f.pdf_erstellen() -pruefe(os.path.exists(os.path.join(angebote, "Angebot_2026-A002_Zweitvorab.pdf")), - f"das zweite Angebot heisst A002 ({sorted(os.listdir(angebote))})") -f.destroy() - -# ---- 8. Das Steuerjournal bucht das Angebot NICHT --------------------- -sys.path.append(JOURNAL) -import db as BST # noqa: E402 - -journal = BST.Journal(os.path.join(tmp, "journal.sqlite3")) -journal.scanne(ordner, 5.0, voll=True) -buchungen = journal.buchungen(jahr=2026) -pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})") -pruefe(buchungen and buchungen[0].voll_nummer == "2026-001", - f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})") -pruefe(not any("A0" in b.voll_nummer for b in buchungen), - "keine Angebots-Nummer im Journal") - -# ---- 9. Zweite Sicherung: auch ohne Kenndaten wird nichts gebucht ----- -from pdf_parser import keine_buchung # noqa: E402 - -nur_name = os.path.join(ordner, "Angebot_2026-A099_OhneKenndaten.pdf") -shutil.copy(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf"), nur_name) -pruefe(keine_buchung(nur_name), - "ein Angebot wird auch am Dateinamen erkannt (falls die Kenndaten fehlen)") -pruefe(keine_buchung(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")), - "und alles im Unterordner Angebote wird uebersprungen") -pruefe(not keine_buchung(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")), - "eine echte Rechnung wird davon NICHT erfasst") -journal.schliessen() - -if not fehler: - shutil.rmtree(tmp, ignore_errors=True) -else: - print(f" (Wegwerf-Ordner bleibt: {tmp})") -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") -sys.exit(1 if fehler else 0) diff --git a/pruef_api.py b/pruef_api.py deleted file mode 100644 index ff8994d..0000000 --- a/pruef_api.py +++ /dev/null @@ -1,167 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: das Fenster ueber die API bedienen - mit Schluessel. - -Warum es das gibt (seine Ansage): *"bau dir steuer logik per api rein, dann -kannst damit die sachen triggern, brauchst meine maus nicht"* und *"mach pw -rein, also schluessel wie bei ssh key, das nur du und das beherbergungssteuer -ding rein kann, weil muss ja sprechen"*. - -Geprueft wird: - -1. Ohne Schluessel geht NICHTS - auch nicht "nur nachsehen". -2. Mit falschem Schluessel ebenfalls nicht. -3. Mit dem Schluessel aus dem gemeinsamen Ordner laesst sich das Fenster - fuellen, nachlesen und eine Vorschau erzeugen. -4. Was einen BELEG schreibt, geht nur im Testlauf - und schreibt dann - tatsaechlich die Rechnung. -5. ping und schau_nach bleiben schluessellos, sonst kann ein aelteres - Steuerjournal nicht mehr anklopfen. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - -tmp = tempfile.mkdtemp(prefix="api_pruef_") -ordner = os.path.join(tmp, "rechnungen") -gemeinsam_ordner = os.path.join(tmp, "install") -os.makedirs(ordner) -os.makedirs(gemeinsam_ordner) - -import config # noqa: E402 -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.BASE_DIR = tmp -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -roh["gemeinsam_ordner"] = gemeinsam_ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -os.environ["CAMPINGHOF_TEST"] = "1" -import api # noqa: E402 -import app as A # noqa: E402 -import gemeinsam # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(120, _reissleine) -_wecker.daemon = True -_wecker.start() - - -messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. -messagebox.askyesno = lambda *a, **k: True - -f = A.RechnungsApp() -f.cfg["output_dir"] = ordner -f.buch = gemeinsam.Nummernbuch(gemeinsam_ordner) -f.api.starten(f.buch, ordner=gemeinsam_ordner) -f.update() - -port = f.api.port -pruefe(bool(port), f"die API laeuft ({port})") - -schluessel = api.schluessel_holen(gemeinsam_ordner) -pruefe(len(schluessel) == 64, f"ein Schluessel liegt im gemeinsamen Ordner ({len(schluessel)} Zeichen)") -pruefe(os.path.exists(os.path.join(gemeinsam_ordner, api.SCHLUESSEL_DATEI)), - "und zwar als Datei neben dem Nummernbuch") -pruefe(api.schluessel_holen(gemeinsam_ordner) == schluessel, - "beim zweiten Lesen kommt derselbe zurueck (kein neuer je Start)") - - -def rufen(**nachricht): - """Direkt anrufen - der Pruefstand spielt hier das andere Programm.""" - was = nachricht.pop("was", "aktion") - return api._reden(port, was, zeitgrenze=20.0, **nachricht) - - -# Das Fenster muss waehrend des Anrufs weiterlaufen: die Aktion wird in seinem -# Faden ausgefuehrt. Also nebenher anrufen und die Ereignisschleife drehen. -def anrufen_und_drehen(**nachricht): - ergebnis = {} - - def anruf(): - ergebnis["antwort"] = rufen(**nachricht) - - t = threading.Thread(target=anruf, daemon=True) - t.start() - while t.is_alive(): - f.update() - return ergebnis.get("antwort") - - -# ---- 1. + 2. Ohne (oder mit falschem) Schluessel: kein Zutritt ------------- -pruefe(anrufen_und_drehen(name="zustand") == "kein zutritt", - "ohne Schluessel: kein Zutritt") -pruefe(anrufen_und_drehen(name="zustand", schluessel="0" * 64) == "kein zutritt", - "falscher Schluessel: kein Zutritt") - -# ---- 5. Die alten zwei Woerter bleiben schluessellos ----------------------- -pruefe(anrufen_und_drehen(was="ping") == api.ANTWORT_HALLO, - "ping antwortet weiterhin ohne Schluessel") - -# ---- 3. Mit Schluessel: fuellen, nachlesen, Vorschau ----------------------- -antwort = anrufen_und_drehen( - name="setze", schluessel=schluessel, - daten={"kunde": {"vorname": "Anna", "nachname": "Beispiel", - "strasse": "Musterweg 1", "plzort": "12345 Musterstadt"}, - "anreise": "01.09.2026", "abreise": "04.09.2026", - "positionen": [{"nr": sorted(f.anzahl_vars)[0], "anzahl": 3}]}) -pruefe(isinstance(antwort, dict) and antwort.get("kunde", {}).get("nachname") == "Beispiel", - f"mit Schluessel laesst sich das Formular fuellen ({str(antwort)[:60]}…)") -pruefe(antwort.get("gesamt", 0) > 0, f"und es ist gerechnet ({antwort.get('gesamt')})") - -antwort = anrufen_und_drehen(name="vorschau", schluessel=schluessel) -pfad = (antwort or {}).get("pfad", "") -pruefe(bool(pfad) and os.path.exists(pfad), f"die Vorschau entsteht ohne Maus ({os.path.basename(pfad)})") -pruefe(not os.listdir(ordner), "und sie landet NICHT im Rechnungsordner") - -# ---- 4. Beleg schreiben --------------------------------------------------- -antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel) -pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")] -pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})") - -# Und im ECHTEN Betrieb nicht: dieselbe Aktion, ohne den Pruefschalter. -# Umgeschaltet wird die Umgebungsvariable selbst - das Programm fragt sie bei -# jeder Aktion neu ab, genau wie beim echten Start. -os.environ.pop("CAMPINGHOF_TEST", None) -antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel) -os.environ["CAMPINGHOF_TEST"] = "1" -pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")), - f"im echten Betrieb verweigert sie den Beleg ({antwort})") -pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1, - "und es kam keine zweite Rechnung dazu") - -# Unbekannter Name sagt, was es gibt - statt still nichts zu tun. -antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel) -pruefe("kenne ich nicht" in str(antwort), f"unbekannte Aktion wird benannt ({str(antwort)[:50]}…)") - -f.api.stoppen(f.buch) -f.destroy() -print() -if fehler: - print(f"FEHLGESCHLAGEN: {len(fehler)}") - for x in fehler: - print(" -", x) - print(f"(Ordner bleibt: {tmp})") - sys.exit(1) -shutil.rmtree(tmp, ignore_errors=True) -print("ALLES GRUEN") diff --git a/pruef_bilder.py b/pruef_bilder.py deleted file mode 100644 index f9da725..0000000 --- a/pruef_bilder.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: Kopf- und Fussbild auf der Rechnung. - -Drei Dinge muessen stimmen, sonst faellt es erst beim Drucken auf: - -1. Ein gewaehltes Bild wird KOPIERT (Ordner "bilder"), nicht nur verlinkt - - sonst ist das Logo weg, sobald der Nutzer seinen Desktop aufraeumt. -2. Das Fussbild steht auf der Kundenrechnung, aber NICHT auf dem Amt-Blatt: - ein Briefpapier-Band traegt Bank und Steuernummer, und die haben auf dem - Blatt fuers Amt nichts zu suchen. -3. Der Haken "Angaben stehen im Bild" laesst die gezeichneten Texte weg - - und zwar nur dort, wo er gilt. -""" -import json -import os -import shutil -import sys -import tempfile - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - -import config # noqa: E402 - -tmp = tempfile.mkdtemp(prefix="bilder_pruef_") -config.BASE_DIR = tmp -config.CONFIG_PATH = os.path.join(tmp, "config.json") -shutil.copy(os.path.join(QUELLE, "config.json"), config.CONFIG_PATH) - -import bilder # noqa: E402 -import pdf_renderer # noqa: E402 -from berechnung import berechne # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def testbild(pfad, farbe): - """Kleines PNG erzeugen - der Pruefstand braucht keine echten Kundenbilder.""" - from PIL import Image - Image.new("RGB", (600, 120), farbe).save(pfad) - return pfad - - -# ---- 1. Uebernehmen: kopieren, nicht verlinken ----------------------------- -fremd = os.path.join(tmp, "vom_desktop.png") -testbild(fremd, (10, 90, 60)) -gespeichert = bilder.uebernehmen(fremd) -pruefe(gespeichert == "bilder/vom_desktop.png", f"Pfad wird kurz gemerkt: {gespeichert}") -pruefe(os.path.exists(os.path.join(tmp, "bilder", "vom_desktop.png")), "Bild liegt im Ordner bilder/") - -# Das Original loeschen - die Rechnung muss trotzdem ein Bild haben. -os.remove(fremd) -pruefe(bool(bilder.aufloesen(gespeichert)), "Bild bleibt da, wenn das Original geloescht wird") - -# Dasselbe Bild zweimal waehlen -> kein zweites Exemplar. -shutil.copy(os.path.join(tmp, "bilder", "vom_desktop.png"), fremd) -pruefe(bilder.uebernehmen(fremd) == "bilder/vom_desktop.png", "gleiches Bild zweimal -> kein Duplikat") -# Anderer Inhalt, gleicher Name -> das alte Bild wird NICHT ueberschrieben. -testbild(fremd, (200, 30, 30)) -zweit = bilder.uebernehmen(fremd) -pruefe(zweit == "bilder/vom_desktop-2.png", f"gleicher Name, anderes Bild -> {zweit}") - -# ---- 2. Rendern: Kunde vs. Amt -------------------------------------------- -cfg = config.load_config() -cfg["firma"]["logo"] = bilder.uebernehmen(testbild(os.path.join(tmp, "kopf.png"), (20, 60, 120))) -cfg["firma"]["fussbild"] = bilder.uebernehmen(testbild(os.path.join(tmp, "fuss.png"), (120, 60, 20))) -cfg["firma"]["bank"] = "Bank Testbank" - -mengen = {1: {"anzahl": 3, "personen": 0}} -r = berechne(cfg["katalog"], mengen, cfg) -kopf = {"art": "rechnung", "rechnungsnummer": "2026-999", "datum": "05.09.2026", - "kunde_zeilen": ["Herr", "Max Muster", "Musterweg 1", "04416 Markkleeberg"], - "anreise": "01.09.2026", "abreise": "04.09.2026", "naechte": 3} -pfad = os.path.join(tmp, "probe.pdf") -pdf_renderer.render_rechnung(pfad, cfg, r, kopf) -pruefe(os.path.exists(pfad), "PDF mit beiden Bildern entsteht") - - -def seiten_bilder_und_text(pdf): - """Je Seite: (Anzahl eingebetteter Bilder, Text).""" - from pypdf import PdfReader - raus = [] - for seite in PdfReader(pdf).pages: - xo = seite.get("/Resources", {}).get("/XObject", {}) - try: - xo = xo.get_object() - except AttributeError: - pass - anzahl = sum(1 for k in xo if xo[k].get_object().get("/Subtype") == "/Image") - raus.append((anzahl, seite.extract_text() or "")) - return raus - - -seiten = seiten_bilder_und_text(pfad) -pruefe(len(seiten) == 1, f"genau EIN Blatt ({len(seiten)} Seiten)") -kunde_bilder, kunde_text = seiten[0] -pruefe(kunde_bilder == 2, f"das Blatt traegt Kopf- UND Fussbild ({kunde_bilder})") -pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Rechnung") -pruefe("Steuernummer" in kunde_text, - "die Steuernummer steht auf der Rechnung (§ 14 Abs. 4 Nr. 2 UStG)") - -# ---- 3. Die Firmenfarbe faerbt das Blatt ---------------------------------- -# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte -# Farbe wirklich im PDF landet - und dass eine kaputte Eingabe das Schreiben -# NICHT verhindert, sondern still auf die Vorgabe zurueckfaellt. -cfg["design"] = {"akzent": "#8a1f1f"} -pfad2 = os.path.join(tmp, "probe_rot.pdf") -pdf_renderer.render_rechnung(pfad2, cfg, r, kopf) -pruefe(pdf_renderer.ACCENT.hexval().endswith("8a1f1f"), - f"gewaehlte Farbe steht im Blatt: {pdf_renderer.ACCENT.hexval()}") -pruefe(pdf_renderer.GREY_BG.red > 0.9, - "helle Flaeche wird aus der Farbe gemischt (bleibt hell)") - -cfg["design"] = {"akzent": "gruen bitte"} -pfad3 = os.path.join(tmp, "probe_kaputt.pdf") -pdf_renderer.render_rechnung(pfad3, cfg, r, kopf) -pruefe(os.path.exists(pfad3), "kaputte Farbe verhindert die Rechnung NICHT") -pruefe(pdf_renderer.ACCENT.hexval().endswith("1f5c3d"), "kaputte Farbe faellt auf die Vorgabe zurueck") - -cfg["design"] = {"akzent": pdf_renderer.AKZENT_VORGABE} - -# Ohne Bilder muss das Blatt aussehen wie immer. -cfg["firma"]["logo"] = "" -cfg["firma"]["fussbild"] = "" -pfad4 = os.path.join(tmp, "probe_ohne_bild.pdf") -pdf_renderer.render_rechnung(pfad4, cfg, r, kopf) -seiten3 = seiten_bilder_und_text(pfad4) -pruefe(seiten3[0][0] == 0, "ohne Bilder: keine Bilder im PDF") -pruefe(cfg["firma"]["name"] in seiten3[0][1], "ohne Bild steht der Firmenname gezeichnet oben") - -print() -print(f"Ordner: {tmp}") -if fehler: - print(f"FEHLGESCHLAGEN: {len(fehler)}") - for f in fehler: - print(" -", f) - sys.exit(1) -print("ALLES GRUEN") diff --git a/pruef_darstellung.py b/pruef_darstellung.py deleted file mode 100644 index d5f8158..0000000 --- a/pruef_darstellung.py +++ /dev/null @@ -1,269 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: hell, dunkel, automatisch - und nichts geht dabei verloren. - -Sein Wortlaut vom 07.09.2026: - - "waere nun noch gut das mann in den einstellungen einstellen kann hell - oder dunkel oder automatik das fehlt sollte eigendlich drin sein" - -GEPRUEFT WIRD DREIERLEI - -1. Die Einstellung wirkt. "hell" ist hell, "dunkel" ist dunkel, "auto" folgt - Windows. - -2. Beim Umschalten bleibt jede Eingabe stehen. Das ist der Punkt, an dem es - beim Bauen schiefging: die Mengen der Leistungszeilen hatte ich einzeln - gesichert, Kundenname und Rechnungsnummer nicht - die _build_-Methoden - legen ihre Variablen selbst an, beim Neubau standen also leere da. Wer - waehrend einer halb getippten Rechnung auf dunkel stellt, darf sie nicht - verlieren. - -3. NACH dem Wechsel traegt kein Widget mehr eine Farbe der anderen Palette. - Das ist die eigentliche Falle beim Umfaerben: ein einzelnes vergessenes - Label leuchtet hell im dunklen Fenster, und niemand findet die Stelle - wieder. Deshalb wird der ganze Widget-Baum abgegangen und mit den Farben - der jeweils ANDEREN Palette verglichen. -""" -import json -import os -import shutil -import sys -import tempfile -import time -import tkinter as tk - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - - -os.chdir(QUELLE) -os.environ["CAMPINGHOF_TEST"] = "1" - -from tkinter import messagebox # noqa: E402 - -messagebox.askyesno = lambda *a, **k: True -for _n in ("showinfo", "showwarning", "showerror"): - setattr(messagebox, _n, lambda *a, **k: None) - -import config # noqa: E402 - -# Auf einer Kopie arbeiten - die echte Konfiguration wird nicht angefasst. -tmp = tempfile.mkdtemp(prefix="darstellung_") -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["nummer_eingerichtet"] = True -roh.pop("darstellung", None) -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), - ensure_ascii=False, indent=2) - -import app as A # noqa: E402 -import theme # noqa: E402 -from pruefhelfer import fehler, pruefe - -f = A.RechnungsApp() - - -def pumpen(sekunden=1.0): - ende = time.time() + sekunden - while time.time() < ende: - f.update() - time.sleep(0.02) - - -pumpen(2.0) - -# ---- Ohne Einstellung: automatisch -------------------------------------- -pruefe(theme.modus(f.cfg) == "auto", - f"ohne Eintrag gilt 'auto' ({theme.modus(f.cfg)!r})") -pruefe(f._dark_mode == theme.windows_dark_mode(), - "und folgt damit dem Windows-Theme") - -# ---- Eingaben, die den Wechsel ueberleben muessen ------------------------ -f.k_vorname.set("Erika") -f.k_name.set("Mustermann") -f.k_strasse.set("Musterweg 12") -f.k_plzort.set("04416 Markkleeberg") -f.r_nummer.set("2026-099") -f.r_anreise.set("01.09.2026") -erste = sorted(f.anzahl_vars)[0] -f.anzahl_vars[erste].set("3") -pumpen(0.5) - - -def farben(dunkel): - """Die Palette der einen oder anderen Darstellung, als Menge.""" - merker = f._dark_mode - f.cfg["darstellung"] = "dunkel" if dunkel else "hell" - f._apply_theme() - satz = {getattr(f, n).lower() for n in dir(f) - if n.startswith("col_") and isinstance(getattr(f, n), str)} - f._dark_mode = merker - return satz - - -HELL = farben(False) -DUNKEL = farben(True) -# Was in BEIDEN Paletten vorkommt, taugt nicht als Beweis. -NUR_HELL = HELL - DUNKEL -NUR_DUNKEL = DUNKEL - HELL - - -def fremdfarben(verboten): - """Alle Widgets, die eine Farbe der falschen Palette tragen.""" - treffer = [] - - def durchgehen(w): - for kind in w.winfo_children(): - for eigenschaft in ("bg", "background", "fg", "foreground"): - try: - wert = str(kind.cget(eigenschaft)).lower() - except tk.TclError: - continue - if wert in verboten: - treffer.append((str(kind), eigenschaft, wert)) - durchgehen(kind) - - durchgehen(f) - return treffer - - -# ---- Die drei Modi ------------------------------------------------------ -for wunsch, soll_dunkel in (("hell", False), ("dunkel", True)): - f.cfg["darstellung"] = wunsch - config.save_config(f.cfg) - f._oberflaeche_neu() - pumpen(0.8) - - pruefe(f._dark_mode == soll_dunkel, - f"'{wunsch}' zeichnet {'dunkel' if soll_dunkel else 'hell'} " - f"(Hintergrund {f.col_bg})") - - # Nichts darf verlorengehen. - pruefe(f.k_name.get() == "Mustermann", - f"nach dem Wechsel steht der Kundenname noch da ({f.k_name.get()!r})") - pruefe(f.r_nummer.get() == "2026-099", - f"und die Rechnungsnummer ({f.r_nummer.get()!r})") - pruefe(f.k_plzort.get() == "04416 Markkleeberg", - f"und die Anschrift ({f.k_plzort.get()!r})") - pruefe(f.r_anreise.get() == "01.09.2026", - f"und das Anreisedatum ({f.r_anreise.get()!r})") - pruefe(f.anzahl_vars.get(erste) is not None - and f.anzahl_vars[erste].get() == "3", - "und die eingetragene Menge in der Leistungszeile") - - # Kein Rest der anderen Palette. - reste = fremdfarben(NUR_DUNKEL if not soll_dunkel else NUR_HELL) - pruefe(not reste, - f"in '{wunsch}' ist keine Farbe der anderen Darstellung " - f"uebrig ({reste[:3]})") - -# ---- Dialoge gehen mittig ueber dem Programmfenster auf ------------------ -# Seine Ansage: "einfach so programmieren das die popup auf dem fenster mittig -# vom main fenster auf gehen". Das ist zugleich die Antwort auf die -# Monitorfrage - steht das Programm auf dem zweiten Bildschirm, geht der -# Dialog dort auf. Vorher klemmte ein max(0, x) jede negative Koordinate auf -# null und riss die Fenster auf den Hauptmonitor zurueck. -d = f.dlg_darstellung() -pumpen(1.2) -d.update_idletasks() -mitte_dialog = d.winfo_rootx() + d.winfo_width() // 2 -mitte_fenster = f.winfo_rootx() + f.winfo_width() // 2 -pruefe(abs(mitte_dialog - mitte_fenster) <= 30, - f"der Dialog steht mittig ueber dem Fenster " - f"({mitte_dialog} vs. {mitte_fenster})") -pruefe(d.winfo_rootx() >= f.winfo_rootx() - 40 - and d.winfo_rootx() + d.winfo_width() <= f.winfo_rootx() + f.winfo_width() + 40, - "und liegt waagerecht innerhalb des Fensters") -pruefe(d.winfo_rooty() >= f.winfo_rooty() - 40, - "und nicht oberhalb davon") -d.destroy() -pumpen(0.3) - -# ---- Der Dialog muss den Klick UEBERLEBEN ------------------------------- -# Gefunden am 07.09.2026 von der Pruefkette: _oberflaeche_neu() loeschte alle -# Kinder des Hauptfensters - und ein Toplevel IST ein Kind. Der offene Dialog -# wurde also beim ersten Klick mitgeloescht, die Zeile danach fasste eine -# Leiche an ("invalid command name .!toplevel"). -# -# Dieser Pruefstand hat es NICHT gefunden, weil er cfg["darstellung"] von Hand -# setzte. Hier wird jetzt der Radioknopf gedrueckt - derselbe Weg, den ein -# Mensch geht. -d = f.dlg_darstellung() -pumpen(1.0) - - -def radioknoepfe(fenster): - """Alle Radiobuttons unterhalb eines Fensters, mit ihrer Beschriftung.""" - treffer = [] - - def durchgehen(w): - for kind in w.winfo_children(): - if kind.winfo_class() == "TRadiobutton": - try: - treffer.append((str(kind.cget("text")), kind)) - except tk.TclError: - pass - durchgehen(kind) - - durchgehen(fenster) - return treffer - - -knoepfe = radioknoepfe(d) -pruefe(len(knoepfe) == len(theme.MODI), - f"der Dialog zeigt alle {len(theme.MODI)} Moeglichkeiten " - f"({[t for t, _ in knoepfe]})") - -ziel = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["dunkel"]), None) -pruefe(ziel is not None, "der Knopf 'Dunkel' ist da") -if ziel is not None: - ziel.invoke() # der echte Klick - pumpen(1.2) - pruefe(bool(d.winfo_exists()), - "der Dialog lebt nach dem Klick noch") - pruefe(f._dark_mode is True, - f"und die Darstellung ist umgestellt (Hintergrund {f.col_bg})") - # Und noch einmal, denn genau das war vorher unmoeglich: - hell = next((w for t, w in knoepfe if t == theme.MODUS_NAMEN["hell"]), None) - if hell is not None: - try: - hell.invoke() - pumpen(1.0) - pruefe(f._dark_mode is False, - "ein ZWEITER Klick im selben Dialog wirkt auch") - except tk.TclError as e: - pruefe(False, f"der zweite Klick scheiterte ({e})") -try: - d.destroy() -except tk.TclError: - pass -pumpen(0.3) - -# ---- Zurueck auf automatisch -------------------------------------------- -f.cfg["darstellung"] = "auto" -config.save_config(f.cfg) -f._oberflaeche_neu() -pumpen(0.8) -pruefe(f._dark_mode == theme.windows_dark_mode(), - "'auto' folgt wieder dem Windows-Theme") - -# ---- Und die Einstellung ueberlebt den Neustart ------------------------- -f.cfg["darstellung"] = "hell" -config.save_config(f.cfg) -f.destroy() -f = A.RechnungsApp() -pumpen(2.0) -pruefe(f._dark_mode is False, - "die Einstellung steht beim naechsten Start wieder da") - -try: - f.destroy() -except Exception: # noqa: BLE001 - pass -shutil.rmtree(tmp, ignore_errors=True) - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Hell, dunkel und automatisch wirken - ohne Datenverlust") diff --git a/pruef_dialoge.py b/pruef_dialoge.py index 32070f5..284dfb0 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -27,22 +27,14 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json" config.CONFIG_PATH = os.path.join(tmp, "config.json") roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["output_dir"] = ordner -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: dlg_nummer zeigt sonst nur eine Meldung statt des -# Fensters, das hier geprueft werden soll. -roh["nummer_eingerichtet"] = False json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) -# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. -os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 from tkinter import messagebox # noqa: E402 messagebox.showinfo = lambda *a, **k: None messagebox.showwarning = lambda *a, **k: None messagebox.showerror = lambda *a, **k: None -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen diff --git a/pruef_doppelte_leistung.py b/pruef_doppelte_leistung.py deleted file mode 100644 index 883ac8d..0000000 --- a/pruef_doppelte_leistung.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: dieselbe Leistung liegt mehrfach im Ordner. - -Sein Fall, woertlich: *"der typ hat mit dem alten programm einfach eine neue -rechnung gemacht, nun hat der die gleiche rechnung 3-4 mal drin, aber mit -anderen nummern. nun muss man die stornieren, heisst aber, dass wir manuell auf -die neue rechnungsnummer verweisen muessen - das musst du in der storno -abfragen, ob er schon eine hat oder eine neue rechnung erstellen will."* - -Also fragt das Storno jetzt zuerst: - A - es folgt eine neue Rechnung (der bisherige Weg) - B - die Leistung ist schon abgerechnet -> Nummer der BLEIBENDEN Rechnung - -Geprueft wird B am echten Fenster: die Knoepfe werden gedrueckt, die Nummer -wirklich eingetippt. Und am Ende muss im Steuerjournal genau EIN Aufenthalt -stehen - nicht zwei. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -JOURNAL = r"C:\claude\beherbergungssteuer" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - -os.environ["CAMPINGHOF_TEST"] = "1" -tmp = tempfile.mkdtemp(prefix="doppelt_leistung_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -roh["nummer_eingerichtet"] = True -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -import app as A # noqa: E402 -import bestand as BE # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -antworten = {"Fertig": False, "Hausnummer": True, "Storno erstellt": False, - "Angaben zum Kunden": True, "Weicht ab": True} -warnungen = [] - - -def askyesno(titel, text, **kw): - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True -messagebox.askyesno = askyesno -messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") - -f = A.RechnungsApp() -f.cfg["output_dir"] = ordner -f._storno_dialog_zeigen = True # NUR das Storno-Popup soll wirklich kommen - - -def widgets(w, klassen, aus=None): - aus = [] if aus is None else aus - for k in w.winfo_children(): - if k.winfo_class() in klassen: - aus.append(k) - widgets(k, klassen, aus) - return aus - - -def rechnung(name, naechte=3): - """Zweimal dieselbe Leistung - so, wie das alte Programm es angerichtet hat.""" - f.reset_formular() - f.k_vorname.set("Anna") - f.k_name.set(name) - f.k_strasse.set("Musterweg 1") - f.k_plzort.set("12345 Musterstadt") - f.r_anreise.set("01.08.2026") - f.r_abreise.set("04.08.2026") - f.anzahl_vars[sorted(f.anzahl_vars)[0]].set(str(naechte)) - f._testwahl = "rechnung" - f.update() - f.pdf_erstellen() - - -rechnung("Meier") # 2026-001 - die bleibt -rechnung("Meier") # 2026-002 - dieselbe Leistung, versehentlich noch einmal -pruefe(sorted(os.listdir(ordner)) == ["Rechnung_2026-001_Meier.pdf", - "Rechnung_2026-002_Meier.pdf"], - f"zwei Rechnungen ueber denselben Aufenthalt ({sorted(os.listdir(ordner))})") - - -def druecke(top, text): - for k in widgets(top, ("TButton", "Button")): - if text.lower() in str(k.cget("text")).lower(): - k.invoke() - return True - return False - - -def storno_mit_popup(meta, nummer=None, schritt1="B: schon abgerechnet", danach=None): - """Storno anstossen und das zweistufige Popup bedienen. - - Erst A oder B - und ERST DANN gibt es das Nummernfeld ("step by step, - nicht alles auf einmal"). - """ - zustand = {"schritt": 1} - - def klick(rest=60): - tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] - if not tops: - if rest: - f.after(100, lambda: klick(rest - 1)) - return - top = tops[-1] - if zustand["schritt"] == 1: - felder = widgets(top, ("TEntry", "Entry")) - zustand["feld_in_schritt1"] = bool(felder and felder[0].winfo_ismapped()) - if druecke(top, schritt1): - zustand["schritt"] = 2 - if nummer is None: - return - f.after(100, lambda: klick(rest - 1)) - return - felder = [e for e in widgets(top, ("TEntry", "Entry")) if e.winfo_ismapped()] - if felder: - felder[0].delete(0, "end") - felder[0].insert(0, nummer) - f.update() - druecke(top, "Übernehmen") - if danach: - # Bei ungueltiger Eingabe bleibt das Fenster offen (so soll es - # sein) - der Pruefstand macht es dann selbst zu. - f.after(100, lambda: druecke(top, danach) if top.winfo_exists() else None) - return - if rest: - f.after(100, lambda: klick(rest - 1)) - - f.after(200, klick) - f._storno_anlegen(meta, ordner) - return zustand - - -zweite = [m for m in BE.rechnungen_im_ordner(ordner) - if str(m.get("rechnungsnummer")) == "2026-002"][0] - -# ---- 1. Falsche Eingaben nimmt das Fenster nicht an ---------------------- -zustand = storno_mit_popup(zweite, "2026-999", danach="Abbrechen") # gibt es nicht -pruefe(zustand.get("feld_in_schritt1") is False, - "im ersten Schritt gibt es noch KEIN Nummernfeld (step by step)") -pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0, - "eine Nummer, die es nicht gibt, schreibt kein Storno") - -storno_mit_popup(zweite, "2026-002", danach="Abbrechen") # sie selbst -pruefe(len([d for d in os.listdir(ordner) if d.startswith("Storno")]) == 0, - "und auf sich selbst verweist auch keines") - -# Die Inhaltspruefung selbst - was passt zusammen und was nicht -schwer, leicht = A.RechnungsApp._passt_zusammen( - {"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0}, - {"nachname": "Schulz", "anreise": "01.08.2026", "gesamt": 39.0}) -pruefe(bool(schwer) and "anderer Gast" in schwer[0], - f"ein anderer Gast ist ein harter Fehler ({schwer})") -schwer, leicht = A.RechnungsApp._passt_zusammen( - {"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0}, - {"nachname": "Meier", "anreise": "05.08.2026", "gesamt": 52.0}) -pruefe(not schwer and len(leicht) == 2, - f"anderer Zeitraum und Betrag sind Hinweise, kein Verbot ({schwer}, {leicht})") -schwer, leicht = A.RechnungsApp._passt_zusammen( - {"nachname": "Meier", "anreise": "01.08.2026", "gesamt": 39.0}, - {"nachname": "meier", "anreise": "01.08.2026", "gesamt": 39.0}) -pruefe(not schwer and not leicht, f"gleiche Leistung passt glatt ({schwer}, {leicht})") - -# ---- 2. Der richtige Weg: auf die bleibende Rechnung verweisen ---------- -storno_mit_popup(zweite, "2026-001") -f.update() -stornos = [d for d in os.listdir(ordner) if d.startswith("Storno")] -pruefe(len(stornos) == 1, f"das Storno ist geschrieben ({stornos})") -pfad = os.path.join(ordner, stornos[0]) -meta = BE.kenndaten_lesen(pfad) or {} -pruefe(meta.get("storno_zu") == "2026-002", f"es hebt 2026-002 auf ({meta.get('storno_zu')})") -pruefe(meta.get("korrektur_nummer") == "2026-001", - f"und verweist auf die bleibende 2026-001 ({meta.get('korrektur_nummer')})") -pruefe(meta.get("ersatz_vorhanden") is True, - f"als BEREITS abgerechnet, nicht als kommende Rechnung ({meta.get('ersatz_vorhanden')})") - -from pypdf import PdfReader # noqa: E402 - -text = (PdfReader(pfad).pages[0].extract_text() or "").replace("\n", " ") -pruefe("Schon abgerechnet" in text and "2026-001" in text, - "auf dem Blatt steht 'Schon abgerechnet: 2026-001'") -pruefe("doppelt in Rechnung gestellt" in text, - "und der Satz, warum es die Rechnung zweimal gab") -pruefe("es folgt keine weitere Rechnung" in text, - "sowie dass keine weitere Rechnung kommt") -pruefe("Berichtigte Rechnung" not in text, - "und NICHT 'Berichtigte Rechnung' - es kommt ja keine") - -# ---- 3. Kein offener Vorgang, keine reservierte Nummer ------------------ -pruefe(f.storno_stand is None, "es bleibt kein Storno-Vorgang offen") -pruefe(str(f.nummer_entry.cget("state")) == "normal", "das Nummernfeld ist nicht gesperrt") -pruefe(f.r_nummer.get() == "2026-004", - f"die naechste Rechnung waere 2026-004 (nach dem Storno 003) ({f.r_nummer.get()})") - -# ---- 4. Was am Ende im Steuerjournal steht ----------------------------- -sys.path.append(JOURNAL) -import db as BST # noqa: E402 -from modell import summiere, aufgehobene # noqa: E402 - -journal = BST.Journal(os.path.join(tmp, "journal.sqlite3")) -journal.scanne(ordner, 5.0, voll=True) -buchungen = journal.buchungen(jahr=2026) -s = summiere(buchungen) -print("--- was im Journal steht ---") -for b in buchungen: - print(f" {b.voll_nummer} {b.nachname:8s} {b.naechte:>3} Nächte {b.gezahlt:>8.2f} €") -pruefe(len(buchungen) == 3, f"drei Zeilen: zwei Rechnungen und das Storno ({len(buchungen)})") -pruefe(s.naechte == 3, f"gemeldet wird EIN Aufenthalt mit 3 Nächten ({s.naechte})") -pruefe(abs(s.gezahlt - 39.0) < 0.005, f"und 39,00 € statt 78,00 € ({s.gezahlt})") -weg = aufgehobene(buchungen) -storniert = [b for b in buchungen if b.id in weg] -pruefe(len(storniert) == 1 and storniert[0].voll_nummer == "2026-002", - f"aufgehoben ist genau die zweite ({[b.voll_nummer for b in storniert]})") -journal.schliessen() - -f.destroy() -if not fehler: - shutil.rmtree(tmp, ignore_errors=True) -else: - print(f" (Wegwerf-Ordner bleibt: {tmp})") -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") -sys.exit(1 if fehler else 0) diff --git a/pruef_flaeche.py b/pruef_flaeche.py deleted file mode 100644 index f3dfdf3..0000000 --- a/pruef_flaeche.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: die verschiebbaren Kaesten (Arbeitsflaeche). - -Seine Ansage: *"wie bei opnsense hast so einen kleinen knopf, dann drueckst -drauf, ab da kannst du schieben, dann hast du einen save button - und dann ist -die fest"*. - -Geprueft wird genau diese Reihenfolge: - -1. Solange nicht "Anordnen" gedrueckt ist, laesst sich NICHTS verschieben - - beim taeglichen Arbeiten darf nichts verrutschen. -2. Im Anordnen-Modus haengt ein Etikett am Zeiger (man soll sehen, dass man - etwas in der Hand hat) und zwei Kaesten tauschen ihre Plaetze. -3. "Verwerfen" holt die Anordnung von vorher zurueck. -4. "Speichern" schreibt sie in die config - erst dann steht sie fest. -5. Der freie Platz gehoert immer dem Kasten mit der langen Liste, egal wo er - gerade sitzt. Sonst wird der Kundenkasten unten abgeschnitten. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - -os.environ["CAMPINGHOF_TEST"] = "1" -tmp = tempfile.mkdtemp(prefix="flaeche_pruef_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.BASE_DIR = tmp -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["output_dir"] = ordner -roh["nummer_eingerichtet"] = True -roh.pop("fenster_layout", None) # ohne gespeicherte Anordnung starten -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) - -import app as A # noqa: E402 -import flaeche as FL # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(120, _reissleine) -_wecker.daemon = True -_wecker.start() - - -messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. -messagebox.askyesno = lambda *a, **k: True - -f = A.RechnungsApp() -f.update() -fl = f.flaeche - -# ---- 1. Die Vorgabe ist seine Anordnung ---------------------------------- -pruefe(fl.layout == FL.STANDARD, - f"Vorgabe: Rechnungsdaten oben links, Kunde darunter, Leistungen rechts ({fl.layout})") - -# ---- 2. Ohne den Knopf laesst sich nichts schieben ----------------------- -pruefe(not fl.anordnen_an, "beim Start ist das Verschieben AUS") -fl._greifen("kunde") -pruefe(fl._zieht is None, "ohne 'Anordnen' greift ein Zug ins Leere") -vorgabe = dict(fl.layout) - -# ---- 3. Anordnen an: Etikett und Tausch ---------------------------------- -f._anordnen_umschalten() -pruefe(fl.anordnen_an, "nach dem Knopf ist das Verschieben AN") -fl._greifen("kunde") -pruefe(fl._zieht == "kunde", "jetzt haelt man den Kasten") -pruefe(getattr(fl, "_etikett", None) is not None, - "und am Zeiger haengt ein Etikett - man SIEHT, dass man zieht") -fl._ziel = "daten" -fl._loslassen("kunde") -pruefe(getattr(fl, "_etikett", None) is None, "nach dem Loslassen ist das Etikett weg") -pruefe(fl.layout["kunde"] == vorgabe["daten"] and fl.layout["daten"] == vorgabe["kunde"], - f"die beiden haben die Plaetze getauscht ({fl.layout})") - -# Gespeichert ist noch NICHTS - erst der Knopf schreibt. -in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout") -pruefe(in_datei is None, f"vor dem Speichern steht nichts in der config ({in_datei})") - -# ---- 4. Verwerfen holt die alte Anordnung zurueck ------------------------ -f._anordnen_verwerfen() -pruefe(fl.layout == vorgabe, f"Verwerfen stellt die Anordnung wieder her ({fl.layout})") -pruefe(not fl.anordnen_an, "und schaltet das Verschieben wieder aus") - -# ---- 5. Speichern macht es fest ------------------------------------------ -f._anordnen_umschalten() -fl._greifen("daten") -fl._ziel = "leistungen" -fl._loslassen("daten") -neu = dict(fl.layout) -f._anordnen_speichern() -in_datei = json.load(open(config.CONFIG_PATH, encoding="utf-8")).get("fenster_layout") -pruefe(in_datei == {n: list(v) for n, v in neu.items()}, - f"Speichern schreibt die Anordnung in die config ({in_datei})") -pruefe(not fl.anordnen_an, "und schaltet das Verschieben aus") - -# ---- 6. Der Platz folgt der langen Liste --------------------------------- -spalte, zeile, hoch = fl.layout["leistungen"] -gewicht_liste = fl.grid_rowconfigure(zeile)["weight"] -pruefe(gewicht_liste > 0, - f"die Zeile mit den Leistungen darf wachsen (Gewicht {gewicht_liste})") -breite_liste = fl.grid_columnconfigure(spalte)["weight"] -breite_andere = fl.grid_columnconfigure(1 - spalte)["weight"] -pruefe(breite_liste > breite_andere, - f"und ihre Spalte ist breiter ({breite_liste} zu {breite_andere})") - -# Eine kaputte Anordnung in der config darf das Fenster nicht zerlegen. -f.cfg["fenster_layout"] = {"kunde": "kaputt", "daten": [0], "leistungen": None} -fl.anordnen() -pruefe(fl.layout == FL.STANDARD, - f"eine unbrauchbare Anordnung faellt auf die Vorgabe zurueck ({fl.layout})") - -f.destroy() -print() -if fehler: - print(f"FEHLGESCHLAGEN: {len(fehler)}") - for x in fehler: - print(" -", x) - sys.exit(1) -shutil.rmtree(tmp, ignore_errors=True) -print("ALLES GRUEN") diff --git a/pruef_freigabe.py b/pruef_freigabe.py deleted file mode 100644 index 270b1ab..0000000 --- a/pruef_freigabe.py +++ /dev/null @@ -1,155 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: die Freigabe vor dem Beleg - und ihre Spur in der SQLite. - -Sein Satz vom 06.09.2026: - - "ich will fuer den pruefer sichtbar machen: ich wollte das so. die software - dokumentiert das, und damit wird es nachvollziehbar, weil es einen - checkmark nun gibt in der sqlite" - "das soll alles von anfang bis ende - pruefbar sein" - -Geprueft wird beides, weil jedes einzeln wertlos waere: - - 1. NEIN -> es entsteht KEIN Beleg, die Nummer bleibt frei, im Buch steht - nichts. Ein Popup, das man verneint und trotzdem eine Rechnung - bekommt, waere schlimmer als gar keins. - 2. JA -> der Beleg entsteht UND im Nummernbuch steht ein Zeitstempel der - Freigabe. Ohne diesen Eintrag koennte spaeter niemand belegen, - dass ausdruecklich freigegeben wurde. -""" -import json -import os -import re -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(120, _reissleine) -_wecker.daemon = True -_wecker.start() - -tmp = tempfile.mkdtemp(prefix="freigabe_pruef_") -ordner = os.path.join(tmp, "rechnungen") -install = os.path.join(tmp, "install") -os.makedirs(ordner) -os.makedirs(install) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -roh["gemeinsam_ordner"] = install -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), - ensure_ascii=False, indent=2) - -os.environ["CAMPINGHOF_TEST"] = "1" -import app as A # noqa: E402 -import gemeinsam # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -antworten = {"Fertig": False, "Hausnummer": True} -gefragt = [] - - -def askyesno(titel, text, **kw): - gefragt.append(titel) - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True -messagebox.askyesno = askyesno -messagebox.showinfo = lambda *a, **k: None -warnungen = [] -messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") - -f = A.RechnungsApp() -f.update() -f.cfg["output_dir"] = ordner -# Im laufenden Programm oeffnet ein 400-ms-Timer das gemeinsame Buch. Den -# erreicht ein Pruefstand nie - also wird dieselbe Stelle direkt gerufen. -# Abgekuerzt wird damit nur das Warten, nicht der Weg. -f._frage_gemeinsam_falls_noetig() -pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist offen") - - -def kunde_fuellen(): - f.k_vorname.set("Anna") - f.k_name.set("Beispiel") - f.k_strasse.set("Musterweg 1") - f.k_plzort.set("12345 Musterstadt") - f.r_anreise.set("01.08.2026") - f.r_abreise.set("05.08.2026") - erste = sorted(f.anzahl_vars)[0] - f.anzahl_vars[erste].set("4") - f.update() - - -# ---- 1. NEIN: es darf nichts entstehen ---------------------------------- -antworten["jetzt erstellen?"] = False -kunde_fuellen() -zaehler_vorher = f.cfg.get("rechnung_zaehler") -nummer_vorher = f.r_nummer.get() -f.pdf_erstellen() - -pdfs = [x for x in os.listdir(ordner) if x.lower().endswith(".pdf")] -pruefe(not pdfs, f"nach Nein: kein PDF im Ordner (gefunden: {pdfs})") -pruefe(any("jetzt erstellen?" in t for t in gefragt), - "die Freigabe wurde ueberhaupt gefragt") -pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, - "nach Nein: der Rechnungszaehler ist unveraendert") -pruefe(f.r_nummer.get() == nummer_vorher, - "nach Nein: die Nummer im Feld ist unveraendert") - -buch = gemeinsam.Nummernbuch(install) -pruefe(not buch.nummern(), f"nach Nein: das Nummernbuch ist leer ({buch.nummern()})") - -# ---- 2. JA: Beleg entsteht und die Freigabe steht in der SQLite --------- -antworten["jetzt erstellen?"] = True -kunde_fuellen() -f.pdf_erstellen() - -pdfs = [x for x in os.listdir(ordner) if x.lower().endswith(".pdf")] -pruefe(len(pdfs) == 1, f"nach Ja: genau ein PDF ({pdfs})") - -nummern = sorted(buch.nummern()) -pruefe(len(nummern) == 1, f"nach Ja: eine Nummer im Buch ({nummern})") - -freigabe = "" -if nummern: - with buch._verbindung() as con: - zeile = con.execute("SELECT freigabe FROM nummern WHERE nummer=?", - (nummern[0],)).fetchone() - freigabe = (zeile["freigabe"] if zeile else "") or "" - -pruefe(bool(freigabe), "die Freigabe steht in der SQLite") -pruefe(bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", freigabe)), - f"der Zeitstempel ist ein ISO-Datum ({freigabe!r})") - -_wecker.cancel() -f.destroy() -shutil.rmtree(tmp, ignore_errors=True) - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Freigabe wirkt und wird dokumentiert") diff --git a/pruef_gemeinsam_automatisch.py b/pruef_gemeinsam_automatisch.py deleted file mode 100644 index eab6c13..0000000 --- a/pruef_gemeinsam_automatisch.py +++ /dev/null @@ -1,149 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: finden sich die beiden Programme von SELBST? - -Sein Einwand vom 07.09.2026, wortwoertlich: - - "wie soll das rechnungstool und steuertool wissen wo es ist, das muss im - ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an das - andere ran - die sind doch nur ueber die gemeinsame sqlite verbunden und - mit api, die sagt hallo da ist was neues" - -Er hat recht: die beiden haengen an genau zwei Faeden - dieser SQLite-Datei -und der kleinen API. Vorher fragte jedes Programm beim ersten Start nach einem -Ordner, und der Nutzer musste in BEIDEN denselben eintragen. Ein Buchstabe -daneben, und jedes Programm fuehrt sein eigenes Buch weiter - auffallen wuerde -das erst, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist -(§ 14 Abs. 4 Nr. 4 UStG). - -GEPRUEFT WIRD - - 1. Beide Programme nennen denselben Standardordner. Das ist die Grundlage; - weichen sie hier ab, hilft alles Weitere nichts. - 2. Der Ordner laesst sich anlegen, und darin entsteht EIN Nummernbuch. - 3. Was das eine eintraegt, findet das andere - ohne dass jemand einen Pfad - eingegeben hat. - 4. Die Dateien, die von sich sagen, sie laegen in beiden Programmen gleich, - sind auch wirklich gleich. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber - und sie prueft sich unter Punkt 4 selbst mit, denn sie traegt -denselben Satz wie die anderen. - -Angefasst wird dabei nichts Echtes: der Standardordner wird ueber die -Umgebungsvariable LOCALAPPDATA auf einen Temp-Ordner umgelenkt. -""" -import os -import shutil -import sys -import tempfile - - -RECHNUNG = r"C:\claude\rechnungstool" -STEUER = r"C:\claude\beherbergungssteuer" - -# ---- 1. Beide Programme meinen denselben Ort ---------------------------- -# Geladen wird jede Fassung EINZELN, damit wirklich die Datei des jeweiligen -# Programms befragt wird und nicht die zufaellig zuerst importierte. -import importlib.util # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def modul_aus(ordner, name): - pfad = os.path.join(ordner, name + ".py") - kennung = f"{name}_{os.path.basename(ordner)}" - spec = importlib.util.spec_from_file_location(kennung, pfad) - m = importlib.util.module_from_spec(spec) - spec.loader.exec_module(m) - return m - - -tmp = tempfile.mkdtemp(prefix="gemeinsam_auto_") -os.environ["LOCALAPPDATA"] = tmp - -g_rechnung = modul_aus(RECHNUNG, "gemeinsam") -g_steuer = modul_aus(STEUER, "gemeinsam") - -a, b = g_rechnung.standard_ordner(), g_steuer.standard_ordner() -pruefe(a == b, f"beide Programme nennen denselben Ordner ({a})") -pruefe(a.startswith(tmp), - "und er haengt an LOCALAPPDATA, nicht an einem festen Pfad im Quelltext") -pruefe(os.path.basename(a) == "ravokk", - f"er heisst 'ravokk', wie er es wollte ({os.path.basename(a)})") - -# ---- 2. Er wird angelegt, und darin liegt EIN Buch ---------------------- -angelegt = g_rechnung.standard_anlegen() -pruefe(bool(angelegt) and os.path.isdir(angelegt), - f"der Ordner wird angelegt ({angelegt})") -pruefe(g_rechnung.DATEINAME == g_steuer.DATEINAME, - f"beide erwarten dieselbe Datei ({g_rechnung.DATEINAME})") -pruefe(g_rechnung.pfad_zur_datei(angelegt) == g_steuer.pfad_zur_datei(angelegt), - "und damit denselben vollen Pfad") - -# ---- 3. Was das eine eintraegt, findet das andere ----------------------- -buch_r = g_rechnung.Nummernbuch(angelegt) -buch_s = g_steuer.Nummernbuch(angelegt) - -buch_r.eintragen("2026-777", jahr=2026, nachname="Probe", quelle="rechnungstool") -gefunden = [n for n in buch_s.nummern(2026) if n == "2026-777"] -pruefe(bool(gefunden), - "was das Rechnungstool eintraegt, sieht das Steuerjournal sofort") - -buch_s.eintragen("2026-778", jahr=2026, nachname="Gegenprobe", quelle="journal") -zurueck = [n for n in buch_r.nummern(2026) if n == "2026-778"] -pruefe(bool(zurueck), "und umgekehrt genauso") - -# GENAU eins - nicht "mindestens eins". Vorher stand hier `>= 1` mit einem -# any(); im Text darueber stand "genau ein Buch". Eine Pruefung, die nicht rot -# werden kann, prueft nichts: laege dort ein zweites Buch, weil eine der -# Fassungen einen anderen Dateinamen bildet, waere sie gruen geblieben - und -# das ist genau der Fall, den dieser Pruefstand abfangen soll. -# -# Die Begleitdateien von SQLite (-wal, -shm) zaehlen nicht mit; sie gehoeren -# zum selben Buch. -buecher = [n for n in os.listdir(angelegt) if n.endswith(".sqlite3")] -pruefe(len(buecher) == 1, - f"es liegt genau EIN Buch dort ({os.listdir(angelegt)})") -pruefe(buecher == [g_rechnung.DATEINAME], - f"und es traegt den Namen, den beide erwarten ({buecher})") - -# ---- 4. Was "in beiden gleich" heisst, ist auch gleich ------------------ -# Neun Dateien tragen im Kopf den Satz, sie laegen in beiden Programmen -# gleich. Das ist eine Zusage an den Naechsten, der eine davon anfasst - und -# geprueft hat sie bis zum 07.09.2026 niemand. Bei hinweis.py stimmte sie da -# schon nicht mehr: gleicher Inhalt, andere Zeilenenden. -# -# Die Liste steht bewusst NICHT hier, sondern wird aus dem Satz selbst -# gelesen. Eine Liste, die jemand von Hand nachfuehren muss, veraltet still -# (und still veraltete Pruefstaende fuehren in die Irre). -MARKE = "Diese Datei liegt in BEIDEN Programmen gleich" - -gemeinsame = sorted(n for n in os.listdir(RECHNUNG) if n.endswith(".py") - and MARKE in open(os.path.join(RECHNUNG, n), - encoding="utf-8").read()) -pruefe(len(gemeinsame) >= 5, - f"{len(gemeinsame)} Dateien sagen von sich, sie seien in beiden gleich") - -for name in gemeinsame: - hier = os.path.join(RECHNUNG, name) - dort = os.path.join(STEUER, name) - if not os.path.isfile(dort): - pruefe(False, f"{name} fehlt im Steuerrechnungstool") - continue - roh_h, roh_d = open(hier, "rb").read(), open(dort, "rb").read() - if roh_h == roh_d: - pruefe(True, f"{name} ist byteweise gleich") - continue - # Nicht gleich - dann wenigstens sagen, WORAN es liegt. Das ist der - # Unterschied zwischen einer Meldung und einer Suche. - if roh_h.replace(b"\r\n", b"\n") == roh_d.replace(b"\r\n", b"\n"): - grund = "nur die Zeilenenden weichen ab" - else: - grund = "der Inhalt weicht ab" - pruefe(False, f"{name}: {grund}") - -shutil.rmtree(tmp, ignore_errors=True) - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Beide Programme finden dasselbe Nummernbuch ohne Nachfrage") diff --git a/pruef_hinweis.py b/pruef_hinweis.py deleted file mode 100644 index e37b5ef..0000000 --- a/pruef_hinweis.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: der Hinweis auf die Erprobungsfassung und die Kenntnisnahme. - -Sein Wortlaut vom 06.09.2026: - - "wenn er nein drueckt oder ablehnt, schliesst sich die software und geht - beim naechsten start wieder mit dem selben auf, bis er auf ja drueckt" - "und das alles auch noch mal in die info mit signatur: user hat dies - gelesen und zur kenntnis genommen" - -Geprueft wird die ganze Strecke, weil jeder Teil fuer sich wertlos waere: - - 1. Der Hinweis kommt beim Start und nennt Haftung, Vorschau und die - Fehleradresse. - 2. NEIN -> das Fenster ist zu, und in der Konfiguration steht NICHTS. - Ein "Nein", das trotzdem vermerkt wird, waere eine Luege. - 3. Neuer Start -> es wird WIEDER gefragt. - 4. JA -> Signatur mit Benutzer, Zeit und Fassung; danach wird nicht mehr - gefragt. - 5. Andere Fassung des Textes -> es wird erneut gefragt. Eine Zustimmung gilt - fuer das, was dagestanden hat, nicht fuer alles Kuenftige. -""" -import json -import os -import time -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(120, _reissleine) -_wecker.daemon = True -_wecker.start() - -tmp = tempfile.mkdtemp(prefix="hinweis_pruef_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["output_dir"] = ordner -roh["nummer_eingerichtet"] = True -for weg in ("hinweis_fassung", "hinweis_zeit", "hinweis_benutzer"): - roh.pop(weg, None) -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), - ensure_ascii=False, indent=2) - -os.environ["CAMPINGHOF_TEST"] = "1" -import app as A # noqa: E402 -import hinweis as H # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -antworten = {"Fertig": False, "Hausnummer": True} -gesehen = [] - - -def askyesno(titel, text, **kw): - gesehen.append((str(titel), str(text))) - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -messagebox.askyesno = askyesno -messagebox.showinfo = lambda *a, **k: None -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")) - - -# ---- 1./2. Ablehnen: Fenster zu, nichts vermerkt ----------------------- -antworten["Erprobungsfassung"] = False -f = A.RechnungsApp() -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") -if treffer: - inhalt = treffer[0][1] - pruefe("ERPROBUNGSFASSUNG" in inhalt.upper(), "er nennt die Erprobungsfassung") - pruefe("Haftung" in inhalt, "er nennt die Haftung") - pruefe("Vorschau" in inhalt, "er verweist auf die Vorschau") - pruefe("git.pcore.de/TheMockTv/rechnungstool" in inhalt, - "er nennt die Adresse fuer Fehlermeldungen") - -def fenster_zu(fenster): - """True, wenn es das Fenster nicht mehr gibt. - - Nach destroy() antwortet winfo_exists() nicht mehr mit 0, sondern wirft - - die ganze Anwendung ist weg. Beides heisst hier dasselbe. - """ - import tkinter - try: - return not fenster.winfo_exists() - except tkinter.TclError: - return True - - -pruefe(fenster_zu(f), "nach Nein ist das Fenster zu") -cfg = gelesen() -pruefe(not cfg.get("hinweis_zeit"), "nach Nein steht nichts in der Konfiguration") - -# ---- 3. Neuer Start: es wird wieder gefragt ---------------------------- -gesehen.clear() -antworten["Erprobungsfassung"] = True -f = A.RechnungsApp() -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") - -# ---- 4. Zustimmen: Signatur steht --------------------------------------- -cfg = gelesen() -pruefe(cfg.get("hinweis_fassung") == H.FASSUNG, - f"die Fassung ist vermerkt ({cfg.get('hinweis_fassung')!r})") -pruefe(bool(cfg.get("hinweis_zeit")), "der Zeitpunkt ist vermerkt") -pruefe(bool(cfg.get("hinweis_benutzer")), "der Benutzer ist vermerkt") -sig = H.signatur(f.cfg) -pruefe("gelesen und zur kenntnis genommen" in sig.lower(), - f"die Signaturzeile ist lesbar ({sig!r})") - -# ---- 5. Kein zweites Mal fragen ----------------------------------------- -gesehen.clear() -f.destroy() -f = A.RechnungsApp() -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") - -# ---- 6. Andere Fassung -> wieder fragen --------------------------------- -gesehen.clear() -f.destroy() -H.FASSUNG = "9999-12-31" # so, als waere der Text geaendert worden -f = A.RechnungsApp() -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") - -_wecker.cancel() -try: - f.destroy() -except Exception: # noqa: BLE001 - pass -shutil.rmtree(tmp, ignore_errors=True) - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Hinweis, Ablehnung und Signatur wirken") diff --git a/pruef_installation.py b/pruef_installation.py deleted file mode 100644 index 55d0d1c..0000000 --- a/pruef_installation.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: installieren, deinstallieren - bleibt der Ordner sauber? - -Seine Ansage vom 07.09.2026: - - "du solltest die auch mal deinstallieren und neu installieren von windows - lassen, dass du auch pruefen kannst, dass der ordner dann leer ist und - sauber - will keine tools die unsauber sind" - -Er hatte recht. Gemessen blieb nach dem Deinstallieren liegen: - - Rechnungstool-1.7.1.exe, Rechnungstool-1.8.0.exe <- vom UPDATER geladen - Daten/config.json, Daten/rechnungstool.pid - -Die EXEs kennt der Installer nicht - die hat der Updater abgelegt. Ohne eine -eigene Regel bleiben sie stehen, und mit jeder Fassung wird es eine mehr. - -ZWEI DINGE MUESSEN GLEICHZEITIG STIMMEN ---------------------------------------- -1. Der PROGRAMMordner ist danach restlos weg. -2. Die DATEN sind unangetastet - Rechnungen, Journal, Nummernbuch. Die sind - zehn Jahre aufbewahrungspflichtig (§ 147 AO); ein Deinstallieren darf sie - nie mitnehmen. - -Beides zusammen geht nur, weil die Daten NEBEN dem Programm liegen: - - %LOCALAPPDATA%/ravokk/ <- Daten, gemeinsames Nummernbuch - Rechnungstool/ <- Programm, wird geleert - Steuerrechnungstool/ <- Programm, wird geleert - -Genau so hat er es beschrieben: *"es gibt ravokk als main verzeichnis, da liegt -config und sqlite drin, und dann gibt es den ordner rechnungstool und -steuertool"* - und dazu, warum es so heisst: *"ravokk ist meine firma und die -programme gehoeren zu der firma"*. - -⚠ DIESER PRUEFSTAND INSTALLIERT WIRKLICH. Er laeuft deshalb nur, wenn das -Setup gebaut ist, und er fasst NUR die Programmordner an - vorhandene Daten -werden vorher gesichert und danach zurueckgelegt. - -Und er hinterlaesst den Zustand, den er vorgefunden hat: war das Programm -vorher installiert, ist es danach wieder installiert. Beim ersten Lauf war es -das NICHT - danach stand ein deinstalliertes Programm da, und der naechste -Schritt lief ins Leere. Ein Pruefstand, der aufraeumt, muss auch sich selbst -aufraeumen. -""" -import os -import shutil -import subprocess -import sys -import tempfile -import time - -HIER = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HIER) - -import version # noqa: E402 -from pruefhelfer import fehler, pruefe - -LOKAL = os.environ.get("LOCALAPPDATA", "") -RAVOKK = os.path.join(LOKAL, "ravokk") -NAME = os.path.splitext(version.EXE_NAME)[0] -PROGRAMMORDNER = os.path.join(RAVOKK, NAME) - - -def setup_datei(): - ordner = os.path.join(HIER, "release") - if not os.path.isdir(ordner): - return "" - for d in sorted(os.listdir(ordner)): - if d.startswith(NAME + "-Setup") and d.endswith(".exe"): - return os.path.join(ordner, d) - return "" - - -def beenden(): - subprocess.run(["powershell", "-NoProfile", "-Command", - "Get-Process | Where-Object { $_.ProcessName -match " - f"'{NAME}|Updater' " + "} | Stop-Process -Force " - "-ErrorAction SilentlyContinue"], capture_output=True) - time.sleep(1.5) - - -def inhalt(ordner): - if not os.path.isdir(ordner): - return [] - gefunden = [] - for wurzel, _, dateien in os.walk(ordner): - for d in dateien: - gefunden.append(os.path.relpath(os.path.join(wurzel, d), ordner)) - return sorted(gefunden) - - -setup = setup_datei() -if not setup: - print(f"UEBERSPRUNGEN | kein Setup in release/ (vorher: python bauen.py)") - sys.exit(0) -if not LOKAL: - print("UEBERSPRUNGEN | kein LOCALAPPDATA") - sys.exit(0) - -# ---- Vorhandene Daten in Sicherheit bringen ----------------------------- -# Hier laeuft echte Software auf einem echten Rechner. Was schon da ist, wird -# beiseitegelegt und hinterher zurueckgestellt - ein Pruefstand darf niemandem -# seine Buchhaltung anfassen. -sicher = tempfile.mkdtemp(prefix="installpruef_") -gesichert = [] -if os.path.isdir(RAVOKK): - for d in os.listdir(RAVOKK): - quelle = os.path.join(RAVOKK, d) - if os.path.isfile(quelle): - shutil.copy2(quelle, os.path.join(sicher, d)) - gesichert.append(d) -print(f" ({len(gesichert)} vorhandene Datei(en) gesichert)") - -beenden() -war_installiert = os.path.exists(os.path.join(PROGRAMMORDNER, "unins000.exe")) -alter_uninstaller = os.path.join(PROGRAMMORDNER, "unins000.exe") -if os.path.exists(alter_uninstaller): - subprocess.run([alter_uninstaller, "/VERYSILENT", "/SUPPRESSMSGBOXES", - "/NORESTART"], capture_output=True) - time.sleep(6) - -# ---- 1. Installieren ---------------------------------------------------- -e = subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], - capture_output=True) -time.sleep(5) -pruefe(e.returncode == 0, f"das Setup laeuft durch (Rueckgabe {e.returncode})") -pruefe(os.path.isdir(PROGRAMMORDNER), - f"das Programm landet unter ravokk ({PROGRAMMORDNER})") -dabei = inhalt(PROGRAMMORDNER) -pruefe(any(d.startswith(NAME + "-") for d in dabei), - f"die Programmdatei liegt dort ({dabei})") -pruefe("Updater.exe" in dabei, "und der Updater daneben") - -# ---- 2. Eine Datei anlegen, wie der Updater es taete --------------------- -# So sieht der Ordner aus, nachdem das Programm ein paar Fassungen lang -# gelaufen ist. Genau diese Dateien blieben frueher liegen. -geerbt = os.path.join(PROGRAMMORDNER, f"{NAME}-9.9.9.exe") -with open(geerbt, "wb") as f: - f.write(b"als haette der Updater sie geladen") -with open(os.path.join(PROGRAMMORDNER, "installiert.json"), "w", - encoding="utf-8") as f: - f.write("{}") - -# ---- 3. Eine Datendatei daneben, die ueberleben MUSS --------------------- -probe = os.path.join(RAVOKK, "pruefstand-beleg.txt") -with open(probe, "w", encoding="utf-8") as f: - f.write("steht fuer Rechnungen, Journal und Nummernbuch") - -# ---- 4. Deinstallieren -------------------------------------------------- -beenden() -u = os.path.join(PROGRAMMORDNER, "unins000.exe") -pruefe(os.path.exists(u), "es gibt einen Uninstaller") -if os.path.exists(u): - subprocess.run([u, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], - capture_output=True) - time.sleep(7) - -uebrig = inhalt(PROGRAMMORDNER) -pruefe(not os.path.isdir(PROGRAMMORDNER), - f"der Programmordner ist danach weg ({uebrig})") -pruefe(not uebrig, - f"und darin liegt nichts mehr - auch keine geladene Fassung ({uebrig})") - -pruefe(os.path.exists(probe), - "die Daten daneben sind unangetastet (Rechnungen, Journal, Nummernbuch)") -pruefe(os.path.isdir(RAVOKK), - "und der Ordner der Firma steht noch") - -# ---- Aufraeumen --------------------------------------------------------- -if os.path.exists(probe): - os.remove(probe) -if war_installiert: - # So hinterlassen, wie es vorgefunden wurde. - subprocess.run([setup, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], - capture_output=True) - time.sleep(5) - print(f" (war vorher installiert - wieder eingerichtet: " - f"{os.path.isdir(PROGRAMMORDNER)})") -for d in gesichert: - ziel = os.path.join(RAVOKK, d) - if not os.path.exists(ziel): - os.makedirs(RAVOKK, exist_ok=True) - shutil.copy2(os.path.join(sicher, d), ziel) -shutil.rmtree(sicher, ignore_errors=True) - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Deinstallieren laesst den Programmordner leer - und die Daten stehen") diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index bd5160b..723a4d1 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -34,15 +34,9 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json" config.CONFIG_PATH = os.path.join(tmp, "config.json") roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) -# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. -os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 @@ -53,7 +47,6 @@ from tkinter import messagebox # noqa: E402 # eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten # von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt. import threading as _th -from pruefhelfer import fehler, pruefe def _reissleine(): @@ -67,6 +60,18 @@ _wecker = _th.Timer(120, _reissleine) _wecker.daemon = True _wecker.start() +fehler = [] + + +def pruefe(bedingung, text): + # Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf + # nicht mit einem UnicodeEncodeError abbrechen. + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kodierung = sys.stdout.encoding or "utf-8" + print(zeile.encode(kodierung, "replace").decode(kodierung)) + if not bedingung: + fehler.append(text) + # ---- Dialoge: jede Frage muss angemeldet sein -------------------------------- antworten = {} @@ -84,11 +89,6 @@ def askyesno(titel, text="", **kw): raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}") -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True messagebox.askyesno = askyesno messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else "")) messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else "")) @@ -135,14 +135,12 @@ antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen # ---- Die Sicherheitsfrage muss auch wirklich abbrechen ---------------------- # "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt # der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe. -# Aus der Sicherheitsfrage ist das Auswahlfenster geworden (Rechnung oder -# Proforma). "Abbrechen" heisst dort: gar nichts schreiben. -f._testwahl = None +antworten["endgültig erstellen"] = False kunde_setzen("Test", "Abbruch", "1") f.pdf_erstellen() pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")], "bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF") -f._testwahl = "rechnung" +antworten["endgültig erstellen"] = True # ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ kunde_setzen("Anna", "Beispiel", "4") diff --git a/pruef_kette.py b/pruef_kette.py index 40c5f73..163ae59 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -33,15 +33,9 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json" config.CONFIG_PATH = os.path.join(tmp, "config.json") roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) -# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. -os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 @@ -52,7 +46,6 @@ from tkinter import messagebox # noqa: E402 # eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten # von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt. import threading as _th -from pruefhelfer import fehler, pruefe def _reissleine(): @@ -66,6 +59,18 @@ _wecker = _th.Timer(120, _reissleine) _wecker.daemon = True _wecker.start() +fehler = [] + + +def pruefe(bedingung, text): + # Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf + # nicht mit einem UnicodeEncodeError abbrechen. + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kodierung = sys.stdout.encoding or "utf-8" + print(zeile.encode(kodierung, "replace").decode(kodierung)) + if not bedingung: + fehler.append(text) + antworten = {"Fertig": False, "Storno wirklich": True, "endgültig erstellen": True, @@ -79,11 +84,6 @@ def askyesno(titel, text="", **kw): raise AssertionError(f"unangemeldeter Dialog: {titel!r}") -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True messagebox.askyesno = askyesno messagebox.showinfo = messagebox.showwarning = messagebox.showerror = \ lambda *a, **k: None diff --git a/pruef_menueleiste.py b/pruef_menueleiste.py deleted file mode 100644 index 7013965..0000000 --- a/pruef_menueleiste.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: die eigene Menueleiste - Wechsel mit EINEM Klick, und am Rand. - -WARUM ES DIESE LEISTE GIBT --------------------------- -`tk.Menu` ist unter Windows ein Menue des Betriebssystems. Es nimmt Farben -entgegen und zeichnet trotzdem im Systemgrau - ein weisser Balken ueber einem -dunklen Fenster. Sein Befund am 07.09.2026: *"nur die einstellunden hilfe -leiste die ist noch hell"*. Deshalb ist die Leiste aus gewoehnlichen Labels -gebaut. - -WAS HIER GEPRUEFT WIRD - -1. Ein Klick auf einen Titel klappt seine Liste auf, ein zweiter auf denselben - Titel klappt sie wieder zu. - -2. Steht eine Liste offen und man klickt auf einen ANDEREN Titel, klappt - dessen Liste auf - mit EINEM Klick. - - Das ging bis zum 07.09.2026 nicht. Solange eine Liste offen ist, haelt ihr - `grab_set` alle Mausklicks der Anwendung fest; die ``-Bindung des - Menuetitels feuert dann gar nicht. Der Klick landete beim Zuklappen und war - damit verbraucht - man musste ein zweites Mal klicken, um von "Datei" zu - "Hilfe" zu kommen. Das native tk.Menu wechselte schon beim blossen - Ueberfahren; zwei Klicks waren also eine Verschlechterung gegenueber dem, - was ersetzt wurde. - - Geprueft wird das ueber ein echtes Klickereignis auf der offenen Liste, mit - den Bildschirmkoordinaten des anderen Titels - also genau der Weg, den der - grab_set erzwingt. Ein direkter Aufruf von aufklappen() wuerde am Problem - vorbeipruefen. - -3. Die Liste bleibt auf dem Bildschirm. Steht das Fenster am unteren Rand, - lief sie vorher darueber hinaus und war nicht mehr zu treffen: die Position - wurde ungeprueft gesetzt, waehrend jede andere neue Position im Programm - ueber theme.auf_bildschirm() laeuft. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber - sonst steht in zwei Programmen zweierlei. -""" -import os -import sys -import time -import tkinter as tk - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - -import menueleiste # noqa: E402 -import theme # noqa: E402 -from pruefhelfer import fehler, pruefe # noqa: E402 - - -class Fenster(theme.ThemeMixin, tk.Tk): - """Ein nacktes Fenster mit Farben - mehr braucht die Leiste nicht.""" - - def __init__(self): - super().__init__() - self.cfg = {} # theme.modus() will etwas mit .get() - self._apply_theme() - self.title("Pruefstand Menueleiste") - self.geometry("560x320" + theme.pruefplatz()) - - -f = Fenster() -gerufen = [] -f.menue = menueleiste.Menueleiste(f, { - "Datei": [("Neu", lambda: gerufen.append("neu")), ("-", None), - ("Beenden", lambda: gerufen.append("beenden"))], - "Bearbeiten": [("Rueckgaengig", lambda: gerufen.append("rueck"))], - "Hilfe": [("Info", lambda: gerufen.append("info"))], -}) - - -def pumpen(sekunden=0.4): - ende = time.time() + sekunden - while time.time() < ende: - f.update() - time.sleep(0.02) - - -pumpen(1.0) - -titel = {str(w.cget("text")): w for w in f.menue.winfo_children() - if isinstance(w, tk.Label)} -pruefe(set(titel) == {"Datei", "Bearbeiten", "Hilfe"}, - f"alle drei Titel stehen in der Leiste ({sorted(titel)})") - - -def klick_auf_titel(name): - """Auf einen Menuetitel klicken - so, wie es die Maus tut. - - Steht schon eine Liste offen, MUSS das Ereignis an sie gehen: ihr grab_set - faengt jeden Klick der Anwendung ab. Genau das ist der Fall, um den es - unter Punkt 2 geht. - """ - w = titel[name] - x = w.winfo_rootx() + w.winfo_width() // 2 - y = w.winfo_rooty() + w.winfo_height() // 2 - ziel = f.menue._offen or w - if ziel is w: - # Achtung: Tk nennt die Bildschirmkoordinaten beim Erzeugen -rootx - # und -rooty; im Ereignis heissen sie dann x_root und y_root. - w.event_generate("", x=w.winfo_width() // 2, - y=w.winfo_height() // 2, rootx=x, rooty=y) - else: - ziel.event_generate("", x=-1, y=-1, rootx=x, rooty=y) - pumpen(0.4) - - -def offener_titel(): - """Welcher Titel gerade offen ist - oder None.""" - anker = f.menue._anker - if anker is None or not f.menue._offen: - return None - return next((t for t, w in titel.items() if w is anker), "?") - - -# ---- 1. Aufklappen und wieder zuklappen --------------------------------- -klick_auf_titel("Datei") -pruefe(offener_titel() == "Datei", f"'Datei' klappt auf ({offener_titel()})") - -klick_auf_titel("Datei") -pruefe(offener_titel() is None, - f"und derselbe Titel klappt wieder zu ({offener_titel()})") - -# ---- 2. Der Wechsel: EIN Klick ------------------------------------------ -klick_auf_titel("Datei") -pruefe(offener_titel() == "Datei", "'Datei' steht offen") - -klick_auf_titel("Hilfe") -pruefe(offener_titel() == "Hilfe", - f"ein Klick auf 'Hilfe' wechselt hinueber ({offener_titel()})") - -klick_auf_titel("Bearbeiten") -pruefe(offener_titel() == "Bearbeiten", - f"und weiter auf 'Bearbeiten' ({offener_titel()})") - -# Ein Klick irgendwo neben die Leiste schliesst - und oeffnet nichts. -pop = f.menue._offen -pop.event_generate("", x=-1, y=-1, - rootx=f.winfo_rootx() + 300, - rooty=f.winfo_rooty() + 250) -pumpen(0.4) -pruefe(offener_titel() is None, - f"ein Klick daneben schliesst und oeffnet nichts ({offener_titel()})") - -# ---- 3. Ein Eintrag fuehrt seinen Befehl aus ---------------------------- -klick_auf_titel("Hilfe") -eintrag = None -for kind in f.menue._offen.winfo_children(): - for enkel in kind.winfo_children(): - if isinstance(enkel, tk.Label) and str(enkel.cget("text")) == "Info": - eintrag = enkel -eintrag.event_generate("", x=2, y=2) -pumpen(0.4) -pruefe(gerufen == ["info"], f"der Eintrag 'Info' fuehrt seinen Befehl aus ({gerufen})") -pruefe(offener_titel() is None, "und die Liste ist danach zu") - -# ---- 4. Am unteren Bildschirmrand bleibt die Liste sichtbar ------------- -# Das Fenster so weit nach unten schieben, dass unter der Leiste kein Platz -# mehr fuer die Klappliste ist. -links, oben = theme.auf_bildschirm(f.winfo_rootx(), f.winfo_rooty()) -unten = oben + f.winfo_screenheight() -f.geometry(f"560x320+{links}+{unten - 120}") -pumpen(0.6) - -klick_auf_titel("Datei") -pop = f.menue._offen -pruefe(pop is not None, "die Liste geht auch am unteren Rand auf") -if pop is not None: - x, y = pop.winfo_rootx(), pop.winfo_rooty() - hoehe, breite = pop.winfo_height(), pop.winfo_width() - grenze_x, grenze_y = theme.auf_bildschirm(x, y, breite, hoehe) - pruefe((x, y) == (grenze_x, grenze_y), - f"und steht vollstaendig auf dem Bildschirm " - f"(Liste {x},{y} bis {x + breite},{y + hoehe})") - -f.destroy() - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Die Menueleiste wechselt mit einem Klick und bleibt im Bild") diff --git a/pruef_montage.py b/pruef_montage.py deleted file mode 100644 index d2332cd..0000000 --- a/pruef_montage.py +++ /dev/null @@ -1,251 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand fuer die Montage-Rechnung - der ganze Weg an echten Dateien. - -Bartl vermietet auch monatsweise an Montagearbeiter. Dieses Blatt schrieb er -bisher von Hand in Word: - - Miete fuer Montagearbeiter inkl. 7 % MwSt. fuer den Monat August 2026 - Netto 467,29 USt 32,71 Brutto 500,00 - -Im Rechnungstool ist das ein eigener Reiter. Geprueft wird deshalb nicht nur -gerechnet, sondern der Weg, den er geht: Reiter waehlen, Kunde und Zeitraum -eintragen, PDF erstellen. Danach liest das STEUERJOURNAL die Datei ein - die -Miete ist eine Beherbergung, die Steuer kommt obendrauf (500 + 25 = 525) und -muss genau so beim Amt ankommen. - -Die echte config.json wird nicht angefasst - es wird mit einer Kopie in einem -Wegwerf-Ordner gearbeitet. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -JOURNAL_PROG = r"C:\claude\beherbergungssteuer" -sys.path.insert(0, QUELLE) - -tmp = tempfile.mkdtemp(prefix="montage_pruef_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. -os.environ["CAMPINGHOF_TEST"] = "1" -import app as A # noqa: E402 -import bestand as BE # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, " - "der nicht beantwortet wird") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - - -# Dialoge automatisch beantworten; jede Frage wird protokolliert. -antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True} -gefragt = [] - - -def askyesno(titel, text, **kw): - gefragt.append(titel) - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -warnungen = [] -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True -messagebox.askyesno = askyesno -messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", - a[1] if len(a) > 1 else "") - -f = A.RechnungsApp() -f.update() -f.cfg["output_dir"] = ordner - -# ---- 0. Der Reiter --------------------------------------------------------- -reiter = [f.blaetter.tab(i, "text").strip() for i in range(f.blaetter.index("end"))] -pruefe(reiter == ["Campingrechnung", "Montage"], f"zwei Reiter da ({reiter})") -pruefe(not f.ist_montage(), "beim Start liegt die Campingrechnung oben") -pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"), - "dort heisst das Feld Anreise") - -f.blaetter.select(1) -f.update() -pruefe(f.ist_montage(), "nach dem Wechsel liegt Montage oben") -pruefe(f.datum_labels["von"].cget("text").startswith("Zeitraum von"), - f"dort heisst dasselbe Feld Zeitraum von ({f.datum_labels['von'].cget('text')!r})") - -# ---- 1. Die Rechnung, wie er sie tippt ------------------------------------ -f.k_anrede.set("Herr") -f.k_vorname.set("Dominik") -f.k_name.set("Schmidt") -f.k_strasse.set("Musterweg 1") -f.k_plzort.set("12345 Musterstadt") -f.r_datum.set("12.05.2026") -f.r_anreise.set("01.08.2026") -f.r_abreise.set("31.08.2026") -f.m_monate.set("1") -f.update() - -pruefe(f.r_naechte.get() == "30", f"30 Naechte im Zeitraum ({f.r_naechte.get()})") -text = f.m_text.cget("text") -pruefe("Miete für Montagearbeiter" in text and "7 % MwSt." in text - and "01.08.2026" in text and "31.08.2026" in text, - f"die Zeile steht so da, wie sie aufs Blatt gehoert ({text!r})") - -f.pdf_erstellen() -pdf = os.path.join(ordner, "Rechnung_2026-001_Schmidt_Montage.pdf") -pruefe(os.path.exists(pdf), f"Montage-Rechnung geschrieben ({os.path.basename(pdf)})") - -# ---- 2. Was in den Kenndaten steht ---------------------------------------- -meta = BE.kenndaten_lesen(pdf) or {} -pruefe(meta.get("vorlage") == "montage", f"vorlage = montage ({meta.get('vorlage')!r})") -pruefe(meta.get("art") == "rechnung", - f"art bleibt rechnung - fuer das Journal eine normale Buchung ({meta.get('art')!r})") -pruefe(float(meta.get("zwischensumme") or 0) == 500.0, - f"Zwischensumme 500,00 ({meta.get('zwischensumme')})") -pruefe(float(meta.get("steuer_satz") or 0) == 5.0, - f"Beherbergungssteuer 5 % ({meta.get('steuer_satz')})") -pruefe(float(meta.get("steuer_betrag") or 0) == 25.0, - f"Steuer 25,00 - obendrauf, nicht drin ({meta.get('steuer_betrag')})") -pruefe(float(meta.get("gesamt") or 0) == 525.0, f"Gesamt 525,00 ({meta.get('gesamt')})") -pruefe(str(meta.get("naechte")) == "30", - f"30 Naechte in den Kenndaten - die Meldung ans Amt ({meta.get('naechte')!r})") -pruefe(meta.get("nachname") == "Schmidt", f"Nachname ({meta.get('nachname')!r})") -bloecke = meta.get("ust_bloecke") or [] -pruefe(len(bloecke) == 1 and int(bloecke[0]["satz"]) == 7, - f"eine USt-Zeile mit 7 % ({bloecke})") -pruefe(bloecke and abs(bloecke[0]["netto"] - 467.29) < 0.005 - and abs(bloecke[0]["ust"] - 32.71) < 0.005, - f"Netto 467,29 und USt 32,71 wie auf seinem Blatt ({bloecke})") -pos = meta.get("positionen") or [] -pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Monat ({pos})") - -# ---- 3. Was auf dem Blatt steht ------------------------------------------- -from pypdf import PdfReader # noqa: E402 - -seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages] -# Seit 05.09.2026 gibt es nur noch EIN Blatt. Ein zweites mit derselben Nummer, -# aber anderem Gesamtbetrag ("fuer das Amt") sieht bei einer Pruefung aus, als -# werde ueber zwei verschiedene Umsaetze abgerechnet. -pruefe(len(seiten) == 1, f"genau EIN Blatt - kein zweites fuers Amt ({len(seiten)})") -kunde_seite = seiten[0].replace("\n", " ") -pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt") -pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum") -pruefe("Rechnungsbetrag Insgesamt" in kunde_seite, - "und die Schlusszeile heisst wie bei ihm: Rechnungsbetrag Insgesamt") -pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite, - "keine Positionstabelle auf dem Montage-Blatt") -pruefe("525,00" in kunde_seite and "500,00" in kunde_seite, - "500,00 und 525,00 stehen drauf") -pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen") -pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1, - "der Gesamtbetrag steht genau einmal in der Datei") - -# ---- 4. Das Steuerjournal liest die Datei --------------------------------- -sys.path.append(JOURNAL_PROG) -import db as BST # noqa: E402 - -journal = BST.Journal(os.path.join(tmp, "journal.sqlite3")) -neu, akt, uebersprungen, kaputt = journal.scanne(ordner, 5.0, voll=True) -pruefe(neu == 1 and not kaputt, f"das Journal liest sie ein ({neu} neu, Fehler: {kaputt})") -buchungen = journal.buchungen(jahr=2026) -pruefe(len(buchungen) == 1, f"eine Buchung im Journal ({len(buchungen)})") -if buchungen: - b = buchungen[0] - pruefe(b.nachname == "Schmidt", f"auf Schmidt gebucht ({b.nachname!r})") - pruefe(abs(b.gezahlt - 500.0) < 0.005, f"Gezahlt 500,00 ({b.gezahlt})") - pruefe(abs(b.steuer - 25.0) < 0.005, f"Steuer 25,00 ({b.steuer})") - pruefe(b.naechte == 30, f"30 Uebernachtungen ({b.naechte})") - pruefe(b.datum.strftime("%d.%m.%Y") == "12.05.2026", f"Datum ({b.datum})") -journal.schliessen() - -# ---- 5. Storno einer Montage-Rechnung ------------------------------------- -offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"] -pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden") -f._storno_anlegen(offen[0], ordner) -storno_pdf = f.storno_stand["pfad"] if f.storno_stand else "" -pruefe(os.path.exists(storno_pdf), f"Storno geschrieben ({os.path.basename(storno_pdf)})") -m2 = BE.kenndaten_lesen(storno_pdf) or {} -pruefe(float(m2.get("zwischensumme") or 0) == -500.0, - f"Storno-Zwischensumme -500,00 ({m2.get('zwischensumme')})") -pruefe(float(m2.get("steuer_betrag") or 0) == -25.0, - f"Beherbergungssteuer wird mitstorniert ({m2.get('steuer_betrag')})") -pruefe(f.ist_montage(), "das Formular steht danach wieder im Montage-Reiter") -pruefe(f.m_monate.get() in ("1", "1.0"), f"mit einem Monat ({f.m_monate.get()!r})") -f._storno_verwerfen("Pruefstand") - -# ---- 6. Die Campingrechnung darf sich nicht veraendert haben -------------- -f.blaetter.select(0) -f.update() -pruefe(not f.ist_montage(), "zurueck im Campingreiter") -pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"), "Feld heisst wieder Anreise") -f.r_anreise.set("01.09.2026") -f.r_abreise.set("05.09.2026") -erste_pos = sorted(f.anzahl_vars)[0] -f.anzahl_vars[erste_pos].set("4") -f.update() -f.pdf_erstellen() -camping = [d for d in os.listdir(ordner) if d.endswith(".pdf") and "Montage" not in d - and not d.startswith("Storno")] -pruefe(len(camping) == 1, f"die Campingrechnung entsteht wie immer ({camping})") -if camping: - t = (PdfReader(os.path.join(ordner, camping[0])).pages[0].extract_text() or "").replace("\n", " ") - pruefe("Tage/Menge" in t, "und hat weiter ihre Positionstabelle") - -# ---- 7. Datum: Tag bis 31, Monat bis 12 ----------------------------------- -p = A.RechnungsApp._datum_ziffern_pruefen -faelle = [("5", "05"), ("9", "09"), ("35", "3"), ("00", "0"), ("31", "31"), - ("013", "0103"), ("1213", "121"), ("1200", "120"), ("3112", "3112")] -for ein, soll in faelle: - pruefe(p(ein) == soll, f"Tippen {ein!r} ergibt {p(ein)!r} (soll {soll!r})") - -f.r_datum.set("31.02.2026") # Tag und Monat je fuer sich moeglich - den Tag gibt es nicht -kaputt = f._datumsfelder_pruefen() -pruefe(len(kaputt) == 1 and "31.02.2026" in kaputt[0], - f"31.02.2026 wird als Datum beanstandet ({kaputt})") -vorher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")]) -warnungen.clear() -f.pdf_erstellen() -nachher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")]) -pruefe(nachher == vorher, "und es wird keine Rechnung mit diesem Datum geschrieben") -pruefe(any("Datum" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})") - -f.destroy() -if not fehler: - shutil.rmtree(tmp, ignore_errors=True) -else: - print(f" (Wegwerf-Ordner bleibt: {tmp})") -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") -sys.exit(1 if fehler else 0) diff --git a/pruef_nummernbuch.py b/pruef_nummernbuch.py deleted file mode 100644 index 4656eca..0000000 --- a/pruef_nummernbuch.py +++ /dev/null @@ -1,186 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal. - -Der Fall, um den es geht (sein Satz vom 03.09.2026): - - "er hat manuelle eingetragen die das andere programm die rechnungs nummer - nicht kennt - mit der sqlite beheben wir das" - -Im Steuerjournal stehen Buchungen aus der alten Excel-Mappe, zu denen es keine -PDF gibt. Das Rechnungstool sucht die naechste freie Nummer bisher nur in -seinem eigenen Ordner - und vergibt so eine Nummer zum zweiten Mal -(§ 14 Abs. 4 Nr. 4 UStG: jede Nummer nur einmal). - -Geprueft wird mit BEIDEN echten Programmen auf EINEM gemeinsamen Ordner. -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -TOOL = r"C:\claude\rechnungstool" -JOURNAL = r"C:\claude\beherbergungssteuer" - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - -os.environ["CAMPINGHOF_TEST"] = "1" # kein Erststart-Dialog - -tmp = tempfile.mkdtemp(prefix="buch_pruef_") -gemeinsam_ordner = os.path.join(tmp, "install") # hier liegt das Buch -rechnungen = os.path.join(tmp, "rechnungen") -journal_daten = os.path.join(tmp, "journal_daten") -for d in (gemeinsam_ordner, rechnungen, journal_daten): - os.makedirs(d) - -# ---- Erst das Steuerjournal (eigener Datenordner) -------------------------- -sys.path.insert(0, JOURNAL) -import ablage # noqa: E402 - -ablage.daten_ordner = lambda: journal_daten -import app as JA # noqa: E402 - das Journal -from modell import Buchung # noqa: E402 -import gemeinsam # noqa: E402 - -del sys.modules["app"] -sys.path.remove(JOURNAL) - -from datetime import date # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: None - -journal = JA.App() -journal.withdraw() -# Altbestand wie aus der Excel-Mappe: Nummern OHNE PDF. -for nr, name in (("001", "Alt Eins"), ("002", "Alt Zwei"), ("003", "Alt Drei")): - journal.journal.speichern(Buchung(date(2026, 5, 1), nr, 2026, name, 2, 50.0, - quelle="xlsx")) -journal.jahr.set(2026) -journal.aktualisieren() -journal.journal.setze("gemeinsam_ordner", gemeinsam_ordner) -journal._buch_oeffnen() -journal._buch_fuellen() - -buch = gemeinsam.Nummernbuch(gemeinsam_ordner) -pruefe(os.path.exists(gemeinsam.pfad_zur_datei(gemeinsam_ordner)), - "das Journal legt das Nummernbuch im gemeinsamen Ordner an") -nummern = buch.nummern() -pruefe(nummern == {"2026-001", "2026-002", "2026-003"}, - f"die drei Altbestands-Nummern stehen drin ({sorted(nummern)})") -eintrag = buch.eintrag("2026-002") -pruefe(eintrag and eintrag["herkunft"] == "xlsx" and eintrag["quelle"] == "journal", - f"mit Herkunft und Quelle ({eintrag and (eintrag['quelle'], eintrag['herkunft'])})") - -# ---- Jetzt das Rechnungstool auf denselben Ordner -------------------------- -sys.path.insert(0, TOOL) -import config # noqa: E402 - -shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = rechnungen -roh["gemeinsam_ordner"] = gemeinsam_ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -import app as A # noqa: E402 - das Rechnungstool -import bestand as BE # noqa: E402 - -antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True, - "Storno wirklich": True} - - -def askyesno(titel, text, **kw): - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True -messagebox.askyesno = askyesno - -tool = A.RechnungsApp() -tool.cfg["output_dir"] = rechnungen -tool._buch_oeffnen() -pruefe(tool.buch is not None, "das Rechnungstool findet dasselbe Buch") -tool._neue_nummer_vorschlagen() -pruefe(tool.r_nummer.get() == "2026-004", - f"es schlaegt 2026-004 vor und ueberspringt den Altbestand ({tool.r_nummer.get()})") - -# ---- Eine Rechnung schreiben: sie muss im Buch landen --------------------- -tool.k_vorname.set("Anna") -tool.k_name.set("Neu") -tool.k_strasse.set("Musterweg 1") -tool.k_plzort.set("12345 Musterstadt") -tool.r_anreise.set("01.09.2026") -tool.r_abreise.set("03.09.2026") -tool.anzahl_vars[sorted(tool.anzahl_vars)[0]].set("2") -tool.update() -tool.pdf_erstellen() -pruefe(os.path.exists(os.path.join(rechnungen, "Rechnung_2026-004_Neu.pdf")), - "die Rechnung 2026-004 ist geschrieben") -e4 = buch.eintrag("2026-004") -pruefe(e4 is not None, "und steht im gemeinsamen Buch") -pruefe(e4 and e4["quelle"] == "rechnungstool" and e4["nachname"] == "Neu", - f"mit Quelle rechnungstool und Namen ({e4 and (e4['quelle'], e4['nachname'])})") -pruefe(e4 and abs(float(e4["zwischensumme"]) - 26.0) < 0.005, - f"und dem Betrag ({e4 and e4['zwischensumme']})") - -# ---- Storno: eigene Nummer ins Buch, beim Verwerfen wieder raus ----------- -offen = [m for m in BE.rechnungen_im_ordner(rechnungen) if m.get("art") != "storno"] -tool._storno_anlegen(offen[0], rechnungen) -storno_nr = tool.storno_stand["nummer"] if tool.storno_stand else "" -pruefe(buch.belegt(storno_nr), f"der Storno {storno_nr} steht im Buch") -e_storno = buch.eintrag(storno_nr) -pruefe(e_storno and e_storno["storno_zu"] == "2026-004", - f"und sagt, welche Rechnung er aufhebt ({e_storno and e_storno['storno_zu']})") -tool._storno_verwerfen("Pruefstand") -pruefe(not buch.belegt(storno_nr), - f"beim Verwerfen ist die Nummer wieder frei ({storno_nr})") - -# ---- Das Journal sieht die neue Rechnung im Buch -------------------------- -journal._buch_oeffnen() -pruefe("2026-004" in journal.buch.nummern(), - "das Journal sieht die Nummer des Rechnungstools im Buch") - -# ---- Faellt das Buch aus, laeuft trotzdem alles weiter -------------------- -tool.cfg["gemeinsam_ordner"] = os.path.join(tmp, "gibtsnicht") -tool._buch_oeffnen() -pruefe(tool.buch is None, "ein nicht erreichbarer Ordner schaltet das Buch ab") -pruefe(tool._belegte_nummern() == set(), "die belegten Nummern sind dann leer") -tool._neue_nummer_vorschlagen() -pruefe(bool(tool.r_nummer.get()), f"und es wird trotzdem eine Nummer vorgeschlagen " - f"({tool.r_nummer.get()})") - -tool.destroy() -journal.journal.schliessen() -journal.destroy() - -if not fehler: - shutil.rmtree(tmp, ignore_errors=True) -else: - print(f" (Wegwerf-Ordner bleibt: {tmp})") -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") -sys.exit(1 if fehler else 0) diff --git a/pruef_nummernsperre.py b/pruef_nummernsperre.py deleted file mode 100644 index 0f2ea49..0000000 --- a/pruef_nummernsperre.py +++ /dev/null @@ -1,332 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: der Rechnungszaehler kann nicht mehr zurueck. - -Sein Einwand: *"er kann jetzt bescheissen, indem er das in den einstellungen -aendert"* - und der Vorschlag dazu: *"dann koennte man noch die pdfs nutzen als -referenz, die haben auch die nummern"*. - -Geprueft wird beides als Quelle: - * die PDFs im Rechnungsordner (Dateiname traegt die Nummer) - * das gemeinsame Nummernbuch (kennt auch Nummern OHNE PDF - Altbestand) - -Vorwaerts darf der Zaehler: eine lueckenlose Folge verlangt das Gesetz nicht. -Zurueck nicht: jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze)") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - -os.environ["CAMPINGHOF_TEST"] = "1" -tmp = tempfile.mkdtemp(prefix="sperre_pruef_") -ordner = os.path.join(tmp, "rechnungen") -gemeinsam_ordner = os.path.join(tmp, "install") -os.makedirs(ordner) -os.makedirs(gemeinsam_ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: steht dort "eingerichtet", zeigt dlg_nummer nur eine -# Meldung statt des Fensters, das hier bedient werden soll. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -import app as A # noqa: E402 -import gemeinsam # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -warnungen = [] -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. -messagebox.askyesno = lambda t, x, **k: ("Fertig" not in t) -messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "") -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") - -f = A.RechnungsApp() -f.cfg["output_dir"] = ordner - -JAHR = 2026 -if int(f.cfg.get("rechnung_jahr", 0)) != JAHR: - print("HINWEIS | Der Pruefstand rechnet mit 2026 - heute ist ein anderes Jahr.") - - -def rechnung(name): - f.reset_formular() - f.k_vorname.set("Anna") - f.k_name.set(name) - f.k_strasse.set("Musterweg 1") - f.k_plzort.set("12345 Musterstadt") - f.r_anreise.set("01.08.2026") - f.r_abreise.set("03.08.2026") - f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2") - f._testwahl = "rechnung" - f.update() - f.pdf_erstellen() - - -# ---- 1. Drei Rechnungen schreiben ---------------------------------------- -for name in ("Eins", "Zwei", "Drei"): - rechnung(name) -dateien = sorted(d for d in os.listdir(ordner) if d.endswith(".pdf")) -pruefe(len(dateien) == 3, f"drei Rechnungen geschrieben ({dateien})") -pruefe(f.cfg.get("rechnung_zaehler") == 3, f"Zaehler steht auf 3 ({f.cfg.get('rechnung_zaehler')})") -pruefe(f.hoechster_stand(JAHR) == 3, f"hoechster Stand aus den PDFs: 3 ({f.hoechster_stand(JAHR)})") - -# ---- 2. Zaehler von Hand zurueckdrehen (der Betrugsfall) ----------------- -f.cfg["rechnung_zaehler"] = 0 -config.save_config(f.cfg) -f._neue_nummer_vorschlagen() -pruefe(f.r_nummer.get() == "2026-004", - f"trotz zurueckgedrehtem Zaehler wird 2026-004 vorgeschlagen ({f.r_nummer.get()})") -pruefe(f.cfg.get("rechnung_zaehler") == 3, - f"und der Zaehler ist wieder angehoben ({f.cfg.get('rechnung_zaehler')})") - -# ---- 3. Auch die Einstellungen lassen ihn nicht zurueck ------------------ -def knoepfe_von(w, aus=None): - aus = [] if aus is None else aus - for k in w.winfo_children(): - if k.winfo_class() in ("TButton", "Button"): - aus.append(k) - knoepfe_von(k, aus) - return aus - - -def _alle_widgets(w, aus=None): - """Jedes Widget unterhalb von w - egal wie tief.""" - aus = [] if aus is None else aus - for k in w.winfo_children(): - aus.append(k) - _alle_widgets(k, aus) - return aus - - -def felder_von(w, aus=None): - aus = [] if aus is None else aus - for k in w.winfo_children(): - if k.winfo_class() in ("TEntry", "Entry"): - aus.append(k) - felder_von(k, aus) - return aus - - -def nummerndialog(neuer_zaehler): - """Dialog oeffnen, Zaehler eintragen, Speichern druecken.""" - f.dlg_nummer() - f.update() - top = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"][-1] - felder = felder_von(top) - felder[1].delete(0, "end") - felder[1].insert(0, str(neuer_zaehler)) - f.update() - for k in knoepfe_von(top): - if "Speichern" in str(k.cget("text")): - k.invoke() - break - f.update() - noch_offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] - for w in noch_offen: - w.destroy() - return bool(noch_offen) - - -warnungen.clear() -blieb_offen = nummerndialog(1) -pruefe(f.cfg.get("rechnung_zaehler") == 3, - f"die Einstellungen setzen den Zaehler NICHT auf 1 ({f.cfg.get('rechnung_zaehler')})") -pruefe(any("vergeben" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})") -pruefe(blieb_offen, "und der Dialog bleibt offen, statt still zu schlucken") - -warnungen.clear() -nummerndialog(50) -pruefe(f.cfg.get("rechnung_zaehler") == 50, - f"nach VORN darf er (Luecken sind erlaubt) ({f.cfg.get('rechnung_zaehler')})") -pruefe(not warnungen, f"und dabei wird nicht gewarnt ({warnungen})") - -# ---- 4. Das Nummernbuch kennt Nummern OHNE PDF -------------------------- -f.cfg["gemeinsam_ordner"] = gemeinsam_ordner -f._buch_oeffnen() -pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist da") -f.buch.eintragen("2026-090", jahr=2026, nachname="AltbestandOhnePDF", - quelle="journal", herkunft="xlsx") -pruefe(f.hoechster_stand(JAHR) == 90, - f"der hoechste Stand kommt jetzt aus dem Buch: 90 ({f.hoechster_stand(JAHR)})") - -f.cfg["rechnung_zaehler"] = 5 -config.save_config(f.cfg) -f._neue_nummer_vorschlagen() -pruefe(f.r_nummer.get() == "2026-091", - f"vorgeschlagen wird 2026-091 - hinter dem Altbestand ({f.r_nummer.get()})") - -warnungen.clear() -nummerndialog(10) -pruefe(f.cfg.get("rechnung_zaehler") == 90, - f"und die Einstellungen kommen auch nicht hinter das Buch zurueck " - f"({f.cfg.get('rechnung_zaehler')})") - -# ---- 5. Ohne PDFs im Ordner: das Buch allein haelt die Sperre ----------- -for d in os.listdir(ordner): - if d.endswith(".pdf"): - os.remove(os.path.join(ordner, d)) -pruefe(f.hoechster_stand(JAHR) == 90, - f"auch ohne PDFs bleibt der Stand bei 90 ({f.hoechster_stand(JAHR)})") - -# ---- 6. Und ohne Buch: die PDFs allein halten sie ---------------------- -rechnung("Nachher") # schreibt 2026-091 -f.buch = None -f.cfg["gemeinsam_ordner"] = "" -pruefe(f.hoechster_stand(JAHR) == 91, - f"ohne Buch zaehlen die PDFs: 91 ({f.hoechster_stand(JAHR)})") -warnungen.clear() -nummerndialog(2) -pruefe(f.cfg.get("rechnung_zaehler") == 91, - f"und auch dann geht es nicht zurueck ({f.cfg.get('rechnung_zaehler')})") - -# ---- 7. Der Erststart: EINMAL setzen duerfen --------------------------- -# Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er einmal -# nennen koennen. Danach ist der Zaehler zu. -f.cfg["nummer_eingerichtet"] = False -f.cfg["rechnung_jahr"] = JAHR -config.save_config(f.cfg) -# Diesen einen Dialog will der Pruefstand wirklich sehen und bedienen. Alles -# andere bleibt im Pruflauf - sonst wartet der naechste Hinweis auf einen Klick. -f._erststart_zeigen = True - - -def erststart(zahl, erwartet_ja=True): - """Popup oeffnen, Zahl eintragen, Uebernehmen druecken.""" - antwort = {"gefragt": False} - - def sicher(titel, text, **kw): - antwort["gefragt"] = True - return erwartet_ja - - messagebox.askyesno = sicher - - def klick(rest=40): - tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] - if tops: - top = tops[-1] - felder = felder_von(top) - if felder: - # Der Dialog fragt seit 05.09.2026 zuerst nach dem WEG: bei 0 - # anfangen oder die eigene Zaehlung weiterfuehren. Ohne diesen - # Klick bleibt das Zahlenfeld gesperrt - hier wird also dasselbe - # gedrueckt wie beim Benutzer, nicht daran vorbei. - for w in _alle_widgets(top): - if w.winfo_class() == "TRadiobutton" and "weiterführen" in str(w.cget("text")): - w.invoke() - break - felder[0].delete(0, "end") - felder[0].insert(0, str(zahl)) - for k in knoepfe_von(top): - if "bernehmen" in str(k.cget("text")): - k.invoke() - # Lehnt der Dialog die Zahl ab, BLEIBT er offen - das ist - # gewollt und wird weiter unten geprueft. Dann muss hier - # aber, wie ein Benutzer, "Spaeter" gedrueckt werden: - # sonst wartet das Programm in wait_window() ewig auf - # einen Klick, den im Pruefstand niemand macht. - top.update() - if top.winfo_exists(): - for spaeter in knoepfe_von(top): - if "ter" in str(spaeter.cget("text")): - spaeter.invoke() - break - return - if rest: - f.after(100, lambda: klick(rest - 1)) - - f.after(200, klick) - f._frage_nummer_falls_noetig() - for w in [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]: - w.destroy() - return antwort["gefragt"] - - -hoch_jetzt = f.hoechster_stand(JAHR) -erststart(hoch_jetzt - 5, True) -pruefe(not f.cfg.get("nummer_eingerichtet"), - "eine Zahl UNTER dem Vergebenen wird beim Erststart nicht angenommen") - -gefragt = erststart(500, True) -pruefe(gefragt, "vor dem Uebernehmen wird noch einmal gefragt") -pruefe(f.cfg.get("nummer_eingerichtet") is True, "danach ist der Zaehler eingerichtet") -pruefe(f.cfg.get("rechnung_zaehler") == 500, f"und steht auf 500 ({f.cfg.get('rechnung_zaehler')})") - -# ---- 8. Ab jetzt ist er zu ------------------------------------------- -meldungen = [] -messagebox.showinfo = lambda *a, **k: meldungen.append(a[1] if len(a) > 1 else "") -vorher = f.cfg.get("rechnung_zaehler") -f.dlg_nummer() -f.update() -offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] -pruefe(not offen, "die Einstellungen oeffnen kein Eingabefenster mehr") -pruefe(f.cfg.get("rechnung_zaehler") == vorher, - f"der Zaehler bleibt, wie er ist ({f.cfg.get('rechnung_zaehler')})") -pruefe(any("Storno" in m for m in meldungen), - f"und es wird auf Storno/Berichtigen verwiesen ({meldungen})") - -# Und der Erststart fragt nicht noch einmal -f.after(200, lambda: None) -f._frage_nummer_falls_noetig() -pruefe(not [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"], - "beim naechsten Start wird nicht wieder gefragt") - -# ---- 9. Jahreswechsel: nach dem Bindestrich faengt es wieder bei 001 an -- -# "beim jahreswechsel sollte die nach dem - zeichen auf 000 gehen, weil neues -# jahr". Der Zaehler des VORjahres darf dabei nichts anheben. -import datetime as _dt - -heute = _dt.date.today().year -f.cfg["rechnung_jahr"] = heute - 1 -f.cfg["rechnung_zaehler"] = 442 -f.cfg["nummer_eingerichtet"] = True -config.save_config(f.cfg) -f._neue_nummer_vorschlagen() -pruefe(f.r_nummer.get() == f"{heute}-001", - f"im neuen Jahr wird {heute}-001 vorgeschlagen ({f.r_nummer.get()})") - -# Auch dann, wenn im Ordner noch die hohen Nummern des Vorjahres liegen. -alt_pdf = os.path.join(ordner, f"Rechnung_{heute - 1}-442_Vorjahr.pdf") -open(alt_pdf, "wb").write(b"%PDF-1.4 kein echtes PDF, nur der Name zaehlt") -vor_dem_anlegen = 91 # so weit ist DIESES Jahr aus den Schritten davor -pruefe(f.hoechster_stand(heute) == vor_dem_anlegen, - f"die Rechnung aus dem Vorjahr hebt dieses Jahr NICHT an " - f"({f.hoechster_stand(heute)}, erwartet {vor_dem_anlegen})") -pruefe(f.hoechster_stand(heute - 1) == 442, - f"fuer ihr eigenes Jahr aber schon ({f.hoechster_stand(heute - 1)})") -f._neue_nummer_vorschlagen() -pruefe(f.r_nummer.get() == f"{heute}-001", - f"und es bleibt bei {heute}-001 ({f.r_nummer.get()})") - -f.destroy() -if not fehler: - shutil.rmtree(tmp, ignore_errors=True) -else: - print(f" (Wegwerf-Ordner bleibt: {tmp})") -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}") -sys.exit(1 if fehler else 0) diff --git a/pruef_nur_storno.py b/pruef_nur_storno.py deleted file mode 100644 index cb55a78..0000000 --- a/pruef_nur_storno.py +++ /dev/null @@ -1,202 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: Version C - nur Storno, es folgt nichts. - -Seine Ansage vom 04.09.2026: - "des wegen sollte mann auch eine version c mit only storno machen - weil das gibt es auch" - -Es gab bisher nur zwei Wege aus dem Storno heraus: - A - es folgt eine neue Rechnung - B - die Leistung ist schon abgerechnet -> Verweis auf die bleibende Nummer - -Beide behaupten etwas, das im dritten Fall nicht stimmt: die Buchung ist -geplatzt oder die Rechnung war komplett irrtuemlich, es wird also gar nichts -abgerechnet. Mit A wartet der Gast ewig auf eine Rechnung, die nie kommt (und -eine Nummer waere unnoetig reserviert), mit B muesste man auf eine Rechnung -verweisen, die es nicht gibt. - -Geprueft wird am echten Fenster: der Knopf wird wirklich gedrueckt, danach -werden Blatt, Kenndaten, Nummernzaehler und das Steuerjournal angesehen. - -Aufruf: python -u pruef_nur_storno.py -""" -import json -import os -import shutil -import sys -import tempfile -import threading - -QUELLE = r"C:\claude\rechnungstool" -JOURNAL = r"C:\claude\beherbergungssteuer" -sys.path.insert(0, QUELLE) - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog") - os._exit(2) - - -_wecker = threading.Timer(150, _reissleine) -_wecker.daemon = True -_wecker.start() - -os.environ["CAMPINGHOF_TEST"] = "1" -tmp = tempfile.mkdtemp(prefix="nur_storno_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 - -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -roh["nummer_eingerichtet"] = True -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - -import app as A # noqa: E402 -import bestand as BE # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. -messagebox.askyesno = lambda *a, **k: True -messagebox.showinfo = lambda *a, **k: None -messagebox.showwarning = lambda *a, **k: None -messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "") - -f = A.RechnungsApp() -f.cfg["output_dir"] = ordner -f._storno_dialog_zeigen = True # das Storno-Popup soll wirklich kommen - - -def widgets(w, klassen, aus=None): - aus = [] if aus is None else aus - for k in w.winfo_children(): - if k.winfo_class() in klassen: - aus.append(k) - widgets(k, klassen, aus) - return aus - - -def druecke(top, text): - for k in widgets(top, ("TButton", "Button")): - if text.lower() in str(k.cget("text")).lower(): - k.invoke() - return True - return False - - -# ---- Eine Rechnung, die gleich wieder aufgehoben wird --------------------- -f.reset_formular() -f.k_vorname.set("Anna") -f.k_name.set("Platzer") -f.k_strasse.set("Musterweg 1") -f.k_plzort.set("12345 Musterstadt") -f.r_anreise.set("01.08.2026") -f.r_abreise.set("04.08.2026") -f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("3") -f._testwahl = "rechnung" -f.update() -f.pdf_erstellen() -pruefe(os.listdir(ordner) == ["Rechnung_2026-001_Platzer.pdf"], - f"die Rechnung liegt im Ordner ({os.listdir(ordner)})") - -zaehler_vorher = f.cfg.get("rechnung_zaehler") - -# ---- Storno, im Fenster auf "C: nur Storno" ------------------------------ -gedrueckt = {"c": False, "knoepfe": []} - - -def klick(rest=60): - tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"] - if not tops: - if rest: - f.after(100, lambda: klick(rest - 1)) - return - top = tops[-1] - gedrueckt["knoepfe"] = [str(k.cget("text")) for k in widgets(top, ("TButton", "Button"))] - gedrueckt["c"] = druecke(top, "C: nur Storno") - if not gedrueckt["c"] and rest: - f.after(100, lambda: klick(rest - 1)) - - -meta = [m for m in BE.rechnungen_im_ordner(ordner) - if str(m.get("rechnungsnummer")) == "2026-001"][0] -f.after(200, klick) -f._storno_anlegen(meta, ordner) - -pruefe(gedrueckt["c"], f"der Knopf 'C: nur Storno' ist im Fenster ({gedrueckt['knoepfe']})") -pruefe(len(gedrueckt["knoepfe"]) == 4, - f"und die Wahl steht in EINEM Schritt: A, B, C, Abbrechen ({gedrueckt['knoepfe']})") - -storno_datei = [n for n in os.listdir(ordner) if n.startswith("Storno_")] -pruefe(len(storno_datei) == 1, f"das Storno-Blatt liegt im Ordner ({storno_datei})") - -# ---- Was auf dem Blatt steht --------------------------------------------- -pfad = os.path.join(ordner, storno_datei[0]) -m = BE.kenndaten_lesen(pfad) or {} -pruefe(m.get("art") == "storno", f"art=storno ({m.get('art')!r})") -pruefe(m.get("nur_storno") is True, - f"die Kenndaten sagen 'nur_storno' ({m.get('nur_storno')!r})") -pruefe(not m.get("korrektur_nummer"), - f"KEINE Folgenummer in den Kenndaten ({m.get('korrektur_nummer')!r})") -pruefe(m.get("ersatz_vorhanden") is False, - f"und kein Verweis auf eine bleibende Rechnung ({m.get('ersatz_vorhanden')!r})") -pruefe(str(m.get("naechte")) == "-3", - f"der Storno traegt -3 Naechte ({m.get('naechte')!r})") - -from pypdf import PdfReader # noqa: E402 -text = " ".join((s.extract_text() or "") for s in PdfReader(pfad).pages).replace("\n", " ") -pruefe("Die Leistung wird nicht abgerechnet" in text, - "auf dem Blatt steht, dass die Leistung nicht abgerechnet wird") -pruefe("berichtigte Rechnung" not in text and "Berichtigte Rechnung" not in text, - "und KEIN Wort von einer berichtigten Rechnung, die nie kommt") -pruefe("bereits mit der Rechnung" not in text, - "und kein Verweis auf eine andere Rechnung") - -# ---- Nummern und offener Vorgang ----------------------------------------- -pruefe(f.storno_stand is None, - "es bleibt KEIN Vorgang offen - das Formular wartet auf nichts") -pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher + 1, - f"nur die Storno-Nummer ist vergeben, keine zweite reserviert " - f"({zaehler_vorher} -> {f.cfg.get('rechnung_zaehler')})") - -# ---- Und was das Steuerjournal daraus macht ------------------------------ -sys.path.insert(0, JOURNAL) -import ablage # noqa: E402 -ablage.daten_ordner = lambda: tmp -from db import Journal # noqa: E402 -from modell import summiere, aufgehobene # noqa: E402 - -j = Journal(os.path.join(tmp, "journal.sqlite3")) -j.scanne(ordner, 5.0, voll=True) -buchungen = j.buchungen(jahr=2026) -weg = aufgehobene(buchungen) -# Verrechnet, nicht weggelassen (Entscheidung vom 04.09.2026, siehe -# modell.summiere im Steuerjournal): beide Zeilen stehen mit ihrem Vorzeichen -# in der Summe und heben sich gegenseitig auf. -s = summiere(buchungen) -print("--- was im Journal steht ---") -for b in buchungen: - print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Naechte {b.gezahlt:>8.2f}") -pruefe(len(buchungen) == 2, f"Rechnung und Storno stehen beide im Journal ({len(buchungen)})") -pruefe(len(weg) == 1, f"die Rechnung ist als aufgehoben erkannt ({len(weg)})") -pruefe(s.naechte == 0 and abs(s.gezahlt) < 0.005, - f"unterm Strich bleibt nichts uebrig: 0 Naechte, 0,00 Euro " - f"(ist {s.naechte}, {s.gezahlt})") -pruefe(s.buchungen == 2, - f"gezaehlt werden aber beide Belege - verrechnet, nicht weggelassen " - f"(ist {s.buchungen})") -j.schliessen() - -f.destroy() -_wecker.cancel() -shutil.rmtree(tmp, ignore_errors=True) - -print("---") -print("alles gruen" if not fehler else f"{len(fehler)} FEHLER") -sys.exit(1 if fehler else 0) diff --git a/pruef_release.py b/pruef_release.py deleted file mode 100644 index e8e13a4..0000000 --- a/pruef_release.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: passt das VEROEFFENTLICHTE Release zu dem, was der Updater sucht? - -DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026) --------------------------------------------- -Das Release war angelegt, die EXE hing dran - und der Updater haette trotzdem -nie etwas gefunden. Der Anhang hiess `Rechnungstool-1.7.0.exe`; gesucht wird -aber `Rechnungstool.exe` (so steht es in version.EXE_NAME, und danach sucht -passendes_release). Die Nummer kommt erst beim ABLEGEN dazu. - -Das Tueckische daran: nichts schlaegt fehl. Der Updater meldet freundlich -"Aktuell. Kein Update noetig." und startet die alte Fassung. Ein Update, das -stumm nie ankommt, ist schlimmer als eines, das laut scheitert. - -Zweiter Fund derselben Runde: die Pruefsummen standen nur im Release-TEXT. Der -Updater liest sie aus einer angehaengten Datei `.sha256`. Ohne die prueft -er nur die Groesse - und eine unterwegs verfaelschte Datei hat genau die -richtige Groesse. - -WAS HIER GEPRUEFT WIRD ----------------------- -Gegen das echte Repository, nicht gegen einen Nachbau: - - 1. Zu der Nummer in version.py gibt es ein Release. - 2. Es haengt eine Datei mit EXAKT dem Namen aus version.EXE_NAME an. - 3. Es haengt die Pruefsumme `.sha256` an. - 4. Die Summe darin ist die der angehaengten Datei - und die stimmt mit dem - ueberein, was hier zuletzt gebaut wurde (release/). - 5. passendes_release() findet dieses Release auch wirklich. - -Ohne Netz wird uebersprungen statt rot gemeldet: ein fehlender Anschluss ist -kein Fehler des Programms. -""" -import os -import sys - -HIER = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HIER) - -import updater # noqa: E402 -import version # noqa: E402 -from pruefhelfer import fehler, pruefe - - -try: - releases = updater.releases_holen(version.REPOSITORY) -except Exception as e: # noqa: BLE001 - ohne Netz ist hier nichts zu pruefen - print(f"UEBERSPRUNGEN | Repository nicht erreichbar ({e})") - sys.exit(0) - -marke = "v" + version.VERSION -dieses = next((r for r in releases if (r.get("tag_name") or "") == marke), None) -pruefe(dieses is not None, f"zu Fassung {version.VERSION} gibt es ein Release ({marke})") -if dieses is None: - print("ROT | ohne Release ist der Rest nicht pruefbar") - sys.exit(1) - -roh = dieses.get("assets") or [] -anhaenge = {a.get("name"): a for a in roh} -# Doppelte Namen: Forgejo laesst zwei Anhaenge mit demselben Namen zu. Beim -# Ersetzen einer Datei blieb die alte einmal stehen (07.09.2026), und welche -# von beiden der Updater dann laedt, ist Glueckssache - unter Umstaenden die -# alte, mit der falschen Pruefsumme daneben. -doppelt = sorted({a["name"] for a in roh - if sum(1 for b in roh if b["name"] == a["name"]) > 1}) -pruefe(not doppelt, f"kein Anhang haengt doppelt am Release ({doppelt})") -print(f" (Anhaenge: {', '.join(sorted(anhaenge)) or 'keine'})") - -pruefe(version.EXE_NAME in anhaenge, - f"der Anhang heisst genau {version.EXE_NAME} - danach sucht der Updater") -pruefe(version.EXE_NAME + ".sha256" in anhaenge, - f"die Pruefsumme haengt als {version.EXE_NAME}.sha256 dran") - -# ---- Die Summe muss zur Datei passen, und zwar zu der gebauten ------------ -gebaut = os.path.join(HIER, "release", version.EXE_NAME) -url_summe = updater.summe_aus_release(dieses, version.EXE_NAME) -if url_summe: - veroeffentlicht = updater.text_holen(url_summe).strip().split()[0] - if os.path.exists(gebaut): - pruefe(veroeffentlicht == updater.sha256(gebaut), - "die veroeffentlichte Summe ist die der zuletzt gebauten Datei") - else: - print(f" (release/{version.EXE_NAME} fehlt - Summe nicht " - f"gegenpruefbar, vorher: python bauen.py)") - pruefe(len(veroeffentlicht) == 64 and all(c in "0123456789abcdef" for c in veroeffentlicht), - f"und sie sieht aus wie eine SHA256 ({veroeffentlicht[:16]}...)") - -# ---- Und findet der Updater das Release ueberhaupt? ----------------------- -treffer = updater.passendes_release(releases, version.EXE_NAME) -pruefe(treffer is not None, "passendes_release findet ueberhaupt ein Release") -if treffer: - pruefe((treffer[0].get("tag_name") or "") == marke, - f"und zwar dieses hier ({treffer[0].get('tag_name')})") - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Das Release ist so, wie der Updater es erwartet") diff --git a/pruef_selbstbezug.py b/pruef_selbstbezug.py deleted file mode 100644 index 32e65d1..0000000 --- a/pruef_selbstbezug.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- 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 -from pruefhelfer import fehler, pruefe - -ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"] - - -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_spaltenbreiten.py b/pruef_spaltenbreiten.py deleted file mode 100644 index f3692b9..0000000 --- a/pruef_spaltenbreiten.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: passt die Leistungstabelle in jedes erlaubte Fenster? - -DER FALL, DER DAZU GEFUEHRT HAT (06.09.2026) --------------------------------------------- -Sein Befund: *"die namen und datums bloecke koennen schmaler werden sind zu -breit und verdrengen die posten block"*. Gemessen stimmte das genau: die -sieben Spalten der Leistungstabelle brauchten 743 Pixel, sichtbar waren 618 - -"E.-Preis" und "Gesamt" standen ausserhalb des Bildes. - -WARUM DAS KEIN ANDERER PRUEFSTAND FINDET ----------------------------------------- -Es stuerzt nichts ab, es rechnet nichts falsch, kein Wert fehlt. Die Rechnung -wird korrekt erstellt - man sieht die Preise nur nicht. Ein Fehler, den man -ansehen muss, um ihn zu bemerken. Genau deshalb steht er hier als Messung. - -Und die Ursache lag nicht dort, wo man sie vermutet: an den Feldbreiten links -zu drehen half NICHTS. Die Arbeitsflaeche verteilte die Breite ueber eine -uniform-Gruppe streng im Verhaeltnis 4:3 - die Spalte war so breit, weil das -Gewicht es vorschrieb, nicht weil der Inhalt es brauchte. - -WAS HIER GEPRUEFT WIRD ----------------------- -Ueber die ganze erlaubte Spanne von der Mindestbreite bis zu einem grossen -Fenster: - - 1. Alle Spalten der Tabelle passen nebeneinander in die sichtbare Flaeche. - 2. Die Formularseite bleibt lesbar - sie darf nicht unter ihre gemessene - Mindestbreite fallen. - 3. Die Formularseite waechst NICHT mit. Zusaetzliche Breite gehoert der - Tabelle; ein 600 Pixel breites Feld fuer eine Postleitzahl macht nichts - besser. - 4. Der Textumbruch der Leistungsspalte folgt ihrer echten Breite. Ist er - groesser, wird der Text abgeschnitten; ist er viel kleiner, bricht er - unnoetig um. -""" -import os -import sys -import time - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - - -os.chdir(QUELLE) -os.environ["CAMPINGHOF_TEST"] = "1" - -from tkinter import messagebox # noqa: E402 - -antworten = {"Erprobungsfassung": True, "Fertig": False, "Hausnummer": True} - - -def askyesno(titel, text, **kw): - for schlue, wert in antworten.items(): - if schlue in titel: - return wert - raise AssertionError(f"unangemeldeter Dialog: {titel!r}") - - -messagebox.askyesno = askyesno -for _n in ("showinfo", "showwarning", "showerror"): - setattr(messagebox, _n, lambda *a, **k: None) - -import app as A # noqa: E402 -from pruefhelfer import fehler, pruefe - -f = A.RechnungsApp() - - -def einpendeln(sekunden=1.2): - """Ereignisschleife drehen - Tk verteilt die Breite erst danach.""" - ende = time.time() + sekunden - while time.time() < ende: - f.update() - time.sleep(0.02) - f.update_idletasks() - - -einpendeln(2.0) - -# Die Mindestbreite ist die untere Grenze dessen, was der Benutzer einstellen -# kann - genau dort muss es noch passen. -MINDEST = f.minsize()[0] -print(f" (Mindestbreite laut minsize: {MINDEST})") - -formular_breiten = [] -for breite in (MINDEST, MINDEST + 40, 1180, 1400, 1600): - f.geometry(f"{breite}x680") - einpendeln() - pf = f.pos_frame - sichtbar = pf.master.winfo_width() # der Canvas darunter - spalten = [(pf.grid_bbox(column=i, row=0) or (0, 0, 0, 0))[2] - for i in range(len(A.RechnungsApp.COLS))] - gesamt = sum(spalten) - formular = f.flaeche.kaesten["kunde"]["aussen"].winfo_width() - formular_breiten.append(formular) - - pruefe(gesamt <= sichtbar, - f"bei {breite:4d} px passen alle {len(spalten)} Spalten " - f"({gesamt} von {sichtbar} px)") - pruefe(formular >= 300, - f"bei {breite:4d} px bleibt die Formularseite lesbar ({formular} px)") - - # Der Umbruch der Textspalte muss zur Spalte passen. - zellen = getattr(f, "_text_zellen", []) - if zellen: - umbruch = int(zellen[0].cget("wraplength")) - text_spalte = spalten[1] - pruefe(umbruch <= text_spalte, - f"bei {breite:4d} px bricht der Leistungstext INNERHALB der " - f"Spalte um ({umbruch} <= {text_spalte})") - pruefe(umbruch >= text_spalte - 30, - f"bei {breite:4d} px nutzt er die Spalte auch aus " - f"({umbruch} >= {text_spalte - 30})") - -# Mehr Platz gehoert der Tabelle, nicht den Adressfeldern. -pruefe(len(set(formular_breiten)) == 1, - f"die Formularseite waechst nicht mit dem Fenster {formular_breiten}") - -try: - f.destroy() -except Exception: # noqa: BLE001 - pass - -if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Die Leistungstabelle passt in jedes erlaubte Fenster") diff --git a/pruef_start_exe.py b/pruef_start_exe.py deleted file mode 100644 index 1dafd4f..0000000 --- a/pruef_start_exe.py +++ /dev/null @@ -1,133 +0,0 @@ -# -*- 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 -from pruefhelfer import fehler, pruefe - - -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 -# Mit --test: dann geht das Fenster auf dem ZWEITEN Monitor auf und nicht -# mitten auf dem Hauptbildschirm, wo gerade jemand arbeitet. Seine Ansage am -# 07.09.2026 - und dieser Pruefstand war der eine, der sie noch nicht befolgte, -# weil er die gebaute EXE ganz normal startet. Sichtbar bleibt das Fenster, -# und genau das wird hier ja geprueft. -p = subprocess.Popen([EXE, "--test"]) -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_storno.py b/pruef_storno.py index e376883..1f29489 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -29,15 +29,9 @@ shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json" config.CONFIG_PATH = os.path.join(tmp, "config.json") roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False roh["output_dir"] = ordner json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2) -# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen. -os.environ["CAMPINGHOF_TEST"] = "1" import app as A # noqa: E402 import bestand as BE # noqa: E402 from tkinter import messagebox # noqa: E402 @@ -48,7 +42,6 @@ from tkinter import messagebox # noqa: E402 # eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten # von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt. import threading as _th -from pruefhelfer import fehler, pruefe def _reissleine(): @@ -62,6 +55,18 @@ _wecker = _th.Timer(120, _reissleine) _wecker.daemon = True _wecker.start() +fehler = [] + + +def pruefe(bedingung, text): + # Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf + # nicht mit einem UnicodeEncodeError abbrechen. + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kodierung = sys.stdout.encoding or "utf-8" + print(zeile.encode(kodierung, "replace").decode(kodierung)) + if not bedingung: + fehler.append(text) + # Dialoge automatisch beantworten; jede Frage wird protokolliert. antworten = {} @@ -81,11 +86,6 @@ def askyesno(titel, text, **kw): antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall -# Die Freigabe vor dem Beleg (§ 14 UStG) wird bewusst bejaht - geprueft wird hier -# der Ablauf DANACH. Dass ein "Nein" keinen Beleg erzeugt, prueft pruef_freigabe.py. -antworten["jetzt erstellen?"] = True -# Der Erprobungshinweis wird hier bejaht - geprueft wird er in pruef_hinweis.py. -antworten["Erprobungsfassung"] = True messagebox.askyesno = askyesno messagebox.showinfo = lambda *a, **k: None warnungen = [] diff --git a/pruef_updater.py b/pruef_updater.py deleted file mode 100644 index 8769853..0000000 --- a/pruef_updater.py +++ /dev/null @@ -1,220 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: der Updater - vor allem die Faelle, in denen er NICHTS tun darf. - -Sein Bild (06.09.2026): - - "der updater schaut beim starten erst: ist auf dem git link eine neue - version mit exe? wenn ja, erst laden, ersetzen und dann starten." - "einmal pruefen auf vollstaendigkeit." - "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, - die heisst anders, dann neu machen" - -Ein Updater ist gefaehrlicher als das Programm, das er aktualisiert: Er -entscheidet ueber die Datei, die der Betrieb braucht. Deshalb wird hier weniger -geprueft, DASS er aktualisiert, als dass er es in den falschen Faellen NICHT -tut - und dass er nie etwas ueberschreibt. - -Ohne Netz: die Release-Liste wird eingesetzt, nicht geholt. -""" -import os -import shutil -import sys -import tempfile - -QUELLE = r"C:\claude\rechnungstool" -sys.path.insert(0, QUELLE) - -import updater as U # noqa: E402 -from pruefhelfer import fehler, pruefe - - -EXE = "Rechnungstool.exe" - -# ---- Versionen vergleichen --------------------------------------------- -pruefe(U.zerlegen("v1.6.0") == (1, 6, 0), "v1.6.0 wird zu (1, 6, 0)") -pruefe(U.zerlegen("") == (0,), "leere Angabe zaehlt als aelteste") -pruefe(U.zerlegen("kaputt") == (0,), "unlesbare Angabe zaehlt als aelteste") -pruefe(U.ist_neuer("v1.7.0", "1.6.9"), "1.7.0 ist neuer als 1.6.9 (nicht alphabetisch!)") -pruefe(not U.ist_neuer("v1.6.0", "1.6.0"), "Gleichstand ist NICHT neuer") -pruefe(U.ist_neuer("v1.10.0", "1.9.0"), "1.10.0 ist neuer als 1.9.0") - -# ---- Die Nummer im Dateinamen ------------------------------------------ -pruefe(U.dateiname(EXE, "v1.6.0") == "Rechnungstool-1.6.0.exe", - "aus Name und Tag wird Rechnungstool-1.6.0.exe") -pruefe(U.dateiname("Rechnungstool-1.5.0.exe", "v1.6.0") == "Rechnungstool-1.6.0.exe", - "eine schon vorhandene Nummer wird ersetzt, nicht angehaengt") -pruefe(U.basis_und_endung(EXE) == ("Rechnungstool", ".exe"), "Stamm und Endung sauber getrennt") - -# ---- Das passende Release finden --------------------------------------- -def release(tag, dateien, entwurf=False, vorab=True): - return {"tag_name": tag, "draft": entwurf, "prerelease": vorab, - "assets": [{"name": n, "size": g, - "browser_download_url": f"http://x/{tag}/{n}"} - for n, g in dateien]} - - -liste = [release("v1.5.0", [(EXE, 10)]), - release("v1.6.0", [(EXE, 20)]), - release("v1.5.5", [(EXE, 15)])] -treffer = U.passendes_release(liste, EXE) -pruefe(treffer and treffer[0]["tag_name"] == "v1.6.0", - "aus einer unsortierten Liste wird die neueste genommen") -pruefe(U.passendes_release([release("v9.9.9", [("anderes.exe", 5)])], EXE) is None, - "ein Release ohne passende Datei zaehlt nicht") -pruefe(U.passendes_release([release("v9.9.9", [(EXE, 5)], entwurf=True)], EXE) is None, - "ein Entwurf zaehlt nicht") -# Vorabversionen MUESSEN zaehlen - alle Fassungen sind so gekennzeichnet. -pruefe(U.passendes_release([release("v2.0.0", [(EXE, 30)], vorab=True)], EXE) is not None, - "Vorabversionen zaehlen mit (sonst faende der Updater nie etwas)") - -# ---- Vollstaendigkeit --------------------------------------------------- -tmp = tempfile.mkdtemp(prefix="upd_pruef_") -probe = os.path.join(tmp, "probe.bin") -with open(probe, "wb") as f: - f.write(b"A" * 1000) -summe = U.sha256(probe) - -gut, _ = U.vollstaendig(probe, 1000, summe) -pruefe(gut, "richtige Groesse und Pruefsumme werden angenommen") -gut, grund = U.vollstaendig(probe, 999, summe) -pruefe(not gut and "Groesse" in grund, f"falsche Groesse wird abgelehnt ({grund})") -gut, grund = U.vollstaendig(probe, 1000, "0" * 64) -pruefe(not gut and "Pruefsumme" in grund, f"falsche Pruefsumme wird abgelehnt ({grund})") -gut, _ = U.vollstaendig(probe, 1000, "") -pruefe(gut, "ohne Pruefsumme entscheidet die Groesse allein") -gut, _ = U.vollstaendig(os.path.join(tmp, "gibtsnicht"), 1000, "") -pruefe(not gut, "eine fehlende Datei ist nicht vollstaendig") - -# ---- Was liegt im Ordner? ---------------------------------------------- -ordner = os.path.join(tmp, "install") -os.makedirs(ordner) -for name, inhalt in (("Rechnungstool-1.5.0.exe", b"ALT"), - ("Rechnungstool-1.6.0.exe", b"AKTUELL"), - ("nichts-zu-tun.txt", b"x")): - with open(os.path.join(ordner, name), "wb") as f: - f.write(inhalt) - -fassung, pfad = U.neueste_installierte(ordner, EXE) -pruefe(fassung == "1.6.0", f"die neueste vorhandene Fassung wird am Namen erkannt ({fassung})") -pruefe(os.path.basename(pfad) == "Rechnungstool-1.6.0.exe", "und der Pfad stimmt") -pruefe(len(U.installierte(ordner, EXE)) == 2, "fremde Dateien im Ordner stoeren nicht") - -# ---- Der ganze Durchlauf, ohne Netz ------------------------------------- -meldungen = [] - - -def sagen(t): - meldungen.append(str(t)) - - -def alle_da(): - return sorted(os.path.basename(p) for _f, p in U.installierte(ordner, EXE)) - - -# 1. gleiche Fassung -> nichts laden, vorhandene starten -meldungen.clear() -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, - holen_=lambda _r: [release("v1.6.0", [(EXE, 11)])]) -pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", - "gleiche Fassung: die vorhandene wird gestartet") -pruefe(alle_da() == ["Rechnungstool-1.5.0.exe", "Rechnungstool-1.6.0.exe"], - "und im Ordner hat sich nichts geaendert") - -# 2. aeltere Fassung im Netz -> nichts tun -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, - holen_=lambda _r: [release("v1.5.0", [(EXE, 11)])]) -pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", - "aeltere Fassung im Netz: es wird nicht aktualisiert") - -# 3. kein Netz -> vorhandene starten und sagen, warum -meldungen.clear() - - -def wirft(_r): - raise OSError("kein Netz") - - -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=wirft) -pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", - "ohne Netz wird die vorhandene Fassung gestartet") -pruefe(any("Kein Update" in m for m in meldungen), "und es wird gesagt, warum") - -# 4. neuere Fassung, aber Groesse passt nicht -> VERWERFEN -meldungen.clear() -falsch = release("v1.9.0", [(EXE, 999999)]) -falsch["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/") -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [falsch]) -pruefe(any("VERWORFEN" in m for m in meldungen), "falsche Groesse: das Update wird verworfen") -pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", - "und die vorhandene Fassung wird gestartet") -pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")), - "die halbe Datei erreicht den Installationsordner NICHT - der wichtigste Punkt") - -# 5. neuere Fassung, alles stimmt -> als NEUE Datei dazu -meldungen.clear() -richtig = release("v1.9.0", [(EXE, 1000)]) -richtig["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/") -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) -pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe", - "die neue Fassung wird gestartet") -pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")), - "sie liegt als EIGENE Datei im Ordner") -pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.6.0.exe")), - "die vorige bleibt liegen - sie ist die Rueckfallebene") -pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.5.0.exe")), - "die vorvorige wurde aufgeraeumt (es bleiben immer zwei)") - -buch = U.buch_lesen(ordner) -pruefe(buch.get("version") == "1.9.0", - f"der Updater fuehrt Buch ueber das, was er installiert hat ({buch})") - -# 6. gleich noch einmal -> nichts laden, es ist ja jetzt aktuell -meldungen.clear() -vorher = alle_da() -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) -pruefe(any("Aktuell" in m for m in meldungen), - f"unmittelbar danach gilt es als aktuell ({meldungen})") -pruefe(alle_da() == vorher, "und es wird nichts noch einmal geladen") - -# 7. Buch verloren, Datei aber da -> nicht noch einmal laden -# Kommt vor, wenn jemand installiert.json loescht. Der Dateiname allein traegt -# die Entscheidung: die 1.9.0 liegt da, also wird sie nur gestartet. -meldungen.clear() -os.remove(os.path.join(ordner, U.BUCH)) -start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) -pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe", - "auch ohne Buch wird die richtige Datei gestartet") -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: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) -print("GRUEN | Der Updater aktualisiert - und laesst in den falschen Faellen die Finger davon") diff --git a/pruef_vorschau.py b/pruef_vorschau.py deleted file mode 100644 index b607c76..0000000 --- a/pruef_vorschau.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pruefstand: der Vorschau-Knopf. - -Die Vorschau soll zeigen, wie das Blatt aussehen WIRD - und dabei nichts -anfassen. Drei Zusicherungen, die alle drei still brechen koennten: - -1. Sie verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch. - Sonst fehlt in der Buchhaltung eine Nummer, fuer die es kein Blatt gibt. -2. Sie legt nichts im Rechnungsordner ab - dort liegt die Buchhaltung. -3. Auf jeder Seite steht "VORSCHAU - keine Rechnung". Ein Blatt ohne diesen - Hinweis koennte ausgedruckt beim Gast landen und als Rechnung gelten. -""" -import json -import os -import shutil -import sys -import tempfile -import threading as _th - -QUELLE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, QUELLE) - -tmp = tempfile.mkdtemp(prefix="vorschau_pruef_") -ordner = os.path.join(tmp, "rechnungen") -os.makedirs(ordner) - -import config # noqa: E402 -shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json")) -config.BASE_DIR = tmp -config.CONFIG_PATH = os.path.join(tmp, "config.json") -roh = json.load(open(config.CONFIG_PATH, encoding="utf-8")) -roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0 -# Der Erststart-Zustand gehoert dem Pruefstand, nicht der config des -# Entwicklerrechners: sonst laeuft er in einen anderen Zweig, sobald hier -# jemand die Nummer einmal eingerichtet hat. -roh["nummer_eingerichtet"] = False -roh["output_dir"] = ordner -json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False) - -os.environ["CAMPINGHOF_TEST"] = "1" -import app as A # noqa: E402 -from tkinter import messagebox # noqa: E402 -from pruefhelfer import fehler, pruefe - - -def _reissleine(): - print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog") - os._exit(2) - - -_wecker = _th.Timer(120, _reissleine) -_wecker.daemon = True -_wecker.start() - - -messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None -# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst. -# Geprueft wird er eigens in pruef_hinweis.py. -messagebox.askyesno = lambda *a, **k: True - -# Im Pruflauf oeffnet das Programm nichts mehr (sonst stehen nach einem Lauf -# dreissig PDF-Fenster offen). Geprueft wird der Pfad, den die Vorschau -# zurueckgibt - und dass wirklich NICHTS aufgemacht wurde. -geoeffnet = [] -A.os.startfile = lambda p, *a, **k: geoeffnet.append(p) - -f = A.RechnungsApp() -f.update() -f.cfg["output_dir"] = ordner -erste = sorted(f.anzahl_vars)[0] - -f.k_vorname.set("Anna") -f.k_name.set("Beispiel") -f.k_strasse.set("Musterweg 1") -f.k_plzort.set("12345 Musterstadt") -f.anzahl_vars[erste].set("3") -f.update() - -nummer_vorher = f.r_nummer.get() -zaehler_vorher = f.cfg.get("rechnung_zaehler") - -pfad = f.vorschau_zeigen() - -pruefe(bool(pfad), f"die Vorschau nennt ihr Blatt ({os.path.basename(str(pfad))})") -pruefe(not geoeffnet, f"im Pruflauf wird KEIN Betrachter aufgemacht ({geoeffnet})") -pruefe(os.path.exists(pfad), "Vorschau-PDF wurde geschrieben") -pruefe(os.path.abspath(ordner) not in os.path.abspath(pfad), - "Vorschau liegt NICHT im Rechnungsordner") -pruefe(not os.listdir(ordner), f"Rechnungsordner bleibt leer ({os.listdir(ordner)})") -pruefe(f.r_nummer.get() == nummer_vorher, - f"Rechnungsnummer unveraendert ({nummer_vorher} -> {f.r_nummer.get()})") -pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, "Zaehler unveraendert") - -from pypdf import PdfReader # noqa: E402 -seiten = [s.extract_text() or "" for s in PdfReader(pfad).pages] -pruefe(all("VORSCHAU" in t for t in seiten), - f"'VORSCHAU' steht auf jeder Seite ({len(seiten)} Seiten)") -pruefe(all("keine Rechnung" in t for t in seiten), "'keine Rechnung' steht auf jeder Seite") - -# Und die echte Rechnung danach traegt die Marke NICHT. -f.pdf_erstellen() -echte = [os.path.join(ordner, n) for n in os.listdir(ordner) if n.lower().endswith(".pdf")] -pruefe(len(echte) == 1, f"danach entsteht genau eine echte Rechnung ({len(echte)})") -if echte: - text = "".join((s.extract_text() or "") for s in PdfReader(echte[0]).pages) - pruefe("VORSCHAU" not in text, "die echte Rechnung traegt KEINE Vorschau-Marke") - -f.destroy() -print() -if fehler: - print(f"FEHLGESCHLAGEN: {len(fehler)}") - for x in fehler: - print(" -", x) - sys.exit(1) -print("ALLES GRUEN") diff --git a/pruefhelfer.py b/pruefhelfer.py deleted file mode 100644 index f3e9155..0000000 --- a/pruefhelfer.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -"""Das Gemeinsame aller Pruefstaende: eine Zeile schreiben und sie merken. - -WARUM ES DIESE DATEI GIBT (07.09.2026) --------------------------------------- -Die Funktion pruefe() stand 35-mal im Quelltext, in sechs leicht verschiedenen -Fassungen. Keine davon war falsch - aber sie sahen unterschiedlich aus, und -wer 37 Laeufe hintereinander durchsieht, liest dann dreierlei Formen fuer -dieselbe Aussage. Wird an der Ausgabe je etwas geaendert, sind es 35 Stellen. - -BENUTZUNG - - from pruefhelfer import fehler, pruefe - - pruefe(a == b, "beide nennen denselben Ordner") - ... - if fehler: - print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") - sys.exit(1) - print("GRUEN | ...") - -`fehler` ist eine Liste und wird beim Importieren geteilt - jeder Pruefstand -laeuft in einem eigenen Prozess, sie gehoert also ihm allein. - -Den Abschluss schreibt jeder Pruefstand selbst. Er sieht nur aehnlich aus: -manche raeumen dort noch einen Temp-Ordner ab, manche zaehlen die -fehlgeschlagenen Punkte einzeln auf. Das ist Inhalt, keine Wiederholung. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber - sonst steht in zwei Programmen zweierlei. -""" -import sys - -# Alles, was nicht erfuellt war - der Text, nicht nur die Anzahl. Wer am Ende -# nur eine Zahl hat, muss den Lauf noch einmal lesen, um zu wissen, was fehlte. -fehler = [] - - -def pruefe(bedingung, text): - """Eine Zusage pruefen, das Ergebnis melden und Fehlschlaege merken.""" - zeile = (" OK | " if bedingung else "FEHLER | ") + text - # Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf - # nicht mit einem UnicodeEncodeError abbrechen. - kodierung = sys.stdout.encoding or "utf-8" - print(zeile.encode(kodierung, "replace").decode(kodierung)) - if not bedingung: - fehler.append(text) diff --git a/pruefsteuerung.py b/pruefsteuerung.py deleted file mode 100644 index ed320de..0000000 --- a/pruefsteuerung.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8 -*- -"""Steuerung fuer Pruefstaende: keine Fenster, keine Popups, keine Maus. - -WARUM ------ -Ein Pruefstand baut das Programm auf, um es zu BEDIENEN - nicht, um es zu -zeigen. Solange er wie ein Benutzer arbeitet, reisst jeder Lauf dem Menschen am -Rechner Fokus und Maus weg, und jeder Dialog bleibt stehen und wartet auf einen -Klick, den niemand macht. - -Deshalb zwei Dinge, beide nur im Testlauf (`--test` / `CAMPINGHOF_TEST=1`): - - * Das Fenster wird gar nicht erst gezeigt (`withdraw()` in app.py). - * Alle Dialoge laufen hierueber statt ueber echte Fenster. - -WAS EIN DIALOG HIER TUT ------------------------ -`showinfo/showwarning/showerror` werden ins PROTOKOLL geschrieben und -verschwinden. `askyesno` und Verwandte schlagen im Fahrplan nach: - - pruefsteuerung.antwortet("Hausnummer", True) - -Passt kein Eintrag, bricht der Lauf mit einer Meldung ab - ABSICHTLICH. Ein -unbekannter Dialog heisst: das Programm fragt etwas Neues, und niemand hat -entschieden, was der Pruefstand darauf antworten soll. Stillschweigend "ja" zu -sagen waere die faule Loesung und wuerde genau die Faelle uebergehen, wegen -denen es die Pruefung gibt. - -BENUTZUNG IM PRUEFSTAND ------------------------ - import pruefsteuerung as PS - PS.antwortet("Storno wirklich", True) - ... - PS.gemeldet("Kundenangaben fehlen") -> True, wenn das Programm es sagte -""" -from tkinter import messagebox - -# Was das Programm gemeldet haette: Liste aus (art, titel, text). -PROTOKOLL = [] -# Fahrplan fuer Ja/Nein-Fragen: Textstueck im Titel -> Antwort. -ANTWORTEN = {} -_aktiv = False - - -def antwortet(titelstueck, wert): - """Legt fest, wie eine Frage beantwortet wird, deren Titel das Stueck enthaelt.""" - ANTWORTEN[titelstueck] = wert - - -def zuruecksetzen(): - PROTOKOLL.clear() - ANTWORTEN.clear() - - -def meldungen(stueck=""): - """Alle Meldungen (oder die, in denen das Stueck vorkommt).""" - return [m for m in PROTOKOLL if stueck in m[1] or stueck in m[2]] - - -def gemeldet(stueck): - """Hat das Programm etwas gemeldet, in dem das Stueck vorkommt?""" - return bool(meldungen(stueck)) - - -def _melden(art): - def gemeldet_(titel="", text="", **_kw): - PROTOKOLL.append((art, str(titel), str(text))) - print(f"[dialog] {art}: {titel}") - return "ok" - return gemeldet_ - - -def _fragen(vorgabe=None): - def gefragt(titel="", text="", **_kw): - PROTOKOLL.append(("frage", str(titel), str(text))) - for stueck, wert in ANTWORTEN.items(): - if stueck in str(titel) or stueck in str(text): - print(f"[dialog] frage: {titel} -> {wert}") - return wert - if vorgabe is not None: - return vorgabe - raise AssertionError( - f"unangemeldeter Dialog: {titel!r}\n" - f" {text!r}\n" - " -> im Pruefstand mit pruefsteuerung.antwortet(, True/False) " - "beantworten") - return gefragt - - -def einschalten(): - """Dialoge umleiten. Mehrfaches Aufrufen schadet nicht.""" - global _aktiv - if _aktiv: - return - messagebox.showinfo = _melden("info") - messagebox.showwarning = _melden("warnung") - messagebox.showerror = _melden("fehler") - messagebox.askyesno = _fragen() - messagebox.askokcancel = _fragen() - messagebox.askretrycancel = _fragen(False) - messagebox.askquestion = _fragen() - _aktiv = True - print("[pruefsteuerung] Dialoge laufen ohne Fenster") diff --git a/reiter.py b/reiter.py deleted file mode 100644 index 8039d17..0000000 --- a/reiter.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -"""Reiterleiste im Pcore-Stil: Knopf mit Unterstrich statt ttk-Karteikarte. - -WARUM NICHT ttk.Notebook ------------------------- -Unter dem clam-Thema laesst sich die Reiterflaeche nicht zuverlaessig -einfaerben: der RUHENDE Reiter blieb hell und sah aus wie der ausgewaehlte - -also genau verkehrt herum. Sein Fund: *"er hebt den aktiven tab nicht hervor, -der inaktive ist der hervorgehobene"*. - -Statt weiter an clam zu drehen, ist die Leiste hier selbst gebaut - und sieht -dabei aus wie die Reiter in seinen Web-Oberflaechen: ein flacher Knopf, der -offene in Akzentfarbe mit einem Balken darunter. - -NACH AUSSEN wie ein Notebook ----------------------------- -`add`, `select`, `index("current"/"end")`, `tab(i, "text")` und das Ereignis -`<>` verhalten sich wie beim Original. So bleiben die -Aufrufer unveraendert - der Storno-Weg waehlt weiter `select(1)`, und die -Pruefstaende lesen die Reiternamen wie bisher. -""" -import tkinter as tk -from tkinter import ttk - -import theme - - -class Reiterleiste(ttk.Frame): - def __init__(self, master, farben=None): - super().__init__(master) - self.f = farben if farben is not None else master - self._seiten = [] # (Rahmen, Text, Knopf, Balken) - self._aktiv = 0 - - self.leiste = ttk.Frame(self) - self.leiste.pack(side="top", fill="x") - self.bereich = ttk.Frame(self) - self.bereich.pack(side="top", fill="both", expand=True) - - # ---- wie beim Notebook ------------------------------------------------ - def add(self, rahmen, text=""): - nr = len(self._seiten) - kasten = ttk.Frame(self.leiste) - kasten.pack(side="left", padx=(0, 4)) - knopf = tk.Label(kasten, text=text.strip(), cursor="hand2", - bg=self.f.col_bg, fg=self.f.col_muted, - font=(theme.SCHRIFT_NAME, 10), padx=18, pady=8) - knopf.pack(side="top", fill="x") - # Der Balken unter dem offenen Reiter - dasselbe Zeichen wie in den - # Web-Oberflaechen. - balken = tk.Frame(kasten, height=2, bg=self.f.col_bg) - balken.pack(side="top", fill="x") - knopf.bind("", lambda _e, i=nr: self.select(i)) - knopf.bind("", lambda _e, i=nr: self._schweben(i, True)) - knopf.bind("", lambda _e, i=nr: self._schweben(i, False)) - self._seiten.append([rahmen, text, knopf, balken]) - if nr == 0: - rahmen.pack(in_=self.bereich, fill="both", expand=True) - self._zeichnen() - - def select(self, nr): - nr = int(nr) - if not (0 <= nr < len(self._seiten)) or nr == self._aktiv: - self._zeichnen() - return - self._seiten[self._aktiv][0].pack_forget() - self._aktiv = nr - self._seiten[nr][0].pack(in_=self.bereich, fill="both", expand=True) - self._zeichnen() - # Dasselbe Ereignis wie beim Notebook - die App haengt daran. - self.event_generate("<>") - - def index(self, was="current"): - return len(self._seiten) if was == "end" else self._aktiv - - def tab(self, nr, key="text"): - return self._seiten[int(nr)][1] if key == "text" else "" - - # ---- Aussehen --------------------------------------------------------- - def _schweben(self, nr, drueber): - if nr == self._aktiv: - return - knopf = self._seiten[nr][2] - knopf.configure(fg=self.f.col_fg if drueber else self.f.col_muted) - - def _zeichnen(self): - for i, (_rahmen, _text, knopf, balken) in enumerate(self._seiten): - offen = (i == self._aktiv) - knopf.configure( - fg=self.f.col_accent if offen else self.f.col_muted, - bg=self.f.col_card if offen else self.f.col_bg, - font=(theme.SCHRIFT_NAME, 10, "bold") if offen - else (theme.SCHRIFT_NAME, 10)) - balken.configure(bg=self.f.col_accent if offen else self.f.col_bg) diff --git a/theme.py b/theme.py index 213e2d3..cf89143 100644 --- a/theme.py +++ b/theme.py @@ -5,254 +5,11 @@ Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben. """ -import ctypes.wintypes -import os import sys import tkinter as tk -from tkinter import font as tkfont -from tkinter import messagebox from tkinter import ttk -# --------------------------------------------------------------------------- -# Diese Datei liegt in BEIDEN Programmen gleich und darf deshalb NICHTS aus -# einem der beiden importieren. Frueher holte sie sich die Ordner und den -# Testlauf-Schalter aus config.py - die gibt es aber nur im Rechnungstool, und -# beim Uebernehmen ins Steuerrechnungstool flog sie beim Import auseinander. -# Die zwei Dinge, die gebraucht werden, stehen jetzt hier. -# --------------------------------------------------------------------------- -def _ordner(): - """Alle Orte, an denen assets/ liegen kann - Quelle wie gepackte 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 titelleiste_faerben(fenster, dunkel): - """Die Titelleiste des Fensters mit einfaerben (Windows 10 ab 1809). - - Tk kann das nicht - die Leiste mit Symbol, Titel und den drei Knoepfen - gehoert Windows, nicht dem Programm. Windows nimmt dafuer aber einen - Hinweis entgegen: DWMWA_USE_IMMERSIVE_DARK_MODE. Ohne den steht ein weisser - Balken ueber einem dunklen Fenster (sein Befund am 07.09.2026: "der icon - und titel leiste auch ... die sind noch hell"). - - Die Nummer des Attributs hat sich einmal geaendert: 19 in den fruehen - 1809er-Fassungen, 20 seit 2004. Beide werden gesetzt; die falsche wird - ignoriert. - """ - if not sys.platform.startswith("win"): - return False - try: - fenster.update_idletasks() - hwnd = ctypes.windll.user32.GetParent(fenster.winfo_id()) or int(fenster.frame(), 16) - wert = ctypes.c_int(1 if dunkel else 0) - gesetzt = False - for attribut in (20, 19): - ergebnis = ctypes.windll.dwmapi.DwmSetWindowAttribute( - ctypes.wintypes.HWND(hwnd), ctypes.c_int(attribut), - ctypes.byref(wert), ctypes.sizeof(wert)) - gesetzt = gesetzt or ergebnis == 0 - # Den Rahmen neu zeichnen lassen, ohne Groesse, Platz, Reihenfolge oder - # Fokus anzufassen. Ohne das bleibt die Leiste stehen, wie sie war - - # die Umstellung wirkt dann erst beim naechsten Start. - SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED - ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) - return gesetzt - except Exception: # noqa: BLE001 - ohne dunkle Titelleiste laeuft es weiter - return False - - -def pruefplatz(): - """Wo ein Pruefstand seine Fenster aufmachen soll: "+x+y" oder "". - - Seine Ansage am 07.09.2026: *"teste links auf dem 1080p monitor nicht mehr - auf dem main ... weil da schau ich nun video"*. - - Vorher lag der Platz bei `Bildschirmbreite + 20` - also RECHTS neben dem - Hauptmonitor. Bei ihm steht der zweite Monitor aber LINKS (x = -1920), und - rechts ist gar nichts: die Fenster gingen ins Leere. Sie liefen zwar, waren - aber nirgends zu sehen - das hat beim Bildermachen einige Zeit gekostet. - - Gesucht wird deshalb der nicht-primaere Monitor. Gibt es keinen, bleibt es - beim alten Verhalten (neben den Bildschirm), denn im Weg sein soll ein - Pruefstand auch dann nicht. - """ - if not sys.platform.startswith("win"): - return "" - try: - class RECT(ctypes.Structure): - _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), - ("right", ctypes.c_long), ("bottom", ctypes.c_long)] - - class MONITORINFO(ctypes.Structure): - _fields_ = [("cbSize", ctypes.wintypes.DWORD), ("rcMonitor", RECT), - ("rcWork", RECT), ("dwFlags", ctypes.wintypes.DWORD)] - - gefunden = [] - - def je_monitor(hmon, hdc, lprect, daten): - info = MONITORINFO() - info.cbSize = ctypes.sizeof(MONITORINFO) - if ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(info)): - r = info.rcWork - gefunden.append((bool(info.dwFlags & 1), # 1 = MONITORINFOF_PRIMARY - r.left, r.top, r.right - r.left)) - return 1 - - RUECKRUF = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HMONITOR, - ctypes.wintypes.HDC, ctypes.POINTER(RECT), - ctypes.wintypes.LPARAM) - ctypes.windll.user32.EnumDisplayMonitors(0, 0, RUECKRUF(je_monitor), 0) - # Der breiteste Nebenmonitor - bei mehreren der linkeste. - neben = sorted((m for m in gefunden if not m[0]), - key=lambda m: (-m[3], m[1])) - if neben: - _, x, y, _ = neben[0] - return f"+{x + 30}+{y + 40}" - except Exception: # noqa: BLE001 - dann eben der alte Weg - pass - return "" - - -def auf_bildschirm(x, y, breite=0, hoehe=0): - """Eine Fensterposition auf den sichtbaren Bereich begrenzen. - - Frueher stand an diesen Stellen `max(0, x)`. Das sollte verhindern, dass - ein Dialog ausserhalb des Bildschirms aufgeht - klemmt aber jede NEGATIVE - Koordinate auf null. Steht der zweite Monitor links (bei ihm x = -1920), - sprang damit jedes Popup zurueck auf den Hauptbildschirm, obwohl das - Programm nebenan lief. Sein Befund am 07.09.2026: *"da gehen noch die - popups auf dem video monitor auf"*. - - Begrenzt wird deshalb auf den GESAMTEN Desktop ueber alle Monitore - (SM_*VIRTUALSCREEN), nicht auf den Hauptmonitor. - """ - if not sys.platform.startswith("win"): - return int(x), int(y) - try: - hole = ctypes.windll.user32.GetSystemMetrics - links, oben = hole(76), hole(77) - rechts, unten = links + hole(78), oben + hole(79) - if breite: - x = min(x, rechts - breite) - if hoehe: - y = min(y, unten - hoehe) - return int(max(links, x)), int(max(oben, y)) - except Exception: # noqa: BLE001 - dann eben ungebremst - return int(x), int(y) - - -def mittig(dialog, ueber): - """Einen Dialog mittig ueber sein Elternfenster setzen. - - Seine Ansage am 07.09.2026: *"einfach so programmieren das die popup auf - dem fenster mittig vom main fenster auf gehen"*. - - Das ist zugleich die Antwort auf die Monitorfrage: steht das Programm auf - dem zweiten Bildschirm, geht der Dialog dort auf - ohne dass irgendwo ein - Monitor gesucht werden muesste. Vorher standen an den Dialogen feste - Versaetze ("+120+140") und ein `max(0, x)`, das jede negative Koordinate - auf null klemmte und die Fenster damit auf den Hauptmonitor zurueckriss. - - Etwas oberhalb der Mitte (ein Drittel statt der Haelfte): so sitzt der - Dialog dort, wo der Blick ohnehin ist, und verdeckt nicht die Mitte des - Formulars darunter. - """ - try: - dialog.update_idletasks() - breite = dialog.winfo_reqwidth() or dialog.winfo_width() - hoehe = dialog.winfo_reqheight() or dialog.winfo_height() - x = ueber.winfo_rootx() + (ueber.winfo_width() - breite) // 2 - y = ueber.winfo_rooty() + (ueber.winfo_height() - hoehe) // 3 - x, y = auf_bildschirm(x, y, breite, hoehe) - dialog.geometry(f"+{x}+{y}") - except tk.TclError: - pass # Fenster schon zu - dann ist die Position gleichgueltig - - -def testlauf(): - """Faehrt gerade ein Pruefstand das Programm? - - Muss mit der Definition in app.py uebereinstimmen - beide Programme - schalten ueber `--test` oder die Umgebungsvariable. - """ - return ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1" - - -# Schrift: Roboto, so wie in den Web-Oberflaechen. Sie wird NICHT auf dem -# Rechner installiert, sondern nur fuer diesen einen Prozess geladen (die -# Windows-Funktion AddFontResourceEx mit FR_PRIVATE). Damit braucht niemand -# Adminrechte, und nach dem Schliessen ist der Rechner wieder wie vorher. -# Findet sich die Datei nicht, bleibt es bei Segoe UI - das Programm muss auch -# ohne mitgelieferte Schrift laufen. -SCHRIFT_NAME = "Segoe UI" -SCHRIFT = (SCHRIFT_NAME, 10) -KLEIN = (SCHRIFT_NAME, 9) -_schrift_geladen = False - - -def schrift_vorbereiten(): - """Roboto laden und zur Schrift des Programms machen. Gibt den Namen zurueck.""" - global SCHRIFT_NAME, SCHRIFT, KLEIN, _schrift_geladen - if _schrift_geladen: - return SCHRIFT_NAME - _schrift_geladen = True - if not sys.platform.startswith("win"): - return SCHRIFT_NAME - FR_PRIVATE = 0x10 - geladen = 0 - for basis in _ordner(): - ordner = os.path.join(basis, "assets", "fonts") - if not os.path.isdir(ordner): - continue - for datei in sorted(os.listdir(ordner)): - if datei.lower().endswith((".ttf", ".otf")): - geladen += ctypes.windll.gdi32.AddFontResourceExW( - os.path.join(ordner, datei), FR_PRIVATE, 0) - if geladen: - break - if not geladen: - print("[schrift] Roboto nicht gefunden - bleibe bei Segoe UI") - return SCHRIFT_NAME - # Ob Windows sie wirklich angenommen hat, verraet erst die Schriftenliste. - if "Roboto" not in tkfont.families(): - print("[schrift] Roboto liess sich nicht laden - bleibe bei Segoe UI") - return SCHRIFT_NAME - SCHRIFT_NAME = "Roboto" - SCHRIFT = (SCHRIFT_NAME, 10) - KLEIN = (SCHRIFT_NAME, 9) - for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont", - "TkIconFont", "TkTooltipFont"): - try: - tkfont.nametofont(name).configure(family=SCHRIFT_NAME, size=10) - except tk.TclError: - pass - print("[schrift] Roboto geladen") - return SCHRIFT_NAME - - -def mischen(vorn, hinten, anteil): - """Wie 'rgba(vorn, anteil)' auf 'hinten' - nur ausgerechnet statt gemalt. - - Tk kann keine halbdurchsichtigen Flaechen (kein backdrop-filter, keine - Alpha-Kanaele auf Widgets). Seine Web-Oberflaechen bauen den Glas-Effekt aus - genau zwei Zutaten: eine getoente Flaeche und ein feiner Rand in - Akzent-Transparenz. Beides laesst sich vorher ausrechnen - das Ergebnis sieht - identisch aus, solange der Grund einfarbig ist. Und der ist es hier. - """ - v = vorn.lstrip("#") - h = hinten.lstrip("#") - teile = [] - for i in (0, 2, 4): - a = int(v[i:i + 2], 16) - b = int(h[i:i + 2], 16) - teile.append(round(a * anteil + b * (1 - anteil))) - return "#%02x%02x%02x" % tuple(teile) - - def windows_dark_mode(): """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" if not sys.platform.startswith("win"): @@ -266,95 +23,35 @@ def windows_dark_mode(): return False -# --------------------------------------------------------------------------- -# Welche Darstellung ist gewuenscht? -# --------------------------------------------------------------------------- -# Drei Moeglichkeiten, gespeichert in der config unter "darstellung": -# -# "auto" - folgt Windows (Voreinstellung, wie es bisher immer war) -# "hell" - immer hell -# "dunkel" - immer dunkel -# -# "auto" ist die Voreinstellung, weil sich das Programm dann verhaelt wie jedes -# andere: wer sein Windows abends auf dunkel stellt, bekommt es auch hier. -MODI = ("auto", "hell", "dunkel") -MODUS_NAMEN = {"auto": "Automatisch (wie Windows)", - "hell": "Hell", - "dunkel": "Dunkel"} - - -def modus(cfg): - """Der eingestellte Modus - immer einer aus MODI.""" - wert = str((cfg or {}).get("darstellung", "auto")).strip().lower() - return wert if wert in MODI else "auto" - - -def ist_dunkel(cfg): - """Soll dunkel gezeichnet werden? Die eine Stelle, die das entscheidet.""" - m = modus(cfg) - if m == "hell": - return False - if m == "dunkel": - return True - return windows_dark_mode() - - class ThemeMixin: """Farben setzen. Erwartet ein tk.Tk als Basis.""" # ----------------------------------------------------------------- Theme def _apply_theme(self): - """Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows).""" - schrift_vorbereiten() # Roboto, sonst Segoe UI - dark = ist_dunkel(getattr(self, "cfg", None)) + """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" + dark = windows_dark_mode() self._dark_mode = dark - # Die Farben sind die aus seinen anderen Sachen (FiveM-Oberflaechen, - # Adminpage, Webseiten): Tuerkis #06C6A4 auf sehr dunklen, gestuften - # Flaechen. Das PROGRAMM traegt diese Handschrift - die RECHNUNG traegt - # die Farbe der jeweiligen Firma (Einstellungen -> Farbe der Rechnung). - # Beides zu vermischen waere falsch: das Blatt gehoert dem Kunden. if dark: - self.col_bg = "#0d0e10" # Fenster-Hintergrund (tiefstes Grau) - self.col_card = "#15171b" # Kachel / Abschnitt - self.col_fg = "#e8e8ea" # Schrift - self.col_field = "#2c2e32" # Eingabefelder (heller als die Kachel) - self.col_feldrand = "#57585b" # Rahmen der Eingabefelder - self.col_sel = "#1f3d38" # aktiv/hover (Akzent, stark abgedunkelt) - self.col_accent = "#06C6A4" # Akzent (Pcore-Tuerkis) - self.col_accent_hell = "#08dbb5" - self.col_auf_accent = "#08221d" # Schrift AUF der Akzentflaeche - self.col_gefahr = "#e85a5a" - self.col_line = "#25282e" - self.col_scroll = "#2f333a" - self.col_muted = "#8a8e96" - self.col_zebra = "#232830" # jede zweite Zeile der Liste + self.col_bg = "#23272e" # Fenster-Hintergrund + self.col_fg = "#e6e6e6" # Schrift + self.col_field = "#2f343c" # Eingabefelder + self.col_sel = "#3a4a44" # aktiv/hover + self.col_accent = "#5fb98f" # Akzent (Gruen, hell) + self.col_line = "#3a3f47" + self.col_scroll = "#4a505a" # Scroll-Griff + self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by") else: - self.col_bg = "#eef0f3" - self.col_card = "#ffffff" - self.col_fg = "#14161a" + self.col_bg = "#f3f3f3" + self.col_fg = "#1a1a1a" self.col_field = "#ffffff" - self.col_feldrand = "#b9bec6" - self.col_sel = "#d7f5ee" - self.col_accent = "#04886f" # dunkler, damit es auf Weiss lesbar ist - self.col_accent_hell = "#06C6A4" - self.col_auf_accent = "#ffffff" - self.col_gefahr = "#c0392b" - self.col_line = "#d8dce1" - self.col_scroll = "#c2c6cc" - self.col_muted = "#5c626b" - self.col_zebra = "#e4e8ee" + self.col_sel = "#cfe3d8" + self.col_accent = "#1f5c3d" + self.col_line = "#cccccc" + self.col_scroll = "#b8b8b8" + self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by") self.configure(bg=self.col_bg) - # Die Titelleiste gehoert Windows - sie muss eigens umgestellt werden. - titelleiste_faerben(self, dark) - # Und das Fenstersymbol: in der dunklen Titelleiste geht die dunkle - # Haelfte des Zeichens sonst unter. Es haengt hier und nicht im - # __init__, damit es beim Umschalten mitgeht - dort wird _apply_theme - # erneut gerufen, das __init__ nicht. - symbol = getattr(self, "_fenstersymbol", None) - if callable(symbol): - symbol() style = ttk.Style(self) try: style.theme_use("clam") @@ -365,132 +62,28 @@ class ThemeMixin: style.configure(".", background=bg, foreground=fg, fieldbackground=field, bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) style.configure("TFrame", background=bg) - style.configure("TLabel", background=bg, foreground=fg, font=SCHRIFT) + style.configure("TLabel", background=bg, foreground=fg) style.configure("TLabelframe", background=bg, bordercolor=self.col_line) style.configure("TLabelframe.Label", background=bg, foreground=accent) - - # --- Kacheln: ein Abschnitt ist eine Flaeche mit Rand, keine Linie --- - style.configure("Karte.TFrame", background=self.col_card, - bordercolor=self.col_line, relief="solid", borderwidth=1) - # Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die - # Ueberschrift in Akzentfarbe - genau wie die Kacheln in den - # Web-Oberflaechen. - # Der Kasten, den man gerade in der Hand hat: blass, gestrichelt. - style.configure("Gegriffen.TFrame", background=self.col_bg, - bordercolor=self.col_muted, relief="solid", borderwidth=1, - lightcolor=self.col_muted, darkcolor=self.col_muted) - # Der Kasten, auf den beim Anordnen gerade gezielt wird. - style.configure("Ziel.TFrame", background=self.col_card, - bordercolor=accent, relief="solid", borderwidth=2, - lightcolor=accent, darkcolor=accent) - style.configure("Karte.TLabelframe", background=self.col_card, - bordercolor=self.col_line, relief="solid", borderwidth=1, - lightcolor=self.col_card, darkcolor=self.col_card) - style.configure("Karte.TLabelframe.Label", background=self.col_card, - foreground=accent, font=(SCHRIFT_NAME, 9, "bold")) - style.configure("Karte.TEntry", fieldbackground=field, foreground=fg) - style.configure("Karte.TLabel", background=self.col_card, foreground=fg, font=SCHRIFT) - style.configure("KarteMuted.TLabel", background=self.col_card, - foreground=self.col_muted, font=KLEIN) - # Ueberschrift eines Abschnitts: klein, gross geschrieben, in Akzentfarbe - - # so wie die Kachelkoepfe in den Web-Oberflaechen. - style.configure("Abschnitt.TLabel", background=self.col_card, - foreground=accent, font=(SCHRIFT_NAME, 9, "bold")) - style.configure("Wert.TLabel", background=self.col_card, foreground=fg, - font=(SCHRIFT_NAME, 10)) - style.configure("Summe.TLabel", background=bg, foreground=accent, - font=(SCHRIFT_NAME, 15, "bold")) - style.configure("Zebra.TLabel", background=self.col_zebra, foreground=fg, font=SCHRIFT) - - # --- Knoepfe im Glas-Stil (wie in seinen Oberflaechen) --- - # Getoente Flaeche, feiner Rand in Akzent-Transparenz, Schrift in der - # Akzentfarbe. Kein Vollton: der wirkt in dieser Palette wie ein - # Fremdkoerper. - glas = mischen(accent, bg, 0.10) # ruhige Flaeche - glas_hover = mischen(accent, bg, 0.20) - rand = mischen(accent, bg, 0.35) - rand_hell = mischen(accent, bg, 0.55) - style.configure("TButton", background=glas, foreground=fg, - bordercolor=rand, padding=(14, 7), font=SCHRIFT, - focuscolor=rand, relief="solid", borderwidth=1, - lightcolor=rand, darkcolor=rand) - style.map("TButton", - background=[("pressed", mischen(accent, bg, 0.28)), ("active", glas_hover)], - bordercolor=[("active", rand_hell)], - lightcolor=[("active", rand_hell)], darkcolor=[("active", rand_hell)], - foreground=[("disabled", self.col_muted)]) - - # Der Knopf, um den es geht: dieselbe Glasflaeche, aber kraeftiger - # getoent, mit Akzentrand und Akzentschrift. - haupt = mischen(accent, bg, 0.22) - style.configure("Haupt.TButton", background=haupt, - foreground=self.col_accent_hell, bordercolor=self.col_accent, - lightcolor=self.col_accent, darkcolor=self.col_accent, - relief="solid", borderwidth=1, - padding=(18, 8), font=(SCHRIFT_NAME, 10, "bold")) - style.map("Haupt.TButton", - background=[("pressed", mischen(accent, bg, 0.45)), - ("active", mischen(accent, bg, 0.34)), - ("disabled", bg)], - foreground=[("disabled", self.col_muted)]) - - # Storno greift in eine ausgestellte Rechnung ein - gleicher Stil, rot. - g_rot = mischen(self.col_gefahr, bg, 0.10) - style.configure("Gefahr.TButton", background=g_rot, foreground=self.col_gefahr, - bordercolor=mischen(self.col_gefahr, bg, 0.35), - lightcolor=mischen(self.col_gefahr, bg, 0.35), - darkcolor=mischen(self.col_gefahr, bg, 0.35), - relief="solid", borderwidth=1, - padding=(14, 7), font=SCHRIFT) - style.map("Gefahr.TButton", - background=[("pressed", mischen(self.col_gefahr, bg, 0.30)), - ("active", mischen(self.col_gefahr, bg, 0.20))], - bordercolor=[("active", mischen(self.col_gefahr, bg, 0.55))], - lightcolor=[("active", mischen(self.col_gefahr, bg, 0.55))], - darkcolor=[("active", mischen(self.col_gefahr, bg, 0.55))]) + style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) + style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", arrowcolor=bg, padding=(10, 3)) style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) style.configure("TCheckbutton", background=bg, foreground=fg) style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) - # Eingabefelder heben sich vom Grund ab - sonst sieht man nicht, wo etwas - # einzutragen ist. JEDES Feld, in das man tippen kann, traegt diese zwei - # Farben: die ttk-Felder ueber die Stile hier, die klassischen Widgets - # (Textfelder, Auswahlliste) ueber feld_optionen() weiter unten. - feld_hell, feld_rand = self.col_field, self.col_feldrand - style.configure("TEntry", fieldbackground=feld_hell, foreground=fg, - insertcolor=fg, bordercolor=feld_rand, - lightcolor=feld_rand, darkcolor=feld_rand, - relief="solid", borderwidth=1, padding=5) - style.map("TEntry", - bordercolor=[("focus", accent)], lightcolor=[("focus", accent)], - darkcolor=[("focus", accent)], - fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))]) - # Die Felder IN der Leistungsliste stehen auf den Zeilenstreifen und - # gingen darin unter - man sah nicht, wo man tippt. Sie bekommen einen - # helleren Grund und einen deutlichen Rahmen, beim Tippen den Akzent. - style.configure("Liste.TEntry", fieldbackground=feld_hell, foreground=fg, - insertcolor=fg, bordercolor=feld_rand, - lightcolor=feld_rand, darkcolor=feld_rand, - relief="solid", borderwidth=1, padding=4) - style.map("Liste.TEntry", - bordercolor=[("focus", accent)], lightcolor=[("focus", accent)], - darkcolor=[("focus", accent)], - fieldbackground=[("focus", mischen(accent, self.col_card, 0.10))]) + style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg) # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert # unsichtbar im Feld. - style.configure("TCombobox", fieldbackground=feld_hell, foreground=fg, - background=feld_hell, arrowcolor=fg, - bordercolor=feld_rand, lightcolor=feld_rand, darkcolor=feld_rand, - selectbackground=feld_hell, selectforeground=fg, padding=3) - style.map("TCombobox", fieldbackground=[("readonly", feld_hell)], - foreground=[("readonly", fg)], background=[("readonly", feld_hell)], - bordercolor=[("focus", accent)], - selectbackground=[("readonly", feld_hell)], - selectforeground=[("readonly", fg)]) + style.configure("TCombobox", fieldbackground=field, foreground=fg, + background=field, arrowcolor=fg, + selectbackground=field, selectforeground=fg) + style.map("TCombobox", fieldbackground=[("readonly", field)], + foreground=[("readonly", fg)], background=[("readonly", field)], + selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget - self.option_add("*TCombobox*Listbox.background", feld_hell) + self.option_add("*TCombobox*Listbox.background", field) self.option_add("*TCombobox*Listbox.foreground", fg) self.option_add("*TCombobox*Listbox.selectBackground", sel) self.option_add("*TCombobox*Listbox.selectForeground", fg) @@ -500,320 +93,31 @@ class ThemeMixin: arrowcolor=fg, relief="flat", gripcount=0) style.map(sbs, background=[("active", accent)], troughcolor=[("disabled", bg)]) - # Treeview - die Tabelle des Steuerrechnungstools. Ohne diesen Block - # bleibt sie weiss im dunklen Fenster: ttk zeichnet Listen nicht ueber - # den "."-Grundstil, sondern nur ueber den eigenen. - style.configure("Treeview", background=self.col_card, - fieldbackground=self.col_card, foreground=fg, - bordercolor=self.col_line, rowheight=24, - font=SCHRIFT) - style.configure("Treeview.Heading", background=bg, foreground=accent, - relief="flat", font=(SCHRIFT_NAME, 9, "bold")) - style.map("Treeview.Heading", background=[("active", self.col_sel)]) - style.map("Treeview", - background=[("selected", self.col_sel)], - foreground=[("selected", fg)]) - # Der Rahmen um die Liste - ttk zeichnet ihn sonst im Systemgrau. - style.configure("Treeview.field", bordercolor=self.col_line) - style.configure("TSeparator", background=self.col_line) - # Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den - # AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man - # ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die - # Akzentfarbe, die ruhenden bleiben zurueckgenommen. - style.configure("TNotebook", background=bg, bordercolor=self.col_line, - tabmargins=(2, 4, 2, 0), borderwidth=0) - # Der OFFENE Reiter gehoert zur Kachel darunter und ist deshalb heller - # als die ruhenden. Vorher war es andersherum: die ruhenden lagen auf der - # helleren Flaeche und sahen aus, als waeren sie ausgewaehlt. - # ⚠️ clam zeichnet die Reiterflaeche aus lightcolor/darkcolor, nicht aus - # background allein - werden die nicht mitgesetzt, bleibt der ruhende - # Reiter hell und sieht aus wie der ausgewaehlte. - style.configure("TNotebook.Tab", background=bg, lightcolor=bg, darkcolor=bg, - foreground=self.col_muted, bordercolor=self.col_line, - # Schmal genug, dass zwoelf Monatsreiter nebeneinander - # passen: bei (20, 9) waren "September" und "Dezember" - # im Steuerrechnungstool abgeschnitten. - padding=(9, 7), font=(SCHRIFT_NAME, 10), borderwidth=1) - style.map("TNotebook.Tab", - background=[("selected", self.col_card), - ("active", mischen(accent, bg, 0.12))], - lightcolor=[("selected", self.col_card), - ("active", mischen(accent, bg, 0.12))], - darkcolor=[("selected", self.col_card), - ("active", mischen(accent, bg, 0.12))], - bordercolor=[("selected", accent)], - foreground=[("selected", accent), ("active", fg)], - font=[("selected", (SCHRIFT_NAME, 10, "bold"))]) - # Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar - # Pixel, die anderen rueckten nach - beim Umschalten sprang die ganze - # Leiste. Erkannt wird der offene Reiter an Farbe und Fettschrift. # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults self.option_add("*background", bg) self.option_add("*foreground", fg) - def feld_optionen(self, mehrzeilig=False, einfuegemarke=True): - """Farben und Rahmen fuer die klassischen Eingabe-Widgets (Text, Listbox). - - ttk-Felder holen sich das aus dem Stil; tk.Text und tk.Listbox koennen - das nicht - sie muessen die Werte einzeln bekommen. Damit trotzdem JEDES - Feld gleich aussieht, kommen sie aus dieser einen Stelle. - """ - opts = { - "bg": self.col_field, - "fg": self.col_fg, - "relief": "solid", - "borderwidth": 1, - "highlightthickness": 0, - } - # Eine Auswahlliste hat keine Einfuegemarke - sie kennt die Option nicht - # und bricht daran ab. - if einfuegemarke: - opts["insertbackground"] = self.col_fg - if mehrzeilig: - opts["padx"] = 4 - opts["pady"] = 3 - return opts - def _style_dialog(self, d): - """Toplevel-Dialog an das Theme angleichen. - - Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht - gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus - und Tastatur fest, waehrend nebenan jemand schreibt. - """ + """Toplevel-Dialog an das Theme angleichen.""" d.configure(bg=self.col_bg) - # Mittig ueber dem Programmfenster - und damit automatisch auf dem - # Bildschirm, auf dem das Programm gerade steht. - d.after(1, lambda: mittig(d, self)) - if testlauf(): - # Ohne grab_set: ein Dialog, der Maus und Tastatur festhaelt, - # blockiert den Menschen, der nebenher arbeitet. - d.grab_set = lambda *a, **k: None - try: - d.attributes("-toolwindow", True) # nicht in die Taskleiste - except tk.TclError: - pass # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht d.after(20, lambda: self._dark_titlebar(d)) def _dark_titlebar(self, win): - """Die Titelleiste eines Fensters an die Darstellung anpassen. - - Frueher machte diese Methode die Leiste nur DUNKEL und tat sonst - nichts. Solange das Programm der Windows-Einstellung folgte, fiel das - nicht auf - seit man hell und dunkel selbst waehlen kann, sehr wohl: - wer von dunkel auf hell stellte, behielt eine schwarze Titelleiste ueber - einem hellen Fenster. Sie zeigt jetzt in beide Richtungen und ist nur - noch eine Weiterleitung; die Arbeit macht titelleiste_faerben(). - """ - titelleiste_faerben(win, bool(getattr(self, "_dark_mode", False))) - - # ------------------------------------------------ Hell / dunkel umstellen - # Der Hinweistext gehoert dem einzelnen Programm - er sagt etwas ueber - # DIESES Programm. Hier steht nur, was dasteht, wenn ihn niemand setzt. - DARSTELLUNG_HINWEIS = "Gilt nur für das Programmfenster." - - def _cfg_speichern(self): - """Die Einstellungen wegschreiben - falls das noetig ist. - - Im Rechnungstool ist die Konfiguration ein gewoehnliches Woerterbuch - und muss ausdruecklich gespeichert werden. Im Steuerrechnungstool ist - sie eine Ablage, die jeden Schreibzugriff sofort in die - Journaltabelle durchreicht - dort ist hier nichts zu tun. - """ - return - - def variablen_sichern(self): - """Alle Tk-Variablen des Fensters als {Name: Wert}. - - WARUM ALLE UND NICHT EINE AUSWAHL - --------------------------------- - Die _build-Methoden legen ihre Variablen selbst an - (`self.k_name = tk.StringVar()`). Nach dem Neubau stehen also neue, - leere da. Beim ersten Versuch waren Kundenname und Rechnungsnummer - weg, waehrend die Mengen blieben - die hatte ich einzeln gesichert. - - Durchgegangen wird deshalb, was das Objekt an Tk-Variablen traegt. - Wer spaeter ein Feld hinzufuegt, muss nichts eintragen; eine Liste zum - Pflegen waere genau die Sorte, die man beim naechsten Feld vergisst. - """ - gesichert = {} - for name in dir(self): - if name.startswith("__"): - continue - try: - wert = getattr(self, name) - except Exception: # noqa: BLE001 - Eigenschaften koennen werfen - continue - if isinstance(wert, (tk.StringVar, tk.IntVar, tk.DoubleVar, - tk.BooleanVar)): - try: - gesichert[name] = wert.get() - except tk.TclError: - pass - return gesichert - - def variablen_zuruecksetzen(self, gesichert): - """Die gesicherten Werte in die neu gebauten Variablen zurueckgeben.""" - for name, wert in gesichert.items(): - neu = getattr(self, name, None) - if isinstance(neu, (tk.StringVar, tk.IntVar, tk.DoubleVar, - tk.BooleanVar)): - try: - neu.set(wert) - except tk.TclError: - pass - - def kinder_loeschen(self): - """Die Oberflaeche abraeumen - aber keine fremden Fenster. - - Ein tk.Toplevel IST ein Kind des Hauptfensters. Der offene - Darstellungsdialog wurde hier bisher mitgeloescht, und die Zeile - danach fasste eine Leiche an ("invalid command name .!toplevel"). - Gefunden am 07.09.2026 von der Pruefkette, mit dem echten Bedienweg - gemessen: Klick auf "Dunkel" -> Farbe wechselt, Fenster ist weg, - Traceback im Log. - - Der Pruefstand hatte es nicht gefunden, weil er cfg["darstellung"] von - Hand setzte und _oberflaeche_neu() direkt rief - also alles ausser dem - Weg, den ein Mensch geht. Er klickt jetzt. - """ - for kind in self.winfo_children(): - if not isinstance(kind, tk.Toplevel): - kind.destroy() - - def dlg_darstellung(self): - """Hell, dunkel oder wie Windows - und sofort sichtbar.""" - d = tk.Toplevel(self) - d.title("Darstellung") - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - rahmen = ttk.Frame(d, padding=18) - rahmen.pack(fill="both", expand=True) - ttk.Label(rahmen, text="Darstellung", - font=(SCHRIFT_NAME, 11, "bold"), - foreground=self.col_accent).pack(anchor="w") - ttk.Label(rahmen, justify="left", wraplength=340, - foreground=self.col_muted, - text=self.DARSTELLUNG_HINWEIS).pack(anchor="w", pady=(6, 12)) - - gewaehlt = tk.StringVar(value=modus(self.cfg)) - - def uebernehmen(): - neu = gewaehlt.get() - if neu == modus(self.cfg): - return - self.cfg["darstellung"] = neu - self._cfg_speichern() - self._oberflaeche_neu() - # Der Dialog gehoert noch zur alten Darstellung - er wird - # mitgefaerbt, sonst steht ein helles Fenster vor einem dunklen. - self._style_dialog(d) - - for wert in MODI: - ttk.Radiobutton(rahmen, text=MODUS_NAMEN[wert], value=wert, - variable=gewaehlt, command=uebernehmen).pack( - anchor="w", pady=3) - - ttk.Separator(rahmen, orient="horizontal").pack(fill="x", pady=(12, 10)) - ttk.Button(rahmen, text="Schließen", width=14, - command=d.destroy).pack(anchor="e") - mittig(d, self) - return d - - -class MeldungMixin: - """Eigene Meldungs- und Ja/Nein-Fenster im Theme des Programms. - - Warum nicht `messagebox`: das sind Fenster von Windows selbst. Sie sind - immer hell, egal was das Programm einstellt - mitten in einer dunklen - Oberflaeche blendet das und sieht nach Fremdkoerper aus. Dieselbe Erfahrung - steckt schon in der eigenen Menueleiste weiter oben. - - ⚠️ Im PRUEFLAUF geht beides weiter ueber `messagebox`. Die Pruefstaende - haengen ihre Antworten dort ein ("auf diese Frage antworte Ja") und lesen - dort mit, was das Programm gemeldet hat. Wuerde hier ein eigenes Fenster - aufgehen, warteten sie auf einen Klick, den niemand macht. Geprueft wird - damit derselbe Weg - nur die Anzeige unterscheidet sich. - """ - - # Die drei Sorten Meldung. `parent=`, `icon=` und `default=` kommen aus den - # frueheren messagebox-Aufrufen mit - sie werden geschluckt, damit an den - # Aufrufstellen nichts umgeschrieben werden muss. - def melden_info(self, titel, text="", **_kw): - return self.melden(titel, text, "info") - - def melden_warnung(self, titel, text="", **_kw): - return self.melden(titel, text, "warnung") - - def melden_fehler(self, titel, text="", **_kw): - return self.melden(titel, text, "fehler") - - def melden(self, titel, text, art="warnung"): - """Eine Meldung mit einem OK-Knopf.""" - if testlauf(): - {"info": messagebox.showinfo, - "warnung": messagebox.showwarning, - "fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text) - return None - return self._meldungsfenster(titel, text, art, knoepfe=None) - - def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw): - """Ja/Nein. Gibt True oder False zurueck - wie askyesno.""" - if testlauf(): - return bool(messagebox.askyesno(titel, text)) - return bool(self._meldungsfenster(titel, text, "frage", - knoepfe=(ja, nein), vorgabe=vorgabe)) - - def _meldungsfenster(self, titel, text, art, knoepfe=None, vorgabe=True): - farbe = {"fehler": self.col_gefahr, "warnung": self.col_gefahr, - "frage": self.col_accent}.get(art, self.col_accent) - d = tk.Toplevel(self) - d.title(titel) - d.transient(self) - d.resizable(False, False) - self._style_dialog(d) - antwort = {"wert": False} - - rahmen = ttk.Frame(d, padding=20) - rahmen.pack(fill="both", expand=True) - tk.Label(rahmen, text=titel, bg=self.col_bg, fg=farbe, justify="left", - font=(SCHRIFT_NAME, 12, "bold")).pack(anchor="w") - tk.Label(rahmen, text=text, bg=self.col_bg, fg=self.col_fg, justify="left", - wraplength=460, font=(SCHRIFT_NAME, 10)).pack(anchor="w", pady=(10, 0)) - - leiste = ttk.Frame(rahmen) - leiste.pack(fill="x", pady=(20, 0)) - - def schliessen(wert): - antwort["wert"] = wert - d.destroy() - - if knoepfe: - ja, nein = knoepfe - ttk.Button(leiste, text=ja, style="Haupt.TButton", - command=lambda: schliessen(True)).pack(side="right") - ttk.Button(leiste, text=nein, - command=lambda: schliessen(False)).pack(side="right", padx=(0, 8)) - d.bind("", lambda _e: schliessen(vorgabe)) - else: - ttk.Button(leiste, text="OK", style="Haupt.TButton", - command=lambda: schliessen(True)).pack(side="right") - d.bind("", lambda _e: schliessen(True)) - d.bind("", lambda _e: schliessen(False)) - d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False)) - - mittig(d, self) - # Nach vorn holen und den Tastaturfokus nehmen. Ohne das kann der Dialog - # beim Start hinter dem Hauptfenster liegen - der Nutzer sieht nur ein - # Fenster, das nicht reagiert, und haelt es fuer haengen geblieben. - d.lift() - d.attributes("-topmost", True) - d.after(100, lambda: d.attributes("-topmost", False)) - d.focus_force() - d.grab_set() - d.focus_set() - self.wait_window(d) - return antwort["wert"] + """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" + if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): + return + try: + import ctypes + win.update_idletasks() + hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) + val = ctypes.c_int(1) + for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) + # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) + SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED + ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) + except Exception as e: # noqa: BLE001 + print("[theme] Titelleiste:", e) diff --git a/updater.py b/updater.py deleted file mode 100644 index 836c448..0000000 --- a/updater.py +++ /dev/null @@ -1,404 +0,0 @@ -# -*- coding: utf-8 -*- -"""Der Updater - er ist der Starter. - -SEIN BILD (06.09.2026) ----------------------- - "der updater schaut beim starten erst: ist auf dem git link eine neue - version mit exe? wenn ja, erst laden, ersetzen und dann starten." - "einmal pruefen auf vollstaendigkeit." - "updater startet kurz als im ram und das main programm ist aus, sonst - kannst es nicht ersetzen." - -WARUM DAS DER RICHTIGE AUFBAU IST ---------------------------------- -Windows laesst eine laufende EXE nicht ueberschreiben. Ein Programm, das sich -selbst aktualisiert, muss deshalb Umwege gehen (Kopie im Temp, warten, bis der -eigene Prozess weg ist, tauschen). All das entfaellt, wenn der Updater das ist, -was der Nutzer startet: Er laeuft VOR dem Programm - die EXE ist gar nicht in -Benutzung, wenn sie ersetzt wird. - -DIE NUMMER STEHT IM DATEINAMEN (seine Idee, 06.09.2026) -------------------------------------------------------- - "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die - heisst anders, dann neu machen" - -Die installierte Datei heisst `Rechnungstool-1.6.0.exe`. Daraus folgt dreierlei: - - * Es wird nie etwas UEBERSCHRIEBEN - eine neue Fassung ist eine neue Datei. - Das Sperrproblem von Windows gibt es damit nicht einmal theoretisch. - * Die alte Fassung bleibt liegen und ist die Rueckfallebene, ohne dass - jemand eine Sicherung anlegen muesste. - * Der Updater sieht AM NAMEN, was installiert ist. Er muss keiner Angabe - glauben, die auseinanderlaufen koennte. - -DIE REGEL, AN DER SOLCHE UPDATER SCHEITERN ------------------------------------------- -Sie schreiben einen halb geladenen Download in die Installation, und das -Programm startet nie wieder. Deshalb hier: in einen Zwischenordner laden, -GROESSE UND PRUEFSUMME gegen die Angaben des Releases halten, und erst dann in -den Installationsordner legen. - -AUFBAU ------- -Dieser Teil ist eine BIBLIOTHEK ohne Fenster und ohne Netzzwang: jede -Entscheidung ist eine eigene Funktion, die sich einzeln pruefen laesst -(pruef_updater.py). Das Startprogramm steht unten unter __main__. - -Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie -hinueber. -""" - -import hashlib -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile -import urllib.request - -API = "https://git.pcore.de/api/v1/repos" - -# Wie die Buchfuehrung des Updaters heisst. Sie liegt neben der EXE und wird -# von IHM gefuehrt - nicht vom Programm. So kann nichts auseinanderlaufen: -# was hier steht, hat der Updater selbst installiert. -BUCH = "installiert.json" - - -# --------------------------------------------------------------------------- -# Versionen vergleichen -# --------------------------------------------------------------------------- -def zerlegen(fassung): - """'v1.6.0' oder '1.6.0' -> (1, 6, 0). Unlesbares wird zu (0,). - - Absichtlich nachsichtig: eine Nummer, die niemand versteht, darf kein - Update ausloesen - sie zaehlt als die aelteste denkbare. - """ - if not fassung: - return (0,) - teile = re.findall(r"\d+", str(fassung)) - return tuple(int(t) for t in teile) if teile else (0,) - - -def ist_neuer(dort, hier): - """Ist `dort` neuer als `hier`? Gleichstand ist NICHT neuer.""" - return zerlegen(dort) > zerlegen(hier) - - -# --------------------------------------------------------------------------- -# Das passende Release finden -# --------------------------------------------------------------------------- -def passendes_release(releases, exe_name): - """Das neueste Release, das eine Datei `exe_name` anhaengt. - - Vorabversionen zaehlen MIT - alle Fassungen dieser Programme sind als - Vorabversion gekennzeichnet, weil sie erprobt werden. Wer hier - `/releases/latest` benutzt, bekommt nichts: Forgejo laesst Vorabversionen - dort aus. Genau das ist beim Bauen aufgefallen. - - Entwuerfe (draft) zaehlen NICHT - die sind nicht veroeffentlicht. - """ - beste = None - for r in releases or []: - if r.get("draft"): - continue - anhang = next((a for a in r.get("assets") or [] - if a.get("name") == exe_name), None) - if not anhang: - continue - if beste is None or ist_neuer(r.get("tag_name"), beste[0].get("tag_name")): - beste = (r, anhang) - return beste - - -def summe_aus_release(release, exe_name): - """Die erwartete Pruefsumme aus dem Anhang `.sha256`, oder ''. - - Das Release haengt neben der EXE eine kleine Textdatei mit der Pruefsumme - an. Fehlt sie, wird nur die Groesse geprueft - dann sagt das der Updater - aber auch, statt so zu tun, als haette er geprueft. - """ - name = exe_name + ".sha256" - for a in release.get("assets") or []: - if a.get("name") == name: - return a.get("browser_download_url") or "" - return "" - - -# --------------------------------------------------------------------------- -# 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: - for brocken in iter(lambda: f.read(1 << 20), b""): - h.update(brocken) - return h.hexdigest() - - -def holen(url, ziel, melden=None): - """Datei laden. Gibt die Anzahl geladener Bytes zurueck.""" - with urllib.request.urlopen(url, timeout=60) as antwort, open(ziel, "wb") as raus: - gesamt = int(antwort.headers.get("Content-Length") or 0) - geladen = 0 - while True: - brocken = antwort.read(1 << 20) - if not brocken: - break - raus.write(brocken) - geladen += len(brocken) - if melden and gesamt: - melden(geladen, gesamt) - return geladen - - -def vollstaendig(pfad, erwartete_groesse, erwartete_summe=""): - """Ist die geladene Datei vollstaendig und unverfaelscht? - - Rueckgabe: (True, "") oder (False, Grund). Ein halb geladener Download darf - die Installation NIE erreichen - lieber gar kein Update als ein kaputtes. - """ - if not os.path.exists(pfad): - return False, "Die geladene Datei ist nicht da." - ist = os.path.getsize(pfad) - if erwartete_groesse and ist != erwartete_groesse: - return False, (f"Groesse stimmt nicht: {ist} Bytes geladen, " - f"{erwartete_groesse} erwartet.") - if erwartete_summe: - hat = sha256(pfad) - if hat.lower() != erwartete_summe.strip().lower(): - return False, "Pruefsumme stimmt nicht." - return True, "" - - -# --------------------------------------------------------------------------- -# Die Nummer steht im Dateinamen -# --------------------------------------------------------------------------- -def basis_und_endung(exe_name): - """'Rechnungstool.exe' -> ('Rechnungstool', '.exe').""" - stamm, endung = os.path.splitext(exe_name) - stamm = re.sub(r"-\d+(?:\.\d+)*$", "", stamm) # eine Nummer wieder abschneiden - return stamm, endung or ".exe" - - -def dateiname(exe_name, fassung): - """'Rechnungstool.exe' + 'v1.6.0' -> 'Rechnungstool-1.6.0.exe'.""" - stamm, endung = basis_und_endung(exe_name) - return f"{stamm}-{str(fassung).lstrip('v')}{endung}" - - -def installierte(ordner, exe_name): - """Was liegt im Ordner? Liste (Fassung, Pfad), neueste zuerst. - - Gelesen wird der DATEINAME - keine Angabe, der man glauben muesste. Eine - Datei ohne Nummer (aus der Zeit vor dieser Fassung) zaehlt als aelteste. - """ - stamm, endung = basis_und_endung(exe_name) - muster = re.compile(re.escape(stamm) + r"(?:-(\d+(?:\.\d+)*))?" + - re.escape(endung) + r"$", re.I) - gefunden = [] - try: - namen = os.listdir(ordner) - except OSError: - return [] - for name in namen: - treffer = muster.match(name) - if treffer: - gefunden.append((treffer.group(1) or "0", os.path.join(ordner, name))) - gefunden.sort(key=lambda x: zerlegen(x[0]), reverse=True) - return gefunden - - -def neueste_installierte(ordner, exe_name): - """(Fassung, Pfad) der neuesten vorhandenen Datei - oder ('0', '').""" - liste = installierte(ordner, exe_name) - return liste[0] if liste else ("0", "") - - -def aufraeumen(ordner, exe_name, behalten=2): - """Alte Fassungen wegraeumen, die neuesten `behalten` bleiben liegen. - - Nicht aus Ordnungsliebe: die vorletzte Fassung IST die Rueckfallebene. - Deshalb wird nie unter zwei geraeumt. - """ - liste = installierte(ordner, exe_name) - weg = [] - for _fassung, pfad in liste[max(2, behalten):]: - try: - os.remove(pfad) - weg.append(os.path.basename(pfad)) - except OSError: - # Laeuft die alte Fassung noch oder haelt ein Virenscanner die - # Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die - # Datei stoert nur, sie schadet nicht, und beim naechsten Mal - # klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg - # ist, taucht dort nicht auf. - pass - return weg - - -# --------------------------------------------------------------------------- -# Buchfuehrung -# --------------------------------------------------------------------------- -def buch_lesen(ordner): - pfad = os.path.join(ordner, BUCH) - try: - with open(pfad, encoding="utf-8") as f: - return json.load(f) - except (OSError, ValueError): - return {} - - -def buch_schreiben(ordner, daten): - pfad = os.path.join(ordner, BUCH) - try: - with open(pfad, "w", encoding="utf-8") as f: - json.dump(daten, f, ensure_ascii=False, indent=2) - return True - except OSError: - return False - - -def releases_holen(repository): - url = f"{API}/{repository}/releases?limit=20" - with urllib.request.urlopen(url, timeout=30) as antwort: - return json.loads(antwort.read().decode("utf-8")) - - -def text_holen(url): - with urllib.request.urlopen(url, timeout=30) as antwort: - return antwort.read().decode("utf-8", "replace").split()[0] - - -# --------------------------------------------------------------------------- -# Der Ablauf -# --------------------------------------------------------------------------- -def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None): - """Ein Durchlauf. Gibt den Pfad der Datei zurueck, die gestartet werden soll. - - Nichts wird ueberschrieben: eine neue Fassung wird als eigene Datei - `-.exe` abgelegt. Die vorige bleibt liegen - sie ist die - Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste. - - `holen_` laesst sich fuer den Pruefstand ersetzen, damit er ohne Netz - auskommt. - """ - holen_ = holen_ or releases_holen - hier, hier_pfad = neueste_installierte(ordner, exe_name) - buch = buch_lesen(ordner) - # Buch und Dateiname zusammen: wer eine Datei umbenennt, verursacht - # hoechstens einen ueberfluessigen Download, keinen Schaden. - if ist_neuer(buch.get("version") or "0", hier): - hier = buch.get("version") - - try: - releases = holen_(repository) - except Exception as e: # noqa: BLE001 - ohne Netz wird einfach gestartet - sagen(f"Kein Update moeglich ({e}). Es wird die vorhandene Fassung gestartet.") - return hier_pfad - - treffer = passendes_release(releases, exe_name) - if not treffer: - sagen("Im Repository liegt keine passende Datei. Vorhandene Fassung wird gestartet.") - return hier_pfad - release, anhang = treffer - dort = release.get("tag_name") or "" - - if not ist_neuer(dort, hier): - sagen(f"Aktuell ({hier}). Kein Update noetig.") - return hier_pfad - - ziel = os.path.join(ordner, dateiname(exe_name, dort)) - if os.path.exists(ziel): - 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) - try: - holen(anhang["browser_download_url"], geladen) - - erwartete_summe = "" - url_summe = summe_aus_release(release, exe_name) - if url_summe: - try: - erwartete_summe = text_holen(url_summe) - except Exception: # noqa: BLE001 - sagen("Pruefsumme nicht ladbar - es wird nur die Groesse geprueft.") - else: - sagen("Dieses Release hat keine Pruefsumme - es wird nur die Groesse geprueft.") - - gut, grund = vollstaendig(geladen, anhang.get("size") or 0, erwartete_summe) - if not gut: - sagen(f"Das Update wurde VERWORFEN: {grund}") - sagen("Es wird die vorhandene Fassung gestartet.") - return hier_pfad - - # Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort. - 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, - "vorher": os.path.basename(hier_pfad) if hier_pfad else ""}) - weg = aufraeumen(ordner, exe_name) - if weg: - sagen("Alte Fassungen entfernt: " + ", ".join(weg)) - sagen(f"Aktualisiert auf {dort}.") - return ziel - finally: - shutil.rmtree(zwischen, ignore_errors=True) - - -def starten(pfad): - """Das Programm starten und den Updater beenden.""" - subprocess.Popen([pfad], close_fds=True) - - -if __name__ == "__main__": - import version - - ordner = os.path.dirname(os.path.abspath( - sys.executable if getattr(sys, "frozen", False) else __file__)) - - print(f"Updater - {version.REPOSITORY}") - zu_starten = durchlauf(ordner, version.REPOSITORY, version.EXE_NAME) - - if zu_starten and os.path.exists(zu_starten): - print("Starte", os.path.basename(zu_starten)) - starten(zu_starten) - else: - print(f"FEHLER: keine startbare Fassung von {version.EXE_NAME} in {ordner}") - input("Enter zum Schliessen ...") diff --git a/version.py b/version.py deleted file mode 100644 index 46dcda8..0000000 --- a/version.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -"""Die Fassung dieses Programms - an EINER Stelle. - -Wozu ----- -Ohne eine Nummer im Programm kann kein Updater vergleichen, ob das, was im -Repository liegt, neuer ist als das, was hier laeuft. Sie steht deshalb hier -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.8.2` -> VERSION "1.8.2". Wer das eine ohne das andere aendert, -baut dem Updater eine Falle. -""" - -VERSION = "1.8.2" - -# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort. -REPOSITORY = "TheMockTv/rechnungstool" - -# Wie die ausgelieferte Datei im Release heisst. -EXE_NAME = "Rechnungstool.exe" diff --git a/wasserzeichen.py b/wasserzeichen.py deleted file mode 100644 index 9a73658..0000000 --- a/wasserzeichen.py +++ /dev/null @@ -1,236 +0,0 @@ -# -*- 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 kachel(bild, kante, rand=0.16): - """Das Logo auf eine helle Kachel mit runden Ecken setzen. - - Seine Ansage am 07.09.2026: *"bei den windows icons kannst du einen - weissen bg setzen hinter die icons, mit abgerundeten ecken, und da das svg - drauf - das geht auf dem desktop unter"*. - - Ein freigestelltes Zeichen ohne Flaeche verschwindet auf einem dunklen - Hintergrundbild - und welchen Hintergrund jemand eingestellt hat, weiss - das Programm nicht. Eine helle Kachel ist auf jedem sichtbar; so machen es - die meisten Programme, und deshalb sieht es auch nicht fremd aus. - - `rand` ist der Abstand zum Kachelrand, als Anteil der Kantenlaenge - so - sieht es in jeder Groesse gleich aus. Die Rundung liegt fest bei 22 %, - etwa dem, was Windows fuer Kacheln verwendet. - """ - from PIL import Image, ImageDraw - kante = int(kante) - # In vierfacher Groesse zeichnen und herunterrechnen: sonst werden die - # runden Ecken bei 32 oder 48 Pixeln treppig. - fein = kante * 4 - maske = Image.new("L", (fein, fein), 0) - ImageDraw.Draw(maske).rounded_rectangle( - (0, 0, fein - 1, fein - 1), radius=int(fein * 0.22), fill=255) - flaeche = Image.new("RGBA", (fein, fein), (0xFF, 0xFF, 0xFF, 255)) - flaeche.putalpha(maske) - - innen = max(1, int(fein * (1 - 2 * rand))) - zeichen = bild.convert("RGBA").resize((innen, innen), Image.LANCZOS) - versatz = (fein - innen) // 2 - flaeche.paste(zeichen, (versatz, versatz), zeichen) - return flaeche.resize((kante, kante), Image.LANCZOS) - - -# Groessen, die eine Windows-ICO enthalten soll. 16 und 24 fuer Titelleiste -# und Listen, 32/48 fuer den Desktop, 256 fuer die grosse Ansicht im Explorer. -ICO_GROESSEN = (16, 24, 32, 48, 64, 128, 256) - - -def symbol_bauen(ziel): - """Die Windows-ICO aus dem Logo erzeugen. Gibt den Pfad zurueck oder ''. - - JEDE Groesse wird als Kachel gebaut: helle Flaeche, runde Ecken, Zeichen - darauf - *"so wie das rustdesk, nur halt alle ecken in der rundung"*. Ein - freigestelltes Logo verschwindet sonst auf einem dunklen - Desktop-Hintergrund. - - Auch die 16 Pixel der Titelleiste: dort ist die Kachel zwar winzig, aber - sichtbar. Ein freigestelltes Zeichen war dort je nach Darstellung mal da - und mal nicht - und ein Symbol, das manchmal fehlt, ist schlechter als - eines, das immer klein ist. - - Bei den kleinsten Groessen wird der Rand knapper gefasst: bliebe der - Abstand anteilig gleich, waere vom Zeichen bei 16 Pixeln fast nichts mehr - uebrig. - """ - quelle = pfad("programm_logo.png") - if not quelle: - return "" - try: - from PIL import Image - roh = Image.open(quelle).convert("RGBA") - # Dieselben Farben wie IM Programm: Tuerkis fuer die Wolke, Dunkelgrau - # fuer das Zeichen daneben. Vorher stand hier das rohe Logo mit seinem - # Petrol #004860 - sein Befund am 07.09.2026: "das schaut blau aus". - # Die Kachel ist weiss, also gilt die HELLE Fassung (dunkel=False). - zeichen = _umfaerben(roh, dunkel=False) - bilder = [] - for g in ICO_GROESSEN: - bilder.append(kachel(zeichen, g, rand=0.10 if g <= 24 else 0.16)) - # Pillow schreibt die uebrigen Groessen selbst aus dem groessten Bild - - # deshalb wird das groesste uebergeben und der Rest angehaengt. - bilder[-1].save(ziel, format="ICO", - sizes=[(g, g) for g in ICO_GROESSEN], - append_images=bilder[:-1]) - return ziel - except Exception as e: # noqa: BLE001 - ohne eigenes Symbol laeuft es weiter - print("[symbol] konnte nicht gebaut werden:", e) - 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. - - Frueher gab es dafuer eine zweite, aufgehellte ICO und einen Schalter - `dunkel`. Seit das Symbol eine helle Kachel ist, ist es in jeder - Darstellung sichtbar - beides ist damit weggefallen. - """ - 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 - - -# Die Farben des Logos - gemessen, nicht geraten: -# #004860 das dunkle Blaugruen (Wolke, Pfeile) -# #181818 das fast schwarze Grau (Dokument bzw. Paragraphenzeichen) -# -# Seine Ansage am 07.09.2026: *"das dunkel blaue machen wir in unserm tuerkis -# gruen und die andere seite in weiss ... wenn es dunkel ist, und beim hellen -# machen wir weiss in dunkel grau"*. Das Zeichen traegt damit dieselbe Farbe -# wie das Programm - und die zweite Haelfte steht in beiden Darstellungen im -# Kontrast statt unterzugehen. -TUERKIS = (0x06, 0xC6, 0xA4) # die Programmfarbe, wie col_accent -NEUTRAL_DUNKEL = (0xF2, 0xF3, 0xF5) # fast weiss - auf dunklem Grund -NEUTRAL_HELL = (0x33, 0x36, 0x3B) # dunkelgrau - auf hellem Grund - - -def _umfaerben(bild, dunkel): - """Das Logo auf die Programmfarben umsetzen. - - Unterschieden wird nach SAETTIGUNG, nicht nach dem genauen Farbwert: das - Blaugruen ist kraeftig gesaettigt, das Grau gar nicht. So bleibt die - Zuordnung auch dann richtig, wenn die Vorlage einmal in einem anderen Ton - gezeichnet wird - und die weichen Kanten aus dem Skalieren gehen denselben - Weg wie die Flaeche, zu der sie gehoeren. - - Die Deckkraft bleibt unangetastet; die Form kommt aus dem Alphakanal. - """ - import colorsys - bild = bild.convert("RGBA") - neutral = NEUTRAL_DUNKEL if dunkel else NEUTRAL_HELL - punkte = list(bild.getdata()) - zwischen = {} - neu = [] - for r, g, b, a in punkte: - if a == 0: - neu.append((r, g, b, a)) - continue - schluessel = (r, g, b) - if schluessel not in zwischen: - _, _, saettigung = colorsys.rgb_to_hls(r / 255, g / 255, b / 255) - zwischen[schluessel] = TUERKIS if saettigung > 0.25 else neutral - neu.append(zwischen[schluessel] + (a,)) - bild.putdata(neu) - return bild - - -def hole(groesse=240, deckkraft=0.06, master=None, melden=print, dunkel=False): - """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), bool(dunkel)) - 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 = _umfaerben(bild, dunkel) - 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, dunkel=False): - """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, dunkel=dunkel)