diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e3fff1e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# 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 3833f58..a21aa42 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,18 @@ _tmp/ *.7z.* release/ rechnungstool.pid + +# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo. +*.exe +setup/ + +# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand +# die EXE nachbauen, die der Updater ausliefert. +!Rechnungstool.spec +!Updater.spec +!Diagnose.spec + +# Muster-PDFs. Der GAST darin ist erfunden - der BETRIEB nicht: sie tragen +# IBAN, Steuernummer und die private Rufnummer des Auftraggebers im Briefkopf. +# In ein oeffentliches Repository gehoert das nicht. +muster/ diff --git a/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md new file mode 100644 index 0000000..c520033 --- /dev/null +++ b/AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md @@ -0,0 +1,95 @@ +# Außendienst: Arbeitsprotokoll auf dem Tablet, Rechnung fällt heraus + +Seine Idee vom 06.09.2026, wörtlich: + +> „mach es an der kundenanzahl fest, und die bekommen eine vorab-nummer, die reserviert ist von der +> disponentin. die kunden haben eh eine kundennummer — heißt, es ist nur die rechnungsnummer, die +> zugeteilt wird. und hans hat nur die kundennummer mit adresse und wohnort im log, wo er +> protokolliert: anfahrt drücken, angekommen drücken … dann arbeit start und stop, dann noch: was +> hat er eigentlich gemacht — also gleich arbeitsprotokoll — und abfahrt. plus die rechnung für den +> kunden anhand des arbeitsblocks, den wir schon haben." + +**Status: Idee. Nichts davon ist gebaut.** + +--- + +## Was sich dadurch ändert + +Die Rechnung wird **nicht mehr getippt, sondern fällt aus dem Arbeitsprotokoll heraus.** Wer ohnehin +Anfahrt, Ankunft, Beginn und Ende drückt, hat am Abend die Rechnung fast fertig, ohne sie +geschrieben zu haben. + +Damit ist das **ein anderes Produkt** als das heutige Rechnungstool: eine Auftrags- und +Zeiterfassung, bei der die Rechnung das Ergebnis ist. Der Arbeitsdatensatz wird primär, die Rechnung +abgeleitet. + +## Die Nummernlogik wird dadurch einfacher + +- **Die Kundennummer hat der Kunde schon.** Reserviert wird nur die **Rechnungsnummer**. +- Die Blockgröße ergibt sich aus den **zugeteilten Aufträgen** — siehe + `AUFGABE_ONLINE_API_MYSQL.md`, Abschnitt „Woher die Blockgröße kommt". +- Hans hat auf dem Gerät nur, was er braucht: Kundennummer, Anschrift, Ort. + +--- + +## ⭐⭐ ENTSCHEIDUNG (Andreas, 06.09.2026): Daten auf dem Server, Rechnung im BÜRO + +> „das liegt auf dem server, und im büro wird die rechnung geschrieben. denke, das ist sauber — +> aber so weit sind wir noch nicht." + +**Das räumt ein Problem ab, und zwar das größte.** + +Wird die Rechnung im Büro geschrieben, **entfällt die Offline-Nummernvergabe komplett**. Das Tablet +erfasst nur Arbeit — und ein Arbeitsprotokoll braucht **keine Rechnungsnummer**. Die entsteht im +Büro, wo Netz da ist. + +→ **Nummernkontingente, Puffer und Blockverlängerung werden damit von einer Voraussetzung zu einer +OPTION** — nötig erst, falls später doch beim Kunden live abgerechnet werden soll. Die Überlegungen +dazu bleiben in `AUFGABE_ONLINE_API_MYSQL.md` stehen, sind aber nicht mehr auf dem kritischen Weg. + +**Das Tablet muss dann nur zweierlei können:** +1. den **Kundenstamm zum Nachschlagen** vorhalten (Zwischenspeicher mit sichtbarem Alter) +2. die **Protokolle zwischenspeichern**, bis wieder Netz da ist + +Beides **ohne Nummernvergabe** — also ohne die Gefahr, dass zwei Geräte sich in die Quere kommen. + +## ⚠️ Drei Punkte, die vorher geklärt sein müssen + +### 1. ⭐ Erfasste Zeit ist NICHT berechnete Zeit + +Anfahrt ist mal berechenbar, mal nicht. Pausen laufen mit, gehören aber nicht auf die Rechnung. + +**Wird das Protokoll eins zu eins zur Rechnungszeile, streitet der Betrieb irgendwann mit dem Kunden +über eine Minute, die das Programm erfunden hat.** + +> **Regel: Aus dem Protokoll wird ein VORSCHLAG, aus dem Vorschlag die Rechnung.** Das Protokoll +> bleibt unverändert stehen, auch wenn anders abgerechnet wird. + +Dann ist beides sauber und man kann später erklären, warum abgewichen wurde. + +### 2. Start/Stopp drücken heißt: das ist ARBEITSZEIT + +Damit hängt Arbeitsrecht daran, nicht nur Steuerrecht: + +- Aufzeichnungs- und Aufbewahrungspflicht (**§ 16 ArbZG**) +- Die Aufzeichnungen gehören dem **Arbeitsverhältnis**, nicht dem Kunden +- → **getrennt von den Kundendaten behandeln**, und sie dürfen nicht einfach in der Rechnung landen + +### 3. ⚠️ „Anfahrt/Angekommen" ist Standortverfolgung von Mitarbeitern + +Sobald Zeiten und Orte zusammenkommen, ist es Mitarbeiterüberwachung. In Deutschland heikel: + +- braucht eine **Rechtsgrundlage** und klare **Zweckbindung** (Art. 88 DSGVO, § 26 BDSG) +- gibt es je einen **Betriebsrat**: mitbestimmungspflichtig (§ 87 Abs. 1 Nr. 6 BetrVG) + +**Beherrschbar, solange der Mitarbeiter selbst drückt** und nichts im Hintergrund mitläuft. Ein +Programm, das ohne Zutun aufzeichnet, wo jemand ist, wäre etwas völlig anderes — und dann wird es +ernst. + +--- + +## Was schon da ist und wiederverwendet wird + +- der **Arbeits-/Leistungsblock** (Katalog) aus dem Rechnungstool +- der **Kundenstamm** (offen, siehe `AUFGABE_KUNDENSTAMM_UND_WEB.md`) +- die **Nummernkontingente** (siehe `AUFGABE_ONLINE_API_MYSQL.md`) diff --git a/AUFGABE_BEWEISKETTE_PRUEFBAR.md b/AUFGABE_BEWEISKETTE_PRUEFBAR.md new file mode 100644 index 0000000..426faf7 --- /dev/null +++ b/AUFGABE_BEWEISKETTE_PRUEFBAR.md @@ -0,0 +1,139 @@ +# 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 new file mode 100644 index 0000000..1c2dbda --- /dev/null +++ b/AUFGABE_KUNDENSTAMM_UND_WEB.md @@ -0,0 +1,83 @@ +# 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 new file mode 100644 index 0000000..31ca040 --- /dev/null +++ b/AUFGABE_ONLINE_API_MYSQL.md @@ -0,0 +1,417 @@ +# Online-Betrieb: API, MySQL, Tablet, Rechnungsversand + +Seine Idee vom 06.09.2026, wörtlich: + +> „die software ist aktuell nur lokal, und da reicht sqlite. wenn wir die online funktion einbauen, +> dann muss man einen api key eintragen, und die api verteilt das auf die mysql — dann ist das +> extern auf dem server und du kannst von überall über domain und apikey auch unterwegs per tablet +> beim kunden die rechnung schreiben, live oder vorab-rechnung, und der smtp-server schickt sie +> direkt dem kunden auf die hinterlegte e-mail. fertig. alles automatisch." + +**Status: Idee. Nichts davon ist gebaut.** Der Schnitt ist richtig gedacht — lokal SQLite, zentral +MySQL, die API als einzige Tür dazwischen. + +--- + +## 1. ⭐ Die Rechnungsnummer darf nur vom Server kommen + +Heute stimmen sich zwei lokale Programme über eine gemeinsame SQLite ab. Zentral wird das +**einfacher**: eine Transaktion in MySQL vergibt die Nummer, fertig. + +**Aber:** Auf dem Tablet beim Kunden ohne Empfang darf keine Nummer entstehen. + +> **Regel: Offline entsteht ein ENTWURF, keine Rechnung.** Die Nummer wird vergeben, sobald der +> Server erreichbar ist. + +Sonst gibt es irgendwann zwei Belege mit derselben Nummer — genau der Fehler, wegen dem das +Amt-Blatt herausgeflogen ist (§ 14 Abs. 4 Nr. 4 UStG). + +Daraus folgt für die Oberfläche: Ein Entwurf muss **sichtbar** ein Entwurf sein, ohne Nummer, und +darf nicht ausgedruckt oder verschickt werden können. + +## ⭐⭐ Das Offline-Problem — und seine Lösung: NUMMERNKONTINGENTE + +Sein Einwand (06.09.2026): + +> „das ding, was wir haben, ist das offline-problem. wenn das internet schwach oder weg ist, bringt +> das online nichts — also musst du auf dem client eine kopie per sqlite haben. wie willst es sonst +> machen?" + +**Er hat recht, und das ersetzt die zu strenge Regel „offline nur Entwürfe" aus Punkt 1.** + +### Was zwischengespeichert werden kann — und was nicht + +| | offline möglich? | +|---|---| +| Kundenstamm, Preise, Einstellungen, alte Rechnungen zum Nachschlagen | **ja** — lokale SQLite, lesend, mit sichtbarem Alter | +| **Vergabe der Rechnungsnummer** | **nein** — zwei Geräte nähmen beide „die nächste" | + +### Die Lösung: der Server gibt Blöcke im Voraus aus + +So machen es Kassensysteme. Tablet A bekommt **2026-101 bis 2026-150**, Tablet B **2026-151 bis +2026-200**. Offline vergibt jedes Gerät aus **seinem eigenen** Block — eine Kollision ist +**unmöglich**, weil kein anderes Gerät diese Nummern besitzt. Bei Netz meldet es den Verbrauch und +holt einen neuen Block. + +→ **Damit sind beim Kunden echte Rechnungen möglich, nicht nur Entwürfe.** + +### Zwei Bedingungen, die dazugehören + +1. **Lücken werden normal — und müssen erklärbar sein.** Kommt Tablet A nur bis 2026-137 und holt + dann einen neuen Block, fehlen 138–150. Das ist zulässig: § 14 UStG verlangt eine **einmalig + vergebene** fortlaufende Nummer, ausdrücklich auch in mehreren Kreisen (z. B. je Filiale). + **Aber das System muss zeigen können, welcher Block wem gehörte und welche Nummern daraus nie + verbraucht wurden.** Sonst sieht ein Prüfer eine Lücke und fragt nach der Rechnung. + → Gehört in den **Steuerprüfungs-Ausdruck** (siehe `AUFGABE_BEWEISKETTE_PRUEFBAR.md`, Schritt 3). +2. **Der Block ist endlich.** Ist er verbraucht und kein Netz da, ist Schluss — dann wieder + Entwürfe. **Lieber ein Gerät, das sagt „ich kann gerade nicht", als eines, das sich Nummern + ausdenkt.** + +### ⭐⭐ Woher die Blockgröße kommt: aus der Disposition (Andreas, 06.09.2026) + +> „die disponentin hat jedem handwerker ja kunden zugeteilt — daraus kann ein block generiert +> werden. die software sagt: handwerker hans werner hat 10 kunden, also bekommt er block A mit 10 +> nummern, der hans werner heißt. dann ist es erklärbar, weil hans die kunden im log hatte. wenn er +> mehr kunden hatte, bekommt er von anfang an 10 puffernummern als schutz. und wenn die nicht +> gebraucht werden, bekommt er sie für den nächsten tag wieder. wenn sie voll sind, überspringt er +> in den nächsten block. der server hält selber immer reserve." + +**Der starke Punkt daran ist die Erklärbarkeit.** Ein Prüfer sieht reservierte Nummernbereiche und +fragt, warum ausgerechnet diese. Hier gibt es eine Antwort **aus dem Betrieb heraus**: Die +Disposition hat Hans an dem Tag zehn Kunden zugeteilt, also zehn Nummern plus Puffer. **Die +Zuteilung ist der Beleg für die Reservierung** — besser als jede technische Begründung. + +### ⚠️ Zwei Korrekturen daran + +**1. Der Block darf NICHT am Kalendertag hängen.** +*„Heute hast du 100 bis 110"* klingt logisch, ist aber gefährlich: Hat Hans zwei Tage keinen +Empfang, wäre er am zweiten Tag arbeitsunfähig, obwohl er noch Nummern hätte. + +> **Der Block gehört ihm, bis er ihn zurückmeldet. Kein Ablaufdatum.** Die Disposition gibt morgens +> neue *dazu*, nimmt aber nichts weg, was er noch hat. + +Am Bild der Disposition ändert das nichts — nur verhungert das Tablet nicht, wenn das Netz drei +Tage weg ist. + +**2. Eine Nummer ist erst verbraucht, wenn ein BELEG entsteht.** +Nicht bei der Zuteilung, und **nicht durch einen Entwurf**. Nur so stimmt, dass unbenutzte Nummern +am nächsten Tag wieder gelten — sonst würden Nummern wiederverwendet, die schon auf einem +Bildschirm standen. + +**Der Rest passt unverändert:** Puffer von Anfang an · Überspringen in den nächsten Block, wenn +voll · **Server hält eigene Reserve** für das, was die Disposition nicht wusste (Notfall, +Zusatzauftrag, Kunde ruft an). + +### ⭐ Beim Synchronisieren: Verbrauch melden, nicht blind neu ausgeben + +Sein Einwand (06.09.2026): + +> „wenn das tablet lte wieder hat, kann er das syncen und bekommt einen neuen block — oder sagt +> einfach: eh, ich habe aber in dem block erst 10 % genutzt" + +**Richtig, und das vermeidet Lücken statt sie nur zu erklären.** Das Gerät meldet seinen Stand: +*„2026-101 bis 110 verbraucht, 111 bis 150 gehören mir noch."* Der Server entscheidet: + +| Lage | Antwort | +|---|---| +| noch viel übrig | **nichts tun** — kein neuer Block, keine verbrannten Nummern | +| fast leer, folgende Nummern frei | **den vorhandenen Block VERLÄNGERN** (101–150 → 101–180) → **keine Lücke** | +| fast leer, folgende Nummern gehören schon jemandem | neuer Block weiter hinten → Lücke, aber eine, die der Server **benennen** kann | + +**Verlängern schlägt Neuausgabe.** Das ist der Unterschied zwischen einem System, das Lücken erzeugt +und sie hinterher erklären muss, und einem, das erst gar keine macht. + +### Warum das nicht optional ist + +> „kannst ja nicht kunden fragen nach wlan" + +Beim Kunden im Keller, im Neubau ohne Anschluss, auf dem Hof: kein WLAN, oft auch kein +Mobilfunknetz. Nach dem Passwort fragen macht kein Handwerker. + +**Zwei praktische Regeln daraus:** + +1. **Der Block muss großzügig sein** — nicht zehn Nummern, sondern so viele, dass eine Woche Arbeit + hineinpasst. Nummern kosten nichts, ein abgerissener Vorgang beim Kunden schon. +2. **Das Gerät warnt, BEVOR es eng wird.** *„Noch 5 Nummern im Vorrat, bitte einmal ins Netz"* — + solange man noch in der Werkstatt steht. Eine Warnung, die erst beim Kunden kommt, ist wertlos. + +Nachgefüllt wird **automatisch**, sobald irgendwo Netz da ist. Der Benutzer soll darüber nicht +nachdenken müssen. + +## 2. ⚠️ Beim Datenschutz kippt die Lage vollständig + +Heute lautet die Aussage im Programm: *Es verlässt nichts den Rechner.* Das ist der ganze +Datenschutztext. Sobald Gästedaten auf einem Server liegen, gilt die DSGVO in voller Breite: + +- verschlüsselte Übertragung (TLS), keine Ausnahme +- **Zugriffstrennung je Betrieb** — ein API-Schlüssel ist ein Passwort, keine Zugriffskontrolle +- Löschkonzept und Aufbewahrungsfristen (§ 147 AO) müssen zusammenpassen +- **Wenn er das für ANDERE Betriebe hostet, ist er deren Auftragsverarbeiter** und braucht einen + Vertrag nach Art. 28 DSGVO. Das ist keine Formalie, sondern Voraussetzung dafür, dass die + Betriebe das Programm überhaupt einsetzen dürfen. + +Der Datenschutztext im Programm muss dann neu geschrieben werden — der heutige wäre schlicht falsch. + +## 3. ⭐⭐ E-Rechnung (ZUGFeRD) — jetzt entscheiden, nicht später + +In Deutschland läuft die Pflicht zur elektronischen Rechnung im B2B an. Wer ein System baut, das +Rechnungen **per Mail verschickt**, sollte die PDF gleich mit **eingebettetem strukturiertem XML** +erzeugen (ZUGFeRD / PDF/A-3). + +Der Unterschied zwischen „von Anfang an mitgedacht" und „später nachrüsten" ist hier ein halber +Neubau: PDF/A-3 stellt andere Anforderungen an Schriften und Farbprofile als das heutige PDF, und +das XML muss aus denselben Daten kommen wie das Blatt — nicht daneben gepflegt werden. + +Stand schon auf der allerersten Aufgabenliste des Rechnungstools. + +## 4. Versand + +- **SMTP genügt zum Senden.** POP3/IMAP braucht man nur, um **Unzustellbarkeitsmeldungen** zu lesen + — und die *muss* man lesen. Sonst meldet das Programm „verschickt", und die Rechnung liegt + nirgends. +- **Eine per Mail verschickte Rechnung ist eine Rechnung** (§ 14 UStG). Für die GoBD muss das + Dokument **genau so archiviert werden, wie es verschickt wurde** — samt Versandnachweis. +- Zustellung ist nicht garantiert. Der Versandstatus gehört sichtbar in die Oberfläche, nicht nur + ins Log. + +## ⭐ Das Produktbild (Andreas, 06.09.2026) — lokal ist der Normalfall + +> „solange er den apikey mit domain nicht hat, kann er auch nur sqlite nutzen — reicht auch, wenn +> einer es als single programm für sich nutzt. will einer das online nutzen, dann trägt er beides +> ein, das programm synct einmal auf die mysql, fertig." + +**Damit ist der Aufbau entschieden: local-first, Online ist eine Zusatzfunktion, die man +einschaltet.** Wer nichts einträgt, merkt vom Server nichts. Kein Konto, keine Pflicht, keine +Abhängigkeit. + +### Die eine Regel, die den Umzug sicher macht + +**„Synct einmal" ist eine EINMALIGE ÜBERNAHME, keine ständige Synchronisierung in beide +Richtungen.** Danach gehört die Nummernvergabe dem Server; die lokale SQLite ist nur noch +Zwischenspeicher für Entwürfe (siehe Punkt 1). + +Zwei Richtungen dauerhaft offen zu halten hieße: zwei Stellen vergeben Nummern. Genau der Fehler, +wegen dem das Amt-Blatt herausflog. + +### Und daraus folgt eine Entscheidung VOR der ersten Übernahme + +Zwei Installationen, die beide 2026-001 bis 2026-050 vergeben haben, lassen sich **nicht** in eine +Datenbank übernehmen — dort lägen Nummern doppelt. Gehen je mehrere Betriebe auf denselben Server, +muss die **Trennung der Nummernkreise vorher** feststehen (eigene Datenbank je Betrieb, oder +Präfix je Betrieb). Nachträglich geht es nicht: die Belege sind dann schon beim Kunden. + +### Was der Umzug NICHT gefährdet + +**Die Wahrheit bleiben die Rechnungs-PDFs** — so steht es schon heute im Nummernbuch-Code: *„Die +Datei ist ein REGISTER, keine zweite Buchhaltung."* Geht bei der Übernahme etwas schief, fehlt +keine Rechnung; es muss nur neu eingelesen werden. + +## ⭐⭐ Die Umschaltung ist eine Tür, die nur in eine Richtung aufgeht + +Sein Modell (06.09.2026): + +> „ein einmal sync, danach ist es only online. die sql ist dann deaktiviert für immer — außer er +> trägt den key und die domain aus, dann hat er aber einen alten datenstand." + +> „das muss er mit einem popup — mit dem speichern-button, den es dafür gibt — am ende noch mal als +> ja/nein-frage kommen, ob ihm das bewusst ist, dass er sozusagen bei 0 anfängt." + +### Harte Anforderung + +**Beim SPEICHERN** von API-Schlüssel und Domain (nicht beim Eintippen) kommt eine Ja/Nein-Frage. +Beim Tippen weiß niemand, ob es ernst gemeint ist — beim Speichern schon. + +Entwurf des Textes: + +> **Auf Online-Betrieb umstellen?** +> +> Die vorhandenen Daten werden einmalig auf den Server übernommen. Danach läuft alles über den +> Server: Rechnungsnummern werden dort vergeben, und die lokale Datenbank wird **nicht mehr +> fortgeführt**. +> +> Entfernen Sie später Schlüssel und Domain, arbeitet das Programm wieder lokal weiter — dann aber +> mit dem Stand von **[Datum von heute]**. Alles, was zwischenzeitlich online entstanden ist, fehlt +> dort. +> +> Diese Umstellung lässt sich nicht rückgängig machen. +> +> Jetzt umstellen? + +Dasselbe Muster wie beim Erprobungshinweis und bei der Freigabe vor dem Beleg: Der Nutzer wird +**vorher** aufgeklärt, und die Entscheidung wird mit Datum vermerkt. + +### Die lokale Datenbank wird STILLGELEGT, nicht gelöscht + +Sie ist der Stand von damals und muss lesbar bleiben — schon wegen § 147 AO. + +### ⭐ Und der Punkt, der daran hängt: die PDFs müssen erreichbar bleiben + +Heute gilt in beiden Programmen: **Die Wahrheit sind die Rechnungs-PDFs, die Datenbank ist nur ein +Register.** Wird künftig auf dem Server erzeugt, muss der Betrieb **jederzeit an alle seine Belege +herankommen — als Dateien**, nicht nur als Ansicht im Browser. + +Der Grund ist **§ 147 AO: zehn Jahre Aufbewahrungspflicht.** Ein System, das den Betrieb von seinen +eigenen Belegen trennen kann — Server fällt aus, Vertrag endet, man zerstreitet sich — bringt den +BETRIEB in Schwierigkeiten. + +→ **Empfehlung: automatische lokale Kopie jeder Rechnung, auch im Online-Betrieb.** Dann bleibt der +Satz „die Wahrheit sind die PDFs" wahr, und der Betrieb hat seine Unterlagen auch dann, wenn nichts +mehr läuft. Das ist zugleich ein Verkaufsargument. + +## 📄 Paperless als Archiv (Andreas, 06.09.2026) — löst die Aufbewahrungsfrage + +> „ich dachte später an ein paperless — da muss eh alles rein" + +**Das ist die bessere Antwort auf § 147 AO als eine Exportfunktion.** Läuft jede Rechnung +automatisch in ein Dokumentensystem, liegt das Archiv **außerhalb** des Programms und überlebt +sowohl einen abgeschalteten Server als auch das Programm selbst. + +**Anbindung ist klein:** Paperless-ngx nimmt Dokumente über eine REST-Schnittstelle **oder über +einen überwachten Ordner** entgegen — Datei hineinlegen, fertig. Für das Rechnungstool eine +zusätzliche Zeile beim Speichern, kein Umbau. + +**⭐ Und die Verschlagwortung ist schon gebaut:** Rechnungsnummer, Datum, Beträge, Art und Vorgang +stehen bereits als maschinenlesbare Kenndaten im `/Subject` jeder PDF (das Steuerjournal liest sie +von dort). Daraus kann das Archiv seine Schlagworte ziehen — ohne Abtippen, ohne Texterkennung. + +⚠️ **Ehrlicher Vorbehalt:** Ein Dokumentensystem macht **nicht automatisch GoBD-konform**. Es +erfüllt den Kern (Belege bleiben unverändert und vollständig), aber dazu gehört eine +**Verfahrensdokumentation** — wie Belege entstehen, wohin sie laufen, wer was darf. Die schreibt +kein Programm. Ein großer Teil davon steht allerdings schon in den Aufgabendateien und +Commit-Texten dieser Projekte. + +## 🔑 Die API ist der Schlüssel zu allem (Andreas, 06.09.2026) + +> „das tool schickt es an die api, die api verteilt das alles. die api ist der schlüssel zu allem — +> wie beim gta fivem server" + +**Das ist seine eigene Serverregel, angewandt:** *Der Wille kommt vom Client, gerechnet wird auf dem +Server.* (Vgl. PcoreLife: nichts im Client, was Serversachen übernimmt.) + +| | Client (Tool/Tablet) | Server (API) | +|---|---|---| +| Was | „Rechnung für diese Leistungen an diesen Kunden" | Nummer vergeben, Beträge und Steuer rechnen, PDF erzeugen | +| Danach | zeigt an, was zurückkommt | verteilt: MySQL · Paperless · Mailversand | + +### ⭐ Folge 1: `berechnung.py` zieht auf den Server + +Heute steht dort ausdrücklich *„die einzige Stelle, an der gerechnet wird"*. Zentral gehört sie auf +den Server. + +**Das löst nebenbei die offene Frage aus `AUFGABE_KUNDENSTAMM_UND_WEB.md`:** die zwei Steuerwelten +(deutsche Bruttopreise 7/19 % gegen polnische Nettopreise mit Reverse Charge). Weiß der Server, +welcher Betrieb anfragt, weiß er auch, wie zu rechnen ist. **Der Client muss davon nichts wissen — +und die Frage „beide Welten in einem Programm oder zwei Fassungen" erledigt sich.** + +### ⭐ Die Aufgabenteilung im Einzelnen (Andreas, 06.09.2026) + +> „das tool schickt nur die metadaten, der server rendert die pdf und das tool bekommt den link. +> oder halt die vorschau muss es selber machen — das auf dem server ist dumm. und paperless macht +> den rest: archivieren und senden an die mitgegebene e-mail, und speichert das unter der +> kundennummer." + +| Wer | Was | +|---|---| +| **Tool** | schickt die Metadaten · zeichnet die **Vorschau lokal** · bekommt den Link zum fertigen PDF | +| **Server (API)** | vergibt die Nummer · rechnet · rendert das endgültige PDF · **verschickt per SMTP** · verteilt | +| **Paperless** | archiviert · verschlagwortet (Kundennummer als Korrespondent/Schlagwort) | + +**Vorschau lokal ist richtig:** ein Netzweg pro Tastendruck wäre sofort träge. + +### ⚠️ Die Regel, die dazugehört: EIN Zeichner, zwei Ausführungsorte + +Zeichnet das Tool die Vorschau und der Server das endgültige Blatt, gibt es **zwei Zeichner — und +zwei Zeichner driften auseinander**. Genau davor schützt sich der heutige Code bereits: +`berechnung.py` wird von Oberfläche UND PDF benutzt, *„damit Bildschirm und Ausdruck garantiert +identisch sind"*. + +Eine Vorschau, die nicht dem entspricht, was hinausgeht, ist **schlimmer als keine** — sie erzeugt +Vertrauen, das nicht gerechtfertigt ist. (Und die Vorschau war seine ausdrückliche Begründung für +das Ganze: *„sonst haben die den storno-aufwand"*.) + +→ **`pdf_renderer.py` und `berechnung.py` bleiben EINE gemeinsame Bibliothek**, die an zwei Orten +ausgeführt wird. Kein zweiter Code. + +### ⚠️ Korrektur: Paperless archiviert, es VERSCHICKT NICHT + +Paperless-ngx nimmt Dokumente entgegen, erkennt Text, verschlagwortet und lagert ein. **Rechnungen +an Kunden mailen kann es nicht.** Der Versand per SMTP gehört auf den Server / in die API. + +Die Ablage unter der Kundennummer geht dagegen sauber — Paperless kennt Korrespondenten und +Schlagworte, die Kundennummer passt in beides. + +### ⛔ Verworfen: PDF auf dem Client zeichnen und hochladen + +Andreas hatte es erwogen (*„ob das schlau ist, weiß ich nicht"*). **Nein**, aus einem Grund: + +Zeichnet der Client den endgültigen Beleg, entscheidet der Client, was darauf steht — und der +Server archiviert blind, was ankommt. Er könnte nicht mehr garantieren, dass zur Nummer, die er +vergeben hat, auch das Blatt gehört, das er ablegt. Eine ältere oder veränderte Fassung des Tools +erzeugte Dokumente, die niemand prüft. + +**Der Beleg entsteht dort, wo auch die Nummer entsteht.** Dazu käme: jede künftige Oberfläche +(Tablet, Browser) müsste den ganzen Renderer mitschleppen. + +Die **Vorschau** bleibt lokal — sie ist kein Beleg: ohne Nummer, mit Wasserzeichen, wie heute. + +### ⭐ Kundenstamm offline: ein ZWISCHENSPEICHER, kein Sync + +Seine Frage: *„wenn ein kunde eine kundennummer bekommt, wie willst die wiederfinden? musst die api +auch abfragen dafür — heißt, ohne internet wird da eh nichts. sonst musst wieder alles lokal und +online speichern, um das schnell zu halten, und dann nur eine art rsync raufpacken."* + +**Der Instinkt stimmt — nur ist es kein Sync, sondern ein Spiegel.** Der Unterschied ist keine +Wortklauberei: + +| | bedeutet | Folge | +|---|---|---| +| **Sync** | beide Seiten dürfen ändern | braucht Konfliktauflösung — wer gewinnt bei doppelter Änderung? | +| **Spiegel** | nur der Server besitzt die Daten, der Client hält eine Lesekopie | **keine Konflikte**, nur ein Stand, der mal älter ist | + +**Und die Datenmenge passt:** Kunden sind wenige und ändern sich selten — anders als Rechnungen. +Den ganzen Kundenstamm lokal zu halten kostet nichts und macht die Suche sofort schnell, auch ohne +Netz. + +**Sein Wort dafür: „eine Art Zwischenspeicher"** — und das ist ehrlicher als „Spiegel", weil es +schon sagt, dass er veralten darf. + +**Zwei Dinge braucht er, sonst wird er selbst zur Fehlerquelle:** + +1. **Er muss sein Alter zeigen.** Klein in der Oberfläche: *„Kundendaten vom 06.09.2026, 15:40"*. + Sonst sucht jemand einen Kunden, findet ihn nicht, legt ihn neu an — dabei war er nur seit + gestern nicht heruntergeladen. **Ein Zwischenspeicher, dem man sein Alter nicht ansieht, wird + irgendwann für die Wahrheit gehalten.** +2. **Er darf nie Quelle für etwas sein, das geschrieben wird.** Lesen und Suchen: ja. Eine Nummer + daraus vergeben: nie. Aufgefrischt wird beim Start (wenn Netz da ist) und auf Knopfdruck. + +**Dieselbe Regel wie bei den Rechnungsnummern:** Ein offline angelegter Kunde bekommt seine Nummer +**erst vom Server** — bis dahin ist er ein Entwurf. Sonst vergeben zwei Tablets dieselbe +Kundennummer. + +### Folge 2: der Client wird schlank + +Das Tablet beim Kunden braucht kein PDF-Werkzeug, keine Steuerlogik, keinen Nummernkreis. Ein Weg +hinein, mehrere hinaus. + +### Der Preis + +**Ohne Server geht nichts außer Entwürfen** (siehe Punkt 1). Bei FiveM ist das dasselbe — nur hängt +hier ein Beleg dran, den jemand zehn Jahre aufbewahren muss. + +### Sicherheit, weil „Schlüssel zu allem" wörtlich zutrifft + +Wenn ein Schlüssel alles öffnet, muss er: **je Installation** vergeben sein (nicht einer für alle), +**widerrufbar**, **nur über TLS** unterwegs, und die API muss **Missbrauch begrenzen** +(Rate-Limit). Sonst ist ein einziger abhandengekommener Schlüssel der Zugang zu allen Gästedaten. + +## 5. Offene Entscheidungen + +- Wer betreibt den Server, und für wen? (nur Bartl, oder mehrere Betriebe) +- Ein Mandant je Datenbank oder ein gemeinsames Schema mit Mandantenspalte? +- Was passiert mit dem lokalen Betrieb — bleibt SQLite als Offline-Fassung, oder wird alles zentral? +- Wie kommt der Bestand in die MySQL, ohne dass Nummern doppelt vergeben werden? + +--- + +**Nichts davon beginnen, bevor die Punkte 1 bis 3 entschieden sind** — sie bestimmen den Aufbau, +nicht die Ausstattung. diff --git a/AUFGABE_PREISVORSCHLAG.md b/AUFGABE_PREISVORSCHLAG.md new file mode 100644 index 0000000..82bbe9c --- /dev/null +++ b/AUFGABE_PREISVORSCHLAG.md @@ -0,0 +1,129 @@ +# 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 new file mode 100644 index 0000000..38903bb --- /dev/null +++ b/AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md @@ -0,0 +1,107 @@ +# 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 new file mode 100644 index 0000000..d030d6f --- /dev/null +++ b/Diagnose.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + # Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start + # nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt + # das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders + # aus als hier. + datas=[('assets/programm.ico', 'assets'), + ('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'), + ('assets/fonts', 'assets/fonts'), + # Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe + # nur ihren Text, aber der Anordnen-Griff fehlt. + ('assets/icons', 'assets/icons')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Diagnose', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + icon='assets/programm.ico', + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/Installer.iss b/Installer.iss new file mode 100644 index 0000000..dcfb2d5 --- /dev/null +++ b/Installer.iss @@ -0,0 +1,127 @@ +; 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 5ed8d4a..389fec8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,30 @@ # 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 new file mode 100644 index 0000000..57d1265 --- /dev/null +++ b/Rechnungstool.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + # Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start + # nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt + # das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders + # aus als hier. + datas=[('assets/programm.ico', 'assets'), + ('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'), + ('assets/fonts', 'assets/fonts'), + # Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe + # nur ihren Text, aber der Anordnen-Griff fehlt. + ('assets/icons', 'assets/icons')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Rechnungstool', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + icon='assets/programm.ico', + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/Updater.spec b/Updater.spec new file mode 100644 index 0000000..a82ec2a --- /dev/null +++ b/Updater.spec @@ -0,0 +1,24 @@ +# -*- mode: python ; coding: utf-8 -*- +# Der Updater ist der Starter: winzige Konsolen-EXE, die vor dem Programm laeuft. +a = Analysis( + ['updater.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['version'], + hookspath=[], + runtime_hooks=[], + excludes=['tkinter', 'reportlab', 'PIL', 'pypdf', 'pypdfium2', 'numpy'], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, a.scripts, a.binaries, a.datas, [], + name='Updater', + debug=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + icon='assets/programm.ico', +) diff --git a/api.py b/api.py new file mode 100644 index 0000000..196de45 --- /dev/null +++ b/api.py @@ -0,0 +1,359 @@ +# -*- 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 8b05b7f..c777e88 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Rechnungstool Campinghof Bartl - Desktop-GUI (Tkinter). +Rechnungstool (powered by ravokk) - Desktop-GUI (Tkinter). Bedienung: 1. Kundenadresse + An-/Abreise eingeben @@ -20,32 +20,84 @@ 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 naechste_freie_nummer, vorhandene_rechnungen +from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer, + rechnungen_im_ordner, + vorhandene_rechnungen) from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn, laufende_pid_lesen) -from theme import ThemeMixin +import flaeche +import icons +import reiter +import theme +from theme import MeldungMixin, ThemeMixin from korrektur import KorrekturMixin from einstellungen import EinstellungenMixin -class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): +# 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): def __init__(self): super().__init__() self.cfg = config.load_config() - self.title("Rechnungstool - " + self.cfg["firma"].get("name", "Campinghof")) - # Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht + 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. hoehe = max(560, min(840, self.winfo_screenheight() - 90)) - self.geometry(f"1060x{hoehe}") - self.minsize(900, 540) + 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.anzahl_vars = {} # nr -> StringVar self.personen_vars = {} # nr -> StringVar @@ -53,15 +105,28 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # 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_positionen() # Positionsliste fuellt den Rest und scrollt + 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._neue_nummer_vorschlagen() self.recalc() @@ -70,19 +135,21 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum. self.protocol("WM_DELETE_WINDOW", self._beenden) - # 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) + # Der Hinweis darf erst aufgehen, wenn das Hauptfenster WIRKLICH + # gezeichnet ist. after_idle reicht dafuer nicht: in der gepackten + # EXE ist das Fenster dann noch nicht sichtbar, der modale Dialog + # sperrt es - und beide bleiben unsichtbar. Das sah aus wie ein + # haengendes Programm (06.09.2026, am Installer aufgefallen). + self.after(50, self._hinweis_wenn_sichtbar) # ---------------------------------------------------------------- Menue 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), @@ -92,82 +159,92 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): "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)], } - 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") + self.menue = menueleiste.Menueleiste(self, self._menus, farben=self) # 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 _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) + def _build_flaeche(self): + """Die Kaesten und der Knopf, mit dem man sie verschieben darf. - 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() + 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 _run_menu(self, cmd): - self._close_pop() - if cmd: - cmd() + # 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 _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 + 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() # ---------------------------------------------------------------- Kopf def _build_kopf(self): - 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)) + # 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)") self.k_anrede = tk.StringVar() self.k_vorname = tk.StringVar() self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal @@ -183,22 +260,26 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("PLZ / Ort *", self.k_plzort), ] for i, (lbl, var) in enumerate(felder): - ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2) + ttk.Label(kunde, text=lbl, style="Karte.TLabel").grid( + row=i, column=0, sticky="w", pady=4) if var is self.k_anrede: # Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen. - w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN) + w = ttk.Combobox(kunde, textvariable=var, width=22, values=ANREDEN) else: - w = ttk.Entry(kunde, textvariable=var, width=38) + # 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.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).", - foreground=self.col_muted, wraplength=330, justify="left").grid( + style="KarteMuted.TLabel", wraplength=250, justify="left").grid( row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0)) kunde.columnconfigure(1, weight=1) - # Rechnungsinfos (rechts) - info = ttk.LabelFrame(top, text="Rechnungsdaten", padding=8) - info.grid(row=0, column=1, sticky="nsew") + info = self.flaeche.kasten("daten", "Rechnungsdaten") self.r_nummer = tk.StringVar() self.r_datum = tk.StringVar(value=datetime.date.today().strftime("%d.%m.%Y")) self.r_anreise = tk.StringVar() @@ -207,13 +288,26 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): rows = [ ("Rechnungsnummer", self.r_nummer), ("Rechnungsdatum", self.r_datum), - ("Anreise (TT.MM.JJJJ)", self.r_anreise), - ("Abreise (TT.MM.JJJJ)", self.r_abreise), + ("Anreise", self.r_anreise), + ("Abreise", self.r_abreise), ] date_vars = (self.r_datum, self.r_anreise, self.r_abreise) + self.datum_labels = {} for i, (lbl, var) in enumerate(rows): - ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2) - ent = ttk.Entry(info, textvariable=var, width=18) + 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) 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 @@ -221,20 +315,27 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self.nummer_entry = ent if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende ent.bind("", self._auto_datum_evt) - 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( + 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( 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, foreground="#b00020", - font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid( - row=5, column=0, columnspan=2, sticky="w", pady=(6, 0)) + 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)) 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 @@ -245,6 +346,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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: @@ -262,6 +364,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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()) @@ -275,7 +400,12 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # (Titel, Ausrichtung, Mindestbreite px). col 1 (Leistung) bekommt das Gewicht. COLS = [ ("Pos.", "center", 38), - ("Leistung", "w", 330), + # 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), ("USt %", "center", 50), ("Anzahl\nTage/Menge", "center", 80), ("Pers.", "center", 56), @@ -284,16 +414,41 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ] def _build_positionen(self): - 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) + # 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()) - canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_bg) + 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) 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) - canvas.bind("", lambda e: canvas.itemconfig(self._pos_win, width=e.width - 18)) + 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.configure(yscrollcommand=sb.set) canvas.pack(side="left", fill="both", expand=True, pady=(4, 0)) sb.pack(side="right", fill="y") @@ -305,17 +460,141 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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): - 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") + 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.Separator(self.pos_frame, orient="horizontal").grid( row=1, column=0, columnspan=len(self.COLS), sticky="ew", pady=(0, 2)) @@ -329,28 +608,147 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): av.trace_add("write", lambda *a: self.recalc()) pv.trace_add("write", lambda *a: self.recalc()) - 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) + # 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) # Personen-Feld NUR bei "pro Person"-Positionen anzeigen if pos["pro_person"]: - 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") + 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") 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="powered by ravokk", foreground=self.col_muted, - font=("Segoe UI", 8)).pack(side="right") + 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") - f = ttk.Frame(self, padding=(12, 6, 12, 10)) + f = ttk.Frame(self, padding=(16, 12, 16, 14)) f.pack(side="bottom", fill="x") ttk.Separator(self, orient="horizontal").pack(side="bottom", fill="x") @@ -360,12 +758,475 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): links = ttk.Frame(f) links.pack(side="left") - ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) + # "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="Storno", command=self.dlg_storno, - 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) + 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") # --------------------------------------------------------- Berechnung def _eingaben(self): @@ -374,11 +1235,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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): - 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))) + 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))) # Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene # USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag. @@ -396,7 +1275,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): rows.append(("Gesamtbetrag:", berechnung.eur(r.gesamt), True)) for i, (lbl, val, fett) in enumerate(rows): - font = ("Segoe UI", 12, "bold") if fett else ("Segoe UI", 10) + font = ((theme.SCHRIFT_NAME, 15, "bold") if fett + else (theme.SCHRIFT_NAME, 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"} @@ -416,7 +1296,12 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): """ if self.storno_stand or self.berichtigung_stand: return - frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg) + # 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()) if frei: self.r_nummer.set(frei) @@ -434,92 +1319,241 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): "strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()}) - def pdf_erstellen(self): + 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.""" r = self.recalc() - 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 + 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, + } - # 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 _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() - # 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. + 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. + """ nachname = self.k_name.get().strip() vorname = self.k_vorname.get().strip() namenszeile = " ".join(t for t in (vorname, nachname) if t) - 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 = { + return { "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": kunde_zeilen, + "kunde_zeilen": [self.k_anrede.get(), namenszeile, self.k_zusatz.get(), + self.k_strasse.get(), self.k_plzort.get()], "nachname": nachname, "vorname": vorname, # Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter @@ -531,6 +1565,146 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): "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"] @@ -545,7 +1719,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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 messagebox.askyesno( + if not self.frage( "Berichtigte Rechnung erstellen?", f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes " "Blatt geschrieben.\n\n" @@ -560,7 +1734,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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 messagebox.askyesno( + if os.path.exists(pfad) and not self.frage( "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?"): @@ -568,14 +1742,25 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): try: pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - messagebox.showerror("Fehler beim Erstellen", + self.melden_fehler("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 messagebox.askyesno("Fertig", + if self.frage("Fertig", f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n" "Jetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) @@ -587,16 +1772,21 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # 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) - if vorhanden: + # 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: liste = "\n".join("- " + os.path.basename(x) for x in vorhanden) - frei = naechste_freie_nummer(out_dir, self.cfg) + if im_buch and not vorhanden: + liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)" + frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern()) if not frei: - messagebox.showerror( + self.melden_fehler( "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 messagebox.askyesno( + if not self.frage( "Rechnungsnummer schon vergeben", f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n" "Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben " @@ -608,24 +1798,102 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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 PDF: Seite 1 = Kunden-Rechnung, Seite 2 = Beherbergungssteuer fuers Amt + # 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. pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - messagebox.showerror("Fehler beim Erstellen", f"PDF konnte nicht erstellt werden:\n{e}") + self.melden_fehler("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}") - hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else "" + # 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 = "" storno_pfad = None if stand: # Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen. @@ -635,7 +1903,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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 messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): + if self.frage("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"): self._oeffne_datei(pfad) if storno_pfad: self._oeffne_datei(storno_pfad) @@ -660,7 +1928,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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. - messagebox.showwarning( + self.melden_warnung( "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, " @@ -670,7 +1938,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): def _beenden(self): """Schliessen - mit Rückfrage, wenn ein Storno noch offen ist.""" if self.storno_stand: - if not messagebox.askyesno( + if not self.frage( "Storno ist noch offen", f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte " @@ -678,13 +1946,17 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): "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 messagebox.askyesno( + if not self.frage( "Storno verwerfen?", f"Der Storno {self.storno_stand['nummer']} zu Rechnung " f"{self.storno_stand['zu']} liegt noch bereit.\n\n" @@ -714,6 +1986,16 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): 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 @@ -722,7 +2004,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): else: os.system(f'xdg-open "{pfad}"') except Exception as e: # noqa: BLE001 - messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") + self.melden_info("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})") if __name__ == "__main__": @@ -733,6 +2015,10 @@ 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 new file mode 100644 index 0000000..a122c13 Binary files /dev/null and b/assets/fonts/Roboto-Italic.ttf differ diff --git a/assets/fonts/Roboto.ttf b/assets/fonts/Roboto.ttf new file mode 100644 index 0000000..5522a36 Binary files /dev/null and b/assets/fonts/Roboto.ttf differ diff --git a/assets/icons/check.png b/assets/icons/check.png new file mode 100644 index 0000000..7dcb1a5 Binary files /dev/null and b/assets/icons/check.png differ diff --git a/assets/icons/close.png b/assets/icons/close.png new file mode 100644 index 0000000..ca051d4 Binary files /dev/null and b/assets/icons/close.png differ diff --git a/assets/icons/dashboard_customize.png b/assets/icons/dashboard_customize.png new file mode 100644 index 0000000..80f2a65 Binary files /dev/null and b/assets/icons/dashboard_customize.png differ diff --git a/assets/icons/drag_indicator.png b/assets/icons/drag_indicator.png new file mode 100644 index 0000000..1b060c1 Binary files /dev/null and b/assets/icons/drag_indicator.png differ diff --git a/assets/icons/save.png b/assets/icons/save.png new file mode 100644 index 0000000..bffa440 Binary files /dev/null and b/assets/icons/save.png differ diff --git a/assets/programm.ico b/assets/programm.ico new file mode 100644 index 0000000..f3778ff Binary files /dev/null and b/assets/programm.ico differ diff --git a/assets/programm_logo.png b/assets/programm_logo.png new file mode 100644 index 0000000..6086c87 Binary files /dev/null and b/assets/programm_logo.png differ diff --git a/assets/programm_logo.svg b/assets/programm_logo.svg new file mode 100644 index 0000000..f16f814 --- /dev/null +++ b/assets/programm_logo.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bauen.py b/bauen.py new file mode 100644 index 0000000..f3aa6dc --- /dev/null +++ b/bauen.py @@ -0,0 +1,138 @@ +# -*- 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 baac192..abfd31e 100644 --- a/bestand.py +++ b/bestand.py @@ -8,6 +8,7 @@ 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 @@ -168,14 +169,37 @@ def rechnungen_im_ordner(ordner): gefunden.append(daten) gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True) return gefunden -def naechste_freie_nummer_nach(ordner, cfg, vergeben): +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): """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) + kandidat = naechste_freie_nummer(ordner, cfg, belegt) if kandidat and kandidat != vergeben: return kandidat ersatz = dict(cfg) @@ -184,15 +208,21 @@ def naechste_freie_nummer_nach(ordner, cfg, vergeben): ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z) except (ValueError, AttributeError): return kandidat - return naechste_freie_nummer(ordner, ersatz) + return naechste_freie_nummer(ordner, ersatz, belegt) -def naechste_freie_nummer(ordner, cfg): - """Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt. +def naechste_freie_nummer(ordner, cfg, belegt=None): + """Erste Nummer JJJJ-NNN, die weder im Ordner noch im Nummernbuch steht. 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 @@ -203,6 +233,6 @@ def naechste_freie_nummer(ordner, cfg): for _ in range(1000): z += 1 kandidat = config.format_rechnungsnummer(jahr, z) - if not vorhandene_rechnungen(ordner, kandidat): + if kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat): return kandidat return None diff --git a/bilder.py b/bilder.py new file mode 100644 index 0000000..535eed5 --- /dev/null +++ b/bilder.py @@ -0,0 +1,117 @@ +# -*- 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 218280d..3ae12a8 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Konfiguration fuer das Rechnungstool Campinghof Bartl. +Konfiguration fuer das Rechnungstool (powered by ravokk). Haelt die Standardwerte (Firmendaten, Steuersaetze, Leistungskatalog, Rechnungsnummer-Zaehler) und kuemmert sich um Laden/Speichern der config.json. @@ -42,9 +42,15 @@ 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. neben der EXE ein Unterordner "Daten" - 2. geht das nicht (EXE liegt z.B. unter "Programme"), dann - der Ordner Rechnungstool unter LOCALAPPDATA + 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). 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 @@ -52,25 +58,68 @@ def _daten_ordner(): """ if not getattr(sys, "frozen", False): return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist - kandidat = os.path.join(EXE_DIR, "Daten") + 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() if not _beschreibbar(kandidat): - kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool") + kandidat = os.path.join(EXE_DIR, "Daten") if not _beschreibbar(kandidat): - return EXE_DIR # letzter Ausweg: wie frueher + 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. for name in ("config.json",): - 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 + 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}") 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) @@ -96,6 +145,18 @@ 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. @@ -112,6 +173,11 @@ 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). @@ -123,10 +189,27 @@ 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 200aa7c..f63155d 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -8,9 +8,12 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. import datetime import os import tkinter as tk -from tkinter import ttk, messagebox, filedialog +from tkinter import ttk, messagebox, filedialog, colorchooser import berechnung +import hinweis +import theme +import bilder import config from helfer import parse_num @@ -22,7 +25,7 @@ class EinstellungenMixin: if (self.cfg.get("output_dir") or "").strip(): return vorschlag = config.default_output_dir() - messagebox.showinfo( + self.melden_info( "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.)") @@ -39,7 +42,7 @@ class EinstellungenMixin: self.cfg["output_dir"] = d config.save_config(self.cfg) config.ensure_output_dir(self.cfg) - messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") + self.melden_info("Speicherort", f"Rechnungen werden gespeichert in:\n{d}") def _info(self): d = tk.Toplevel(self) @@ -49,13 +52,24 @@ class EinstellungenMixin: self._style_dialog(d) frm = ttk.Frame(d, padding=18) frm.pack(fill="both", expand=True) - ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"), + ttk.Label(frm, text=config.PROGRAMM, font=(theme.SCHRIFT_NAME, 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)) - ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w") + # 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.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()) @@ -73,7 +87,7 @@ class EinstellungenMixin: frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) - ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"), + ttk.Label(frm, wraplength=580, justify="left", font=(theme.SCHRIFT_NAME, 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 " @@ -91,7 +105,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=("Segoe UI", 9, "bold")).grid( + ttk.Label(liste, text=t, font=(theme.SCHRIFT_NAME, 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) @@ -119,7 +133,7 @@ class EinstellungenMixin: "bezeichnung": row["bez"].get().strip() or "USt", "extra_blatt": bool(row["extra"].get())}) if not neu: - messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) + self.melden_warnung("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d) return self.cfg["steuersaetze"] = neu config.save_config(self.cfg) @@ -132,6 +146,70 @@ 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") @@ -182,7 +260,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=("Segoe UI", 9, "bold")).grid( + ttk.Label(inner, text=t, font=(theme.SCHRIFT_NAME, 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) @@ -223,7 +301,7 @@ class EinstellungenMixin: "pro_person": bool(row["pro_person"].get()), }) if not neu: - messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) + self.melden_warnung("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d) return self.cfg["katalog"] = neu config.save_config(self.cfg) @@ -269,7 +347,10 @@ class EinstellungenMixin: d.transient(self) d.grab_set() self._style_dialog(d) - d.geometry("640x640") + 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) frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) @@ -284,57 +365,129 @@ 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=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Entry(frm, textvariable=v, width=44).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2) vars_[key] = v frm.columnconfigure(1, weight=1) base = len(felder) - # 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) + # 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 = {} - 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 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) - 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)) + 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.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) + 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)) t1.insert("1.0", self.cfg["texte"].get("dank1", "")) - 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) + 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)) t2.insert("1.0", self.cfg["texte"].get("dank2", "")) - 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) + 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) vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) - ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) + ttk.Entry(frm, textvariable=vh, width=44).grid(row=base + 5, column=1, sticky="ew", padx=(8, 0), pady=2) def speichern(): for key, v in vars_.items(): self.cfg["firma"][key] = v.get() - self.cfg["firma"]["logo"] = v_logo.get().strip() + 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["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("Rechnungstool - " + self.cfg["firma"].get("name", "")) + self.title(config.PROGRAMM) d.destroy() btns = ttk.Frame(frm) - btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") + btns.grid(row=base + 6, 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) @@ -348,14 +501,39 @@ 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(): - self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get())) - self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get())) + 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 config.save_config(self.cfg) self._neue_nummer_vorschlagen() d.destroy() diff --git a/einzelinstanz.py b/einzelinstanz.py index cdd10cc..3311efa 100644 --- a/einzelinstanz.py +++ b/einzelinstanz.py @@ -41,6 +41,10 @@ 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 new file mode 100644 index 0000000..fe850c1 --- /dev/null +++ b/flaeche.py @@ -0,0 +1,308 @@ +# -*- 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 new file mode 100644 index 0000000..7fc329e --- /dev/null +++ b/gemeinsam.py @@ -0,0 +1,399 @@ +# -*- 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 new file mode 100644 index 0000000..170b3ee --- /dev/null +++ b/hinweis.py @@ -0,0 +1,109 @@ +# -*- 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 new file mode 100644 index 0000000..3cd0c7b --- /dev/null +++ b/icons.py @@ -0,0 +1,62 @@ +# -*- 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 77038df..9b5f894 100644 --- a/korrektur.py +++ b/korrektur.py @@ -23,11 +23,13 @@ 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, safe_filename +from helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename from storno import storno_rechnung_bauen @@ -67,7 +69,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 "" - messagebox.showinfo( + self.melden_info( "Rechnung geladen", f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n" "Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – " @@ -91,7 +93,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. - messagebox.showerror( + self.melden_fehler( "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 " @@ -102,10 +104,17 @@ class KorrekturMixin: try: config.save_config(self.cfg) except OSError as e: # noqa: BLE001 - messagebox.showwarning( + self.melden_warnung( "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() @@ -128,14 +137,14 @@ class KorrekturMixin: """ storno = modus == "storno" if self.storno_stand: - messagebox.showinfo( + self.melden_info( "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: - messagebox.showinfo( + self.melden_info( "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.") @@ -153,7 +162,7 @@ class KorrekturMixin: if d.get("art") not in ("storno", "berichtigung") and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte] if not offen: - messagebox.showwarning( + self.melden_warnung( "Nichts zu korrigieren", f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.") return @@ -195,9 +204,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, - bg=self.col_field, fg=self.col_fg, - selectbackground=self.col_sel, selectforeground=self.col_fg, - relief="flat", font=("Consolas", 9)) + selectbackground=self.col_sel, + selectforeground=self.col_fg, + font=("Consolas", 9), **self.feld_optionen(einfuegemarke=False)) self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0)) self._auswahl.grid_remove() self._kandidaten = [] @@ -295,12 +304,239 @@ 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", "")) - nummer = naechste_freie_nummer(out_dir, self.cfg) + # 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()) if not nummer: - messagebox.showerror("Keine freie Nummer", + self.melden_fehler("Keine freie Nummer", "Es wurde keine freie Rechnungsnummer gefunden.") return @@ -310,7 +546,18 @@ 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. - folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer) + 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()) # 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) @@ -324,7 +571,7 @@ class KorrekturMixin: # kann das Programm nichts erfinden; dann muss der Nutzer entscheiden. maengel = kunde_maengel(kunde) if maengel: - if not messagebox.askyesno( + if not self.frage( "Angaben zum Kunden unvollständig", f"In Rechnung {alt_nummer} steht nicht alles:\n\n- " + "\n- ".join(maengel) + @@ -333,10 +580,17 @@ 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": "", + "anreise": "", "abreise": "", + "naechte": -abs(alt_naechte) if alt_naechte else "", "kunde_zeilen": kunde_zeilen, "nachname": kunde.get("nachname", ""), "vorname": kunde.get("vorname", ""), @@ -349,6 +603,12 @@ 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, } @@ -359,12 +619,14 @@ class KorrekturMixin: try: pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen - messagebox.showerror("Fehler beim Erstellen", + self.melden_fehler("Fehler beim Erstellen", f"Storno-PDF konnte nicht erstellt werden:\n{e}") print("[storno] FEHLER:", e) return - self.storno_stand = { + # 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 { "pfad": pfad, "nummer": nummer, "zu": alt_nummer, "vorgang": str(meta.get("vorgang") or alt_nummer), "zu_datum": str(meta.get("datum") or ""), @@ -373,6 +635,24 @@ 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 "") @@ -382,7 +662,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.") - messagebox.showinfo( + self.melden_info( "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 " @@ -405,14 +685,28 @@ 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("") - 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 "")) + 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 "")) self.recalc() hinweise = [] diff --git a/menueleiste.py b/menueleiste.py new file mode 100644 index 0000000..7b0f578 --- /dev/null +++ b/menueleiste.py @@ -0,0 +1,169 @@ +# -*- 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 new file mode 100644 index 0000000..fa9b5a9 --- /dev/null +++ b/ordnerwahl.py @@ -0,0 +1,223 @@ +# -*- 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 bfc69ff..a72f2a7 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab). +PDF-Renderer fuer die Rechnung (reportlab). render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF im Layout der Excel-Vorlage: @@ -10,47 +10,66 @@ 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, PageBreak, - Flowable, + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, ) +import bilder import config from berechnung import eur, eur_kurz, runde -class _AmtMarke(Flowable): - """Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt. - - 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) +# 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) + +ACCENT = colors.HexColor(AKZENT_VORGABE) 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 @@ -100,36 +119,46 @@ def _styles(): # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- -def _kopf_fuss(canvas, doc, cfg, amt=False): - """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile).""" +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.""" firma = cfg["firma"] canvas.saveState() + if vorschau: + _vorschau_marke(canvas) # --- Kopf --- - 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 + # 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_ok = False - 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 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_ok: # Firmenname + Untertitel rechtsbuendig neben dem Logo @@ -155,13 +184,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) # --- Fusszeile --- - # - # 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. + # Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der + # Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG). fy = MARGIN_BOT - 4 * mm canvas.setStrokeColor(LINE) canvas.setLineWidth(0.6) @@ -172,9 +196,17 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): col_m = MARGIN_L + CONTENT_W * 0.40 col_r = PAGE_W - MARGIN_R - 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", "")] + 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_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")] for i, t in enumerate(rows_l): @@ -188,90 +220,17 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): # ---- Hauptfunktion ---------------------------------------------------------- -def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): - """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte). +def _positionstabelle(story, st, rechnung, ohne_ust=False): + """Die gewohnte Positionstabelle der Campingrechnung. - 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 + 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).""" 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("USt %", st["th"]), + Paragraph("" if ohne_ust else "USt %", st["th"]), Paragraph("Anzahl
Tage/Menge", st["th"]), Paragraph("Pers.", st["th"]), Paragraph("E.-Preis", st["th"]), @@ -282,7 +241,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): data.append([ Paragraph(str(z.nr), st["cell_c"]), Paragraph(z.leistung, st["leistung"]), - Paragraph(str(z.satz), st["cell_c"]), + Paragraph("" if ohne_ust else 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"]), @@ -305,31 +264,177 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): ("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 ---- - summen_rows = [[ + # 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 [[ Paragraph("Nettobetrag:", st["info_l"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), ]] - for b in rechnung.bloecke: + for b in ([] if angebot else 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 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: + # 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: summen_rows.append([ Paragraph("Zwischensumme:", st["info_l"]), Paragraph(eur(rechnung.brutto), st["cell_r"]), @@ -341,9 +446,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): Paragraph(eur(a.betrag), st["cell_r"]), ]) - endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt + endsumme = rechnung.gesamt summen_rows.append([ - Paragraph("Gesamtbetrag:", st["total_l"]), + Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else + "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", + st["total_l"]), Paragraph(eur(endsumme), st["total_v"]), ]) style_cmds = [ @@ -358,9 +465,13 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): summen.setStyle(TableStyle(style_cmds)) story.append(summen) - # 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: + # 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: for a in rechnung.aufschlaege: if not a.bezeichnung: continue @@ -378,53 +489,84 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(Spacer(1, 8 * mm)) - # ---- 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 "" + # ---- 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. 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. + 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. 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"])) - 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"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)) def _kenndaten(rechnung, kopf): @@ -452,6 +594,22 @@ 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" @@ -499,6 +657,8 @@ 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", ""), @@ -507,56 +667,55 @@ 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): +def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False): """ pfad : Zielpfad der PDF cfg : geladene Konfiguration rechnung : berechnung.Rechnung kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen - 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. + 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. """ + _design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit st = _styles() - 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 + 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 seite(c, d): - _kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite)) + def seite(c, d): + _kopf_fuss(c, d, cfg, vorschau=vorschau) - 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")) + doc.build(story, onFirstPage=seite, onLaterPages=seite) return pfad diff --git a/pruef_angebot.py b/pruef_angebot.py new file mode 100644 index 0000000..2349b3d --- /dev/null +++ b/pruef_angebot.py @@ -0,0 +1,249 @@ +# -*- 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 new file mode 100644 index 0000000..ff8994d --- /dev/null +++ b/pruef_api.py @@ -0,0 +1,167 @@ +# -*- 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 new file mode 100644 index 0000000..f9da725 --- /dev/null +++ b/pruef_bilder.py @@ -0,0 +1,137 @@ +# -*- 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 new file mode 100644 index 0000000..d5f8158 --- /dev/null +++ b/pruef_darstellung.py @@ -0,0 +1,269 @@ +# -*- 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 284dfb0..32070f5 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -27,14 +27,22 @@ 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 new file mode 100644 index 0000000..883ac8d --- /dev/null +++ b/pruef_doppelte_leistung.py @@ -0,0 +1,257 @@ +# -*- 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 new file mode 100644 index 0000000..f3dfdf3 --- /dev/null +++ b/pruef_flaeche.py @@ -0,0 +1,137 @@ +# -*- 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 new file mode 100644 index 0000000..270b1ab --- /dev/null +++ b/pruef_freigabe.py @@ -0,0 +1,155 @@ +# -*- 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 new file mode 100644 index 0000000..eab6c13 --- /dev/null +++ b/pruef_gemeinsam_automatisch.py @@ -0,0 +1,149 @@ +# -*- 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 new file mode 100644 index 0000000..e37b5ef --- /dev/null +++ b/pruef_hinweis.py @@ -0,0 +1,184 @@ +# -*- 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 new file mode 100644 index 0000000..55d0d1c --- /dev/null +++ b/pruef_installation.py @@ -0,0 +1,190 @@ +# -*- 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 723a4d1..bd5160b 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -34,9 +34,15 @@ 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 @@ -47,6 +53,7 @@ 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(): @@ -60,18 +67,6 @@ _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 = {} @@ -89,6 +84,11 @@ 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,12 +135,14 @@ 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. -antworten["endgültig erstellen"] = False +# Aus der Sicherheitsfrage ist das Auswahlfenster geworden (Rechnung oder +# Proforma). "Abbrechen" heisst dort: gar nichts schreiben. +f._testwahl = None 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") -antworten["endgültig erstellen"] = True +f._testwahl = "rechnung" # ---- Ausgangslage: zwei echte Rechnungen ------------------------------------ kunde_setzen("Anna", "Beispiel", "4") diff --git a/pruef_kette.py b/pruef_kette.py index 163ae59..40c5f73 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -33,9 +33,15 @@ 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 @@ -46,6 +52,7 @@ 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(): @@ -59,18 +66,6 @@ _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, @@ -84,6 +79,11 @@ 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 new file mode 100644 index 0000000..7013965 --- /dev/null +++ b/pruef_menueleiste.py @@ -0,0 +1,184 @@ +# -*- 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 new file mode 100644 index 0000000..d2332cd --- /dev/null +++ b/pruef_montage.py @@ -0,0 +1,251 @@ +# -*- 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 new file mode 100644 index 0000000..4656eca --- /dev/null +++ b/pruef_nummernbuch.py @@ -0,0 +1,186 @@ +# -*- 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 new file mode 100644 index 0000000..0f2ea49 --- /dev/null +++ b/pruef_nummernsperre.py @@ -0,0 +1,332 @@ +# -*- 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 new file mode 100644 index 0000000..cb55a78 --- /dev/null +++ b/pruef_nur_storno.py @@ -0,0 +1,202 @@ +# -*- 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 new file mode 100644 index 0000000..e8e13a4 --- /dev/null +++ b/pruef_release.py @@ -0,0 +1,98 @@ +# -*- 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 new file mode 100644 index 0000000..32e65d1 --- /dev/null +++ b/pruef_selbstbezug.py @@ -0,0 +1,114 @@ +# -*- 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 new file mode 100644 index 0000000..f3692b9 --- /dev/null +++ b/pruef_spaltenbreiten.py @@ -0,0 +1,128 @@ +# -*- 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 new file mode 100644 index 0000000..1dafd4f --- /dev/null +++ b/pruef_start_exe.py @@ -0,0 +1,133 @@ +# -*- 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 1f29489..e376883 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -29,9 +29,15 @@ 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 @@ -42,6 +48,7 @@ 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(): @@ -55,18 +62,6 @@ _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 = {} @@ -86,6 +81,11 @@ 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 new file mode 100644 index 0000000..8769853 --- /dev/null +++ b/pruef_updater.py @@ -0,0 +1,220 @@ +# -*- 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 new file mode 100644 index 0000000..b607c76 --- /dev/null +++ b/pruef_vorschau.py @@ -0,0 +1,115 @@ +# -*- 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 new file mode 100644 index 0000000..f3e9155 --- /dev/null +++ b/pruefhelfer.py @@ -0,0 +1,47 @@ +# -*- 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 new file mode 100644 index 0000000..ed320de --- /dev/null +++ b/pruefsteuerung.py @@ -0,0 +1,103 @@ +# -*- 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 new file mode 100644 index 0000000..8039d17 --- /dev/null +++ b/reiter.py @@ -0,0 +1,94 @@ +# -*- 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 cf89143..213e2d3 100644 --- a/theme.py +++ b/theme.py @@ -5,11 +5,254 @@ 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"): @@ -23,35 +266,95 @@ 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 zum Windows-Theme (hell/dunkel) setzen.""" - dark = windows_dark_mode() + """Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows).""" + schrift_vorbereiten() # Roboto, sonst Segoe UI + dark = ist_dunkel(getattr(self, "cfg", None)) 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 = "#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") + 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 else: - self.col_bg = "#f3f3f3" - self.col_fg = "#1a1a1a" + self.col_bg = "#eef0f3" + self.col_card = "#ffffff" + self.col_fg = "#14161a" self.col_field = "#ffffff" - 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.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.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") @@ -62,28 +365,132 @@ 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) + style.configure("TLabel", background=bg, foreground=fg, font=SCHRIFT) style.configure("TLabelframe", background=bg, bordercolor=self.col_line) style.configure("TLabelframe.Label", background=bg, foreground=accent) - style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line) - style.map("TButton", background=[("active", sel)], foreground=[("active", fg)]) + + # --- 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("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)]) - style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=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))]) # 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=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)]) + 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)]) # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget - self.option_add("*TCombobox*Listbox.background", field) + self.option_add("*TCombobox*Listbox.background", feld_hell) self.option_add("*TCombobox*Listbox.foreground", fg) self.option_add("*TCombobox*Listbox.selectBackground", sel) self.option_add("*TCombobox*Listbox.selectForeground", fg) @@ -93,31 +500,320 @@ 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.""" + """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. + """ 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): - """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) + """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"] diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..836c448 --- /dev/null +++ b/updater.py @@ -0,0 +1,404 @@ +# -*- 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 new file mode 100644 index 0000000..46dcda8 --- /dev/null +++ b/version.py @@ -0,0 +1,23 @@ +# -*- 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 new file mode 100644 index 0000000..9a73658 --- /dev/null +++ b/wasserzeichen.py @@ -0,0 +1,236 @@ +# -*- 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)