Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb04f5ad74 | ||
|
|
2b09d15cd0 | ||
|
|
5babfc3e2e | ||
|
|
08426b3d9b | ||
|
|
b5bf6f401f | ||
|
|
9794632ae4 | ||
|
|
3790b0a86f | ||
|
|
688dfb53c4 | ||
|
|
adbb035cf1 | ||
|
|
f7801f46f0 | ||
|
|
20876102d5 | ||
|
|
42e459ad26 | ||
|
|
8a391b8dfa | ||
|
|
73042b771c | ||
|
|
fec0e8449c | ||
|
|
05ec56e4b8 | ||
|
|
4cdc286d5b | ||
|
|
95a9063ce1 | ||
|
|
b3cb19743f | ||
|
|
c1e328c087 | ||
|
|
5403e01dd5 | ||
|
|
2ec41fb4e4 | ||
|
|
5c9e9c2627 | ||
|
|
2faf8c1b9a | ||
|
|
a715d1235b | ||
|
|
1c2b115108 | ||
|
|
2e3a191f19 | ||
|
|
5ebb02b267 | ||
|
|
0ad03fd1a4 | ||
|
|
fe0c4a8e08 | ||
|
|
4cf46e9932 | ||
|
|
ca9ac212cf | ||
|
|
142dddc647 | ||
|
|
37b5c60266 | ||
|
|
01f6a127c2 |
23
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||
15
.gitignore
vendored
|
|
@ -9,3 +9,18 @@ _tmp/
|
|||
*.7z.*
|
||||
release/
|
||||
rechnungstool.pid
|
||||
|
||||
# Gebaute Dateien - die gehoeren ins Release, nicht ins Repo.
|
||||
*.exe
|
||||
setup/
|
||||
|
||||
# Die Bauanleitungen selbst gehoeren SEHR WOHL hinein: ohne sie kann niemand
|
||||
# die EXE nachbauen, die der Updater ausliefert.
|
||||
!Rechnungstool.spec
|
||||
!Updater.spec
|
||||
!Diagnose.spec
|
||||
|
||||
# Muster-PDFs. Der GAST darin ist erfunden - der BETRIEB nicht: sie tragen
|
||||
# IBAN, Steuernummer und die private Rufnummer des Auftraggebers im Briefkopf.
|
||||
# In ein oeffentliches Repository gehoert das nicht.
|
||||
muster/
|
||||
|
|
|
|||
95
AUFGABE_AUSSENDIENST_ARBEITSPROTOKOLL.md
Normal file
|
|
@ -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`)
|
||||
139
AUFGABE_BEWEISKETTE_PRUEFBAR.md
Normal file
|
|
@ -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.
|
||||
83
AUFGABE_KUNDENSTAMM_UND_WEB.md
Normal file
|
|
@ -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.
|
||||
417
AUFGABE_ONLINE_API_MYSQL.md
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
# Online-Betrieb: API, MySQL, Tablet, Rechnungsversand
|
||||
|
||||
Seine Idee vom 06.09.2026, wörtlich:
|
||||
|
||||
> „die software ist aktuell nur lokal, und da reicht sqlite. wenn wir die online funktion einbauen,
|
||||
> dann muss man einen api key eintragen, und die api verteilt das auf die mysql — dann ist das
|
||||
> extern auf dem server und du kannst von überall über domain und apikey auch unterwegs per tablet
|
||||
> beim kunden die rechnung schreiben, live oder vorab-rechnung, und der smtp-server schickt sie
|
||||
> direkt dem kunden auf die hinterlegte e-mail. fertig. alles automatisch."
|
||||
|
||||
**Status: Idee. Nichts davon ist gebaut.** Der Schnitt ist richtig gedacht — lokal SQLite, zentral
|
||||
MySQL, die API als einzige Tür dazwischen.
|
||||
|
||||
---
|
||||
|
||||
## 1. ⭐ Die Rechnungsnummer darf nur vom Server kommen
|
||||
|
||||
Heute stimmen sich zwei lokale Programme über eine gemeinsame SQLite ab. Zentral wird das
|
||||
**einfacher**: eine Transaktion in MySQL vergibt die Nummer, fertig.
|
||||
|
||||
**Aber:** Auf dem Tablet beim Kunden ohne Empfang darf keine Nummer entstehen.
|
||||
|
||||
> **Regel: Offline entsteht ein ENTWURF, keine Rechnung.** Die Nummer wird vergeben, sobald der
|
||||
> Server erreichbar ist.
|
||||
|
||||
Sonst gibt es irgendwann zwei Belege mit derselben Nummer — genau der Fehler, wegen dem das
|
||||
Amt-Blatt herausgeflogen ist (§ 14 Abs. 4 Nr. 4 UStG).
|
||||
|
||||
Daraus folgt für die Oberfläche: Ein Entwurf muss **sichtbar** ein Entwurf sein, ohne Nummer, und
|
||||
darf nicht ausgedruckt oder verschickt werden können.
|
||||
|
||||
## ⭐⭐ Das Offline-Problem — und seine Lösung: NUMMERNKONTINGENTE
|
||||
|
||||
Sein Einwand (06.09.2026):
|
||||
|
||||
> „das ding, was wir haben, ist das offline-problem. wenn das internet schwach oder weg ist, bringt
|
||||
> das online nichts — also musst du auf dem client eine kopie per sqlite haben. wie willst es sonst
|
||||
> machen?"
|
||||
|
||||
**Er hat recht, und das ersetzt die zu strenge Regel „offline nur Entwürfe" aus Punkt 1.**
|
||||
|
||||
### Was zwischengespeichert werden kann — und was nicht
|
||||
|
||||
| | offline möglich? |
|
||||
|---|---|
|
||||
| Kundenstamm, Preise, Einstellungen, alte Rechnungen zum Nachschlagen | **ja** — lokale SQLite, lesend, mit sichtbarem Alter |
|
||||
| **Vergabe der Rechnungsnummer** | **nein** — zwei Geräte nähmen beide „die nächste" |
|
||||
|
||||
### Die Lösung: der Server gibt Blöcke im Voraus aus
|
||||
|
||||
So machen es Kassensysteme. Tablet A bekommt **2026-101 bis 2026-150**, Tablet B **2026-151 bis
|
||||
2026-200**. Offline vergibt jedes Gerät aus **seinem eigenen** Block — eine Kollision ist
|
||||
**unmöglich**, weil kein anderes Gerät diese Nummern besitzt. Bei Netz meldet es den Verbrauch und
|
||||
holt einen neuen Block.
|
||||
|
||||
→ **Damit sind beim Kunden echte Rechnungen möglich, nicht nur Entwürfe.**
|
||||
|
||||
### Zwei Bedingungen, die dazugehören
|
||||
|
||||
1. **Lücken werden normal — und müssen erklärbar sein.** Kommt Tablet A nur bis 2026-137 und holt
|
||||
dann einen neuen Block, fehlen 138–150. Das ist zulässig: § 14 UStG verlangt eine **einmalig
|
||||
vergebene** fortlaufende Nummer, ausdrücklich auch in mehreren Kreisen (z. B. je Filiale).
|
||||
**Aber das System muss zeigen können, welcher Block wem gehörte und welche Nummern daraus nie
|
||||
verbraucht wurden.** Sonst sieht ein Prüfer eine Lücke und fragt nach der Rechnung.
|
||||
→ Gehört in den **Steuerprüfungs-Ausdruck** (siehe `AUFGABE_BEWEISKETTE_PRUEFBAR.md`, Schritt 3).
|
||||
2. **Der Block ist endlich.** Ist er verbraucht und kein Netz da, ist Schluss — dann wieder
|
||||
Entwürfe. **Lieber ein Gerät, das sagt „ich kann gerade nicht", als eines, das sich Nummern
|
||||
ausdenkt.**
|
||||
|
||||
### ⭐⭐ Woher die Blockgröße kommt: aus der Disposition (Andreas, 06.09.2026)
|
||||
|
||||
> „die disponentin hat jedem handwerker ja kunden zugeteilt — daraus kann ein block generiert
|
||||
> werden. die software sagt: handwerker hans werner hat 10 kunden, also bekommt er block A mit 10
|
||||
> nummern, der hans werner heißt. dann ist es erklärbar, weil hans die kunden im log hatte. wenn er
|
||||
> mehr kunden hatte, bekommt er von anfang an 10 puffernummern als schutz. und wenn die nicht
|
||||
> gebraucht werden, bekommt er sie für den nächsten tag wieder. wenn sie voll sind, überspringt er
|
||||
> in den nächsten block. der server hält selber immer reserve."
|
||||
|
||||
**Der starke Punkt daran ist die Erklärbarkeit.** Ein Prüfer sieht reservierte Nummernbereiche und
|
||||
fragt, warum ausgerechnet diese. Hier gibt es eine Antwort **aus dem Betrieb heraus**: Die
|
||||
Disposition hat Hans an dem Tag zehn Kunden zugeteilt, also zehn Nummern plus Puffer. **Die
|
||||
Zuteilung ist der Beleg für die Reservierung** — besser als jede technische Begründung.
|
||||
|
||||
### ⚠️ Zwei Korrekturen daran
|
||||
|
||||
**1. Der Block darf NICHT am Kalendertag hängen.**
|
||||
*„Heute hast du 100 bis 110"* klingt logisch, ist aber gefährlich: Hat Hans zwei Tage keinen
|
||||
Empfang, wäre er am zweiten Tag arbeitsunfähig, obwohl er noch Nummern hätte.
|
||||
|
||||
> **Der Block gehört ihm, bis er ihn zurückmeldet. Kein Ablaufdatum.** Die Disposition gibt morgens
|
||||
> neue *dazu*, nimmt aber nichts weg, was er noch hat.
|
||||
|
||||
Am Bild der Disposition ändert das nichts — nur verhungert das Tablet nicht, wenn das Netz drei
|
||||
Tage weg ist.
|
||||
|
||||
**2. Eine Nummer ist erst verbraucht, wenn ein BELEG entsteht.**
|
||||
Nicht bei der Zuteilung, und **nicht durch einen Entwurf**. Nur so stimmt, dass unbenutzte Nummern
|
||||
am nächsten Tag wieder gelten — sonst würden Nummern wiederverwendet, die schon auf einem
|
||||
Bildschirm standen.
|
||||
|
||||
**Der Rest passt unverändert:** Puffer von Anfang an · Überspringen in den nächsten Block, wenn
|
||||
voll · **Server hält eigene Reserve** für das, was die Disposition nicht wusste (Notfall,
|
||||
Zusatzauftrag, Kunde ruft an).
|
||||
|
||||
### ⭐ Beim Synchronisieren: Verbrauch melden, nicht blind neu ausgeben
|
||||
|
||||
Sein Einwand (06.09.2026):
|
||||
|
||||
> „wenn das tablet lte wieder hat, kann er das syncen und bekommt einen neuen block — oder sagt
|
||||
> einfach: eh, ich habe aber in dem block erst 10 % genutzt"
|
||||
|
||||
**Richtig, und das vermeidet Lücken statt sie nur zu erklären.** Das Gerät meldet seinen Stand:
|
||||
*„2026-101 bis 110 verbraucht, 111 bis 150 gehören mir noch."* Der Server entscheidet:
|
||||
|
||||
| Lage | Antwort |
|
||||
|---|---|
|
||||
| noch viel übrig | **nichts tun** — kein neuer Block, keine verbrannten Nummern |
|
||||
| fast leer, folgende Nummern frei | **den vorhandenen Block VERLÄNGERN** (101–150 → 101–180) → **keine Lücke** |
|
||||
| fast leer, folgende Nummern gehören schon jemandem | neuer Block weiter hinten → Lücke, aber eine, die der Server **benennen** kann |
|
||||
|
||||
**Verlängern schlägt Neuausgabe.** Das ist der Unterschied zwischen einem System, das Lücken erzeugt
|
||||
und sie hinterher erklären muss, und einem, das erst gar keine macht.
|
||||
|
||||
### Warum das nicht optional ist
|
||||
|
||||
> „kannst ja nicht kunden fragen nach wlan"
|
||||
|
||||
Beim Kunden im Keller, im Neubau ohne Anschluss, auf dem Hof: kein WLAN, oft auch kein
|
||||
Mobilfunknetz. Nach dem Passwort fragen macht kein Handwerker.
|
||||
|
||||
**Zwei praktische Regeln daraus:**
|
||||
|
||||
1. **Der Block muss großzügig sein** — nicht zehn Nummern, sondern so viele, dass eine Woche Arbeit
|
||||
hineinpasst. Nummern kosten nichts, ein abgerissener Vorgang beim Kunden schon.
|
||||
2. **Das Gerät warnt, BEVOR es eng wird.** *„Noch 5 Nummern im Vorrat, bitte einmal ins Netz"* —
|
||||
solange man noch in der Werkstatt steht. Eine Warnung, die erst beim Kunden kommt, ist wertlos.
|
||||
|
||||
Nachgefüllt wird **automatisch**, sobald irgendwo Netz da ist. Der Benutzer soll darüber nicht
|
||||
nachdenken müssen.
|
||||
|
||||
## 2. ⚠️ Beim Datenschutz kippt die Lage vollständig
|
||||
|
||||
Heute lautet die Aussage im Programm: *Es verlässt nichts den Rechner.* Das ist der ganze
|
||||
Datenschutztext. Sobald Gästedaten auf einem Server liegen, gilt die DSGVO in voller Breite:
|
||||
|
||||
- verschlüsselte Übertragung (TLS), keine Ausnahme
|
||||
- **Zugriffstrennung je Betrieb** — ein API-Schlüssel ist ein Passwort, keine Zugriffskontrolle
|
||||
- Löschkonzept und Aufbewahrungsfristen (§ 147 AO) müssen zusammenpassen
|
||||
- **Wenn er das für ANDERE Betriebe hostet, ist er deren Auftragsverarbeiter** und braucht einen
|
||||
Vertrag nach Art. 28 DSGVO. Das ist keine Formalie, sondern Voraussetzung dafür, dass die
|
||||
Betriebe das Programm überhaupt einsetzen dürfen.
|
||||
|
||||
Der Datenschutztext im Programm muss dann neu geschrieben werden — der heutige wäre schlicht falsch.
|
||||
|
||||
## 3. ⭐⭐ E-Rechnung (ZUGFeRD) — jetzt entscheiden, nicht später
|
||||
|
||||
In Deutschland läuft die Pflicht zur elektronischen Rechnung im B2B an. Wer ein System baut, das
|
||||
Rechnungen **per Mail verschickt**, sollte die PDF gleich mit **eingebettetem strukturiertem XML**
|
||||
erzeugen (ZUGFeRD / PDF/A-3).
|
||||
|
||||
Der Unterschied zwischen „von Anfang an mitgedacht" und „später nachrüsten" ist hier ein halber
|
||||
Neubau: PDF/A-3 stellt andere Anforderungen an Schriften und Farbprofile als das heutige PDF, und
|
||||
das XML muss aus denselben Daten kommen wie das Blatt — nicht daneben gepflegt werden.
|
||||
|
||||
Stand schon auf der allerersten Aufgabenliste des Rechnungstools.
|
||||
|
||||
## 4. Versand
|
||||
|
||||
- **SMTP genügt zum Senden.** POP3/IMAP braucht man nur, um **Unzustellbarkeitsmeldungen** zu lesen
|
||||
— und die *muss* man lesen. Sonst meldet das Programm „verschickt", und die Rechnung liegt
|
||||
nirgends.
|
||||
- **Eine per Mail verschickte Rechnung ist eine Rechnung** (§ 14 UStG). Für die GoBD muss das
|
||||
Dokument **genau so archiviert werden, wie es verschickt wurde** — samt Versandnachweis.
|
||||
- Zustellung ist nicht garantiert. Der Versandstatus gehört sichtbar in die Oberfläche, nicht nur
|
||||
ins Log.
|
||||
|
||||
## ⭐ Das Produktbild (Andreas, 06.09.2026) — lokal ist der Normalfall
|
||||
|
||||
> „solange er den apikey mit domain nicht hat, kann er auch nur sqlite nutzen — reicht auch, wenn
|
||||
> einer es als single programm für sich nutzt. will einer das online nutzen, dann trägt er beides
|
||||
> ein, das programm synct einmal auf die mysql, fertig."
|
||||
|
||||
**Damit ist der Aufbau entschieden: local-first, Online ist eine Zusatzfunktion, die man
|
||||
einschaltet.** Wer nichts einträgt, merkt vom Server nichts. Kein Konto, keine Pflicht, keine
|
||||
Abhängigkeit.
|
||||
|
||||
### Die eine Regel, die den Umzug sicher macht
|
||||
|
||||
**„Synct einmal" ist eine EINMALIGE ÜBERNAHME, keine ständige Synchronisierung in beide
|
||||
Richtungen.** Danach gehört die Nummernvergabe dem Server; die lokale SQLite ist nur noch
|
||||
Zwischenspeicher für Entwürfe (siehe Punkt 1).
|
||||
|
||||
Zwei Richtungen dauerhaft offen zu halten hieße: zwei Stellen vergeben Nummern. Genau der Fehler,
|
||||
wegen dem das Amt-Blatt herausflog.
|
||||
|
||||
### Und daraus folgt eine Entscheidung VOR der ersten Übernahme
|
||||
|
||||
Zwei Installationen, die beide 2026-001 bis 2026-050 vergeben haben, lassen sich **nicht** in eine
|
||||
Datenbank übernehmen — dort lägen Nummern doppelt. Gehen je mehrere Betriebe auf denselben Server,
|
||||
muss die **Trennung der Nummernkreise vorher** feststehen (eigene Datenbank je Betrieb, oder
|
||||
Präfix je Betrieb). Nachträglich geht es nicht: die Belege sind dann schon beim Kunden.
|
||||
|
||||
### Was der Umzug NICHT gefährdet
|
||||
|
||||
**Die Wahrheit bleiben die Rechnungs-PDFs** — so steht es schon heute im Nummernbuch-Code: *„Die
|
||||
Datei ist ein REGISTER, keine zweite Buchhaltung."* Geht bei der Übernahme etwas schief, fehlt
|
||||
keine Rechnung; es muss nur neu eingelesen werden.
|
||||
|
||||
## ⭐⭐ Die Umschaltung ist eine Tür, die nur in eine Richtung aufgeht
|
||||
|
||||
Sein Modell (06.09.2026):
|
||||
|
||||
> „ein einmal sync, danach ist es only online. die sql ist dann deaktiviert für immer — außer er
|
||||
> trägt den key und die domain aus, dann hat er aber einen alten datenstand."
|
||||
|
||||
> „das muss er mit einem popup — mit dem speichern-button, den es dafür gibt — am ende noch mal als
|
||||
> ja/nein-frage kommen, ob ihm das bewusst ist, dass er sozusagen bei 0 anfängt."
|
||||
|
||||
### Harte Anforderung
|
||||
|
||||
**Beim SPEICHERN** von API-Schlüssel und Domain (nicht beim Eintippen) kommt eine Ja/Nein-Frage.
|
||||
Beim Tippen weiß niemand, ob es ernst gemeint ist — beim Speichern schon.
|
||||
|
||||
Entwurf des Textes:
|
||||
|
||||
> **Auf Online-Betrieb umstellen?**
|
||||
>
|
||||
> Die vorhandenen Daten werden einmalig auf den Server übernommen. Danach läuft alles über den
|
||||
> Server: Rechnungsnummern werden dort vergeben, und die lokale Datenbank wird **nicht mehr
|
||||
> fortgeführt**.
|
||||
>
|
||||
> Entfernen Sie später Schlüssel und Domain, arbeitet das Programm wieder lokal weiter — dann aber
|
||||
> mit dem Stand von **[Datum von heute]**. Alles, was zwischenzeitlich online entstanden ist, fehlt
|
||||
> dort.
|
||||
>
|
||||
> Diese Umstellung lässt sich nicht rückgängig machen.
|
||||
>
|
||||
> Jetzt umstellen?
|
||||
|
||||
Dasselbe Muster wie beim Erprobungshinweis und bei der Freigabe vor dem Beleg: Der Nutzer wird
|
||||
**vorher** aufgeklärt, und die Entscheidung wird mit Datum vermerkt.
|
||||
|
||||
### Die lokale Datenbank wird STILLGELEGT, nicht gelöscht
|
||||
|
||||
Sie ist der Stand von damals und muss lesbar bleiben — schon wegen § 147 AO.
|
||||
|
||||
### ⭐ Und der Punkt, der daran hängt: die PDFs müssen erreichbar bleiben
|
||||
|
||||
Heute gilt in beiden Programmen: **Die Wahrheit sind die Rechnungs-PDFs, die Datenbank ist nur ein
|
||||
Register.** Wird künftig auf dem Server erzeugt, muss der Betrieb **jederzeit an alle seine Belege
|
||||
herankommen — als Dateien**, nicht nur als Ansicht im Browser.
|
||||
|
||||
Der Grund ist **§ 147 AO: zehn Jahre Aufbewahrungspflicht.** Ein System, das den Betrieb von seinen
|
||||
eigenen Belegen trennen kann — Server fällt aus, Vertrag endet, man zerstreitet sich — bringt den
|
||||
BETRIEB in Schwierigkeiten.
|
||||
|
||||
→ **Empfehlung: automatische lokale Kopie jeder Rechnung, auch im Online-Betrieb.** Dann bleibt der
|
||||
Satz „die Wahrheit sind die PDFs" wahr, und der Betrieb hat seine Unterlagen auch dann, wenn nichts
|
||||
mehr läuft. Das ist zugleich ein Verkaufsargument.
|
||||
|
||||
## 📄 Paperless als Archiv (Andreas, 06.09.2026) — löst die Aufbewahrungsfrage
|
||||
|
||||
> „ich dachte später an ein paperless — da muss eh alles rein"
|
||||
|
||||
**Das ist die bessere Antwort auf § 147 AO als eine Exportfunktion.** Läuft jede Rechnung
|
||||
automatisch in ein Dokumentensystem, liegt das Archiv **außerhalb** des Programms und überlebt
|
||||
sowohl einen abgeschalteten Server als auch das Programm selbst.
|
||||
|
||||
**Anbindung ist klein:** Paperless-ngx nimmt Dokumente über eine REST-Schnittstelle **oder über
|
||||
einen überwachten Ordner** entgegen — Datei hineinlegen, fertig. Für das Rechnungstool eine
|
||||
zusätzliche Zeile beim Speichern, kein Umbau.
|
||||
|
||||
**⭐ Und die Verschlagwortung ist schon gebaut:** Rechnungsnummer, Datum, Beträge, Art und Vorgang
|
||||
stehen bereits als maschinenlesbare Kenndaten im `/Subject` jeder PDF (das Steuerjournal liest sie
|
||||
von dort). Daraus kann das Archiv seine Schlagworte ziehen — ohne Abtippen, ohne Texterkennung.
|
||||
|
||||
⚠️ **Ehrlicher Vorbehalt:** Ein Dokumentensystem macht **nicht automatisch GoBD-konform**. Es
|
||||
erfüllt den Kern (Belege bleiben unverändert und vollständig), aber dazu gehört eine
|
||||
**Verfahrensdokumentation** — wie Belege entstehen, wohin sie laufen, wer was darf. Die schreibt
|
||||
kein Programm. Ein großer Teil davon steht allerdings schon in den Aufgabendateien und
|
||||
Commit-Texten dieser Projekte.
|
||||
|
||||
## 🔑 Die API ist der Schlüssel zu allem (Andreas, 06.09.2026)
|
||||
|
||||
> „das tool schickt es an die api, die api verteilt das alles. die api ist der schlüssel zu allem —
|
||||
> wie beim gta fivem server"
|
||||
|
||||
**Das ist seine eigene Serverregel, angewandt:** *Der Wille kommt vom Client, gerechnet wird auf dem
|
||||
Server.* (Vgl. PcoreLife: nichts im Client, was Serversachen übernimmt.)
|
||||
|
||||
| | Client (Tool/Tablet) | Server (API) |
|
||||
|---|---|---|
|
||||
| Was | „Rechnung für diese Leistungen an diesen Kunden" | Nummer vergeben, Beträge und Steuer rechnen, PDF erzeugen |
|
||||
| Danach | zeigt an, was zurückkommt | verteilt: MySQL · Paperless · Mailversand |
|
||||
|
||||
### ⭐ Folge 1: `berechnung.py` zieht auf den Server
|
||||
|
||||
Heute steht dort ausdrücklich *„die einzige Stelle, an der gerechnet wird"*. Zentral gehört sie auf
|
||||
den Server.
|
||||
|
||||
**Das löst nebenbei die offene Frage aus `AUFGABE_KUNDENSTAMM_UND_WEB.md`:** die zwei Steuerwelten
|
||||
(deutsche Bruttopreise 7/19 % gegen polnische Nettopreise mit Reverse Charge). Weiß der Server,
|
||||
welcher Betrieb anfragt, weiß er auch, wie zu rechnen ist. **Der Client muss davon nichts wissen —
|
||||
und die Frage „beide Welten in einem Programm oder zwei Fassungen" erledigt sich.**
|
||||
|
||||
### ⭐ Die Aufgabenteilung im Einzelnen (Andreas, 06.09.2026)
|
||||
|
||||
> „das tool schickt nur die metadaten, der server rendert die pdf und das tool bekommt den link.
|
||||
> oder halt die vorschau muss es selber machen — das auf dem server ist dumm. und paperless macht
|
||||
> den rest: archivieren und senden an die mitgegebene e-mail, und speichert das unter der
|
||||
> kundennummer."
|
||||
|
||||
| Wer | Was |
|
||||
|---|---|
|
||||
| **Tool** | schickt die Metadaten · zeichnet die **Vorschau lokal** · bekommt den Link zum fertigen PDF |
|
||||
| **Server (API)** | vergibt die Nummer · rechnet · rendert das endgültige PDF · **verschickt per SMTP** · verteilt |
|
||||
| **Paperless** | archiviert · verschlagwortet (Kundennummer als Korrespondent/Schlagwort) |
|
||||
|
||||
**Vorschau lokal ist richtig:** ein Netzweg pro Tastendruck wäre sofort träge.
|
||||
|
||||
### ⚠️ Die Regel, die dazugehört: EIN Zeichner, zwei Ausführungsorte
|
||||
|
||||
Zeichnet das Tool die Vorschau und der Server das endgültige Blatt, gibt es **zwei Zeichner — und
|
||||
zwei Zeichner driften auseinander**. Genau davor schützt sich der heutige Code bereits:
|
||||
`berechnung.py` wird von Oberfläche UND PDF benutzt, *„damit Bildschirm und Ausdruck garantiert
|
||||
identisch sind"*.
|
||||
|
||||
Eine Vorschau, die nicht dem entspricht, was hinausgeht, ist **schlimmer als keine** — sie erzeugt
|
||||
Vertrauen, das nicht gerechtfertigt ist. (Und die Vorschau war seine ausdrückliche Begründung für
|
||||
das Ganze: *„sonst haben die den storno-aufwand"*.)
|
||||
|
||||
→ **`pdf_renderer.py` und `berechnung.py` bleiben EINE gemeinsame Bibliothek**, die an zwei Orten
|
||||
ausgeführt wird. Kein zweiter Code.
|
||||
|
||||
### ⚠️ Korrektur: Paperless archiviert, es VERSCHICKT NICHT
|
||||
|
||||
Paperless-ngx nimmt Dokumente entgegen, erkennt Text, verschlagwortet und lagert ein. **Rechnungen
|
||||
an Kunden mailen kann es nicht.** Der Versand per SMTP gehört auf den Server / in die API.
|
||||
|
||||
Die Ablage unter der Kundennummer geht dagegen sauber — Paperless kennt Korrespondenten und
|
||||
Schlagworte, die Kundennummer passt in beides.
|
||||
|
||||
### ⛔ Verworfen: PDF auf dem Client zeichnen und hochladen
|
||||
|
||||
Andreas hatte es erwogen (*„ob das schlau ist, weiß ich nicht"*). **Nein**, aus einem Grund:
|
||||
|
||||
Zeichnet der Client den endgültigen Beleg, entscheidet der Client, was darauf steht — und der
|
||||
Server archiviert blind, was ankommt. Er könnte nicht mehr garantieren, dass zur Nummer, die er
|
||||
vergeben hat, auch das Blatt gehört, das er ablegt. Eine ältere oder veränderte Fassung des Tools
|
||||
erzeugte Dokumente, die niemand prüft.
|
||||
|
||||
**Der Beleg entsteht dort, wo auch die Nummer entsteht.** Dazu käme: jede künftige Oberfläche
|
||||
(Tablet, Browser) müsste den ganzen Renderer mitschleppen.
|
||||
|
||||
Die **Vorschau** bleibt lokal — sie ist kein Beleg: ohne Nummer, mit Wasserzeichen, wie heute.
|
||||
|
||||
### ⭐ Kundenstamm offline: ein ZWISCHENSPEICHER, kein Sync
|
||||
|
||||
Seine Frage: *„wenn ein kunde eine kundennummer bekommt, wie willst die wiederfinden? musst die api
|
||||
auch abfragen dafür — heißt, ohne internet wird da eh nichts. sonst musst wieder alles lokal und
|
||||
online speichern, um das schnell zu halten, und dann nur eine art rsync raufpacken."*
|
||||
|
||||
**Der Instinkt stimmt — nur ist es kein Sync, sondern ein Spiegel.** Der Unterschied ist keine
|
||||
Wortklauberei:
|
||||
|
||||
| | bedeutet | Folge |
|
||||
|---|---|---|
|
||||
| **Sync** | beide Seiten dürfen ändern | braucht Konfliktauflösung — wer gewinnt bei doppelter Änderung? |
|
||||
| **Spiegel** | nur der Server besitzt die Daten, der Client hält eine Lesekopie | **keine Konflikte**, nur ein Stand, der mal älter ist |
|
||||
|
||||
**Und die Datenmenge passt:** Kunden sind wenige und ändern sich selten — anders als Rechnungen.
|
||||
Den ganzen Kundenstamm lokal zu halten kostet nichts und macht die Suche sofort schnell, auch ohne
|
||||
Netz.
|
||||
|
||||
**Sein Wort dafür: „eine Art Zwischenspeicher"** — und das ist ehrlicher als „Spiegel", weil es
|
||||
schon sagt, dass er veralten darf.
|
||||
|
||||
**Zwei Dinge braucht er, sonst wird er selbst zur Fehlerquelle:**
|
||||
|
||||
1. **Er muss sein Alter zeigen.** Klein in der Oberfläche: *„Kundendaten vom 06.09.2026, 15:40"*.
|
||||
Sonst sucht jemand einen Kunden, findet ihn nicht, legt ihn neu an — dabei war er nur seit
|
||||
gestern nicht heruntergeladen. **Ein Zwischenspeicher, dem man sein Alter nicht ansieht, wird
|
||||
irgendwann für die Wahrheit gehalten.**
|
||||
2. **Er darf nie Quelle für etwas sein, das geschrieben wird.** Lesen und Suchen: ja. Eine Nummer
|
||||
daraus vergeben: nie. Aufgefrischt wird beim Start (wenn Netz da ist) und auf Knopfdruck.
|
||||
|
||||
**Dieselbe Regel wie bei den Rechnungsnummern:** Ein offline angelegter Kunde bekommt seine Nummer
|
||||
**erst vom Server** — bis dahin ist er ein Entwurf. Sonst vergeben zwei Tablets dieselbe
|
||||
Kundennummer.
|
||||
|
||||
### Folge 2: der Client wird schlank
|
||||
|
||||
Das Tablet beim Kunden braucht kein PDF-Werkzeug, keine Steuerlogik, keinen Nummernkreis. Ein Weg
|
||||
hinein, mehrere hinaus.
|
||||
|
||||
### Der Preis
|
||||
|
||||
**Ohne Server geht nichts außer Entwürfen** (siehe Punkt 1). Bei FiveM ist das dasselbe — nur hängt
|
||||
hier ein Beleg dran, den jemand zehn Jahre aufbewahren muss.
|
||||
|
||||
### Sicherheit, weil „Schlüssel zu allem" wörtlich zutrifft
|
||||
|
||||
Wenn ein Schlüssel alles öffnet, muss er: **je Installation** vergeben sein (nicht einer für alle),
|
||||
**widerrufbar**, **nur über TLS** unterwegs, und die API muss **Missbrauch begrenzen**
|
||||
(Rate-Limit). Sonst ist ein einziger abhandengekommener Schlüssel der Zugang zu allen Gästedaten.
|
||||
|
||||
## 5. Offene Entscheidungen
|
||||
|
||||
- Wer betreibt den Server, und für wen? (nur Bartl, oder mehrere Betriebe)
|
||||
- Ein Mandant je Datenbank oder ein gemeinsames Schema mit Mandantenspalte?
|
||||
- Was passiert mit dem lokalen Betrieb — bleibt SQLite als Offline-Fassung, oder wird alles zentral?
|
||||
- Wie kommt der Bestand in die MySQL, ohne dass Nummern doppelt vergeben werden?
|
||||
|
||||
---
|
||||
|
||||
**Nichts davon beginnen, bevor die Punkte 1 bis 3 entschieden sind** — sie bestimmen den Aufbau,
|
||||
nicht die Ausstattung.
|
||||
129
AUFGABE_PREISVORSCHLAG.md
Normal file
|
|
@ -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
|
||||
107
AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md
Normal file
|
|
@ -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).
|
||||
48
Diagnose.spec
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['app.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
# Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start
|
||||
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
|
||||
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
|
||||
# aus als hier.
|
||||
datas=[('assets/programm.ico', 'assets'),
|
||||
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
|
||||
('assets/fonts', 'assets/fonts'),
|
||||
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe
|
||||
# nur ihren Text, aber der Anordnen-Griff fehlt.
|
||||
('assets/icons', 'assets/icons')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='Diagnose',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
icon='assets/programm.ico',
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
127
Installer.iss
Normal file
|
|
@ -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:
|
||||
; <Name>-<Nummer>.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}"
|
||||
25
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.
|
||||
|
||||
|
|
|
|||
48
Rechnungstool.spec
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['app.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
# Roboto MUSS mit in die exe: sie wird nicht installiert, sondern beim Start
|
||||
# nur fuer den Prozess geladen (theme.schrift_vorbereiten). Fehlt sie, faellt
|
||||
# das Programm still auf Segoe UI zurueck - dann sieht es beim Kunden anders
|
||||
# aus als hier.
|
||||
datas=[('assets/programm.ico', 'assets'),
|
||||
('assets/programm_logo.png', 'assets'),('assets/logo.png', 'assets'),
|
||||
('assets/fonts', 'assets/fonts'),
|
||||
# Die Icons (Material Symbols vom Hub) - ohne sie zeigen die Knoepfe
|
||||
# nur ihren Text, aber der Anordnen-Griff fehlt.
|
||||
('assets/icons', 'assets/icons')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='Rechnungstool',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
icon='assets/programm.ico',
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
24
Updater.spec
Normal file
|
|
@ -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',
|
||||
)
|
||||
185
api.py
|
|
@ -18,11 +18,38 @@ 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:
|
||||
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
|
||||
|
|
@ -42,6 +69,8 @@ hinueber.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import secrets
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
|
|
@ -50,6 +79,33 @@ 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):
|
||||
|
|
@ -66,28 +122,58 @@ class _Handler(socketserver.StreamRequestHandler):
|
|||
if not zeile:
|
||||
return
|
||||
try:
|
||||
was = str(json.loads(zeile).get("was", ""))
|
||||
nachricht = json.loads(zeile)
|
||||
was = str(nachricht.get("was", ""))
|
||||
except (ValueError, AttributeError):
|
||||
was = ""
|
||||
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):
|
||||
def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
|
||||
self.melden = bei_push
|
||||
self.aktion = aktion
|
||||
self.schluessel = schluessel
|
||||
super().__init__(adresse, _Handler)
|
||||
|
||||
|
||||
|
|
@ -99,19 +185,30 @@ class MiniApi:
|
|||
und holt sie im Fenster mit after() ab.
|
||||
"""
|
||||
|
||||
def __init__(self, programm, bei_push):
|
||||
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):
|
||||
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
||||
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)
|
||||
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
|
||||
|
|
@ -150,12 +247,14 @@ class MiniApi:
|
|||
self.port = 0
|
||||
|
||||
|
||||
def _reden(port, was, zeitgrenze=1.5):
|
||||
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({"was": was}) + "\n").encode("utf-8"))
|
||||
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):
|
||||
|
|
@ -192,3 +291,69 @@ def anklopfen(buch, programm):
|
|||
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()
|
||||
|
|
|
|||
BIN
assets/fonts/Roboto-Italic.ttf
Normal file
BIN
assets/fonts/Roboto.ttf
Normal file
BIN
assets/icons/check.png
Normal file
|
After Width: | Height: | Size: 327 B |
BIN
assets/icons/close.png
Normal file
|
After Width: | Height: | Size: 326 B |
BIN
assets/icons/dashboard_customize.png
Normal file
|
After Width: | Height: | Size: 372 B |
BIN
assets/icons/drag_indicator.png
Normal file
|
After Width: | Height: | Size: 522 B |
BIN
assets/icons/save.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
assets/programm.ico
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/programm_logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
86
assets/programm_logo.svg
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="512" height="512" style="fill:none;"/>
|
||||
<clipPath id="_clip1">
|
||||
<rect id="Artboard11" serif:id="Artboard1" x="0" y="0" width="512" height="512"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<g transform="matrix(1.71181,0,0,1.71181,-182.326,-182.373)">
|
||||
<path d="M222.131,215.66L222.13,208.593L238.702,219.494L222.133,230.399L222.131,215.66ZM216.151,130.644C218.188,126.804 220.833,123.524 224.142,119.685C234.021,108.226 250.634,106.769 256,106.556C265.488,106.18 280.606,111.644 287.029,118.435C289.041,120.563 302.548,133.524 301.132,155.668L293.14,155.646C293.608,141.507 288.592,132.589 281.714,125.1C276.056,118.94 266.39,114.408 256,114.561C242.327,114.761 218.593,124.295 219.086,155.601L210.89,155.613C211.196,148.263 211.937,142.778 213.154,138.311C205.649,135.18 198.203,136.466 193.569,139.394C183.234,145.923 181.557,158.851 184.735,165.224L184.044,165.286C162.105,162.842 155.274,179.91 154.506,190.518C153.545,203.798 166.473,214.688 176.844,215.811L222.131,215.66L222.13,223.927L175.884,223.738C156.922,221.252 145.975,205.354 146.25,190.467C146.541,174.685 156.471,160.659 175.619,157.066C176.48,145.398 181.102,139.165 187.459,134.119C195.54,127.705 207.218,127.153 216.151,130.644Z" style="fill:rgb(18,89,109);"/>
|
||||
<g id="Rechts">
|
||||
<g transform="matrix(1.08045,0,0,1.08946,-26.4473,-31.9847)">
|
||||
<path d="M311.686,357.525L361.495,357.613L311.693,401.686L311.686,357.525Z" style="fill:none;stroke:rgb(38,38,40);stroke-width:4.49px;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:1;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.681839,0,0,1.4522,106.655,-49.5232)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.556676,0,0,1.50959,118.792,-61.5356)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.676877,0,0,2.05605,46.3212,-170.669)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(4.48954e-17,0.733197,-1.08299,6.63138e-17,560.556,13.6836)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.08855e-17,0.504399,-1.08299,6.63138e-17,560.556,115.222)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.13346e-17,0.511732,-1.08299,6.63138e-17,560.556,134.957)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.09753e-17,0.505865,-1.08299,6.63138e-17,560.556,159.477)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.09753e-17,0.505865,-1.08299,6.63138e-17,560.556,181.958)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.71495e-17,0.606698,-0.691771,4.23587e-17,450.899,169.989)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<g transform="matrix(6.01625e-17,0.982528,-0.691771,4.23587e-17,450.899,63.3131)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
<path d="M357.833,218.242L357.84,256L358.068,366.277L366.192,359.225L366.228,208.007C364.834,210.529 359.069,217.267 357.833,218.242Z" style="fill:rgb(38,38,40);"/>
|
||||
<g transform="matrix(-0.988012,0,0,1,508.931,0)">
|
||||
<path d="M166.719,248.342L233.736,247.827C241.376,246.629 246.996,242.211 248.7,233.593L249.007,178.945L236.779,178.962L256,154.176L256,140.213L219.977,187.353L240.538,187.306L240.516,232.777C239.978,235.67 236.567,240.101 232.286,239.877L166.716,240.019L166.719,248.342Z" style="fill:rgb(38,38,40);"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M290.321,215.66L335.608,215.811C345.979,214.688 358.907,203.798 357.946,190.518C357.178,179.91 350.347,162.842 328.408,165.286L327.717,165.224C330.895,158.851 329.218,145.923 318.883,139.394C312.886,135.605 304.481,133.711 292.087,142.516L288.929,134.747C297.75,127.533 314.633,125.896 324.993,134.119C331.35,139.165 335.972,145.398 336.833,157.066C355.981,160.659 365.911,174.685 366.202,190.467C366.477,205.354 355.53,221.252 336.568,223.738L290.322,223.927L290.321,215.66ZM290.321,215.66L290.319,230.399L273.75,219.494L290.322,208.593L290.321,215.66Z" style="fill:rgb(38,38,40);"/>
|
||||
<g id="Links">
|
||||
<g transform="matrix(1,0,0,-1,-211.587,578.778)">
|
||||
<path d="M357.519,173.141L357.84,256L357.465,370.771L365.776,360.268L365.738,173.141L357.519,173.141Z" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(6.01625e-17,0.982528,-1.28255,7.85334e-17,513.134,63.3131)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.71495e-17,0.606698,-1.12029,6.8598e-17,480.729,169.965)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.09753e-17,0.505865,-1.12029,6.8598e-17,480.729,181.968)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(-1.06695e-18,0.505865,-1.12029,-1.08738e-16,480.729,159.483)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.13346e-17,0.511732,-1.12032,6.85998e-17,480.738,135.022)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(3.08855e-17,0.504399,-1.12032,6.85998e-17,480.738,115.184)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(4.48954e-17,0.733197,-1.12032,6.85998e-17,480.738,13.6631)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.733197,8.97907e-17,-2.14404e-16,-1.75074,422.168,730.675)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.733197,8.97907e-17,-2.14404e-16,-1.75074,441.103,730.675)">
|
||||
<rect x="339.843" y="199.711" width="8.568" height="80.586" style="fill:rgb(14,84,103);"/>
|
||||
</g>
|
||||
<path d="M166.719,248.342L233.736,247.827C241.376,246.629 246.996,242.211 248.7,233.593L249.007,178.945L236.779,178.962L256,154.176L256,140.213L219.977,187.353L240.538,187.306L240.516,232.777C239.978,235.67 236.567,240.101 232.286,239.877L166.777,240.019L166.719,248.342Z" style="fill:rgb(14,79,101);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
138
bauen.py
Normal file
|
|
@ -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()
|
||||
117
bilder.py
Normal file
|
|
@ -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
|
||||
90
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)
|
||||
|
|
@ -124,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).
|
||||
|
|
@ -135,8 +189,16 @@ 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,
|
||||
|
|
|
|||
151
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("<Escape>", lambda e: d.destroy())
|
||||
d.bind("<Return>", 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)
|
||||
|
|
@ -175,11 +189,11 @@ class EinstellungenMixin:
|
|||
name = v_leistung.get().strip()
|
||||
preis = parse_num(v_preis.get())
|
||||
if not name:
|
||||
messagebox.showwarning("Bezeichnung fehlt",
|
||||
self.melden_warnung("Bezeichnung fehlt",
|
||||
"Bitte eine Bezeichnung eintragen.", parent=d)
|
||||
return
|
||||
if preis <= 0:
|
||||
messagebox.showwarning("Preis fehlt",
|
||||
self.melden_warnung("Preis fehlt",
|
||||
"Bitte einen Monatspreis groesser als 0 eintragen.",
|
||||
parent=d)
|
||||
return
|
||||
|
|
@ -246,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)
|
||||
|
|
@ -287,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)
|
||||
|
|
@ -333,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)
|
||||
|
|
@ -348,53 +365,105 @@ 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")
|
||||
|
||||
|
|
@ -407,7 +476,7 @@ class EinstellungenMixin:
|
|||
jahr = self.cfg.get("rechnung_jahr")
|
||||
zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0)
|
||||
hoch = self.hoechster_stand(jahr)
|
||||
messagebox.showinfo(
|
||||
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"
|
||||
|
|
@ -454,7 +523,7 @@ class EinstellungenMixin:
|
|||
# (§ 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:
|
||||
messagebox.showwarning(
|
||||
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 "
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
308
flaeche.py
Normal file
|
|
@ -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("<Button-1>", lambda e, n=name: self._greifen(n))
|
||||
kopf.bind("<B1-Motion>", lambda e, n=name: self._ziehen(e, n))
|
||||
kopf.bind("<ButtonRelease-1>", 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)
|
||||
55
gemeinsam.py
|
|
@ -55,6 +55,9 @@ CREATE TABLE IF NOT EXISTS nummern (
|
|||
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);
|
||||
|
|
@ -83,7 +86,51 @@ CREATE TABLE IF NOT EXISTS nachrichten (
|
|||
|
||||
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
|
||||
"steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu",
|
||||
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad")
|
||||
"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):
|
||||
|
|
@ -197,6 +244,12 @@ class Nummernbuch:
|
|||
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 -------------------------------------------------------------
|
||||
|
|
|
|||
109
hinweis.py
Normal file
|
|
@ -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}).")
|
||||
62
icons.py
Normal file
|
|
@ -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
|
||||
80
korrektur.py
|
|
@ -23,6 +23,7 @@ import tkinter as tk
|
|||
from tkinter import ttk, messagebox
|
||||
|
||||
import berechnung
|
||||
import theme
|
||||
import config
|
||||
import pdf_renderer
|
||||
import berechnung
|
||||
|
|
@ -68,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 – "
|
||||
|
|
@ -92,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 "
|
||||
|
|
@ -103,7 +104,7 @@ 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.")
|
||||
|
|
@ -136,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.")
|
||||
|
|
@ -161,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
|
||||
|
|
@ -203,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 = []
|
||||
|
|
@ -341,13 +342,21 @@ class KorrekturMixin:
|
|||
|
||||
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.
|
||||
|
||||
Rueckgabe: ("neu", None) | ("vorhanden", "2026-338") | None (Abbruch).
|
||||
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.
|
||||
|
|
@ -367,7 +376,7 @@ class KorrekturMixin:
|
|||
rahmen = ttk.Frame(d, padding=18)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
ttk.Label(rahmen, text=f"Storno zu Rechnung {alt_nummer}",
|
||||
font=("Segoe UI", 13, "bold")).pack(anchor="w")
|
||||
font=(theme.SCHRIFT_NAME, 13, "bold")).pack(anchor="w")
|
||||
|
||||
# ---- Schritt 1: A oder B ------------------------------------------
|
||||
schritt1 = ttk.Frame(rahmen)
|
||||
|
|
@ -376,15 +385,21 @@ class KorrekturMixin:
|
|||
"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=("Segoe UI", 10, "bold")).pack(anchor="w")
|
||||
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=("Segoe UI", 10, "bold")).pack(anchor="w")
|
||||
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, 0))
|
||||
"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)
|
||||
|
|
@ -397,7 +412,7 @@ class KorrekturMixin:
|
|||
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=("Segoe UI", 9))
|
||||
font=(theme.SCHRIFT_NAME, 9))
|
||||
stand.pack(anchor="w", pady=(8, 0))
|
||||
|
||||
def suchen(*_a):
|
||||
|
|
@ -446,6 +461,10 @@ class KorrekturMixin:
|
|||
wahl["art"] = "neu"
|
||||
d.destroy()
|
||||
|
||||
def nimm_nur():
|
||||
wahl["art"] = "nur"
|
||||
d.destroy()
|
||||
|
||||
def uebernehmen():
|
||||
nr = suchen()
|
||||
if not nr:
|
||||
|
|
@ -453,7 +472,7 @@ class KorrekturMixin:
|
|||
stand.configure(text="Bitte die Nummer der bleibenden Rechnung eintragen.",
|
||||
fg="#a00000")
|
||||
return
|
||||
if gefunden["leicht"] and not messagebox.askyesno(
|
||||
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 "
|
||||
|
|
@ -484,6 +503,8 @@ class KorrekturMixin:
|
|||
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")
|
||||
|
||||
|
|
@ -491,13 +512,14 @@ class KorrekturMixin:
|
|||
d.protocol("WM_DELETE_WINDOW", d.destroy)
|
||||
d.bind("<Escape>", lambda _e: d.destroy())
|
||||
d.update_idletasks()
|
||||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||||
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
|
||||
theme.mittig(d, self)
|
||||
d.grab_set()
|
||||
self.wait_window(d)
|
||||
if wahl["art"] == "vorhanden":
|
||||
return "vorhanden", wahl["nummer"]
|
||||
return ("neu", None) if wahl["art"] == "neu" else None
|
||||
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."""
|
||||
|
|
@ -514,7 +536,7 @@ class KorrekturMixin:
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -528,6 +550,11 @@ class KorrekturMixin:
|
|||
# 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())
|
||||
|
|
@ -544,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) +
|
||||
|
|
@ -579,6 +606,9 @@ class KorrekturMixin:
|
|||
# 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,
|
||||
}
|
||||
|
|
@ -589,14 +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
|
||||
|
||||
# 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 == "vorhanden" else {
|
||||
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 ""),
|
||||
|
|
@ -615,7 +645,7 @@ class KorrekturMixin:
|
|||
if art_folge == "vorhanden":
|
||||
self._storno_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
if messagebox.askyesno(
|
||||
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 "
|
||||
|
|
@ -632,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 "
|
||||
|
|
|
|||
169
menueleiste.py
Normal file
|
|
@ -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("<Enter>", lambda e, w=lbl: w.configure(bg=self.f.col_sel))
|
||||
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.f.col_bg))
|
||||
lbl.bind("<Button-1>",
|
||||
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("<Enter>",
|
||||
lambda e, w=eintrag: w.configure(bg=self.f.col_sel))
|
||||
eintrag.bind("<Leave>",
|
||||
lambda e, w=eintrag: w.configure(bg=self.f.col_field))
|
||||
eintrag.bind("<Button-1>", 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("<Button-1>", self._klick_ausserhalb, add="+")
|
||||
pop.bind("<Escape>", 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 `<Button-1>`-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
|
||||
|
|
@ -6,6 +6,14 @@ Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
|
|||
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.
|
||||
|
|
@ -25,6 +33,8 @@ import os
|
|||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
|
||||
import theme
|
||||
|
||||
import gemeinsam
|
||||
|
||||
BREITE = 780
|
||||
|
|
@ -39,6 +49,18 @@ class OrdnerWahl(tk.Toplevel):
|
|||
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)
|
||||
|
|
@ -47,7 +69,7 @@ class OrdnerWahl(tk.Toplevel):
|
|||
breite = BREITE - 60
|
||||
|
||||
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
|
||||
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
|
||||
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 "
|
||||
|
|
@ -56,10 +78,10 @@ class OrdnerWahl(tk.Toplevel):
|
|||
"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="#a00000",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
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 die Programme liegen.").grid(
|
||||
"der Ordner, in dem auch die Rechnungen liegen.").grid(
|
||||
row=2, column=0, sticky="w", pady=(0, 14))
|
||||
|
||||
wahl = ttk.Frame(rahmen)
|
||||
|
|
@ -67,11 +89,18 @@ class OrdnerWahl(tk.Toplevel):
|
|||
wahl.columnconfigure(0, weight=1)
|
||||
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
|
||||
self.feld.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
|
||||
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
|
||||
# "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",
|
||||
font=("Segoe UI", 9))
|
||||
bg=self._bg,
|
||||
font=(theme.SCHRIFT_NAME, 9))
|
||||
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
|
|
@ -89,9 +118,8 @@ class OrdnerWahl(tk.Toplevel):
|
|||
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
|
||||
self.update_idletasks()
|
||||
hoehe = max(self.winfo_reqheight(), 300)
|
||||
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
|
||||
y = master.winfo_rooty() + 90
|
||||
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
|
||||
self.geometry(f"{BREITE}x{hoehe}")
|
||||
theme.mittig(self, master)
|
||||
self.minsize(BREITE, hoehe)
|
||||
self.grab_set()
|
||||
self.lift()
|
||||
|
|
@ -127,12 +155,12 @@ class OrdnerWahl(tk.Toplevel):
|
|||
"""
|
||||
ordner = self.pfad.get().strip()
|
||||
if not ordner:
|
||||
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
|
||||
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="#a00000")
|
||||
self.stand.configure(text=grund, fg=self._warn)
|
||||
self.ok.state(["disabled"])
|
||||
return
|
||||
self.ok.state(["!disabled"])
|
||||
|
|
@ -144,27 +172,39 @@ class OrdnerWahl(tk.Toplevel):
|
|||
self.stand.configure(
|
||||
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
|
||||
"das ist der Ordner des anderen Programms. Genau richtig.",
|
||||
fg="#1f5c3d")
|
||||
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="#444444")
|
||||
"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:
|
||||
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
|
||||
parent=self)
|
||||
self._melden("Ordner geht nicht", grund or "Bitte einen Ordner wählen.")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
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",
|
||||
icon="question", default="yes", parent=self):
|
||||
"Nein = zurück zur Auswahl"):
|
||||
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
|
||||
self.feld.focus_set()
|
||||
return
|
||||
|
|
|
|||
396
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):
|
||||
|
|
@ -265,12 +297,8 @@ def _montage_zeile(story, st, rechnung, kopf):
|
|||
story.append(dreier)
|
||||
|
||||
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
||||
|
||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
||||
"""
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
|
||||
# Absenderzeile (klein, unterstrichen)
|
||||
story.append(Paragraph(
|
||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
|
|
@ -358,15 +386,12 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
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.
|
||||
# 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")
|
||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||
story.append(Paragraph(titel, st["h"]))
|
||||
if angebot:
|
||||
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
|
||||
|
|
@ -402,20 +427,14 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
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.
|
||||
# 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 gesamtrechnung_betont and not angebot:
|
||||
if rechnung.aufschlaege and not angebot:
|
||||
summen_rows.append([
|
||||
Paragraph("Zwischensumme:", st["info_l"]),
|
||||
Paragraph(eur(rechnung.brutto), st["cell_r"]),
|
||||
|
|
@ -427,7 +446,7 @@ 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("Voraussichtlicher Gesamtbetrag:" if angebot else
|
||||
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
|
||||
|
|
@ -446,14 +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.
|
||||
# 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"]))
|
||||
elif not gesamtrechnung_betont:
|
||||
else:
|
||||
for a in rechnung.aufschlaege:
|
||||
if not a.bezeichnung:
|
||||
continue
|
||||
|
|
@ -471,75 +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 ""
|
||||
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. "
|
||||
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
|
||||
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 <b>nicht</b> um eine zusätzliche "
|
||||
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
|
||||
"ihre Nummer.", st["dank"]))
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
elif angebot:
|
||||
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
|
||||
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
|
||||
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
|
||||
block.append(Paragraph(
|
||||
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
|
||||
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
|
||||
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
|
||||
"stehen wir gern zur Verfügung.",
|
||||
st["dank"]))
|
||||
else:
|
||||
if art == "korrektur" and kopf.get("ersetzt"):
|
||||
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
|
||||
if kopf.get("storno_nummer"):
|
||||
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
|
||||
block.append(Paragraph(hinweis + ".", st["dank"]))
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
if texte.get("dank2"):
|
||||
block.append(Paragraph(texte["dank2"], st["dank"]))
|
||||
if texte.get("hinweis"):
|
||||
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
|
||||
story.append(KeepTogether(block))
|
||||
f"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 <b>nicht</b> 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):
|
||||
|
|
@ -631,6 +658,7 @@ def _kenndaten(rechnung, kopf):
|
|||
"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", ""),
|
||||
|
|
@ -650,56 +678,44 @@ def _kenndaten(rechnung, kopf):
|
|||
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=({"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, gesamtrechnung_betont=False) # Kunde
|
||||
# Ein Angebot bekommt KEIN Blatt fuers Amt: gemeldet wird eine
|
||||
# Uebernachtung erst, wenn sie abgerechnet ist.
|
||||
if rechnung.aufschlaege and kopf.get("art") != "angebot":
|
||||
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
|
||||
|
|
|
|||
|
|
@ -32,16 +32,6 @@ QUELLE = r"C:\claude\rechnungstool"
|
|||
JOURNAL = r"C:\claude\beherbergungssteuer"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
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)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
||||
|
|
@ -63,12 +53,17 @@ 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}
|
||||
|
||||
|
|
@ -80,6 +75,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 = lambda *a, **k: None
|
||||
warnungen = []
|
||||
|
|
|
|||
167
pruef_api.py
Normal file
|
|
@ -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")
|
||||
137
pruef_bilder.py
Normal file
|
|
@ -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")
|
||||
269
pruef_darstellung.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -27,6 +27,10 @@ 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.
|
||||
|
|
@ -37,6 +41,8 @@ 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,16 +26,6 @@ QUELLE = r"C:\claude\rechnungstool"
|
|||
JOURNAL = r"C:\claude\beherbergungssteuer"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
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)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
||||
|
|
@ -64,6 +54,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
|
|||
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}
|
||||
|
|
@ -77,6 +68,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 = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
||||
|
|
|
|||
137
pruef_flaeche.py
Normal file
|
|
@ -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")
|
||||
155
pruef_freigabe.py
Normal file
|
|
@ -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")
|
||||
149
pruef_gemeinsam_automatisch.py
Normal file
|
|
@ -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")
|
||||
184
pruef_hinweis.py
Normal file
|
|
@ -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")
|
||||
190
pruef_installation.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -34,6 +34,10 @@ 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)
|
||||
|
||||
|
|
@ -49,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():
|
||||
|
|
@ -62,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 = {}
|
||||
|
|
@ -91,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 ""))
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ 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)
|
||||
|
||||
|
|
@ -48,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():
|
||||
|
|
@ -61,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,
|
||||
|
|
@ -86,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
|
||||
|
|
|
|||
184
pruef_menueleiste.py
Normal file
|
|
@ -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 `<Button-1>`-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("<Button-1>", x=w.winfo_width() // 2,
|
||||
y=w.winfo_height() // 2, rootx=x, rooty=y)
|
||||
else:
|
||||
ziel.event_generate("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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")
|
||||
|
|
@ -37,6 +37,10 @@ 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)
|
||||
|
||||
|
|
@ -45,6 +49,7 @@ 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():
|
||||
|
|
@ -57,16 +62,6 @@ _wecker = threading.Timer(150, _reissleine)
|
|||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
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 = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}
|
||||
|
|
@ -82,6 +77,11 @@ def askyesno(titel, text, **kw):
|
|||
|
||||
|
||||
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 "")
|
||||
|
|
@ -155,7 +155,10 @@ pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Mo
|
|||
from pypdf import PdfReader # noqa: E402
|
||||
|
||||
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
|
||||
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})")
|
||||
# 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")
|
||||
|
|
@ -166,9 +169,8 @@ pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
|
|||
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")
|
||||
amt_seite = seiten[1].replace("\n", " ")
|
||||
pruefe("Beherbergungssteuer" not in amt_seite,
|
||||
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -23,16 +23,6 @@ import threading
|
|||
TOOL = r"C:\claude\rechnungstool"
|
||||
JOURNAL = r"C:\claude\beherbergungssteuer"
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
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)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
|
|
@ -66,6 +56,7 @@ 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
|
||||
|
|
@ -100,6 +91,10 @@ 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)
|
||||
|
|
@ -118,6 +113,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
|
||||
|
||||
tool = A.RechnungsApp()
|
||||
|
|
|
|||
|
|
@ -22,16 +22,6 @@ import threading
|
|||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
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)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
|
|
@ -55,14 +45,21 @@ 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 "")
|
||||
|
|
@ -117,6 +114,15 @@ def knoepfe_von(w, aus=None):
|
|||
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():
|
||||
|
|
@ -204,7 +210,9 @@ pruefe(f.cfg.get("rechnung_zaehler") == 91,
|
|||
f.cfg["nummer_eingerichtet"] = False
|
||||
f.cfg["rechnung_jahr"] = JAHR
|
||||
config.save_config(f.cfg)
|
||||
A.TESTLAUF = False # den Erststart-Dialog wirklich zeigen
|
||||
# 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):
|
||||
|
|
@ -223,11 +231,30 @@ def erststart(zahl, erwartet_ja=True):
|
|||
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))
|
||||
|
|
|
|||
202
pruef_nur_storno.py
Normal file
|
|
@ -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)
|
||||
98
pruef_release.py
Normal file
|
|
@ -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 `<exe>.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 `<exe>.sha256` an.
|
||||
4. Die Summe darin ist die der angehaengten Datei - und die stimmt mit dem
|
||||
ueberein, was hier zuletzt gebaut wurde (release/<exe>).
|
||||
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")
|
||||
114
pruef_selbstbezug.py
Normal file
|
|
@ -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 <module>
|
||||
NameError: name 'self' is not defined
|
||||
|
||||
Im `if __name__ == "__main__"`-Block stand `self.melden_fehler(...)`. Dort gibt
|
||||
es kein `self` - das Fenster wird ja gerade NICHT gebaut. Beim Umbau auf die
|
||||
eigenen Meldungsfenster war die Zeile mitgezogen worden.
|
||||
|
||||
WARUM DAS KEINE SYNTAXPRUEFUNG FINDET
|
||||
-------------------------------------
|
||||
`ast.parse` und `py_compile` pruefen die FORM, nicht die Bedeutung. Ein
|
||||
undefinierter Name faellt erst auf, wenn die Zeile ausgefuehrt wird - und diese
|
||||
hier laeuft nur, wenn ein ZWEITES Fenster gestartet wird und sich das erste
|
||||
nicht nach vorn holen laesst. Also fast nie. Genau deshalb ist sie durch alle
|
||||
Pruefstaende gekommen.
|
||||
|
||||
WAS HIER GEPRUEFT WIRD
|
||||
----------------------
|
||||
Jede Verwendung des Namens `self` muss in einer Funktion stehen, die `self` auch
|
||||
als ersten Parameter hat. Alles andere ist ein Ueberbleibsel aus einer Methode.
|
||||
|
||||
Geprueft werden beide Programme - der Fehler war in einem, haette aber genauso
|
||||
im anderen stecken koennen.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
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")
|
||||
128
pruef_spaltenbreiten.py
Normal file
|
|
@ -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")
|
||||
133
pruef_start_exe.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -29,6 +29,10 @@ 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)
|
||||
|
||||
|
|
@ -44,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():
|
||||
|
|
@ -57,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 = {}
|
||||
|
|
@ -88,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 = []
|
||||
|
|
|
|||
220
pruef_updater.py
Normal file
|
|
@ -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")
|
||||
115
pruef_vorschau.py
Normal file
|
|
@ -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")
|
||||
47
pruefhelfer.py
Normal file
|
|
@ -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)
|
||||
103
pruefsteuerung.py
Normal file
|
|
@ -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(<Stueck>, 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")
|
||||
94
reiter.py
Normal file
|
|
@ -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
|
||||
`<<NotebookTabChanged>>` 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("<Button-1>", lambda _e, i=nr: self.select(i))
|
||||
knopf.bind("<Enter>", lambda _e, i=nr: self._schweben(i, True))
|
||||
knopf.bind("<Leave>", 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("<<NotebookTabChanged>>")
|
||||
|
||||
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)
|
||||
786
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,45 +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))
|
||||
style.configure("TNotebook.Tab", background=field, foreground=self.col_muted,
|
||||
bordercolor=self.col_line, padding=(16, 7),
|
||||
font=("Segoe UI", 10))
|
||||
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", bg), ("active", sel)],
|
||||
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", ("Segoe UI", 10, "bold"))],
|
||||
expand=[("selected", (1, 1, 1, 0))])
|
||||
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("<Return>", lambda _e: schliessen(vorgabe))
|
||||
else:
|
||||
ttk.Button(leiste, text="OK", style="Haupt.TButton",
|
||||
command=lambda: schliessen(True)).pack(side="right")
|
||||
d.bind("<Return>", lambda _e: schliessen(True))
|
||||
d.bind("<Escape>", 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"]
|
||||
|
|
|
|||
404
updater.py
Normal file
|
|
@ -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 `<exe>.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
|
||||
`<Name>-<Nummer>.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 ...")
|
||||
23
version.py
Normal file
|
|
@ -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"
|
||||
236
wasserzeichen.py
Normal file
|
|
@ -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)
|
||||