Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb04f5ad74 | ||
|
|
2b09d15cd0 | ||
|
|
5babfc3e2e | ||
|
|
08426b3d9b | ||
|
|
b5bf6f401f | ||
|
|
9794632ae4 | ||
|
|
3790b0a86f | ||
|
|
688dfb53c4 | ||
|
|
adbb035cf1 | ||
|
|
f7801f46f0 | ||
|
|
20876102d5 | ||
|
|
42e459ad26 | ||
|
|
8a391b8dfa |
44 changed files with 2481 additions and 456 deletions
23
.gitattributes
vendored
Normal file
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
|
||||
|
|
@ -108,21 +108,28 @@ kein Nebenbei — Menüs, Tabellen, Dialoge, Statusleiste. Vorher planen.
|
|||
|
||||
---
|
||||
|
||||
## ⚠️ Gefundener Altfehler — `pruef_wache` (Steuerrechnungstool) ⬜ OFFEN
|
||||
## ✅ 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` erwartet nach einem Push des Rechnungstools in der Statuszeile den Hinweis
|
||||
**„Rechnungstool meldet"**. Tatsächlich steht dort das Ergebnis des anschließenden Einlesens
|
||||
(`0 neu, 0 aktualisiert, 1 unverändert`; bei der Ausgangsmessung: `Nummernbuch: 1 Nummern
|
||||
eingetragen`). Das Einlesen **überschreibt die Meldung**, bevor der Nutzer sie lesen kann.
|
||||
`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.
|
||||
|
||||
**Zu klären:** Ist das ein Fehler im Programm (die Meldung soll stehen bleiben, das Ergebnis
|
||||
danebengehören) oder eine veraltete Erwartung im Prüfstand? Nicht nebenbei entscheiden — die
|
||||
Statuszeile ist das Einzige, was dem Nutzer sagt, dass die andere Anwendung etwas gemeldet hat.
|
||||
**Die Frage war:** Fehler im Programm oder veraltete Erwartung im Prüfstand?
|
||||
|
||||
Folgefehler derselben Stelle: „dann kommt keine Meldung dazu".
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
129
AUFGABE_PREISVORSCHLAG.md
Normal file
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
|
||||
222
Installer.iss
222
Installer.iss
|
|
@ -1,95 +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
|
||||
DefaultDirName={localappdata}\Programs\{#Name}
|
||||
DefaultGroupName={#Name}
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
|
||||
OutputDir=setup
|
||||
OutputBaseFilename=Rechnungstool-Setup-{#Version}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
; Das Programmsymbol - auch fuer den Installer selbst und den
|
||||
; Eintrag unter "Apps & Features".
|
||||
SetupIconFile=assets\programm.ico
|
||||
UninstallDisplayIcon={app}\{#ExeProgramm}
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
|
||||
; Erprobungsfassung - das steht auch im Programm selbst.
|
||||
AppComments=Erprobungsfassung. Benutzung auf eigene Gefahr.
|
||||
|
||||
[Languages]
|
||||
Name: "deutsch"; MessagesFile: "compiler:Languages\German.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Verknüpfung auf dem Desktop anlegen"; \
|
||||
GroupDescription: "Zusätzliche Verknüpfungen:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; Der Updater - das ist, was der Benutzer startet.
|
||||
Source: "dist\{#ExeUpdater}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; Das Programm selbst, mit der Nummer im Namen. Der Updater erkennt daran,
|
||||
; was installiert ist, und legt neue Fassungen daneben statt darueber.
|
||||
Source: "dist\{#ExeProgramm}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; Beide Verknuepfungen zeigen auf den UPDATER, nicht auf das Programm.
|
||||
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeUpdater}"
|
||||
Name: "{group}\{#Name} deinstallieren"; Filename: "{uninstallexe}"
|
||||
Name: "{userdesktop}\{#Name}"; Filename: "{app}\{#ExeUpdater}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#ExeUpdater}"; Description: "{#Name} jetzt starten"; \
|
||||
Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallDelete]
|
||||
; Nur die Buchfuehrung des Updaters.
|
||||
; NICHTS von den Daten des Betriebs - keine Rechnungen, kein Journal, kein
|
||||
; Nummernbuch. Die liegen ohnehin woanders und muessen zehn Jahre bleiben.
|
||||
Type: files; Name: "{app}\installiert.json"
|
||||
; 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}"
|
||||
4
api.py
4
api.py
|
|
@ -138,6 +138,10 @@ class _Handler(socketserver.StreamRequestHandler):
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
330
app.py
330
app.py
|
|
@ -29,6 +29,7 @@ from tkinter import ttk, messagebox
|
|||
|
||||
import api
|
||||
import config
|
||||
import menueleiste
|
||||
import berechnung
|
||||
import gemeinsam
|
||||
import hinweis
|
||||
|
|
@ -65,17 +66,31 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
self.cfg = config.load_config()
|
||||
self.title(config.PROGRAMM)
|
||||
self._fenstersymbol()
|
||||
# Fensterhoehe an den Bildschirm anpassen, damit nichts unter den Rand rutscht
|
||||
# Fenstergroesse an den Bildschirm anpassen, damit nichts unter den
|
||||
# Rand rutscht. Die BREITE ist gemessen, nicht geraten: die
|
||||
# Leistungstabelle braucht 743 Pixel fuer ihre sieben Spalten, dazu
|
||||
# Bildlaufleiste, Rahmen und die Formularseite links - zusammen rund
|
||||
# 1180. Bei den frueheren 1060 waren "E.-Preis" und "Gesamt"
|
||||
# abgeschnitten (sein Befund am 06.09.2026: die Formularbloecke
|
||||
# "verdrengen die posten block"). Passt das nicht auf den Bildschirm,
|
||||
# wird so breit wie moeglich aufgemacht.
|
||||
hoehe = max(560, min(840, self.winfo_screenheight() - 90))
|
||||
self.geometry(f"1060x{hoehe}")
|
||||
self.minsize(900, 540)
|
||||
breite = max(1000, min(1180, self.winfo_screenwidth() - 60))
|
||||
self.geometry(f"{breite}x{hoehe}")
|
||||
# Die Mindestbreite ist keine Geschmacksfrage: unterhalb von 1060
|
||||
# Pixeln passen die sieben Spalten der Leistungstabelle selbst dann
|
||||
# nicht mehr nebeneinander, wenn die Textspalte auf ihrer
|
||||
# Mindestbreite steht - "E.-Preis" und "Gesamt" waeren dann nicht mehr
|
||||
# zu sehen und auch nicht zu erreichen. Frueher stand hier 900.
|
||||
self.minsize(1080, 540)
|
||||
if config.testlauf():
|
||||
# Ein Pruefstand bedient das Programm, waehrend nebenan jemand
|
||||
# arbeitet. Das Fenster geht deshalb NEBEN dem Bildschirm auf (auf
|
||||
# dem zweiten Monitor, wenn einer da ist) - sichtbar, aber nicht im
|
||||
# Weg. Unsichtbar machen waere falsch: dann sieht niemand mehr, was
|
||||
# der Pruefstand eigentlich tut.
|
||||
self.geometry(f"+{self.winfo_screenwidth() + 20}+40")
|
||||
self.geometry(theme.pruefplatz()
|
||||
or f"+{self.winfo_screenwidth() + 20}+40")
|
||||
try:
|
||||
# Kein Eintrag in der Taskleiste: das Fenster liegt neben
|
||||
# dem Bildschirm, und ein Eintrag, den man anklickt, ohne
|
||||
|
|
@ -132,7 +147,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
def _build_menu(self):
|
||||
# Vollstaendig eigene Menueleiste + eigene Dropdowns -> komplett theme-konform
|
||||
# (native tk-Menubar/Popups lassen sich unter Windows nicht einfaerben).
|
||||
self._open_pop = None
|
||||
self._menus = {
|
||||
"Datei": [
|
||||
("Vorschau ansehen", self.vorschau_zeigen),
|
||||
|
|
@ -150,72 +164,16 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
("Rechnungsnummer…", self.dlg_nummer),
|
||||
("Speicherort der Rechnungen…", self.dlg_speicherort),
|
||||
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
|
||||
("-", None),
|
||||
("Darstellung…", self.dlg_darstellung),
|
||||
],
|
||||
"Hilfe": [("Info", self._info)],
|
||||
}
|
||||
bar = tk.Frame(self, bg=self.col_bg)
|
||||
bar.pack(side="top", fill="x")
|
||||
for text, items in self._menus.items():
|
||||
lbl = tk.Label(bar, text=text, bg=self.col_bg, fg=self.col_fg, padx=8, pady=3)
|
||||
lbl.pack(side="left")
|
||||
lbl.bind("<Enter>", lambda e, w=lbl: w.configure(bg=self.col_sel))
|
||||
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
|
||||
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
|
||||
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
|
||||
self.menue = menueleiste.Menueleiste(self, self._menus, farben=self)
|
||||
# bind statt bind_all: sonst feuert Strg+P auch aus einem offenen
|
||||
# Dialog heraus und schreibt im Formular dahinter eine Rechnung.
|
||||
self.bind("<Control-p>", lambda e: self.pdf_erstellen())
|
||||
|
||||
def _menu_popup(self, anchor, items):
|
||||
if self._open_pop: # offenes Menu zu -> Toggle
|
||||
self._close_pop()
|
||||
return
|
||||
pop = tk.Toplevel(self)
|
||||
pop.overrideredirect(True)
|
||||
pop.configure(bg=self.col_line) # 1px Rahmen = col_line statt weiss
|
||||
inner = tk.Frame(pop, bg=self.col_field)
|
||||
inner.pack(padx=1, pady=1)
|
||||
for label, cmd in items:
|
||||
if label == "-":
|
||||
tk.Frame(inner, bg=self.col_line, height=1).pack(fill="x", pady=3)
|
||||
continue
|
||||
it = tk.Label(inner, text=label, bg=self.col_field, fg=self.col_fg,
|
||||
anchor="w", padx=16, pady=5)
|
||||
it.pack(fill="x")
|
||||
it.bind("<Enter>", lambda e, w=it: w.configure(bg=self.col_sel))
|
||||
it.bind("<Leave>", lambda e, w=it: w.configure(bg=self.col_field))
|
||||
it.bind("<Button-1>", lambda e, c=cmd: self._run_menu(c))
|
||||
pop.update_idletasks()
|
||||
pop.geometry(f"+{anchor.winfo_rootx()}+{anchor.winfo_rooty() + anchor.winfo_height()}")
|
||||
self._open_pop = pop
|
||||
pop.grab_set()
|
||||
pop.bind("<Button-1>", self._maybe_close_pop, add="+")
|
||||
pop.bind("<Escape>", lambda e: self._close_pop())
|
||||
self._dark_titlebar(pop)
|
||||
|
||||
def _maybe_close_pop(self, event):
|
||||
pop = self._open_pop
|
||||
if not pop:
|
||||
return
|
||||
if (event.x_root < pop.winfo_rootx() or event.x_root > pop.winfo_rootx() + pop.winfo_width()
|
||||
or event.y_root < pop.winfo_rooty() or event.y_root > pop.winfo_rooty() + pop.winfo_height()):
|
||||
self._close_pop()
|
||||
|
||||
def _run_menu(self, cmd):
|
||||
self._close_pop()
|
||||
if cmd:
|
||||
cmd()
|
||||
|
||||
def _close_pop(self):
|
||||
if getattr(self, "_open_pop", None):
|
||||
try:
|
||||
self._open_pop.grab_release()
|
||||
self._open_pop.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self._open_pop = None
|
||||
|
||||
# ------------------------------------------------------- Arbeitsflaeche
|
||||
def _build_flaeche(self):
|
||||
"""Die Kaesten und der Knopf, mit dem man sie verschieben darf.
|
||||
|
||||
|
|
@ -225,6 +183,17 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
"""
|
||||
leiste = ttk.Frame(self, padding=(16, 8, 16, 0))
|
||||
leiste.pack(side="top", fill="x")
|
||||
|
||||
# Das Programmlogo oben LINKS und gross. Es stand zuerst klein (18 px)
|
||||
# unten in der Statuszeile - sein Befund am 07.09.2026: "ist mir zu
|
||||
# klein geht unter mach es doch gros oben links weil da ist eh platz".
|
||||
# Hier ist wirklich Platz: die Leiste traegt sonst nur den Knopf
|
||||
# "Anordnen", und der sitzt rechts.
|
||||
self._kopf_bild = wasserzeichen.kopf(
|
||||
34, master=self, dunkel=getattr(self, "_dark_mode", False))
|
||||
if self._kopf_bild is not None:
|
||||
ttk.Label(leiste, image=self._kopf_bild).pack(side="left")
|
||||
|
||||
self.werkzeuge = ttk.Frame(leiste)
|
||||
self.werkzeuge.pack(side="right")
|
||||
|
||||
|
|
@ -295,13 +264,18 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
row=i, column=0, sticky="w", pady=4)
|
||||
if var is self.k_anrede:
|
||||
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
|
||||
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
|
||||
w = ttk.Combobox(kunde, textvariable=var, width=22, values=ANREDEN)
|
||||
else:
|
||||
w = ttk.Entry(kunde, textvariable=var, width=38)
|
||||
# 22 Zeichen: das laengste, was hier realistisch steht, ist eine
|
||||
# Strasse mit Hausnummer ("Bornaer Chaussee 36" = 19). Breiter
|
||||
# gemacht hat das Feld nur die linke Spalte aufgeblaeht und der
|
||||
# Leistungstabelle den Platz genommen. Getippt werden kann
|
||||
# weiterhin beliebig viel - das Feld scrollt mit.
|
||||
w = ttk.Entry(kunde, textvariable=var, width=22)
|
||||
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
|
||||
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
|
||||
style="KarteMuted.TLabel", wraplength=330, justify="left").grid(
|
||||
style="KarteMuted.TLabel", wraplength=250, justify="left").grid(
|
||||
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
kunde.columnconfigure(1, weight=1)
|
||||
|
||||
|
|
@ -314,8 +288,8 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
rows = [
|
||||
("Rechnungsnummer", self.r_nummer),
|
||||
("Rechnungsdatum", self.r_datum),
|
||||
("Anreise (TT.MM.JJJJ)", self.r_anreise),
|
||||
("Abreise (TT.MM.JJJJ)", self.r_abreise),
|
||||
("Anreise", self.r_anreise),
|
||||
("Abreise", self.r_abreise),
|
||||
]
|
||||
date_vars = (self.r_datum, self.r_anreise, self.r_abreise)
|
||||
self.datum_labels = {}
|
||||
|
|
@ -326,7 +300,14 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
self.datum_labels["von"] = beschriftung
|
||||
elif var is self.r_abreise:
|
||||
self.datum_labels["bis"] = beschriftung
|
||||
ent = ttk.Entry(info, textvariable=var, width=18)
|
||||
# 12 Zeichen reichen fuer "TT.MM.JJJJ" und jede Rechnungsnummer.
|
||||
# Die Form stand frueher in der Beschriftung ("Anreise
|
||||
# (TT.MM.JJJJ)") und hat den ganzen Block breit gemacht - Breite,
|
||||
# die der Leistungstabelle rechts fehlte. Sie steht jetzt einmal
|
||||
# als graue Zeile unter dem Block. Als Platzhalter IM Feld waere
|
||||
# sie gefaehrlich: ein nicht geloeschter Platzhalter stuende als
|
||||
# Anreisedatum auf der Rechnung.
|
||||
ent = ttk.Entry(info, textvariable=var, width=12)
|
||||
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
if var is self.r_nummer:
|
||||
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
|
||||
|
|
@ -339,12 +320,17 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
ttk.Label(info, textvariable=self.r_naechte, style="Karte.TLabel",
|
||||
font=(theme.SCHRIFT_NAME, 11, "bold")).grid(
|
||||
row=4, column=1, sticky="w", padx=(8, 0))
|
||||
ttk.Label(info, text="Datum als TT.MM.JJJJ – die Punkte setzt das "
|
||||
"Programm beim Tippen.",
|
||||
style="KarteMuted.TLabel", wraplength=250,
|
||||
justify="left").grid(row=5, column=0, columnspan=2,
|
||||
sticky="w", pady=(8, 0))
|
||||
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
|
||||
self.storno_hinweis = tk.StringVar(value="")
|
||||
ttk.Label(info, textvariable=self.storno_hinweis, style="Karte.TLabel",
|
||||
foreground=self.col_gefahr,
|
||||
font=(theme.SCHRIFT_NAME, 9, "bold"), wraplength=200, justify="left").grid(
|
||||
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
font=(theme.SCHRIFT_NAME, 9, "bold"), wraplength=250, justify="left").grid(
|
||||
row=6, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
info.columnconfigure(1, weight=1)
|
||||
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
|
|
@ -414,7 +400,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
# (Titel, Ausrichtung, Mindestbreite px). col 1 (Leistung) bekommt das Gewicht.
|
||||
COLS = [
|
||||
("Pos.", "center", 38),
|
||||
("Leistung", "w", 330),
|
||||
# Die Textspalte hat als einzige Gewicht - sie bekommt allen freien
|
||||
# Platz. Ihre Mindestbreite ist deshalb bewusst klein: bei einem engen
|
||||
# Fenster soll sie schrumpfen und umbrechen, statt die Preisspalten
|
||||
# rechts aus dem Bild zu schieben. Bei den frueheren 330 tat sie genau
|
||||
# das - "E.-Preis" und "Gesamt" waren nicht mehr zu sehen.
|
||||
("Leistung", "w", 200),
|
||||
("USt %", "center", 50),
|
||||
("Anzahl\nTage/Menge", "center", 80),
|
||||
("Pers.", "center", 56),
|
||||
|
|
@ -451,7 +442,13 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
self._pos_win = canvas.create_window((0, 0), window=self.pos_frame, anchor="nw")
|
||||
self.pos_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
# pos_frame auf Canvas-Breite ziehen (minus Luft rechts zum Scrollbalken)
|
||||
canvas.bind("<Configure>", lambda e: canvas.itemconfig(self._pos_win, width=e.width - 18))
|
||||
def breite_gesetzt(e):
|
||||
canvas.itemconfig(self._pos_win, width=e.width - 18)
|
||||
# after_idle, weil die Spaltenbreite erst NACH dem Neuverteilen
|
||||
# feststeht - direkt im Ereignis gemessen kaeme der alte Wert.
|
||||
self.after_idle(self._umbruch_nachziehen)
|
||||
|
||||
canvas.bind("<Configure>", breite_gesetzt)
|
||||
canvas.configure(yscrollcommand=sb.set)
|
||||
canvas.pack(side="left", fill="both", expand=True, pady=(4, 0))
|
||||
sb.pack(side="right", fill="y")
|
||||
|
|
@ -463,9 +460,127 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
|
||||
self._render_positionszeilen()
|
||||
|
||||
# ------------------------------------------------------- Darstellung
|
||||
def _oberflaeche_neu(self):
|
||||
"""Die ganze Oberflaeche neu zeichnen - fuer den Wechsel hell/dunkel.
|
||||
|
||||
WARUM NEU BAUEN UND NICHT UMFAERBEN
|
||||
-----------------------------------
|
||||
Die Farben stehen an ueber sechzig Stellen fest in den Widgets
|
||||
(tk.Label mit bg=..., Streifen der Leistungsliste, Kopfzeilen der
|
||||
Kaesten). Sie einzeln nachzuziehen hiesse, ein Verzeichnis aller
|
||||
eingefaerbten Widgets zu fuehren - und wer beim naechsten neuen Widget
|
||||
vergisst, es einzutragen, hat einen hellen Fleck im dunklen Fenster,
|
||||
den niemand mehr findet. Neu bauen kann nichts vergessen.
|
||||
|
||||
WAS DABEI ERHALTEN BLEIBT
|
||||
-------------------------
|
||||
Alles Eingetippte. Ein Themenwechsel darf keine halb getippte Rechnung
|
||||
wegwerfen. Um die Tk-Variablen kuemmert sich variablen_sichern() im
|
||||
ThemeMixin - dort steht auch, warum ALLE gesichert werden und nicht
|
||||
eine gepflegte Auswahl.
|
||||
|
||||
Hier stehen nur die Dinge, die es sonst nirgends gibt: die Mengen und
|
||||
Personenzahlen der Leistungszeilen (sie haengen an Woerterbuechern,
|
||||
nicht am Fenster) und das Blatt, das gerade vorn war.
|
||||
"""
|
||||
gesichert = self.variablen_sichern()
|
||||
mengen = {nr: v.get() for nr, v in self.anzahl_vars.items()}
|
||||
personen = {nr: v.get() for nr, v in self.personen_vars.items()}
|
||||
blatt = None
|
||||
try:
|
||||
blatt = self.blaetter.index(self.blaetter.select())
|
||||
except Exception: # noqa: BLE001 - dann eben das erste Blatt
|
||||
pass
|
||||
|
||||
self.kinder_loeschen()
|
||||
self.flaeche = None
|
||||
self._text_zellen = []
|
||||
|
||||
self._apply_theme()
|
||||
self._build_menu()
|
||||
self._build_summen()
|
||||
self._build_flaeche()
|
||||
self._build_kopf()
|
||||
self._build_positionen()
|
||||
self.flaeche.anordnen()
|
||||
|
||||
self.variablen_zuruecksetzen(gesichert)
|
||||
for nr, wert in mengen.items():
|
||||
if nr in self.anzahl_vars:
|
||||
self.anzahl_vars[nr].set(wert)
|
||||
for nr, wert in personen.items():
|
||||
if nr in self.personen_vars:
|
||||
self.personen_vars[nr].set(wert)
|
||||
if blatt is not None:
|
||||
try:
|
||||
self.blaetter.select(blatt)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._buch_anzeigen()
|
||||
self.recalc()
|
||||
|
||||
# Der Dialog selbst steht im ThemeMixin (theme.py) - er sah in beiden
|
||||
# Programmen gleich aus. Verschieden ist nur der Hinweis darin und die
|
||||
# Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen.
|
||||
DARSTELLUNG_HINWEIS = (
|
||||
"Gilt nur für das Programmfenster. Die Farbe der Rechnung steht unter "
|
||||
"Einstellungen → Firmendaten – das Blatt gehört dem Kunden, nicht dem "
|
||||
"Programm.")
|
||||
|
||||
def _cfg_speichern(self):
|
||||
"""Hier ist die Konfiguration eine config.json und will geschrieben sein."""
|
||||
config.save_config(self.cfg)
|
||||
|
||||
def _umbruch_nachziehen(self, runde=0, vorige_breite=None):
|
||||
"""Den Textumbruch der Leistungsspalte auf ihre echte Breite setzen.
|
||||
|
||||
Die Spalte ist die einzige mit Gewicht - sie bekommt allen freien
|
||||
Platz und schrumpft bei engem Fenster bis auf ihre Mindestbreite. Ein
|
||||
fest eingetragener Umbruch passt dann nicht mehr: er bricht zu spaet,
|
||||
der Text laeuft ueber die Spalte hinaus und wird abgeschnitten. Genau
|
||||
das war nach dem Umbau am 06.09.2026 bei Position 1 zu sehen
|
||||
("... inkl. Pkw, Stro").
|
||||
|
||||
Es braucht mehrere Runden, und zwar aus einem Grund, der beim ersten
|
||||
Versuch Zeit gekostet hat: beim ersten Messen steht die Spalte noch auf
|
||||
ihrer MINDESTbreite, weil die Labels noch mit dem alten Umbruch
|
||||
vermessen sind. Setzt man danach nur einmal, bleibt der Text auf der
|
||||
Mindestbreite umgebrochen, obwohl die Spalte laengst breiter ist - der
|
||||
Text stand dann dreizeilig in einer Spalte, in die er zweizeilig
|
||||
gepasst haette. Also wird gemessen, gesetzt und noch einmal gemessen,
|
||||
bis sich nichts mehr aendert.
|
||||
|
||||
Abgebrochen wird, wenn zwei Messungen dieselbe SPALTENBREITE ergeben -
|
||||
nicht, wenn sich der Umbruch nicht geaendert hat. Der Unterschied ist
|
||||
entscheidend und hat beim ersten Versuch eine Runde gekostet: die
|
||||
Spalte kann breiter werden, ohne dass ich etwas geaendert habe (sie
|
||||
bekommt den freien Platz des Fensters). Wer dann abbricht, weil sein
|
||||
eigener Wert gleich blieb, laesst den Umbruch auf dem alten Stand
|
||||
stehen - beim Vergroessern bricht der Text unnoetig um, beim
|
||||
Verkleinern laeuft er aus der Spalte heraus. Die Rundengrenze ist der
|
||||
zweite Riegel gegen ein Aufschaukeln.
|
||||
"""
|
||||
if not getattr(self, "_text_zellen", None):
|
||||
return
|
||||
kasten = self.pos_frame.grid_bbox(column=1, row=0)
|
||||
if not kasten or kasten[2] < 40:
|
||||
return
|
||||
breite = kasten[2] - 14 # padx=6 auf beiden Seiten, etwas Luft
|
||||
for lbl in self._text_zellen:
|
||||
try:
|
||||
if int(lbl.cget("wraplength")) != breite:
|
||||
lbl.configure(wraplength=breite)
|
||||
except tk.TclError: # Zeile wurde zwischendurch neu gebaut
|
||||
pass
|
||||
if runde < 5 and breite != vorige_breite:
|
||||
self.after_idle(
|
||||
lambda: self._umbruch_nachziehen(runde + 1, breite))
|
||||
|
||||
def _render_positionszeilen(self):
|
||||
for w in self.pos_frame.winfo_children():
|
||||
w.destroy()
|
||||
self._text_zellen = []
|
||||
self.anzahl_vars.clear()
|
||||
self.personen_vars.clear()
|
||||
self.gesamt_lbls.clear()
|
||||
|
|
@ -512,7 +627,12 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
return lbl
|
||||
|
||||
zelle(str(nr), 0)
|
||||
zelle(pos["leistung"], 1, anker="w", umbruch=330)
|
||||
# Der Umbruch wird nicht fest gesetzt: die Leistungsspalte ist die
|
||||
# einzige, die mitwaechst, und ein fester Wert schnitt den Text ab,
|
||||
# sobald die Spalte schmaler wurde als er. _umbruch_nachziehen()
|
||||
# holt ihn nach jeder Groessenaenderung.
|
||||
self._text_zellen.append(
|
||||
zelle(pos["leistung"], 1, anker="w", umbruch=200))
|
||||
zelle(str(pos["satz"]), 2)
|
||||
ttk.Entry(self.pos_frame, textvariable=av, width=8, justify="center",
|
||||
style="Liste.TEntry").grid(
|
||||
|
|
@ -619,12 +739,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
def _build_summen(self):
|
||||
# Statuszeile ganz unten
|
||||
status = ttk.Frame(self, padding=(12, 2))
|
||||
# Das Programmlogo neben der Marke - klein, immer sichtbar. Es steht
|
||||
# rechts bei "powered by ravokk", weil dort die Kennung des Werkzeugs
|
||||
# sitzt und nicht die des Kunden.
|
||||
self._kopf_bild = wasserzeichen.kopf(18, master=self)
|
||||
if self._kopf_bild is not None:
|
||||
ttk.Label(status, image=self._kopf_bild).pack(side="right", padx=(0, 6))
|
||||
status.pack(side="bottom", fill="x")
|
||||
ttk.Label(status, text=config.MARKE, foreground=self.col_muted,
|
||||
font=(theme.SCHRIFT_NAME, 8)).pack(side="right")
|
||||
|
|
@ -661,7 +775,8 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
|
||||
def _wasserzeichen_setzen(self, groesse=240):
|
||||
"""Das Programmlogo blass hinter die Arbeitsflaeche legen."""
|
||||
bild = wasserzeichen.hole(groesse, 0.06, master=self)
|
||||
bild = wasserzeichen.hole(groesse, 0.06, master=self,
|
||||
dunkel=getattr(self, "_dark_mode", False))
|
||||
if bild is None:
|
||||
return
|
||||
# Die Referenz MUSS bleiben - sonst raeumt Python das Bild weg und Tk
|
||||
|
|
@ -675,8 +790,6 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
def _fenstersymbol(self):
|
||||
"""Das Programmsymbol auf das Fenster legen."""
|
||||
return wasserzeichen.symbol_setzen(self)
|
||||
print("[symbol] assets/programm.ico nicht gefunden")
|
||||
return False
|
||||
|
||||
def _hinweis_wenn_sichtbar(self, versuche=0):
|
||||
"""Wartet, bis das Fenster gezeichnet ist, und fragt dann.
|
||||
|
|
@ -751,12 +864,30 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
"""
|
||||
self._buch_oeffnen()
|
||||
if self.buch is None and not config.testlauf():
|
||||
self.dlg_gemeinsam(erststart=True)
|
||||
# Kein Dialog mehr beim ersten Start: der Ordner steht fest und
|
||||
# ist in beiden Programmen derselbe (gemeinsam.standard_ordner).
|
||||
# Gefragt wurde frueher - und wer in den zwei Programmen zwei
|
||||
# verschiedene Pfade eintippte, merkte es erst, wenn eine
|
||||
# Rechnungsnummer zum zweiten Mal vergeben war.
|
||||
ordner = gemeinsam.standard_anlegen()
|
||||
if ordner:
|
||||
self.cfg["gemeinsam_ordner"] = ordner
|
||||
config.save_config(self.cfg)
|
||||
self._buch_oeffnen()
|
||||
if self.buch is None:
|
||||
# Nur wenn selbst das nicht geht, wird gefragt.
|
||||
self.dlg_gemeinsam(erststart=True)
|
||||
self._buch_anzeigen()
|
||||
|
||||
def dlg_gemeinsam(self, erststart=False):
|
||||
# Ist noch keiner gesetzt, wird der Rechnungsordner vorgeschlagen:
|
||||
# den kennen beide Programme, er wird mitgesichert, und er ueberlebt
|
||||
# ein Deinstallieren. Der Ordner, in dem das Programm liegt, taugt
|
||||
# dafuer NICHT mehr - seit dem Installer sind das zwei getrennte.
|
||||
ordner = ordnerwahl.ordner_abfragen(
|
||||
self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or ""))
|
||||
self, "Rechnungstool",
|
||||
str(self.cfg.get("gemeinsam_ordner", "") or "")
|
||||
or str(self.cfg.get("output_dir", "") or ""))
|
||||
if not ordner:
|
||||
if erststart:
|
||||
self.melden_warnung(
|
||||
|
|
@ -910,8 +1041,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
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 fertig["ok"]:
|
||||
|
|
@ -1053,7 +1183,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
except Exception as e: # noqa: BLE001
|
||||
print("[push]", e)
|
||||
finally:
|
||||
self.after(250, self._push_abholen)
|
||||
self._wieder(250, self._push_abholen)
|
||||
|
||||
def _nachtragen(self):
|
||||
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
|
||||
|
|
@ -1260,8 +1390,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
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() + 130}")
|
||||
theme.mittig(d, self)
|
||||
d.grab_set()
|
||||
self.wait_window(d)
|
||||
return wahl["art"]
|
||||
|
|
@ -1306,6 +1435,23 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
||||
|
||||
# ------------------------------------------------- Bedienung ueber die API
|
||||
def _wieder(self, ms, was):
|
||||
"""Einen sich selbst planenden Timer neu setzen - nur solange es das
|
||||
Fenster noch gibt.
|
||||
|
||||
Ohne das melden die beiden Dauertimer beim Beenden
|
||||
`invalid command name ..._api_pumpe`: Tk hat das Fenster samt Callback
|
||||
schon abgeraeumt, der eingeplante Aufruf laeuft aber noch. Das
|
||||
stuerzt nichts ab, landet aber als Fehler im Log - und ein Log, in dem
|
||||
beim normalen Beenden immer zwei Fehler stehen, liest irgendwann
|
||||
niemand mehr.
|
||||
"""
|
||||
try:
|
||||
if self.winfo_exists():
|
||||
self.after(ms, was)
|
||||
except tk.TclError:
|
||||
pass # Fenster ist weg - dann gibt es nichts mehr zu tun
|
||||
|
||||
def _api_pumpe(self):
|
||||
"""Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus.
|
||||
|
||||
|
|
@ -1315,7 +1461,7 @@ class RechnungsApp(ThemeMixin, MeldungMixin, KorrekturMixin, EinstellungenMixin,
|
|||
try:
|
||||
self._api_schlange.abarbeiten(self._api_aktion)
|
||||
finally:
|
||||
self.after(150, self._api_pumpe)
|
||||
self._wieder(150, self._api_pumpe)
|
||||
|
||||
def _api_aktion(self, name, daten):
|
||||
"""Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py).
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 42 KiB |
43
bauen.py
43
bauen.py
|
|
@ -79,9 +79,19 @@ def main():
|
|||
# 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"):
|
||||
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"],
|
||||
|
|
@ -94,14 +104,33 @@ def main():
|
|||
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}")
|
||||
for name in sorted(os.listdir(os.path.join(HIER, "dist"))):
|
||||
p = os.path.join(HIER, "dist", name)
|
||||
print(f" {name:44s} {os.path.getsize(p):>10,d} B {summe(p)}")
|
||||
setup = os.path.join(HIER, "setup")
|
||||
for name in sorted(os.listdir(setup)) if os.path.isdir(setup) else []:
|
||||
p = os.path.join(setup, name)
|
||||
print()
|
||||
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)}")
|
||||
|
||||
|
||||
|
|
|
|||
55
config.py
55
config.py
|
|
@ -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,19 +58,42 @@ 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
40
flaeche.py
40
flaeche.py
|
|
@ -42,6 +42,12 @@ STANDARD = {
|
|||
|
||||
|
||||
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
|
||||
|
|
@ -85,23 +91,43 @@ class Arbeitsflaeche(ttk.Frame):
|
|||
def _platz_verteilen(self):
|
||||
"""Der freie Platz gehoert dem Kasten mit der langen Liste.
|
||||
|
||||
Feste Gewichte 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.
|
||||
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=1, uniform="spalten")
|
||||
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 Spalte mit der Liste bekommt mehr Breite als die andere.
|
||||
self.columnconfigure(spalte, weight=4, uniform="spalten")
|
||||
self.columnconfigure(1 - spalte, weight=3, uniform="spalten")
|
||||
# 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:
|
||||
|
|
|
|||
43
gemeinsam.py
43
gemeinsam.py
|
|
@ -90,6 +90,49 @@ FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
|
|||
"freigabe")
|
||||
|
||||
|
||||
def standard_ordner():
|
||||
"""Wo das Nummernbuch liegt, wenn niemand etwas anderes einstellt.
|
||||
|
||||
WARUM ES DIESEN FESTEN ORT GIBT (07.09.2026)
|
||||
--------------------------------------------
|
||||
Vorher fragte jedes Programm beim ersten Start nach einem Ordner - und der
|
||||
Nutzer musste in BEIDEN denselben eintragen. Sein Einwand, wortwoertlich:
|
||||
|
||||
"wie soll das rechnungstool und steuertool wissen wo es ist, das muss
|
||||
im ... ravokk liegen und dann zusammen, sonst kommt das eine nicht an
|
||||
das andere ran"
|
||||
|
||||
Er hat recht: die beiden sind ueber genau zwei Dinge verbunden - diese
|
||||
SQLite-Datei und die kleine API, die "hier ist etwas Neues" ruft. Wenn
|
||||
schon der Ort geraten werden muss, ist die Verbindung nur so gut wie die
|
||||
Aufmerksamkeit dessen, der zweimal denselben Pfad tippt. Ein falscher
|
||||
Buchstabe faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal
|
||||
vergeben wurde.
|
||||
|
||||
Deshalb: ein fester Ort, den beide von selbst kennen. Gefragt wird nur
|
||||
noch, wer ihn ausdruecklich verschieben will (Menue -> Gemeinsamer Ordner) -
|
||||
etwa auf ein Netzlaufwerk, wenn die Programme auf zwei Rechnern laufen.
|
||||
|
||||
LOCALAPPDATA und nicht "Dokumente": dort liegen die Programmdaten ohnehin,
|
||||
und niemand raeumt den Ordner versehentlich auf. Das Buch ist ein
|
||||
REGISTER, kein Beleg - geht es verloren, fehlt keine Rechnung, es wird beim
|
||||
naechsten Einlesen neu gefuellt.
|
||||
"""
|
||||
heim = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
return os.path.join(heim, "ravokk")
|
||||
|
||||
|
||||
def standard_anlegen():
|
||||
"""Den festen Ordner anlegen und zurueckgeben - oder '' wenn das nicht geht."""
|
||||
ordner = standard_ordner()
|
||||
try:
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
return ordner
|
||||
except OSError as e:
|
||||
log.warning("Gemeinsamer Ordner %s liess sich nicht anlegen: %s", ordner, e)
|
||||
return ""
|
||||
|
||||
|
||||
def pfad_zur_datei(ordner):
|
||||
"""Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner."""
|
||||
return os.path.join(ordner or "", DATEINAME)
|
||||
|
|
|
|||
|
|
@ -512,8 +512,7 @@ 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":
|
||||
|
|
|
|||
169
menueleiste.py
Normal file
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.
|
||||
|
|
@ -73,7 +81,7 @@ class OrdnerWahl(tk.Toplevel):
|
|||
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)
|
||||
|
|
@ -81,8 +89,14 @@ 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",
|
||||
bg=self._bg,
|
||||
|
|
@ -104,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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -73,6 +63,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}
|
||||
|
||||
|
|
|
|||
11
pruef_api.py
11
pruef_api.py
|
|
@ -52,6 +52,7 @@ 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():
|
||||
|
|
@ -63,16 +64,6 @@ _wecker = threading.Timer(120, _reissleine)
|
|||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
k = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(k, "replace").decode(k))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
|
||||
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
|
||||
|
|
|
|||
|
|
@ -30,14 +30,7 @@ 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
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
print(("OK | " if bedingung else "FEHL | ") + text)
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
from pruefhelfer import fehler, pruefe
|
||||
|
||||
|
||||
def testbild(pfad, farbe):
|
||||
|
|
|
|||
269
pruef_darstellung.py
Normal file
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")
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
|
|||
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():
|
||||
|
|
@ -55,16 +56,6 @@ _wecker = threading.Timer(120, _reissleine)
|
|||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
k = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(k, "replace").decode(k))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
|
||||
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
|
||||
|
|
|
|||
|
|
@ -28,16 +28,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
|
||||
kod = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kod, "replace").decode(kod))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
|
|
@ -70,6 +60,7 @@ 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 = []
|
||||
|
|
|
|||
149
pruef_gemeinsam_automatisch.py
Normal file
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")
|
||||
|
|
@ -31,16 +31,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
|
||||
kod = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kod, "replace").decode(kod))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
|
|
@ -71,6 +61,7 @@ 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 = []
|
||||
|
|
|
|||
190
pruef_installation.py
Normal file
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")
|
||||
|
|
@ -53,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():
|
||||
|
|
@ -66,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 = {}
|
||||
|
|
|
|||
|
|
@ -52,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():
|
||||
|
|
@ -65,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,
|
||||
|
|
|
|||
184
pruef_menueleiste.py
Normal file
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")
|
||||
|
|
@ -49,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():
|
||||
|
|
@ -61,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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
@ -65,6 +55,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -31,16 +31,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")
|
||||
|
|
@ -69,6 +59,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
|
||||
|
||||
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
|
||||
# Geprueft wird er eigens in pruef_hinweis.py.
|
||||
|
|
|
|||
98
pruef_release.py
Normal file
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")
|
||||
|
|
@ -32,19 +32,10 @@ import ast
|
|||
import io
|
||||
import os
|
||||
import sys
|
||||
from pruefhelfer import fehler, pruefe
|
||||
|
||||
ORDNER = [r"C:\claude\rechnungstool", r"C:\claude\beherbergungssteuer"]
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kod = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kod, "replace").decode(kod))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def fundstellen(quelltext):
|
||||
"""Alle Zeilen, in denen `self` steht, ohne dass es eines gibt."""
|
||||
|
|
|
|||
128
pruef_spaltenbreiten.py
Normal file
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")
|
||||
|
|
@ -36,16 +36,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
|
|||
sys.path.insert(0, HIER)
|
||||
|
||||
import version # noqa: E402
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kod = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kod, "replace").decode(kod))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
from pruefhelfer import fehler, pruefe
|
||||
|
||||
|
||||
stamm, endung = os.path.splitext(version.EXE_NAME)
|
||||
|
|
@ -57,7 +48,12 @@ if not os.path.exists(EXE):
|
|||
sys.exit(0)
|
||||
|
||||
u = ctypes.windll.user32
|
||||
p = subprocess.Popen([EXE])
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -48,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():
|
||||
|
|
@ -61,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 = {}
|
||||
|
|
|
|||
|
|
@ -25,16 +25,7 @@ QUELLE = r"C:\claude\rechnungstool"
|
|||
sys.path.insert(0, QUELLE)
|
||||
|
||||
import updater as U # noqa: E402
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kod = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kod, "replace").decode(kod))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
from pruefhelfer import fehler, pruefe
|
||||
|
||||
|
||||
EXE = "Rechnungstool.exe"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=Fal
|
|||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
from pruefhelfer import fehler, pruefe
|
||||
|
||||
|
||||
def _reissleine():
|
||||
|
|
@ -51,16 +52,6 @@ _wecker = _th.Timer(120, _reissleine)
|
|||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
k = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(k, "replace").decode(k))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
|
||||
# Der Erprobungshinweis wird von der pauschalen Antwort unten mit erfasst.
|
||||
|
|
|
|||
47
pruefhelfer.py
Normal file
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)
|
||||
401
theme.py
401
theme.py
|
|
@ -5,13 +5,181 @@ 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
|
||||
|
||||
import config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
|
@ -34,11 +202,9 @@ def schrift_vorbereiten():
|
|||
_schrift_geladen = True
|
||||
if not sys.platform.startswith("win"):
|
||||
return SCHRIFT_NAME
|
||||
import ctypes
|
||||
import os
|
||||
FR_PRIVATE = 0x10
|
||||
geladen = 0
|
||||
for basis in (config.BASE_DIR, config.RESOURCE_DIR, os.path.dirname(os.path.abspath(__file__))):
|
||||
for basis in _ordner():
|
||||
ordner = os.path.join(basis, "assets", "fonts")
|
||||
if not os.path.isdir(ordner):
|
||||
continue
|
||||
|
|
@ -100,15 +266,48 @@ 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."""
|
||||
"""Farben passend zur Einstellung setzen (hell, dunkel oder wie Windows)."""
|
||||
schrift_vorbereiten() # Roboto, sonst Segoe UI
|
||||
dark = windows_dark_mode()
|
||||
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
|
||||
|
|
@ -147,6 +346,15 @@ class ThemeMixin:
|
|||
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")
|
||||
|
|
@ -292,6 +500,22 @@ 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
|
||||
|
|
@ -307,7 +531,10 @@ class ThemeMixin:
|
|||
# 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,
|
||||
padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=1)
|
||||
# Schmal genug, dass zwoelf Monatsreiter nebeneinander
|
||||
# passen: bei (20, 9) waren "September" und "Dezember"
|
||||
# im Steuerrechnungstool abgeschnitten.
|
||||
padding=(9, 7), font=(SCHRIFT_NAME, 10), borderwidth=1)
|
||||
style.map("TNotebook.Tab",
|
||||
background=[("selected", self.col_card),
|
||||
("active", mischen(accent, bg, 0.12))],
|
||||
|
|
@ -356,11 +583,12 @@ class ThemeMixin:
|
|||
und Tastatur fest, waehrend nebenan jemand schreibt.
|
||||
"""
|
||||
d.configure(bg=self.col_bg)
|
||||
if config.testlauf():
|
||||
# Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set -
|
||||
# ein Dialog, der Maus und Tastatur festhaelt, blockiert den
|
||||
# Menschen, der nebenher schreibt.
|
||||
d.geometry(f"+{d.winfo_screenwidth() + 20}+40")
|
||||
# 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
|
||||
|
|
@ -370,22 +598,130 @@ class ThemeMixin:
|
|||
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:
|
||||
|
|
@ -417,7 +753,7 @@ class MeldungMixin:
|
|||
|
||||
def melden(self, titel, text, art="warnung"):
|
||||
"""Eine Meldung mit einem OK-Knopf."""
|
||||
if config.testlauf():
|
||||
if testlauf():
|
||||
{"info": messagebox.showinfo,
|
||||
"warnung": messagebox.showwarning,
|
||||
"fehler": messagebox.showerror}.get(art, messagebox.showwarning)(titel, text)
|
||||
|
|
@ -426,7 +762,7 @@ class MeldungMixin:
|
|||
|
||||
def frage(self, titel, text="", ja="Ja", nein="Nein", vorgabe=True, **_kw):
|
||||
"""Ja/Nein. Gibt True oder False zurueck - wie askyesno."""
|
||||
if config.testlauf():
|
||||
if testlauf():
|
||||
return bool(messagebox.askyesno(titel, text))
|
||||
return bool(self._meldungsfenster(titel, text, "frage",
|
||||
knoepfe=(ja, nein), vorgabe=vorgabe))
|
||||
|
|
@ -469,10 +805,7 @@ class MeldungMixin:
|
|||
d.bind("<Escape>", lambda _e: schliessen(False))
|
||||
d.protocol("WM_DELETE_WINDOW", lambda: schliessen(False))
|
||||
|
||||
d.update_idletasks()
|
||||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||||
y = self.winfo_rooty() + 140
|
||||
d.geometry(f"+{max(0, x)}+{max(0, y)}")
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -245,6 +245,11 @@ def aufraeumen(ordner, exe_name, behalten=2):
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier.
|
|||
Regel
|
||||
-----
|
||||
Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das
|
||||
"v": Tag `v1.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert,
|
||||
"v": Tag `v1.8.2` -> VERSION "1.8.2". Wer das eine ohne das andere aendert,
|
||||
baut dem Updater eine Falle.
|
||||
"""
|
||||
|
||||
VERSION = "1.7.0"
|
||||
VERSION = "1.8.2"
|
||||
|
||||
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
|
||||
REPOSITORY = "TheMockTv/rechnungstool"
|
||||
|
|
|
|||
138
wasserzeichen.py
138
wasserzeichen.py
|
|
@ -45,12 +45,97 @@ def pfad(name):
|
|||
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:
|
||||
|
|
@ -64,7 +149,51 @@ def symbol_setzen(fenster, melden=print):
|
|||
return False
|
||||
|
||||
|
||||
def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
|
||||
# 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
|
||||
|
|
@ -77,7 +206,7 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
|
|||
fenster = master if master is not None else tk._default_root
|
||||
if fenster is None:
|
||||
return None
|
||||
schluessel = (id(fenster), groesse, round(deckkraft, 3))
|
||||
schluessel = (id(fenster), groesse, round(deckkraft, 3), bool(dunkel))
|
||||
if schluessel in _lager:
|
||||
return _lager[schluessel]
|
||||
p = pfad("programm_logo.png")
|
||||
|
|
@ -87,6 +216,7 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
|
|||
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
|
||||
|
|
@ -96,11 +226,11 @@ def hole(groesse=240, deckkraft=0.06, master=None, melden=print):
|
|||
return fertig
|
||||
|
||||
|
||||
def kopf(hoehe=28, master=None, melden=print):
|
||||
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)
|
||||
return hole(hoehe, 1.0, master=master, melden=melden, dunkel=dunkel)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue