Compare commits

...

14 commits

Author SHA1 Message Date
TheMockTv
5c68278191 Sicherheitsfrage vor dem Erstellen, Datenordner, toter Aufruf raus
- "Rechnung endgueltig erstellen?" nennt Nummer, Kunde und Betrag und weist
  darauf hin, dass danach nur noch Storno oder Berichtigung bleibt
  (§ 14 Abs. 4 Nr. 4 UStG, § 31 Abs. 5 UStDV). Geprueft ist auch, dass "Nein"
  wirklich nichts schreibt - sonst waere die Frage eine Attrappe.
- Als EXE legt das Programm sich beim ersten Start einen Ordner "Daten" neben
  die EXE (config.json). Ist dort nicht schreibbar (EXE unter "Programme"),
  weicht es nach LOCALAPPDATA aus. Lag die config.json noch direkt neben der
  EXE, zieht sie um - sonst faengt das Programm ohne Firmendaten neu an.
- Aufruf von _storno_fortsetzen entfernt: die Methode gibt es nicht mehr, seit
  die Kette auf dem Storno-Blatt steht. War unerreichbar, waere aber beim
  naechsten Anfassen ein AttributeError geworden.
- Pruefstaende raeumen ihren Wegwerf-Ordner weg und haben eine Reissleine
  (Abbruch nach zwei Minuten), damit kein Dialog mehr ewig offen steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 21:20:11 +02:00
TheMockTv
3ea31b3421 Die Kette steht auf dem Storno-Blatt - kein gemerkter Zustand noetig
Statt einer Zustandsmaschine ("offener Storno" ueber den Neustart retten) traegt
das Storno jetzt einfach beide Nummern:

    Storno zu Rechnung:  2026-002
    Neue Rechnung:       2026-004

und im Text "2026-002 -> 2026-003 -> 2026-004". Damit ist die Kette aus dem
Beleg allein lesbar. Wird das Programm zwischendurch geschlossen, sagt der
Storno, unter welcher Nummer die berichtigte Rechnung gehoert; der Dialog
nennt sie ebenfalls, statt nur "bereits storniert" zu melden.

naechste_freie_nummer_nach() bestimmt die Folgenummer, damit sie nicht von der
Reihenfolge abhaengt, in der der Zaehler mitzieht.

Ausserdem: die Pruefstaende raeumen ihren Wegwerf-Ordner wieder weg, wenn sie
gruen sind. Ein Testlauf hatte 398 Ordner im Temp-Verzeichnis hinterlassen.
Bei Fehlern bleibt der Ordner stehen, damit man hineinsehen kann.

Weitere Funde des Code-Agenten behoben:
- "Kopie von Rechnung_...pdf" und klein geschriebene Dateinamen zaehlen jetzt
  bei der Nummernvergabe mit (vorher galt die Nummer als frei).
- Strg+P feuerte auch aus einem offenen Dialog heraus (bind_all -> bind).

167 Pruefungen gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 20:42:31 +02:00
TheMockTv
d22631467d Windows-Kopien zaehlen mit, und bei doppelter Nummer wird nicht geraten
Duplikatanzeige gehoert nicht hierher, sondern in den Steuerrechner - wieder
ausgebaut (duplikate.py, pruef_duplikate.py).

Zwei echte Loecher, die dabei auffielen:

1. vorhandene_rechnungen() uebersah Windows-Kopien. "Rechnung_2026-013 (1).pdf"
   und "... - Kopie.pdf" galten nicht als vergeben, die Nummer wurde also ein
   zweites Mal ausgegeben. Genau so ist der Altbestand entstanden. Jetzt zaehlt
   alles mit, was nach der Nummer KEINE weitere Ziffer hat - "2026-0135" bleibt
   getrennt.

2. Liegt eine Nummer mehrfach im Ordner, nahm der Storno-Dialog blind den ersten
   Treffer. Jetzt erscheint eine Auswahlliste mit Datum, Name, Betrag und
   Dateiname; ohne Auswahl passiert nichts, und die Meldung nennt die konkrete
   Datei.

pruef_kernregeln.py: 37 Pruefungen (10 neue), pruef_nummern.py 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 20:20:13 +02:00
TheMockTv
67a09e4085 Doppelte Rechnungen anzeigen (Datei > Doppelte Rechnungen)
Der Altbestand wurde nie geprueft - die Sperre verhindert nur NEUE Dubletten.
Neues Modul duplikate.py findet zwei Sorten, und sie bedeuten nicht dasselbe:

  Nummer doppelt - zwei Geldbelege mit derselben Rechnungsnummer. Der harte
                   Fall (§ 14 Abs. 4 Nr. 4 UStG).
  Inhalt doppelt - gleicher Gast, gleiches Datum, gleicher Betrag unter zwei
                   Nummern. Nur ein Hinweis, kann echt sein.

Der schwierige Teil ist das NICHT-Finden: Storno und berichtigte Rechnung
haben absichtlich eigene Nummern, und ein Berichtigungsblatt traegt sogar die
Nummer der Rechnung, die es berichtigt. Nichts davon ist eine Dublette.

PDFs ohne Kenndaten fallen nicht durch: ihre Nummer wird aus dem Dateinamen
gelesen und sie werden getrennt aufgelistet - das ist genau der ungepruefte
Altbestand.

Beim Start laeuft die Pruefung still mit; liegt etwas vor, steht es rot in der
Statuszeile. Kein Popup beim Hochfahren.

pruef_duplikate.py (12 Pruefungen): Ordner mit dem echten Schlamassel gebaut -
zweimal 2026-013 fuer zwei verschiedene Gaeste, dazu eine saubere Storno-Kette
und ein Berichtigungsblatt, die NICHT gemeldet werden duerfen, ein
inhaltsgleiches Paar und eine fremde PDF. Fenster angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 20:08:22 +02:00
TheMockTv
1060a6421f Vorgangsnummer: eine Klammer um die ganze Kette
Bisher zeigte jeder Beleg nur auf seinen Vorgaenger (storno_zu, ersetzt,
storno_nummer). Ein zweites Programm musste sich die Kette daraus
zusammensuchen. Jetzt tragen ALLE Blaetter einer Kette dieselbe
Vorgangsnummer - die der ersten Rechnung. Gruppieren heisst dort: nach
"vorgang" sortieren, fertig. Eine normale Rechnung ist ihr eigener Vorgang.

Wichtig fuer den Fall "schon korrigierte Rechnung wird noch einmal
storniert": Storno und Berichtigung uebernehmen den VORGANG der alten
Rechnung, nicht deren Nummer - sonst zerfaellt die Kette in zwei.

Die Einzelbezuege bleiben zusaetzlich stehen, damit man die Reihenfolge
innerhalb der Kette lesen kann.

Neu pruef_kette.py (13 Pruefungen): spielt den schlimmsten Fall durch -
Rechnung, Storno, berichtigte Rechnung, Berichtigung der Anschrift, zweiter
Storno, zweite berichtigte Rechnung. Alle sechs landen unter einem
Schluessel, die Summe der Kette ergibt genau die letzte gueltige Rechnung
(27,30 €), und der Steuerrechner liest davon 5 Geldbelege - das
Berichtigungsblatt zaehlt nicht mit.

148 Pruefungen gruen (100 + 27 + 13 + 8) plus 7 Dialoge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 20:03:17 +02:00
TheMockTv
d67448c58f Funde der drei Agenten: zwei kaputte Dialoge, haengende Flags, scharfe Pruefstaende
REGRESSION aus der Modulaufteilung (Code-Review-Agent):
- einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog
  und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres,
  modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar.
  KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu:
  pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder.

HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen):
- "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten
  die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die
  Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf
  dem Blatt. reset_formular fragt jetzt nach und verwirft sauber.
- _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang
  laeuft (traf auch dlg_nummer).
- Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW).
- _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt
  den Zaehler trotzdem zurueckzudrehen.
- Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie
  nicht hergibt.
- Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts.
- storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt.
- EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt
  naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug
  das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm.

PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt):
- Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht:
  Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner,
  Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der
  internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber
  SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert".
- Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der
  Summenblock.
- Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere
  Liste, "nicht" in einem deutschen Text.
- Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen.
- pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle.
- Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs
  werden jetzt rot.

RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur
Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist -
ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem
bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern
nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste.

142 Pruefungen gruen (100 + 27 + 8 + 7).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:57:31 +02:00
TheMockTv
2277a12e0f Zwei getrennte Knoepfe und app.py in Module aufgeteilt
Knoepfe: "Storno" und "Berichtigen" setzen den Weg schon beim Oeffnen fest,
das Popup fragt dann nur noch die Nummer. Vorher entschied ein zweiter Knopf
im Popup - der ist raus.

app.py war 1802 Zeilen, jetzt 672. Neu daneben:
  helfer.py        Zahlen, Datum, Namen, Pruefungen        (87)
  bestand.py       was im Rechnungsordner liegt, nur lesen (171)
  storno.py        den spiegelbildlichen Storno rechnen     (57)
  einzelinstanz.py nur ein Fenster zulassen                 (90)
  theme.py         Farben nach Windows hell/dunkel         (120)
  korrektur.py     Storno- und Berichtigungsweg            (306)
  einstellungen.py die Dialoge aus dem Menue               (358)

Die Oberflaeche bleibt eine Klasse - die Teile sind Mixins, damit kein
einziger self-Zugriff umgeschrieben werden musste. Kein Modul importiert
app.py zurueck, keine ungenutzten Importe mehr. Die Pruefstaende holen die
Funktionen jetzt aus dem Modul, dem sie gehoeren.

pruef_storno.py 100 gruen, pruef_nummern.py 7 gruen, Fenster nach der
Aufteilung gestartet und angesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:41:00 +02:00
TheMockTv
d3962ec8d1 Berichtigen: zweiter Knopf im Storno-Popup, alles haengt an einem Flag
Fuer Rechnungen, bei denen nur die Kundenangaben falsch sind - falsche
Betraege bleiben der Storno-Weg.

- Kein eigener Dialog: das Storno-Popup hat jetzt zwei Knoepfe. "Nur
  berichtigen" laedt die Rechnung ins Formular, sperrt die Nummer und setzt
  ein Flag; "PDF erstellen" schreibt daraus das berichtigte Blatt.
- Die Rechnung behaelt Nummer, Datum und Betraege; dazu kommt "Berichtigt am".
  Der Zaehler bleibt stehen - das Blatt verbraucht keine Nummer.
- Ueberschrift "Berichtigte Rechnung", darunter die vollstaendige Rechnung,
  darunter der Bezug nach § 31 Abs. 5 UStDV und der Satz, dass es KEINE
  zusaetzliche Leistung und keine zweite Rechnung ist. Ohne den kann ein
  zweites Blatt mit ausgewiesener USt die Steuer nach § 14c UStG ein zweites
  Mal ausloesen.
- Kenndaten des Blattes tragen Nullen und "art": "berichtigung". Damit zaehlt
  ein Programm, das die Art nicht kennt, 0,00 € statt den Umsatz doppelt; der
  Steuerrechner ueberspringt es ganz (einnahmen.py).
- Dateiname "Berichtigte Rechnung_JJJJ-MM-TT_<Nummer>.pdf".

pruef_storno.py: 100 Pruefungen gruen, darunter der ganze Berichtigungsweg
inklusive Text auf dem Blatt und Gegenprobe mit dem Steuerrechner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:32:24 +02:00
TheMockTv
abb462725f Altrechnungen: Namen aufteilen und Anschrift aus dem Rechnungstext holen
Die bisherigen Rechnungen tragen den ganzen Namen in der Vornamen-Zeile und
oft gar keine Anschrift. Beim Stornieren stand dann nur "Andres Schaefer" da
und niemand wusste, wer der Gast ist.

- name_aufteilen(): am Leerzeichen trennen, das letzte Wort ist der Nachname.
- adresse_aus_pdf(): der Adressblock steht im Fliesstext zwischen
  "Rechnung an:" und der Rechnungsnummer. Anrede wird an der Auswahlliste
  erkannt, PLZ/Ort an der 4- bis 5-stelligen Zahl am Zeilenanfang, die Zeile
  davor ist die Strasse - aber nur, wenn sie nicht die Namenszeile ist.
- kundendaten_ermitteln(): Kenndaten zuerst, Text als Rueckfall; benutzt vom
  Storno (der geht an denselben Empfaenger) und vom Formular.
- Der Hinweis nach dem Storno sagt jetzt, was aus dem Text gelesen wurde und
  was in der alten Rechnung schlicht nicht drinsteht.

Es wird nichts erfunden: fehlt die Anschrift in der alten PDF, bleiben die
Felder leer und die Pflichtpruefung greift.

pruef_storno.py: 80 Pruefungen gruen, darunter eine echte Altrechnung im
Muster "alles in der Vornamen-Zeile" - mit und ohne Anschrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:20:05 +02:00
TheMockTv
0095305176 Kundenfelder: nicht nur "irgendwas", sondern brauchbar geprueft
Leerzeichen allein waren schon vorher raus, aber "x" oder "123" als Nachname
und "12345" ohne Ort gingen durch.

- Felder werden vor der Pruefung gesaeubert (Rand- und Mehrfachleerzeichen),
  damit geprueft wird, was nachher gedruckt wird.
- Vorname, Nachname/Firma, Strasse brauchen mindestens zwei Buchstaben.
- PLZ/Ort braucht mindestens vier Ziffern UND zwei Buchstaben - damit gehen
  auch 4-stellige Postleitzahlen (AT, NL) durch, aber nicht PLZ ohne Ort.
- Fehlende Hausnummer wird nur nachgefragt, nicht gesperrt: Postfach- und
  Feldweg-Anschriften gibt es wirklich, eine falsche Sperre waere schlimmer.

pruef_storno.py: 67 Pruefungen gruen, darunter Auslandsanschriften und kurze
Namen, die durchgehen MUESSEN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:14:40 +02:00
TheMockTv
d2fee38bfd Storno: Nummer eintippen statt Auswahlliste, Anrede als Liste, Pflichtfelder
- Storno-Dialog: die alte Nummer wird eingetippt, das Programm sucht sie im
  Ordner und zeigt sofort Datum, Name und den Betrag des Stornos. Kurze
  Schreibweise (2026-3) findet 2026-003.
- Zwei Funde beim Pruefen: Storno-PDFs heissen "Storno_..." und fielen sowohl
  aus der Ordnerliste als auch aus der Nummernpruefung heraus. Dadurch galt
  eine schon stornierte Rechnung als stornierbar und eine vom Storno belegte
  Nummer als frei - die haette ein zweites Mal vergeben werden koennen.
- Anrede ist eine Auswahlliste (tippbar fuer Sonderfaelle).
- Vorname, Nachname, Strasse und PLZ/Ort sind Pflicht; ohne sie wird keine
  PDF geschrieben (§ 14 Abs. 4 Nr. 1 UStG). Die alte "trotzdem erstellen?"-
  Frage ist raus.

Prueflauf: pruef_storno.py 46 Pruefungen gruen, pruef_nummern.py 7 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:11:37 +02:00
TheMockTv
790ac4c0f6 Storno statt Ueberschreiben: Rechnungskette 005 -> Storno -> Korrektur
Eine ausgestellte Rechnung darf nicht geaendert und nicht geloescht werden
(§ 146 Abs. 4 AO), und jede Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4
UStG). Korrigiert wird deshalb ueber ein eigenes Dokument mit neuer Nummer,
das sich auf die alte bezieht (§ 31 Abs. 5 UStDV).

- Knopf "Rechnung stornieren": alte Nummer waehlen, Programm liest deren PDF
  und schreibt eine Stornorechnung mit eigener neuer Nummer, Betraege
  negativ, mit Bezug auf Nummer und Datum der alten Rechnung.
- Danach steht das Formular auf der naechsten Nummer, gefuellt aus der alten
  Rechnung; die Nummer ist gesperrt. "PDF erstellen" fragt noch einmal nach
  und legt bei Nein das Storno-PDF wieder weg - dann bleibt allein das
  Original stehen.
- Die alte Rechnung wird nie angefasst.
- Metadaten v3: art / storno_zu / ersetzt / storno_nummer, dazu Anschrift und
  Positionen, damit eine Rechnung wieder ins Formular geladen werden kann.
  Alle Felder aus v1/v2 bleiben unveraendert - daran haengt das Steuerjournal.
- Zaehler laeuft nur noch vorwaerts; eine vergebene Nummer wird nicht mehr
  ueberschrieben, sondern die naechste freie angeboten.
- Combobox im Storno-Dialog blieb leer: die StringVar hing nur an einer
  lokalen Variable und wurde weggeraeumt. Jetzt ohne textvariable.

Prueflauf (pruef_storno.py, 36 Pruefungen gruen): echte PDFs in einem
Wegwerf-Ordner, danach liest der Steuerrechner den Ordner - 001 + Storno +
Korrektur ergeben genau die berichtigte Rechnung, kein Beleg unsicher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 19:06:45 +02:00
TheMockTv
1564d769c9 Nur eine Instanz: zweiter Start holt das offene Fenster nach vorn
Zwei offene Fenster lesen beim Start denselben Zaehler aus der config und
schlagen darum dieselbe Rechnungsnummer vor - die Nummer wurde doppelt
vergeben.

- Windows-Mutex sperrt eine zweite Instanz ab.
- Die laufende Instanz legt ihre PID neben der config.json ab; die zweite
  sucht darueber das Fenster (nicht ueber den Titel - der haengt am
  Firmennamen aus der config) und holt es nach vorn.
- Popup nur noch als Rueckfall, wenn kein Fenster gefunden wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
2026-09-02 18:28:58 +02:00
TheMockTv
a0cfc31ae6 Kenndaten-Metadaten v2: USt-Aufteilung je Satz
Der Steuerrechner braucht Brutto->Netto je Steuersatz; auf einer Rechnung
stecken 7 % (Uebernachtung) und 19 % (Waesche, Gas, Rad) gemischt drin, und
die Aufteilung stand bisher nur im Fliesstext.

Neu in /Subject: netto_gesamt, ust_gesamt, ust_bloecke[{satz,netto,ust,brutto}].
Alle Felder der Version 1 bleiben unveraendert - am zwischensumme/steuer_satz/
steuer_betrag haengt der Import des Steuerjournals.

Verifiziert an einer gemischten Testrechnung (7 % + 19 %): Summe der Bloecke
= Zwischensumme = netto_gesamt + ust_gesamt; Steuerjournal liest die v2-PDF
unveraendert ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TxFbNUnzkEk7FRFcZQoepm
2026-08-01 12:33:53 +02:00
16 changed files with 3071 additions and 508 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ config.json
_tmp/
*.7z.*
release/
rechnungstool.pid

792
app.py
View file

@ -8,60 +8,36 @@ Bedienung:
3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
Aufgeteilt in:
helfer.py - Zahlen, Datum, Namen, Pruefungen
bestand.py - was im Rechnungsordner liegt (nur lesen)
storno.py - den spiegelbildlichen Storno rechnen
einzelinstanz.py - nur ein Fenster zulassen
theme.py - Farben nach Windows hell/dunkel
korrektur.py - Storno- und Berichtigungsweg
einstellungen.py - die Dialoge aus dem Menue "Einstellungen"
"""
import os
import sys
import datetime
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinter import ttk, messagebox
import config
import berechnung
import pdf_renderer
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
from bestand import naechste_freie_nummer, vorhandene_rechnungen
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
laufende_pid_lesen)
from theme import ThemeMixin
from korrektur import KorrekturMixin
from einstellungen import EinstellungenMixin
def parse_num(s):
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
s = (s or "").strip().replace(",", ".")
if not s:
return 0.0
try:
return float(s)
except ValueError:
return 0.0
def parse_date(s):
"""'28.01.2026' -> date; sonst None"""
s = (s or "").strip()
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def safe_filename(s):
keep = "-_.() "
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
if not sys.platform.startswith("win"):
return False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
except OSError:
return False
class RechnungsApp(tk.Tk):
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def __init__(self):
super().__init__()
self.cfg = config.load_config()
@ -74,6 +50,12 @@ class RechnungsApp(tk.Tk):
self.anzahl_vars = {} # nr -> StringVar
self.personen_vars = {} # nr -> StringVar
self.gesamt_lbls = {} # nr -> Label
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
self.storno_stand = None
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
self.berichtigung_stand = None
self._apply_theme() # Farben nach Windows hell/dunkel
self._build_menu()
@ -84,95 +66,15 @@ class RechnungsApp(tk.Tk):
self._neue_nummer_vorschlagen()
self.recalc()
# Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
self.protocol("WM_DELETE_WINDOW", self._beenden)
# Titelleiste dunkel (nach dem Mappen des Fensters)
self.after(60, lambda: self._dark_titlebar(self))
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
self.after(250, self._frage_speicherort_falls_noetig)
# ----------------------------------------------------------------- Theme
def _apply_theme(self):
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
dark = windows_dark_mode()
self._dark_mode = dark
if dark:
self.col_bg = "#23272e" # Fenster-Hintergrund
self.col_fg = "#e6e6e6" # Schrift
self.col_field = "#2f343c" # Eingabefelder
self.col_sel = "#3a4a44" # aktiv/hover
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
self.col_line = "#3a3f47"
self.col_scroll = "#4a505a" # Scroll-Griff
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
else:
self.col_bg = "#f3f3f3"
self.col_fg = "#1a1a1a"
self.col_field = "#ffffff"
self.col_sel = "#cfe3d8"
self.col_accent = "#1f5c3d"
self.col_line = "#cccccc"
self.col_scroll = "#b8b8b8"
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
self.configure(bg=self.col_bg)
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
self.col_sel, self.col_accent)
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg)
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
style.configure("TLabelframe.Label", background=bg, foreground=accent)
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
arrowcolor=bg, padding=(10, 3))
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)])
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
style.configure("TSeparator", background=self.col_line)
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen."""
d.configure(bg=self.col_bg)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)
# ---------------------------------------------------------------- Menue
def _build_menu(self):
@ -185,7 +87,7 @@ class RechnungsApp(tk.Tk):
("Formular zurücksetzen", self.reset_formular),
("Ausgabeordner öffnen", self._ordner_oeffnen),
("-", None),
("Beenden", self.destroy),
("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist
],
"Einstellungen": [
("Steuersätze…", self.dlg_steuern),
@ -205,7 +107,9 @@ class RechnungsApp(tk.Tk):
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.bind_all("<Control-p>", lambda e: self.pdf_erstellen())
# 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
@ -271,16 +175,25 @@ class RechnungsApp(tk.Tk):
self.k_strasse = tk.StringVar()
self.k_plzort = tk.StringVar()
felder = [
("Anrede (optional)", self.k_anrede),
("Vorname (optional)", self.k_vorname),
("Nachname / Firma", self.k_name),
("Anrede", self.k_anrede),
("Vorname *", self.k_vorname),
("Nachname / Firma *", self.k_name),
("Zusatz (optional)", self.k_zusatz),
("Straße / Nr.", self.k_strasse),
("PLZ / Ort", self.k_plzort),
("Straße / Nr. *", self.k_strasse),
("PLZ / Ort *", self.k_plzort),
]
for i, (lbl, var) in enumerate(felder):
ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
if var is self.k_anrede:
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
else:
w = ttk.Entry(kunde, textvariable=var, width=38)
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
foreground=self.col_muted, wraplength=330, justify="left").grid(
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
kunde.columnconfigure(1, weight=1)
# Rechnungsinfos (rechts)
@ -302,11 +215,20 @@ class RechnungsApp(tk.Tk):
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
ent = ttk.Entry(info, textvariable=var, width=18)
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
if var is self.r_nummer:
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
# vergibt dann das Programm, damit keine doppelt sein kann.
self.nummer_entry = ent
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
ent.bind("<KeyRelease>", self._auto_datum_evt)
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid(
row=4, column=1, sticky="w", padx=(8, 0))
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
self.storno_hinweis = tk.StringVar(value="")
ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020",
font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid(
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
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())
@ -439,6 +361,10 @@ class RechnungsApp(tk.Tk):
links = ttk.Frame(f)
links.pack(side="left")
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
ttk.Button(links, text="Storno", command=self.dlg_storno,
width=12).pack(side="left", padx=4)
ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen,
width=14).pack(side="left", padx=4)
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
# --------------------------------------------------------- Berechnung
@ -483,12 +409,30 @@ class RechnungsApp(tk.Tk):
# ----------------------------------------------------------- Aktionen
def _neue_nummer_vorschlagen(self):
jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year)
zaehler = self.cfg.get("rechnung_zaehler", 0)
heute_jahr = datetime.date.today().year
if heute_jahr != jahr:
jahr, zaehler = heute_jahr, 0
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
"""Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander.
Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld
zu diesem Vorgang und wird nicht angefasst.
"""
if self.storno_stand or self.berichtigung_stand:
return
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
if frei:
self.r_nummer.set(frei)
def kunde_pruefen(self):
"""Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung.
Saeubert die Felder und prueft sie mit kunde_maengel().
"""
for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
self.k_strasse, self.k_plzort):
var.set(saeubern(var.get()))
return kunde_maengel({"vorname": self.k_vorname.get(),
"nachname": self.k_name.get(),
"strasse": self.k_strasse.get(),
"plzort": self.k_plzort.get()})
def pdf_erstellen(self):
r = self.recalc()
@ -500,9 +444,45 @@ class RechnungsApp(tk.Tk):
if not nummer:
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
return
if not self.k_name.get().strip():
if not messagebox.askyesno("Kein Kundenname",
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
# Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
maengel = self.kunde_pruefen()
if maengel:
messagebox.showwarning(
"Kundenangaben fehlen",
"So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) +
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
"Rechnung (§ 14 Abs. 4 Nr. 1 UStG).")
return
# Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne
# (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer.
if not ziffern(self.k_strasse.get()):
if not messagebox.askyesno(
"Hausnummer fehlt",
f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n"
"Trotzdem so auf die Rechnung schreiben?"):
return
# Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht
# mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder
# eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum
# Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin
# schon, darum hier nur bei einer ganz normalen Rechnung.
if not self.storno_stand and not self.berichtigung_stand:
wer = " ".join(t for t in (self.k_vorname.get().strip(),
self.k_name.get().strip()) if t)
if not messagebox.askyesno(
"Rechnung endgültig erstellen?",
f"Rechnung {nummer} für {wer or 'ohne Namen'} über "
f"{berechnung.eur(r.gesamt)}.\n\n"
"Sind alle Angaben richtig? Danach lässt sich die Rechnung "
"nicht mehr ändern.\n\n"
"Eine ausgestellte Rechnung darf weder überschrieben noch "
"gelöscht werden. Korrigiert wird sie nur noch über "
"„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche "
"Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt "
"es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).",
icon="question", default="yes"):
return
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
@ -513,6 +493,26 @@ class RechnungsApp(tk.Tk):
namenszeile = " ".join(t for t in (vorname, nachname) if t)
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()]
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
stand = self.storno_stand
if stand:
if not messagebox.askyesno(
"Storno wirklich durchführen?",
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
f"und {nummer} wird die berichtigte Rechnung.\n\n"
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
"ihm zugegangen ist.\n\n"
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
icon="warning", default="yes"):
self._storno_verwerfen("vom Benutzer verworfen")
messagebox.showinfo(
"Storno verworfen",
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
return
kopf = {
"rechnungsnummer": nummer,
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
@ -522,8 +522,92 @@ class RechnungsApp(tk.Tk):
"kunde_zeilen": kunde_zeilen,
"nachname": nachname,
"vorname": vorname,
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
"strasse": self.k_strasse.get().strip(),
"plzort": self.k_plzort.get().strip()},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen],
}
if stand:
kopf["art"] = "korrektur"
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
kopf["ersetzt"] = stand["zu"]
kopf["storno_nummer"] = stand["nummer"]
out_dir = config.ensure_output_dir(self.cfg)
# ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ----
ber = self.berichtigung_stand
if ber:
kopf["art"] = "berichtigung"
kopf["vorgang"] = ber.get("vorgang") or ber["zu"]
kopf["berichtigt_zu"] = ber["zu"]
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
if not messagebox.askyesno(
"Berichtigte Rechnung erstellen?",
f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes "
"Blatt geschrieben.\n\n"
"Nummer, Leistungen und Beträge bleiben unverändert – die alte "
"Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es "
"ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?",
default="yes"):
return
# Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das
# Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere
# Berichtigungen zur selben Rechnung auseinander (sortierbar).
pfad = os.path.join(
out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}"
f"_{safe_filename(ber['zu'])}.pdf")
if os.path.exists(pfad) and not messagebox.askyesno(
"Berichtigung von heute ersetzen?",
f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n"
f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"):
return
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
f"Berichtigung konnte nicht erstellt werden:\n{e}")
print("[berichtigung] FEHLER:", e)
return
print(f"[berichtigung] erstellt -> {pfad}")
self.berichtigung_stand = None
self._storno_anzeigen()
if messagebox.askyesno("Fertig",
f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n"
"Jetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
return
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
# Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen
# der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter
# Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
vorhanden = vorhandene_rechnungen(out_dir, nummer)
if vorhanden:
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
frei = naechste_freie_nummer(out_dir, self.cfg)
if not frei:
messagebox.showerror(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
return
if not messagebox.askyesno(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
icon="warning", default="yes"):
return
nummer = frei
self.r_nummer.set(nummer)
kopf["rechnungsnummer"] = nummer
fname = f"Rechnung_{safe_filename(nummer)}"
if self.k_name.get().strip():
fname += "_" + safe_filename(self.k_name.get().strip())
@ -542,8 +626,19 @@ class RechnungsApp(tk.Tk):
print(f"[pdf] erstellt -> {pfad}")
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
storno_pfad = None
if stand:
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
storno_pfad = stand["pfad"]
self.storno_stand = None
self._storno_anzeigen()
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
f"({os.path.basename(storno_pfad)}).\n"
"Beide Blätter gehören zum Gast.") + hinweis
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
if storno_pfad:
self._oeffne_datei(storno_pfad)
def _zaehler_aus_nummer_speichern(self, nummer):
try:
@ -551,12 +646,58 @@ class RechnungsApp(tk.Tk):
jahr, z = int(jahr_s), int(z_s)
except (ValueError, AttributeError):
return
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
# vergeben - genau der Fehler, den wir loswerden wollen.
alt_jahr = self.cfg.get("rechnung_jahr", 0)
alt_z = self.cfg.get("rechnung_zaehler", 0)
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
return
self.cfg["rechnung_jahr"] = jahr
self.cfg["rechnung_zaehler"] = z
config.save_config(self.cfg)
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben
# Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben
# ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt.
messagebox.showwarning(
"Rechnungsnummer nicht gespeichert",
f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n"
f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, "
"sonst wird die nächste Nummer noch einmal vergeben.")
self._neue_nummer_vorschlagen()
def _beenden(self):
"""Schliessen - mit Rückfrage, wenn ein Storno noch offen ist."""
if self.storno_stand:
if not messagebox.askyesno(
"Storno ist noch offen",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte "
"Rechnung fehlt noch.\n\n"
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
return
self.destroy()
def reset_formular(self):
# Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die
# neu eingetippten Daten plötzlich zum Storno der fremden Rechnung.
if self.storno_stand:
if not messagebox.askyesno(
"Storno verwerfen?",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} liegt noch bereit.\n\n"
"Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und "
f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n"
"Wirklich zurücksetzen?", icon="warning", default="no"):
return
self._storno_verwerfen("beim Zurücksetzen verworfen")
elif self.berichtigung_stand:
# Da ist noch nichts geschrieben - einfach fallenlassen.
self.berichtigung_stand = None
self._storno_anzeigen()
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
v.set("")
@ -571,28 +712,6 @@ class RechnungsApp(tk.Tk):
def _ordner_oeffnen(self):
self._oeffne_datei(config.ensure_output_dir(self.cfg))
def _frage_speicherort_falls_noetig(self):
if (self.cfg.get("output_dir") or "").strip():
return
vorschlag = config.default_output_dir()
messagebox.showinfo(
"Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
initialdir=os.path.expanduser("~"))
self.cfg["output_dir"] = d if d else vorschlag
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
def dlg_speicherort(self):
aktuell = config.ensure_output_dir(self.cfg)
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
if d:
self.cfg["output_dir"] = d
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
def _oeffne_datei(self, pfad):
try:
@ -605,331 +724,24 @@ class RechnungsApp(tk.Tk):
except Exception as e: # noqa: BLE001
messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
def _info(self):
d = tk.Toplevel(self)
d.title("Info")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
frm = ttk.Frame(d, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(frm, justify="left",
text="Daten eingeben, PDF erstellen, drucken.\n"
"Steuersätze, Preise und Firmendaten lassen sich\n"
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
d.bind("<Escape>", lambda e: d.destroy())
d.bind("<Return>", lambda e: d.destroy())
d.grab_set()
# ====================================================== Einstellungen
def dlg_steuern(self):
d = tk.Toplevel(self)
d.title("Steuersätze")
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("620x470")
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"),
text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses "
"Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei "
"'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den "
"Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8))
liste = ttk.Frame(frm)
liste.pack(fill="x")
arbeit = []
for s in self.cfg.get("steuersaetze", []):
arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))),
"bez": tk.StringVar(value=s.get("bezeichnung", "")),
"extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))})
def render_saetze():
for w in liste.winfo_children():
w.destroy()
for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]):
ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 3), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2)
ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w")
ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2)
ttk.Button(liste, text="✕", width=3,
command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4)
def satz_hinzufuegen():
arbeit.append({"satz": tk.StringVar(value="0"),
"bez": tk.StringVar(value="Mehrwertsteuer"),
"extra": tk.BooleanVar(value=False)})
render_saetze()
render_saetze()
ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0))
def speichern():
neu = []
for row in arbeit:
s = row["satz"].get().strip()
if s == "":
continue
neu.append({"satz": int(parse_num(s)),
"bezeichnung": row["bez"].get().strip() or "USt",
"extra_blatt": bool(row["extra"].get())})
if not neu:
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
return
self.cfg["steuersaetze"] = neu
config.save_config(self.cfg)
self._render_positionszeilen()
self.recalc()
d.destroy()
btns = ttk.Frame(frm)
btns.pack(side="bottom", fill="x", pady=(12, 0))
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
def dlg_katalog(self):
d = tk.Toplevel(self)
d.title("Leistungskatalog / Preise")
d.transient(self)
d.grab_set()
self._style_dialog(d)
info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left",
text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob "
"pro Person gerechnet wird. Weitere Steuersätze legst du unter "
"Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X "
"rechts eine Leistung entfernen.")
info_lbl.pack(side="top", fill="x")
# alle definierten Steuersaetze sind je Position waehlbar
saetze_cfg = self.cfg.get("steuersaetze", [])
def satz_label(satz):
for s in saetze_cfg:
if int(s.get("satz", -1)) == int(satz or 0):
return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –")
return f"{satz} %"
def combo_werte(aktuell):
opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg]
al = satz_label(aktuell)
if al not in opts:
opts.append(al)
return opts
def satz_aus_label(lbl):
try:
return int(parse_num(str(lbl).split("%")[0]))
except (ValueError, IndexError):
return 0
# Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen)
arbeit = []
for pos in self.cfg["katalog"]:
arbeit.append({
"leistung": tk.StringVar(value=pos["leistung"]),
"satz": tk.StringVar(value=str(pos["satz"])),
"preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])),
"pro_person": tk.BooleanVar(value=bool(pos["pro_person"])),
})
def render():
for w in inner.winfo_children():
w.destroy()
for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]):
ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 4), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1)
ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we")
cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get()))
cb.set(satz_label(row["satz"].get()))
cb.bind("<<ComboboxSelected>>",
lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get()))))
cb.grid(row=i, column=2, padx=4, pady=1, sticky="w")
ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1)
ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1)
ttk.Button(inner, text="✕", width=3,
command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1)
def hinzufuegen():
arbeit.append({
"leistung": tk.StringVar(value="Neue Leistung"),
"satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"),
"preis": tk.StringVar(value="0,00"),
"pro_person": tk.BooleanVar(value=False),
})
render()
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox("all"))
canvas.yview_moveto(1.0)
def speichern():
neu = []
for i, row in enumerate(arbeit, start=1):
name = row["leistung"].get().strip()
if not name:
continue # leere Zeilen verwerfen
neu.append({
"nr": i,
"leistung": name,
"satz": int(parse_num(row["satz"].get())),
"preis": parse_num(row["preis"].get()),
"pro_person": bool(row["pro_person"].get()),
})
if not neu:
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
return
self.cfg["katalog"] = neu
config.save_config(self.cfg)
self._render_positionszeilen()
self.recalc()
d.destroy()
# Buttonleiste unten zuerst -> bleibt immer sichtbar
btns = ttk.Frame(d, padding=8)
btns.pack(side="bottom", fill="x")
ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4)
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
# Scrollbereich
body = ttk.Frame(d)
body.pack(side="top", fill="both", expand=True)
canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg)
sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas, padding=8)
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=sb.set)
canvas.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
render()
# Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile)
d.update_idletasks()
inhalt_w = inner.winfo_reqwidth()
canvas.configure(width=inhalt_w)
win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80)
win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90,
d.winfo_screenheight() - 120)
d.geometry(f"{win_w}x{int(win_h)}")
d.minsize(min(win_w, 820), 420)
info_lbl.configure(wraplength=win_w - 24)
def dlg_firma(self):
d = tk.Toplevel(self)
d.title("Firmendaten & Texte")
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("640x640")
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
felder = [
("name", "Firmenname"), ("untertitel", "Untertitel"),
("absender", "Absenderzeile"), ("inhaber", "Inhaber"),
("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"),
("steuernummer", "Steuernummer"), ("homepage", "Homepage"),
("email", "E-Mail"), ("telefon", "Telefon"),
]
vars_ = {}
for i, (key, lbl) in enumerate(felder):
ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
vars_[key] = v
frm.columnconfigure(1, weight=1)
base = len(felder)
# Logo (Bilddatei) mit Durchsuchen / Entfernen
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
logo_row = ttk.Frame(frm)
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
def logo_waehlen():
p = filedialog.askopenfilename(
title="Logo auswählen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
if p:
v_logo.set(p)
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
def speichern():
for key, v in vars_.items():
self.cfg["firma"][key] = v.get()
self.cfg["firma"]["logo"] = v_logo.get().strip()
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
self.cfg["texte"]["hinweis"] = vh.get().strip()
config.save_config(self.cfg)
self.title("Rechnungstool - " + self.cfg["firma"].get("name", ""))
d.destroy()
btns = ttk.Frame(frm)
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
def dlg_nummer(self):
d = tk.Toplevel(self)
d.title("Rechnungsnummer")
d.transient(self)
d.grab_set()
self._style_dialog(d)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year)))
v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0)))
ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n"
"vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.",
justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3)
ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3)
ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3)
ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3)
def speichern():
self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get()))
self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get()))
config.save_config(self.cfg)
self._neue_nummer_vorschlagen()
d.destroy()
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
if __name__ == "__main__":
# Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen.
_sperre = einzelinstanz_pruefen()
if _sperre is False:
# Laeuft schon: dessen Fenster nach vorn holen und still beenden.
if not fenster_nach_vorn(laufende_pid_lesen()):
_r = tk.Tk()
_r.withdraw()
messagebox.showerror(
"Rechnungstool läuft bereits",
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""")
_r.destroy()
sys.exit(0)
eigene_pid_merken()
app = RechnungsApp()
app.mainloop()

208
bestand.py Normal file
View file

@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""Was im Rechnungsordner liegt - lesen, nicht schreiben.
Der Ordner ist die Buchhaltung: welche Nummern vergeben sind, was in einer alten
Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen.
"""
import datetime
import json
import os
import config
from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename
def vorhandene_rechnungen(ordner, nummer):
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
unter einem anderen Namen als zweite Datei durch.
"""
# Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben
# Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal
# vergeben.
koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer))
try:
namen = os.listdir(ordner)
except OSError:
return []
# Windows haengt beim Kopieren " - Kopie" an oder setzt "Kopie von " davor.
VORSAETZE = ("kopie von ", "kopie (2) von ", "copy of ")
treffer = []
for name in namen:
if not name.lower().endswith(".pdf"):
continue
stamm = name[:-4]
for vorsatz in VORSAETZE:
if stamm.lower().startswith(vorsatz):
stamm = stamm[len(vorsatz):]
break
# Nach der Nummer darf alles kommen, nur keine weitere Ziffer. Damit
# zaehlen auch "Rechnung_2026-013 (1).pdf" und "... - Kopie.pdf" mit -
# genau so sind die alten Dubletten entstanden. "2026-0135" faellt raus,
# weil dort eine Ziffer folgt.
klein = stamm.lower()
for k in koepfe:
if klein.startswith(k.lower()) and (
len(stamm) == len(k) or not stamm[len(k)].isdigit()):
treffer.append(os.path.join(ordner, name))
break
return sorted(treffer)
def kenndaten_lesen(pfad):
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
spiegeln, was rausgegangen ist.
"""
try:
from pypdf import PdfReader
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
if not str(roh).strip().startswith("{"):
return None
daten = json.loads(str(roh))
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
return None
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
return None
daten["_pfad"] = pfad
return daten
def adresse_aus_pdf(pfad):
"""Adressblock aus dem Fliesstext einer Rechnung lesen.
Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren.
Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer.
"""
try:
from pypdf import PdfReader
zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines()
except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF
print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})")
return {}
start = next((i for i, z in enumerate(zeilen)
if z.strip().startswith("Rechnung an")), None)
if start is None:
return {}
block = []
for z in zeilen[start + 1:start + 8]:
t = z.strip()
if not t or t.startswith(("Rechnungsnummer", "Stornonummer")):
break
block.append(t)
if not block:
return {}
daten = {}
if block[0] in ANREDEN:
daten["anrede"] = block.pop(0)
# PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und
# noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn
# sie nicht selbst die Namenszeile ist.
for i in range(len(block) - 1, -1, -1):
teile = block[i].split()
if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5:
daten["plzort"] = block.pop(i)
if i - 1 >= 1:
daten["strasse"] = block.pop(i - 1)
break
if block:
daten["name"] = block.pop(0)
if block:
daten["zusatz"] = " ".join(block)
return daten
def kundendaten_ermitteln(meta):
"""Kunde einer alten Rechnung so vollstaendig wie moeglich.
Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext.
Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen
entstanden -, wird das letzte Wort zum Nachnamen.
Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur
aus dem Text kamen und deshalb geprueft gehoeren.
"""
kunde = dict(meta.get("kunde") or {})
woher = []
vorname = saeubern(kunde.get("vorname") or meta.get("vorname"))
nachname = saeubern(kunde.get("nachname") or meta.get("nachname"))
if not kunde.get("plzort") and meta.get("_pfad"):
aus_text = adresse_aus_pdf(meta["_pfad"])
for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"),
("strasse", "Straße"), ("plzort", "PLZ / Ort")):
if aus_text.get(feld) and not kunde.get(feld):
kunde[feld] = aus_text[feld]
woher.append(klartext)
if not (vorname or nachname) and aus_text.get("name"):
vorname = aus_text["name"]
woher.append("Name")
if not nachname:
# Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt
# wird am Leerzeichen: das letzte Wort ist der Nachname.
vorname, nachname = name_aufteilen(vorname)
if nachname:
woher.append("Nachname vom Vornamen abgetrennt")
kunde["vorname"], kunde["nachname"] = vorname, nachname
return kunde, woher
def rechnungen_im_ordner(ordner):
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
stornierbar ist, entscheidet der Aufrufer.
"""
try:
namen = sorted(os.listdir(ordner))
except OSError:
return []
gefunden = []
for name in namen:
# Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob
# eine PDF von uns stammt, sagen ohnehin erst die Kenndaten.
if not name.lower().endswith(".pdf"):
continue
daten = kenndaten_lesen(os.path.join(ordner, name))
if daten:
gefunden.append(daten)
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
return gefunden
def naechste_freie_nummer_nach(ordner, cfg, vergeben):
"""Erste freie Nummer NACH <vergeben> - fuer die Rechnung, die auf ein Storno folgt.
Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon
mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch
einmal.
"""
kandidat = naechste_freie_nummer(ordner, cfg)
if kandidat and kandidat != vergeben:
return kandidat
ersatz = dict(cfg)
try:
jahr, z = str(vergeben).split("-")
ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z)
except (ValueError, AttributeError):
return kandidat
return naechste_freie_nummer(ordner, ersatz)
def naechste_freie_nummer(ordner, cfg):
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
"""
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
z = cfg.get("rechnung_zaehler", 0)
# Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends
# sonst - sonst schlaegt das Formular 2026-001 vor und der Storno nimmt
# gleichzeitig noch 2025-088.
if datetime.date.today().year != jahr:
jahr, z = datetime.date.today().year, 0
for _ in range(1000):
z += 1
kandidat = config.format_rechnungsnummer(jahr, z)
if not vorhandene_rechnungen(ordner, kandidat):
return kandidat
return None

View file

@ -16,11 +16,59 @@ import sys
# - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar),
# gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS
if getattr(sys, "frozen", False):
BASE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR)
EXE_DIR = os.path.dirname(sys.executable)
RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR)
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = BASE_DIR
EXE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = EXE_DIR
def _beschreibbar(ordner):
"""Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten."""
try:
os.makedirs(ordner, exist_ok=True)
probe = os.path.join(ordner, ".schreibprobe")
with open(probe, "w") as f:
f.write("x")
os.remove(probe)
return True
except OSError:
return False
def _daten_ordner():
"""Der Ordner, den das Programm sich beim ersten Start selbst anlegt.
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
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
dass das Programm ohne Firmendaten und Zaehler neu anfaengt.
"""
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")
if not _beschreibbar(kandidat):
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool")
if not _beschreibbar(kandidat):
return EXE_DIR # letzter Ausweg: wie frueher
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
return kandidat
BASE_DIR = _daten_ordner()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).

366
einstellungen.py Normal file
View file

@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
"""Die Dialoge aus dem Menue "Einstellungen" - Steuersaetze, Katalog, Firma, Nummer.
Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht
hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
"""
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import berechnung
import config
from helfer import parse_num
class EinstellungenMixin:
"""Einstellungsdialoge. Erwartet die Felder der RechnungsApp."""
def _frage_speicherort_falls_noetig(self):
if (self.cfg.get("output_dir") or "").strip():
return
vorschlag = config.default_output_dir()
messagebox.showinfo(
"Speicherort der Rechnungen",
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
initialdir=os.path.expanduser("~"))
self.cfg["output_dir"] = d if d else vorschlag
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
def dlg_speicherort(self):
aktuell = config.ensure_output_dir(self.cfg)
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
if d:
self.cfg["output_dir"] = d
config.save_config(self.cfg)
config.ensure_output_dir(self.cfg)
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
def _info(self):
d = tk.Toplevel(self)
d.title("Info")
d.transient(self)
d.resizable(False, False)
self._style_dialog(d)
frm = ttk.Frame(d, padding=18)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
foreground=self.col_accent).pack(anchor="w")
ttk.Label(frm, justify="left",
text="Daten eingeben, PDF erstellen, drucken.\n"
"Steuersätze, Preise und Firmendaten lassen sich\n"
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
d.bind("<Escape>", lambda e: d.destroy())
d.bind("<Return>", lambda e: d.destroy())
d.grab_set()
# ====================================================== Einstellungen
def dlg_steuern(self):
d = tk.Toplevel(self)
d.title("Steuersätze")
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("620x470")
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"),
text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses "
"Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei "
"'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den "
"Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8))
liste = ttk.Frame(frm)
liste.pack(fill="x")
arbeit = []
for s in self.cfg.get("steuersaetze", []):
arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))),
"bez": tk.StringVar(value=s.get("bezeichnung", "")),
"extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))})
def render_saetze():
for w in liste.winfo_children():
w.destroy()
for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]):
ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 3), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2)
ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w")
ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2)
ttk.Button(liste, text="✕", width=3,
command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4)
def satz_hinzufuegen():
arbeit.append({"satz": tk.StringVar(value="0"),
"bez": tk.StringVar(value="Mehrwertsteuer"),
"extra": tk.BooleanVar(value=False)})
render_saetze()
render_saetze()
ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0))
def speichern():
neu = []
for row in arbeit:
s = row["satz"].get().strip()
if s == "":
continue
neu.append({"satz": int(parse_num(s)),
"bezeichnung": row["bez"].get().strip() or "USt",
"extra_blatt": bool(row["extra"].get())})
if not neu:
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
return
self.cfg["steuersaetze"] = neu
config.save_config(self.cfg)
self._render_positionszeilen()
self.recalc()
d.destroy()
btns = ttk.Frame(frm)
btns.pack(side="bottom", fill="x", pady=(12, 0))
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
def dlg_katalog(self):
d = tk.Toplevel(self)
d.title("Leistungskatalog / Preise")
d.transient(self)
d.grab_set()
self._style_dialog(d)
info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left",
text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob "
"pro Person gerechnet wird. Weitere Steuersätze legst du unter "
"Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X "
"rechts eine Leistung entfernen.")
info_lbl.pack(side="top", fill="x")
# alle definierten Steuersaetze sind je Position waehlbar
saetze_cfg = self.cfg.get("steuersaetze", [])
def satz_label(satz):
for s in saetze_cfg:
if int(s.get("satz", -1)) == int(satz or 0):
return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –")
return f"{satz} %"
def combo_werte(aktuell):
opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg]
al = satz_label(aktuell)
if al not in opts:
opts.append(al)
return opts
def satz_aus_label(lbl):
try:
return int(parse_num(str(lbl).split("%")[0]))
except (ValueError, IndexError):
return 0
# Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen)
arbeit = []
for pos in self.cfg["katalog"]:
arbeit.append({
"leistung": tk.StringVar(value=pos["leistung"]),
"satz": tk.StringVar(value=str(pos["satz"])),
"preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])),
"pro_person": tk.BooleanVar(value=bool(pos["pro_person"])),
})
def render():
for w in inner.winfo_children():
w.destroy()
for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]):
ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid(
row=0, column=c, padx=4, pady=(0, 4), sticky="w")
for i, row in enumerate(arbeit, start=1):
ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1)
ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we")
cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get()))
cb.set(satz_label(row["satz"].get()))
cb.bind("<<ComboboxSelected>>",
lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get()))))
cb.grid(row=i, column=2, padx=4, pady=1, sticky="w")
ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1)
ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1)
ttk.Button(inner, text="✕", width=3,
command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1)
def hinzufuegen():
arbeit.append({
"leistung": tk.StringVar(value="Neue Leistung"),
"satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"),
"preis": tk.StringVar(value="0,00"),
"pro_person": tk.BooleanVar(value=False),
})
render()
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox("all"))
canvas.yview_moveto(1.0)
def speichern():
neu = []
for i, row in enumerate(arbeit, start=1):
name = row["leistung"].get().strip()
if not name:
continue # leere Zeilen verwerfen
neu.append({
"nr": i,
"leistung": name,
"satz": int(parse_num(row["satz"].get())),
"preis": parse_num(row["preis"].get()),
"pro_person": bool(row["pro_person"].get()),
})
if not neu:
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
return
self.cfg["katalog"] = neu
config.save_config(self.cfg)
self._render_positionszeilen()
self.recalc()
d.destroy()
# Buttonleiste unten zuerst -> bleibt immer sichtbar
btns = ttk.Frame(d, padding=8)
btns.pack(side="bottom", fill="x")
ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4)
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
# Scrollbereich
body = ttk.Frame(d)
body.pack(side="top", fill="both", expand=True)
canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg)
sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas, padding=8)
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=sb.set)
canvas.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
render()
# Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile)
d.update_idletasks()
inhalt_w = inner.winfo_reqwidth()
canvas.configure(width=inhalt_w)
win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80)
win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90,
d.winfo_screenheight() - 120)
d.geometry(f"{win_w}x{int(win_h)}")
d.minsize(min(win_w, 820), 420)
info_lbl.configure(wraplength=win_w - 24)
def dlg_firma(self):
d = tk.Toplevel(self)
d.title("Firmendaten & Texte")
d.transient(self)
d.grab_set()
self._style_dialog(d)
d.geometry("640x640")
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
felder = [
("name", "Firmenname"), ("untertitel", "Untertitel"),
("absender", "Absenderzeile"), ("inhaber", "Inhaber"),
("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"),
("steuernummer", "Steuernummer"), ("homepage", "Homepage"),
("email", "E-Mail"), ("telefon", "Telefon"),
]
vars_ = {}
for i, (key, lbl) in enumerate(felder):
ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
vars_[key] = v
frm.columnconfigure(1, weight=1)
base = len(felder)
# Logo (Bilddatei) mit Durchsuchen / Entfernen
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
logo_row = ttk.Frame(frm)
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
def logo_waehlen():
p = filedialog.askopenfilename(
title="Logo auswählen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
if p:
v_logo.set(p)
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
def speichern():
for key, v in vars_.items():
self.cfg["firma"][key] = v.get()
self.cfg["firma"]["logo"] = v_logo.get().strip()
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
self.cfg["texte"]["hinweis"] = vh.get().strip()
config.save_config(self.cfg)
self.title("Rechnungstool - " + self.cfg["firma"].get("name", ""))
d.destroy()
btns = ttk.Frame(frm)
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
def dlg_nummer(self):
d = tk.Toplevel(self)
d.title("Rechnungsnummer")
d.transient(self)
d.grab_set()
self._style_dialog(d)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year)))
v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0)))
ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n"
"vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.",
justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3)
ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3)
ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3)
ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3)
def speichern():
self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get()))
self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get()))
config.save_config(self.cfg)
self._neue_nummer_vorschlagen()
d.destroy()
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")

90
einzelinstanz.py Normal file
View file

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""Nur ein Fenster zulassen und das offene nach vorn holen.
Zwei Fenster lesen beim Start denselben Zähler und schlagen dieselbe
Rechnungsnummer vor - die Nummer wird dann doppelt vergeben.
"""
import os
import sys
import config
def einzelinstanz_pruefen():
"""Sperrt das Programm gegen ein zweites Fenster.
Beide Fenster lesen beim Start denselben Zaehler aus der config und
schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt
vergeben. Darum: nur eine Instanz je Windows-Sitzung.
Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder
False, wenn schon eine Instanz laeuft.
"""
if not sys.platform.startswith("win"):
return None
import ctypes
from ctypes import wintypes
ERROR_ALREADY_EXISTS = 183
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
k32.CreateMutexW.restype = wintypes.HANDLE
handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
return False
return handle
# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json.
PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid")
def eigene_pid_merken():
"""Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet."""
try:
with open(PID_DATEI, "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except OSError:
pass
def laufende_pid_lesen():
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""
try:
with open(PID_DATEI, "r", encoding="ascii") as f:
return int(f.read().strip())
except (OSError, ValueError):
return None
def fenster_nach_vorn(pid):
"""Holt das Fenster des Prozesses <pid> nach vorn. True bei Erfolg.
Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel
haengt am Firmennamen aus der config und aendert sich mit ihm.
"""
if not pid or not sys.platform.startswith("win"):
return False
import ctypes
from ctypes import wintypes
SW_RESTORE = 9
u32 = ctypes.WinDLL("user32", use_last_error=True)
u32.IsWindowVisible.argtypes = [wintypes.HWND]
u32.IsIconic.argtypes = [wintypes.HWND]
u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u32.SetForegroundWindow.argtypes = [wintypes.HWND]
u32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
treffer = []
def besuch(hwnd, _lparam):
fremd = wintypes.DWORD()
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd))
# nur das echte Hauptfenster: sichtbar und mit Titel
if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd):
treffer.append(hwnd)
return False
return True
CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
u32.EnumWindows(CB(besuch), 0)
if not treffer:
return False
hwnd = treffer[0]
if u32.IsIconic(hwnd):
u32.ShowWindow(hwnd, SW_RESTORE)
u32.SetForegroundWindow(hwnd)
return True

87
helfer.py Normal file
View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Kleine Helfer, die überall gebraucht werden: Zahlen, Datum, Namen, Prüfungen."""
import datetime
import config
def parse_num(s):
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
s = (s or "").strip().replace(",", ".")
if not s:
return 0.0
try:
return float(s)
except ValueError:
return 0.0
def parse_date(s):
"""'28.01.2026' -> date; sonst None"""
s = (s or "").strip()
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def safe_filename(s):
keep = "-_.() "
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
def saeubern(s):
"""Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem.
Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss
genau das, was nachher auf dem Blatt steht.
"""
return " ".join(str(s or "").split())
def buchstaben(s):
return sum(1 for c in str(s) if c.isalpha())
def ziffern(s):
return sum(1 for c in str(s) if c.isdigit())
# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt
# tippbar, damit Sonderfaelle nicht am Programm scheitern.
ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"]
def nummer_normalisieren(s):
"""'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck.
Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen.
"""
s = str(s or "").strip()
teile = s.split("-")
if len(teile) == 2:
try:
return config.format_rechnungsnummer(int(teile[0]), int(teile[1]))
except ValueError:
pass
return s
def kunde_maengel(kunde):
"""Beanstandungen an Name und Anschrift; leere Liste = in Ordnung.
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
Anschrift. Vier Ziffern reichen, damit auch oesterreichische und
niederlaendische Postleitzahlen durchgehen.
"""
maengel = []
for feld, schluessel in (("Vorname", "vorname"),
("Nachname / Firma", "nachname"),
("Straße / Nr.", "strasse")):
wert = saeubern(kunde.get(schluessel))
if not wert:
maengel.append(f"{feld}: ist leer")
elif buchstaben(wert) < 2:
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
ort = saeubern(kunde.get("plzort"))
if not ort:
maengel.append("PLZ / Ort: ist leer")
elif ziffern(ort) < 4 or buchstaben(ort) < 2:
maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, "
"z. B. 04416 Markkleeberg")
return maengel
def name_aufteilen(ganz):
"""'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname."""
teile = str(ganz or "").split()
if len(teile) > 1:
return " ".join(teile[:-1]), teile[-1]
return "", (teile[0] if teile else "")

429
korrektur.py Normal file
View file

@ -0,0 +1,429 @@
# -*- coding: utf-8 -*-
"""Storno- und Berichtigungsweg.
Zwei Wege, ein Popup-Bauplan:
Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer,
danach die berichtigte Rechnung. Zwei Nummern verbraucht.
Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
Die alte Rechnung wird in beiden Faellen NIE angefasst. § 146 Abs. 4 AO verbietet
genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr
feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses
Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im
Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes
Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG,
§ 31 Abs. 5 UStDV).
"""
import datetime
import os
import tkinter as tk
from tkinter import ttk, messagebox
import berechnung
import config
import pdf_renderer
from bestand import (kundendaten_ermitteln, naechste_freie_nummer,
naechste_freie_nummer_nach, rechnungen_im_ordner)
from helfer import kunde_maengel, nummer_normalisieren, safe_filename
from storno import storno_rechnung_bauen
class KorrekturMixin:
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
# ------------------------------------------------------------- Storno
def _storno_anzeigen(self):
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
stand = self.storno_stand
if stand:
self.storno_hinweis.set(
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
f"Beim Erstellen wird noch einmal gefragt.")
self.nummer_entry.configure(state="readonly")
elif self.berichtigung_stand:
self.storno_hinweis.set(
f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n"
"Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.")
self.nummer_entry.configure(state="readonly")
else:
self.storno_hinweis.set("")
self.nummer_entry.configure(state="normal")
def _berichtigung_starten(self, meta, out_dir):
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
nummer = str(meta.get("rechnungsnummer", ""))
fehlend = self._formular_aus_meta(meta)
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
"vorgang": str(meta.get("vorgang") or nummer),
"meta": meta}
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
self.r_nummer.set(nummer)
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
self._storno_anzeigen()
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
messagebox.showinfo(
"Rechnung geladen",
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
f"bleibt liegen.{hinweis}")
def _storno_verwerfen(self, grund=""):
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
"""
stand = self.storno_stand
if not stand:
return
try:
if os.path.exists(stand["pfad"]):
os.remove(stand["pfad"])
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
except OSError as e: # noqa: BLE001
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
# obwohl der Nutzer sie behalten wollte.
messagebox.showerror(
"Storno konnte nicht gelöscht werden",
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
"und noch einmal versuchen. Es wurde nichts verändert.")
return
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
try:
config.save_config(self.cfg)
except OSError as e: # noqa: BLE001
messagebox.showwarning(
"Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
self.storno_stand = None
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if grund:
print(f"[storno] {grund}")
def dlg_storno(self):
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
self._dlg_korrektur("storno")
def dlg_berichtigen(self):
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
self._dlg_korrektur("berichtigung")
def _dlg_korrektur(self, modus):
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
"storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung
"berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer
"""
storno = modus == "storno"
if self.storno_stand:
messagebox.showinfo(
"Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return
if self.berichtigung_stand:
messagebox.showinfo(
"Berichtigung läuft schon",
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
return
out_dir = config.ensure_output_dir(self.cfg)
alle = rechnungen_im_ordner(out_dir)
stornos = {nummer_normalisieren(d.get("storno_zu")): d
for d in alle if d.get("art") == "storno"}
stornierte = set(stornos)
# Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung?
ersetzte = {nummer_normalisieren(d.get("ersetzt"))
for d in alle if d.get("art") == "korrektur"}
offen = [d for d in alle
if d.get("art") not in ("storno", "berichtigung")
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
if not offen:
messagebox.showwarning(
"Nichts zu korrigieren",
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
return
d = tk.Toplevel(self)
d.title("Rechnung stornieren" if storno else "Rechnung berichtigen")
d.transient(self)
d.grab_set()
self._style_dialog(d)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, justify="left", wraplength=470, text=(
"Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert "
"liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine "
"Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach "
"die berichtigte Rechnung."
if storno else
"Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, "
"Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt "
"nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, "
"dafür gibt es den Storno.")).grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3)
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
# dann die Tcl-Variable und das Feld steht leer da.
ent = ttk.Entry(frm, width=20)
ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0))
ent.focus_set()
v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.")
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
# Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das
# ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten,
# wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit
# "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist.
self._auswahl = tk.Listbox(frm, height=4, exportselection=False,
bg=self.col_field, fg=self.col_fg,
selectbackground=self.col_sel, selectforeground=self.col_fg,
relief="flat", font=("Consolas", 9))
self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0))
self._auswahl.grid_remove()
self._kandidaten = []
def kandidaten_zur_nummer(nr):
return [x for x in alle
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
and x.get("art") != "berichtigung"]
def suche():
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
nr = nummer_normalisieren(ent.get())
if not nr:
self._kandidaten = []
self._auswahl.grid_remove()
return None, "Nummer eintragen – das Programm sucht die Rechnung."
gefunden = kandidaten_zur_nummer(nr)
if not gefunden:
self._kandidaten = []
self._auswahl.grid_remove()
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
# Mehrere Belege unter derselben Nummer -> auswählen lassen.
if len(gefunden) > 1:
if gefunden != self._kandidaten:
self._kandidaten = gefunden
self._auswahl.delete(0, "end")
for x in gefunden:
self._auswahl.insert("end",
f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} "
f"{berechnung.eur(x.get('gesamt') or 0):>11s} "
f"{os.path.basename(x.get('_pfad',''))}")
self._auswahl.grid()
self._auswahl.selection_clear(0, "end")
auswahl = self._auswahl.curselection()
if not auswahl:
return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – "
"das darf es nicht geben.\nBitte unten auswählen, "
"welcher Beleg gemeint ist.")
treffer = gefunden[auswahl[0]]
else:
self._kandidaten = []
self._auswahl.grid_remove()
treffer = gefunden[0]
if treffer.get("art") == "storno":
return None, f"{nr} ist selbst eine Stornorechnung."
nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer"))
if nr_norm in stornierte:
st = stornos.get(nr_norm) or {}
folgt = st.get("korrektur_nummer") or ""
if nr_norm in ersetzte or not folgt:
return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} "
"storniert – ein zweites Mal geht nicht.")
# Auf dem Storno steht, welche Nummer die berichtigte Rechnung
# bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie
# noch, wird sie einfach unter dieser Nummer geschrieben.
return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. "
f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – "
"die steht auch auf dem Storno-Blatt.")
gesamt = float(treffer.get("gesamt") or 0)
mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n"
if len(gefunden) > 1 else "")
return treffer, (
mehrfach +
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
f" {treffer.get('nachname','')}"
+ (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\n"
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(gesamt)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
def vorschau(*_a):
v_info.set(suche()[1])
ent.bind("<KeyRelease>", vorschau)
self._auswahl.bind("<<ListboxSelect>>", vorschau)
def anlegen(*_a):
meta, meldung = suche()
if not meta:
v_info.set(meldung)
return
d.destroy()
if storno:
self._storno_anlegen(meta, out_dir)
else:
self._berichtigung_starten(meta, out_dir)
ent.bind("<Return>", anlegen)
btns = ttk.Frame(frm)
btns.grid(row=4, column=0, columnspan=2, pady=(14, 0), sticky="e")
ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden",
command=anlegen).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
frm.columnconfigure(1, weight=1)
def _storno_anlegen(self, meta, out_dir):
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
alt_nummer = str(meta.get("rechnungsnummer", ""))
nummer = naechste_freie_nummer(out_dir, self.cfg)
if not nummer:
messagebox.showerror("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
return
r = storno_rechnung_bauen(meta, self.cfg)
heute = datetime.date.today().strftime("%d.%m.%Y")
# Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest -
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer)
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
kunde, woher = kundendaten_ermitteln(meta)
namenszeile = " ".join(t for t in (kunde.get("vorname", ""),
kunde.get("nachname", "")) if t)
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
kunde.get("strasse", ""), kunde.get("plzort", "")]
# Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast
# und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts,
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
maengel = kunde_maengel(kunde)
if maengel:
if not messagebox.askyesno(
"Angaben zum Kunden unvollständig",
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
+ "\n- ".join(maengel) +
"\n\nDas Storno geht an den Gast und trägt dieselben Angaben. "
"Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?",
icon="warning", default="no"):
return
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "", "naechte": "",
"kunde_zeilen": kunde_zeilen,
"nachname": kunde.get("nachname", ""),
"vorname": kunde.get("vorname", ""),
"art": "storno",
# Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine
# schon berichtigte Rechnung noch einmal storniert, bleibt die ganze
# Kette unter EINEM Schluessel.
"vorgang": str(meta.get("vorgang") or alt_nummer),
"storno_zu": alt_nummer,
# alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette
# aus dem Storno allein lesbar ist.
"korrektur_nummer": folge_nummer or "",
"storno_datum": str(meta.get("datum") or ""),
"kunde": kunde,
}
name = f"Storno_{safe_filename(nummer)}"
if kopf["nachname"]:
name += "_" + safe_filename(kopf["nachname"])
pfad = os.path.join(out_dir, name + ".pdf")
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
self.storno_stand = {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"vorgang": str(meta.get("vorgang") or alt_nummer),
"zu_datum": str(meta.get("datum") or ""),
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
}
self._zaehler_aus_nummer_speichern(nummer)
print(f"[storno] erstellt -> {pfad}")
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(folge_nummer or "")
self._storno_anzeigen()
hinweis = ""
if fehlend:
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
messagebox.showinfo(
"Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
f"\"PDF erstellen\" drücken.{hinweis}")
def _formular_aus_meta(self, meta):
"""Formular mit den Daten der alten Rechnung füllen.
Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2)
tragen weder Anschrift noch Positionen in den Kenndaten.
"""
kunde, woher = kundendaten_ermitteln(meta)
self.k_vorname.set(kunde.get("vorname", ""))
self.k_name.set(kunde.get("nachname", ""))
self.k_anrede.set(str(kunde.get("anrede", "") or ""))
self.k_zusatz.set(str(kunde.get("zusatz", "") or ""))
self.k_strasse.set(str(kunde.get("strasse", "") or ""))
self.k_plzort.set(str(kunde.get("plzort", "") or ""))
self.r_anreise.set(str(meta.get("anreise", "") or ""))
self.r_abreise.set(str(meta.get("abreise", "") or ""))
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
for nr, var in self.anzahl_vars.items():
var.set("")
self.personen_vars[nr].set("")
for p in meta.get("positionen") or []:
nr = p.get("nr")
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
self.recalc()
hinweise = []
if woher:
hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher))
fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()),
("PLZ / Ort", self.k_plzort.get()),
("Nachname", self.k_name.get()))
if not wert]
if fehlt:
hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt))
if not (meta.get("positionen") or []):
hinweise.append("Leistungen konnten nicht übernommen werden")
return "; ".join(hinweise)

View file

@ -208,10 +208,30 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
if len(kunde_flow) == 1:
kunde_flow.append(Paragraph("&nbsp;", st["addr"]))
art = kopf.get("art", "rechnung")
info_rows = [
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
kopf.get("rechnungsnummer", "")),
("Rechnungsdatum:", kopf.get("datum", "")),
]
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
if art == "storno" and kopf.get("storno_zu"):
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
if kopf.get("storno_datum"):
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
if kopf.get("korrektur_nummer"):
info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
# Berichtigung, damit man die Reihenfolge sieht.
if kopf.get("berichtigt_am"):
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
@ -239,7 +259,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "")
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
# eindeutig als Aufhebung erkennbar ist.
titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
# Positionstabelle
@ -342,22 +366,64 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
continue
satz_txt = eur_kurz(a.satz).replace(",00", "")
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
if art == "storno":
# Auf dem Storno wird nichts erhoben, sondern zurueckgenommen.
story.append(Paragraph(
f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} "
f"({satz_txt} %) wird mit aufgehoben.", st["dank"]))
else:
story.append(Paragraph(
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
story.append(Spacer(1, 8 * mm))
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
texte = cfg.get("texte", {})
block = []
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
if art == "storno":
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# nichts geschuldet, sondern aufgehoben.
bezug = kopf.get("storno_zu", "")
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet." + kette, st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
# Aussteller. Also bleibt der Satz drauf.
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
@ -368,11 +434,44 @@ def _kenndaten(rechnung, kopf):
Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus,
statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck
aendert sich nichts - Metadaten stehen nicht auf dem Blatt.
Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm,
den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 %
stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben
unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt
der Import des Steuerjournals, das darf nicht brechen.
Version 3 kommt mit dem Storno dazu:
"art" - "rechnung", "storno" oder "korrektur"
"storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung
"ersetzt" - bei der Korrektur die Nummer der falschen Rechnung
"kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand,
damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt
sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht.
Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
"""
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
if kopf.get("art") == "berichtigung":
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
"vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""),
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
"berichtigt_am": kopf.get("berichtigt_am", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
}, ensure_ascii=False)
daten = {
"quelle": "rechnungstool",
"version": 1,
"version": 3,
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""),
@ -382,6 +481,31 @@ def _kenndaten(rechnung, kopf):
"steuer_satz": float(aufschlag.satz) if aufschlag else 0.0,
"steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0,
"gesamt": runde(rechnung.gesamt),
# neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto)
"netto_gesamt": runde(rechnung.netto_gesamt),
"ust_gesamt": runde(rechnung.ust_gesamt),
"ust_bloecke": [
{"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust),
"brutto": runde(b.brutto)}
for b in rechnung.bloecke
],
# neu in Version 3
"art": kopf.get("art", "rechnung"),
# Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte
# Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der
# allerersten Rechnung. Ein anderes Programm gruppiert danach in einem
# Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen.
# Eine normale Rechnung ist ihr eigener Vorgang.
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
"storno_zu": kopf.get("storno_zu", ""),
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
"storno_datum": kopf.get("storno_datum", ""),
"ersetzt": kopf.get("ersetzt", ""),
"storno_nummer": kopf.get("storno_nummer", ""),
"anreise": kopf.get("anreise", ""),
"abreise": kopf.get("abreise", ""),
"kunde": kopf.get("kunde", {}),
"positionen": kopf.get("positionen", []),
}
return json.dumps(daten, ensure_ascii=False)
@ -405,7 +529,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
ziel, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung")
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)

116
pruef_dialoge.py Normal file
View file

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""Pruefstand: geht jeder Dialog ueberhaupt auf?
Klingt banal, ist es nicht: beim Aufteilen von app.py in Module sind zwei
Dialoge stumm kaputtgegangen (fehlende Importe), und kein anderer Pruefstand
hat es gemerkt, weil keiner sie oeffnet. Dieser hier oeffnet ALLE.
Geprueft wird ausserdem, dass jeder Dialog auch wieder zugeht - ein modales
Fenster, das haengenbleibt, sperrt das Programm.
"""
import json
import os
import shutil
import sys
import tempfile
import tkinter as tk
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="dialog_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
messagebox.showerror = lambda *a, **k: None
messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# 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
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = 0
f = A.RechnungsApp()
f.update()
# Damit "Storno"/"Berichtigen" nicht an "nichts zu korrigieren" scheitern:
# eine echte Rechnung in den Wegwerf-Ordner legen.
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2")
f.update()
f.pdf_erstellen()
DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer",
"dlg_storno", "dlg_berichtigen"]
for name in DIALOGE:
offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)]
try:
getattr(f, name)()
f.update()
except Exception as e: # noqa: BLE001 - genau das wollen wir sehen
print(f"ABSTURZ | {name}: {type(e).__name__}: {e}")
fehler += 1
continue
neu = [w for w in f.winfo_children()
if isinstance(w, tk.Toplevel) and w not in offen_vorher]
if not neu:
print(f"FEHLER | {name}: kein Fenster aufgegangen")
fehler += 1
continue
titel = neu[-1].title()
for w in neu:
w.grab_release()
w.destroy()
f.update()
print(f" OK | {name} -> „{titel}“")
# Der Speicherort-Dialog oeffnet einen Systemdialog - nur die Vorstufe pruefen
try:
f._frage_speicherort_falls_noetig()
f.update()
print(" OK | _frage_speicherort_falls_noetig")
except Exception as e: # noqa: BLE001
print(f"ABSTURZ | _frage_speicherort_falls_noetig: {type(e).__name__}: {e}")
fehler += 1
f.destroy()
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
if not fehler: # fehler ist hier ein Zaehler
import shutil as _sh
_sh.rmtree(tmp, ignore_errors=True)
print("---")
print("alles gruen" if not fehler else f"{fehler} Fehler")
sys.exit(1 if fehler else 0)

399
pruef_kernregeln.py Normal file
View file

@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht:
1. Eine Rechnungsnummer wird nie zweimal vergeben.
2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht.
Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte
trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit
sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE
Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird.
Darum hier:
- Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists.
Eine Datei, die noch da ist, kann trotzdem veraendert worden sein.
- Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut.
- Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen.
"""
import hashlib
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="kern_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# 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
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_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 = {}
gefragt = []
meldungen = []
def askyesno(titel, text="", **kw):
gefragt.append(titel)
for schluessel, wert in antworten.items():
if schluessel in titel:
return wert
# Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen
# Ablauf faelschlich fuer ein Ergebnis.
raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}")
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else ""))
messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else ""))
messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else ""))
def bestand_lesen():
"""Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt."""
stand = {}
for name in sorted(os.listdir(ordner)):
if name.lower().endswith(".pdf"):
with open(os.path.join(ordner, name), "rb") as f:
stand[name] = hashlib.sha256(f.read()).hexdigest()
return stand
def unveraendert(vorher, ausser=()):
"""Alle Dateien aus <vorher> sind byteweise unveraendert (bis auf <ausser>)."""
jetzt = bestand_lesen()
for name, summe in vorher.items():
if name in ausser:
continue
if jetzt.get(name) != summe:
return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert"))
return True, ""
def kunde_setzen(vor, nach, tage="2"):
f.k_vorname.set(vor)
f.k_name.set(nach)
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.anzahl_vars[erste].set(tage)
f.update()
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
erste = sorted(f.anzahl_vars)[0]
antworten["Fertig"] = False # "jetzt oeffnen?" immer nein
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
# ---- Die Sicherheitsfrage muss auch wirklich abbrechen ----------------------
# "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt
# der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe.
antworten["endgültig erstellen"] = False
kunde_setzen("Test", "Abbruch", "1")
f.pdf_erstellen()
pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")],
"bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF")
antworten["endgültig erstellen"] = True
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
kunde_setzen("Anna", "Beispiel", "4")
f.pdf_erstellen()
kunde_setzen("Bernd", "Zweit", "3")
f.pdf_erstellen()
pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf",
"Rechnung_2026-002_Zweit.pdf"],
f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})")
# =============================================================================
# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt
# =============================================================================
print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---")
vorher = bestand_lesen()
gefragt.clear()
antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?"
kunde_setzen("Carla", "Dritt", "1")
f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand
f.update()
f.pdf_erstellen()
pruefe(any("schon vergeben" in t for t in gefragt),
f"Programm meldet die vergebene Nummer ({gefragt})")
ok, warum = unveraendert(vorher)
pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})")
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")),
"die neue Rechnung bekam die nächste FREIE Nummer 2026-003")
pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})")
print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---")
vorher = bestand_lesen()
antworten["Rechnungsnummer schon vergeben"] = False
kunde_setzen("Doris", "Vier", "1")
f.r_nummer.set("2026-002")
f.update()
f.pdf_erstellen()
ok, warum = unveraendert(vorher)
pruefe(ok and bestand_lesen() == vorher,
f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})")
print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---")
f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus
f.berichtigung_stand = None
f.storno_stand = None
f._neue_nummer_vorschlagen()
pruefe(f.r_nummer.get() == "2026-004",
f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})")
print("--- Regel 1: der Zähler darf nur vorwärts ---")
# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine
# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst
# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor.
kunde_setzen("Emil", "Hoch", "1")
f.r_nummer.set("2026-009")
f.update()
f.pdf_erstellen()
pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})")
kunde_setzen("Frida", "Tief", "1")
f.r_nummer.set("2026-007")
f.update()
f.pdf_erstellen()
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")),
"die niedrigere freie Nummer 2026-007 wurde geschrieben")
pruefe(f.cfg["rechnung_zaehler"] == 9,
f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})")
f._neue_nummer_vorschlagen()
pruefe(f.r_nummer.get() == "2026-010",
f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})")
print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---")
f.cfg["rechnung_jahr"] = 2025
f.cfg["rechnung_zaehler"] = 87
f._neue_nummer_vorschlagen()
vom_formular = f.r_nummer.get()
vom_storno = BE.naechste_freie_nummer(ordner, f.cfg)
pruefe(vom_formular == vom_storno,
f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})")
pruefe(vom_formular.startswith("2026-"),
f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})")
f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3
config.save_config(f.cfg)
# =============================================================================
# REGEL 2: ausgestellte Rechnung nie anfassen
# =============================================================================
print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---")
vorher = bestand_lesen()
gefragt.clear()
f.dlg_storno()
f.update()
import tkinter as tk # noqa: E402
from tkinter import ttk # noqa: E402
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
feld = knopf = None
for rahmen in dlg.winfo_children():
for w in rahmen.winfo_children():
if isinstance(w, ttk.Entry):
feld = w
elif isinstance(w, ttk.Frame):
for b in w.winfo_children():
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
knopf = b
pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf")
feld.delete(0, "end")
feld.insert(0, "2026-001")
knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen()
f.update()
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001",
f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})")
storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else ""
ok, warum = unveraendert(vorher)
pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})")
print("--- Regel 2: was steht auf dem Storno-Blatt? ---")
from pypdf import PdfReader # noqa: E402
text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text()
pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf")
pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf")
# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also
# NACH ihrer Beschriftung, nicht daneben.
zeilen = text.splitlines()
def betraege_nach(label, wieviele=6):
"""Die Zahlenwerte, die auf die Zeile mit <label> folgen."""
for i, z in enumerate(zeilen):
if label in z:
return [w.strip() for w in zeilen[i + 1:i + 1 + wieviele]
if "," in w and any(c.isdigit() for c in w)]
return []
pos = betraege_nach("Storno der Rechnung")
pruefe(bool(pos), f"Positionszeile hat Beträge ({pos})")
pruefe(all(w.startswith("-") for w in pos),
f"Positionszeile ist NEGATIV, nicht nur der Summenblock ({pos})")
gesamt = betraege_nach("Gesamtbetrag", 1)
pruefe(gesamt and gesamt[0].startswith("-"), f"Gesamtbetrag negativ ({gesamt})")
netto = betraege_nach("Nettobetrag", 1)
pruefe(netto and netto[0].startswith("-"), f"Nettobetrag negativ ({netto})")
print("--- Regel 2: berichtigte Rechnung schreiben, alter Bestand bleibt ---")
vorher = bestand_lesen()
antworten["Storno wirklich"] = True
kunde_setzen("Anna", "Beispiel", "3")
f.pdf_erstellen()
ok, warum = unveraendert(vorher)
pruefe(ok, f"Storno und alle Rechnungen unverändert ({warum})")
pruefe(len(bestand_lesen()) == len(vorher) + 1, "genau eine Datei kam dazu")
# =============================================================================
# Berichtigung: zweimal am selben Tag darf nicht stillschweigend überschreiben
# =============================================================================
print("--- Berichtigung zweimal am selben Tag ---")
antworten["Berichtigte Rechnung erstellen"] = True
meta = next(x for x in BE.rechnungen_im_ordner(ordner)
if x.get("rechnungsnummer") == "2026-002")
f._berichtigung_starten(meta, ordner)
f.k_strasse.set("Neue Straße 5")
f.update()
f.pdf_erstellen()
erste_fassung = bestand_lesen()
ziel = [n for n in erste_fassung if n.startswith("Berichtigte Rechnung")]
pruefe(len(ziel) == 1, f"Berichtigungsblatt geschrieben ({ziel})")
antworten["Berichtigung von heute ersetzen"] = False # NEIN, nicht ersetzen
f._berichtigung_starten(meta, ordner)
f.k_strasse.set("Ganz andere Straße 9")
f.update()
gefragt.clear()
f.pdf_erstellen()
pruefe(any("ersetzen" in t for t in gefragt), f"vor dem Ersetzen wird gefragt ({gefragt})")
ok, warum = unveraendert(erste_fassung)
pruefe(ok, f"bei Nein bleibt die erste Fassung unangetastet ({warum})")
# =============================================================================
# Der Altbestand: dieselbe Nummer liegt MEHRFACH im Ordner (Windows-Kopie).
# Dann darf der Storno-Dialog nicht raten, welcher Beleg gemeint ist.
# =============================================================================
print("--- zwei Belege unter einer Nummer ---")
# Die abgelehnte Berichtigung liegt noch im Zwischenspeicher - so wie der Nutzer
# es dann macht: zuruecksetzen.
f.reset_formular()
pruefe(f.berichtigung_stand is None, "Zurücksetzen räumt die offene Berichtigung weg")
import shutil as _sh # noqa: E402
quelle = os.path.join(ordner, "Rechnung_2026-002_Zweit.pdf")
kopie = os.path.join(ordner, "Rechnung_2026-002_Zweit (1).pdf")
_sh.copy(quelle, kopie)
pruefe(len(BE.vorhandene_rechnungen(ordner, "2026-002")) == 2,
"die Windows-Kopie zählt bei der Nummernvergabe mit")
f.dlg_storno()
f.update()
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
feld = liste = knopf = None
for rahmen in dlg.winfo_children():
for w in rahmen.winfo_children():
if isinstance(w, ttk.Entry):
feld = w
elif isinstance(w, tk.Listbox):
liste = w
elif isinstance(w, ttk.Frame):
for b in w.winfo_children():
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
knopf = b
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
meldezeile = w
pruefe(liste is not None, "der Dialog hat eine Auswahlliste")
feld.delete(0, "end")
feld.insert(0, "2026-002")
feld.event_generate("<KeyRelease>")
f.update()
meldung = f.getvar(meldezeile.cget("textvariable"))
pruefe("2×" in meldung or "2x" in meldung, f"die Doppelvergabe wird gemeldet ({meldung.splitlines()[0]})")
pruefe(liste.winfo_ismapped() and liste.size() == 2,
f"beide Belege stehen zur Auswahl ({liste.size()})")
vorher = bestand_lesen()
knopf.invoke() # ohne Auswahl darf NICHTS passieren
f.update()
pruefe(f.storno_stand is None, "ohne Auswahl wird kein Storno angelegt")
pruefe(bestand_lesen() == vorher, "und nichts geschrieben")
# bewusst den ZWEITEN Eintrag wählen - welche Datei das ist, sagt die Liste
zweiter = liste.get(1)
datei_zwei = zweiter.split()[-1]
liste.selection_set(1)
liste.event_generate("<<ListboxSelect>>")
f.update()
gewaehlt = f.getvar(meldezeile.cget("textvariable"))
pruefe(datei_zwei in gewaehlt,
f"die Meldung nennt genau die gewählte Datei ({datei_zwei})")
pruefe(liste.get(0).split()[-1] not in gewaehlt,
"und NICHT die andere Datei mit derselben Nummer")
knopf.invoke()
f.update()
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-002",
f"nach der Auswahl läuft das Storno ({f.storno_stand})")
f._storno_verwerfen("Pruefstand raeumt auf")
os.remove(kopie)
f.destroy()
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
if not fehler:
import shutil as _sh
_sh.rmtree(tmp, ignore_errors=True)
print("---")
print(f"Ordner: {ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

189
pruef_kette.py Normal file
View file

@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
"""Pruefstand: haelt die Kette zusammen, und kann ein anderes Programm sie ordnen?
Der Ordner ist die Buchhaltung. Ein zweites Programm (Steuerrechner,
Steuerjournal) muss aus den PDFs allein erkennen koennen, was zusammengehoert:
welche Rechnung wurde storniert, welche ersetzt sie, welches Blatt berichtigt
nur die Anschrift.
Dafuer traegt JEDES Blatt einer Kette dieselbe Vorgangsnummer - die der ersten
Rechnung. Gruppieren heisst dann: nach "vorgang" sortieren, fertig. Ohne den
Schluessel muesste man sich die Kette ueber storno_zu/ersetzt zusammensuchen.
Durchgespielt wird der schlimmste Fall: Rechnung -> Storno -> berichtigte
Rechnung -> Berichtigung der Anschrift -> noch ein Storno -> zweite berichtigte
Rechnung. Alle sechs muessen unter EINEM Schluessel landen und in der Summe
genau die letzte gueltige Rechnung ergeben.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="kette_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# 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
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_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,
"Berichtigte Rechnung erstellen": True, "Hausnummer": True}
def askyesno(titel, text="", **kw):
for schluessel, wert in antworten.items():
if schluessel in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = \
lambda *a, **k: None
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
erste = sorted(f.anzahl_vars)[0]
def kunde(strasse="Musterweg 1", tage="4"):
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set(strasse)
f.k_plzort.set("12345 Musterstadt")
f.anzahl_vars[erste].set(tage)
f.update()
def rechnung_mit(nummer):
return next(x for x in BE.rechnungen_im_ordner(ordner)
if x.get("rechnungsnummer") == nummer and x.get("art") != "berichtigung")
# 1. die urspruengliche Rechnung
kunde(tage="4")
f.pdf_erstellen()
# 2. + 3. Storno und berichtigte Rechnung (falsche Naechtezahl)
f._storno_anlegen(rechnung_mit("2026-001"), ordner)
kunde(tage="3")
f.pdf_erstellen()
# 4. Berichtigung der Anschrift auf der berichtigten Rechnung
f._berichtigung_starten(rechnung_mit("2026-003"), ordner)
f.k_strasse.set("Richtige Straße 7")
f.update()
f.pdf_erstellen()
# 5. + 6. dieselbe Rechnung noch einmal stornieren und neu schreiben
f._storno_anlegen(rechnung_mit("2026-003"), ordner)
kunde(strasse="Richtige Straße 7", tage="2")
f.pdf_erstellen()
f.destroy()
alle = BE.rechnungen_im_ordner(ordner)
print("--- was im Ordner liegt ---")
for x in sorted(alle, key=lambda d: (d.get("vorgang", ""), d.get("rechnungsnummer", ""))):
print(f" {x.get('vorgang',''):10s} {x.get('art',''):12s} "
f"{x.get('rechnungsnummer',''):10s} {float(x.get('gesamt') or 0):9.2f} "
f"{os.path.basename(x['_pfad'])}")
pruefe(len(alle) == 6, f"sechs Belege im Ordner ({len(alle)})")
pruefe(all(x.get("vorgang") for x in alle), "jeder Beleg trägt eine Vorgangsnummer")
# Genau das macht das andere Programm: nach "vorgang" gruppieren.
ketten = {}
for x in alle:
ketten.setdefault(x["vorgang"], []).append(x)
pruefe(len(ketten) == 1, f"alle sechs hängen an EINEM Vorgang ({list(ketten)})")
pruefe(list(ketten) == ["2026-001"],
f"der Schlüssel ist die erste Rechnung ({list(ketten)})")
kette = ketten.get("2026-001", [])
arten = sorted(x.get("art") for x in kette)
pruefe(arten == ["berichtigung", "korrektur", "korrektur", "rechnung", "storno", "storno"],
f"Arten der Kette stimmen ({arten})")
# Die Summe ueber die Kette muss die letzte gueltige Rechnung sein.
letzte = max((x for x in kette if x.get("art") in ("rechnung", "korrektur")),
key=lambda d: d["rechnungsnummer"])
summe = round(sum(float(x.get("gesamt") or 0) for x in kette), 2)
pruefe(abs(summe - float(letzte["gesamt"])) < 0.005,
f"Summe der Kette = letzte gültige Rechnung ({summe:.2f} vs {letzte['gesamt']:.2f})")
# Jeder Beleg zeigt ausserdem einzeln auf seinen Vorgaenger.
for x in kette:
if x.get("art") == "storno":
pruefe(bool(x.get("storno_zu")), f"Storno {x['rechnungsnummer']} nennt seine Rechnung")
elif x.get("art") == "korrektur":
pruefe(bool(x.get("ersetzt")) and bool(x.get("storno_nummer")),
f"Korrektur {x['rechnungsnummer']} nennt Rechnung und Storno")
elif x.get("art") == "berichtigung":
pruefe(bool(x.get("berichtigt_zu")),
f"Berichtigung nennt die Rechnung ({x.get('berichtigt_zu')})")
# Gegenprobe mit dem Steuerrechner: er darf nur die Geldbelege sehen.
sys.path.insert(0, r"C:\claude\steuerrechner")
import einnahmen # noqa: E402
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(len(liste) == 5 and not kaputt,
f"Steuerrechner liest 5 Belege, kein Fehler ({len(liste)}, {kaputt})")
jahr = round(sum(e.gesamt for e in liste), 2)
pruefe(abs(jahr - float(letzte["gesamt"])) < 0.005,
f"Jahressumme = letzte gültige Rechnung ({jahr:.2f} vs {letzte['gesamt']:.2f})")
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
if not fehler:
import shutil as _sh
_sh.rmtree(tmp, ignore_errors=True)
print("---")
print(f"Ordner: {ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

72
pruef_nummern.py Normal file
View file

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""Pruefstand: welche Nummern gelten als vergeben, welche als frei?
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt
bestand.py direkt. Geprueft wird beides - vorhandene_rechnungen() und
naechste_freie_nummer(), zusammen sind sie die Nummernvergabe.
"""
import os
import sys
import tempfile
sys.path.insert(0, r"C:\claude\rechnungstool")
from bestand import naechste_freie_nummer, vorhandene_rechnungen # noqa: E402
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
dateien = [
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
"Rechnung_2026-0135_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
"Rechnung_2026-777_Gross.PDF", # Endung GROSS geschrieben - zaehlt trotzdem
"Rechnung_2026-013_Meier.txt", # richtiger Name, aber kein PDF
"Rechnung_2026-050_Alt.pdf", # und drei Windows-Kopien davon:
"Rechnung_2026-050_Alt (1).pdf",
"Rechnung_2026-050_Alt - Kopie.pdf",
"Rechnung_2026-050 (2).pdf",
"Kopie von Rechnung_2026-050_Alt.pdf", # aeltere Windows-Fassung
"rechnung_2026-050_klein.pdf", # von Hand klein geschrieben
"Rechnung_2026-0501_Neu.pdf", # laengere Nummer - darf NICHT mitzaehlen
"Rechnung_2026-014_Meier.pdf", # andere Nummer
"Rechnung_2026-020.pdf", # ohne Kundenname
"Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch
"Notiz_2026-013.txt", # kein PDF
]
for d in dateien:
with open(os.path.join(ordner, d), "w") as f:
f.write("x")
faelle = [
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
("2026-0135", ["Rechnung_2026-0135_Schmidt.pdf"], "laengere Nummer trifft nur sich selbst"),
("2026-777", ["Rechnung_2026-777_Gross.PDF"], "Endung GROSS geschrieben wird gefunden"),
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"),
("2026-050", ["Kopie von Rechnung_2026-050_Alt.pdf", "Rechnung_2026-050 (2).pdf",
"Rechnung_2026-050_Alt (1).pdf", "Rechnung_2026-050_Alt - Kopie.pdf",
"Rechnung_2026-050_Alt.pdf", "rechnung_2026-050_klein.pdf"],
"alle Windows-Kopien und Kleinschreibung zaehlen mit"),
("2026-0501", ["Rechnung_2026-0501_Neu.pdf"], "laengere Nummer bleibt getrennt"),
("2026-099", [], "freie Nummer"),
("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"),
]
fehler = 0
for nummer, erwartet, was in faelle:
ist = [os.path.basename(p) for p in vorhandene_rechnungen(ordner, nummer)]
ok = ist == erwartet
fehler += 0 if ok else 1
print((" OK " if ok else "FEHLER") + f" | {nummer} -> {ist} ({was})")
# leerer / nicht vorhandener Ordner darf nicht knallen
if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []:
print("FEHLER | fehlender Ordner")
fehler += 1
else:
print(" OK | fehlender Ordner gibt leere Liste")
# Wegwerf-Ordner nur bei Fehlern behalten
if not fehler:
import shutil as _sh
_sh.rmtree(ordner, ignore_errors=True)
print("---")
print("alles gruen" if fehler == 0 else f"{fehler} Fehler")
sys.exit(1 if fehler else 0)

437
pruef_storno.py Normal file
View file

@ -0,0 +1,437 @@
# -*- coding: utf-8 -*-
"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner.
Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt:
1. Rechnung 2026-001 erstellen
2. sie stornieren -> Storno 2026-002 mit negativen Betraegen
3. berichtigte Rechnung 2026-003 mit geaenderter Menge
4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben
5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da
Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im
Wegwerf-Ordner gearbeitet.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="storno_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402
# --- Reissleine ---------------------------------------------------------
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
# 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
def _reissleine():
import os as _os
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
"der nicht beantwortet wird")
_os._exit(2)
_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 = {}
gefragt = []
def askyesno(titel, text, **kw):
gefragt.append(titel)
for schlue, wert in antworten.items():
if schlue in titel:
return wert
# Kein stilles "Nein": ein nicht angemeldeter Dialog wuerde den Ablauf
# abbrechen und der Pruefstand haelt das Ergebnis faelschlich fuer gewollt.
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
warnungen = []
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "")
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
# ---- 1. normale Rechnung ------------------------------------------------
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("4")
f.update()
f.pdf_erstellen()
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
meta1 = BE.kenndaten_lesen(erste_pdf)
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
pruefe(meta1.get("art") == "rechnung", "art = rechnung")
pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten")
pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten")
brutto1 = float(meta1["zwischensumme"])
gesamt1 = float(meta1["gesamt"])
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
# ---- 2. stornieren ------------------------------------------------------
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
f._storno_anlegen(offen[0], ordner)
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
meta2 = BE.kenndaten_lesen(storno_pdf)
pruefe(meta2.get("art") == "storno", "art = storno")
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
import re as _re
pruefe(bool(_re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", str(meta2.get("storno_datum") or "")))
and meta2.get("storno_datum") == meta1.get("datum"),
f"Datum der alten Rechnung steht drin ({meta2.get('storno_datum')!r})")
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
pruefe(bool(meta2["ust_bloecke"])
and all(b["netto"] < 0 and b["ust"] < 0 for b in meta2["ust_bloecke"]),
f"alle USt-Bloecke echt negativ ({meta2['ust_bloecke']})")
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})")
# ---- 3. berichtigte Rechnung -------------------------------------------
antworten["Storno wirklich"] = True
f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte
f.update()
f.pdf_erstellen()
dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
meta3 = BE.kenndaten_lesen(dritte)
pruefe(meta3.get("art") == "korrektur", "art = korrektur")
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da")
gesamt3 = float(meta3["gesamt"])
print(f" Rechnung 003: gesamt {gesamt3:.2f}")
# ---- 4. was sieht der Steuerrechner? ------------------------------------
sys.path.insert(0, r"C:\claude\steuerrechner")
import einnahmen # noqa: E402
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})")
pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})")
pruefe(not any(e.unsicher for e in liste),
"kein Beleg als unsicher markiert")
summe = round(sum(e.gesamt for e in liste), 2)
netto = round(sum(e.netto for e in liste), 2)
ust = round(sum(e.ust for e in liste), 2)
pruefe(abs(summe - gesamt3) < 0.005,
f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})")
print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
# ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
antworten["Storno wirklich"] = False
offen = [d for d in BE.rechnungen_im_ordner(ordner)
if d.get("art") == "korrektur"]
f._storno_anlegen(offen[0], ordner)
storno2 = f.storno_stand["pfad"]
zaehler_mit_storno = f.cfg["rechnung_zaehler"]
pruefe(os.path.exists(storno2), "zweites Storno geschrieben")
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen() # -> Frage wird mit Nein beantwortet
pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht")
pruefe(f.storno_stand is None, "Zwischenspeicher geleert")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1,
f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})")
pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da")
pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3,
"es liegen wieder genau 3 PDFs im Ordner")
# ---- 6. Suche im Storno-Dialog (Nummer eintippen, nicht auswaehlen) -----
import tkinter as tk # noqa: E402
from tkinter import ttk # noqa: E402
f.dlg_storno()
f.update()
dialog = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
feld = None
schild = None
for rahmen in dialog.winfo_children():
for w in rahmen.winfo_children():
if isinstance(w, ttk.Entry):
feld = w
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
schild = w
pruefe(feld is not None and schild is not None, "Eingabefeld und Meldezeile im Dialog da")
def tippe(text):
feld.delete(0, "end")
feld.insert(0, text)
feld.event_generate("<KeyRelease>")
f.update()
return f.getvar(schild.cget("textvariable"))
meldung = tippe("2026-003")
pruefe("Der Storno lautet" in meldung, f"gueltige Nummer gefunden: {meldung.splitlines()[0]}")
meldung = tippe("2026-3")
pruefe("2026-003" in meldung, "kurze Schreibweise 2026-3 findet 2026-003")
meldung = tippe("2026-001")
pruefe("storniert" in meldung and "zweites Mal" in meldung,
f"schon stornierte Rechnung wird abgelehnt ({meldung})")
meldung = tippe("2026-002")
pruefe("selbst eine Stornorechnung" in meldung, "Storno selbst ist nicht stornierbar")
meldung = tippe("2026-999")
pruefe("keine Rechnung" in meldung, "unbekannte Nummer wird abgelehnt")
meldung = tippe("")
pruefe("Nummer eintragen" in meldung, "leeres Feld sagt was zu tun ist")
dialog.destroy()
# ---- 7. Pflichtfelder: ohne Anschrift wird nichts geschrieben -----------
vorher = sorted(os.listdir(ordner))
warnungen.clear()
f.k_strasse.set("")
f.k_plzort.set("")
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen()
pruefe(warnungen and "Kundenangaben fehlen" in warnungen[0],
f"fehlende Anschrift wird gemeldet ({warnungen})")
pruefe(sorted(os.listdir(ordner)) == vorher, "ohne Anschrift wurde keine PDF geschrieben")
warnungen.clear()
f.k_vorname.set("")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.update()
f.pdf_erstellen()
pruefe(sorted(os.listdir(ordner)) == vorher, "auch ohne Vornamen wird nichts geschrieben")
f.k_vorname.set("Anna")
f.update()
f.pdf_erstellen()
pruefe(len(os.listdir(ordner)) == len(vorher) + 1,
"mit allen Pflichtfeldern entsteht die PDF")
# ---- 8. Was zaehlt als "richtig ausgefuellt"? ---------------------------
def kunde_setzen(vor, nach, strasse, ort):
f.k_vorname.set(vor)
f.k_name.set(nach)
f.k_strasse.set(strasse)
f.k_plzort.set(ort)
return f.kunde_pruefen()
faelle_schlecht = [
(" ", "Meier", "Musterweg 1", "12345 Musterstadt", "Vorname nur Leerzeichen"),
("Anna", " ", "Musterweg 1", "12345 Musterstadt", "Nachname nur Leerzeichen"),
("Anna", "x", "Musterweg 1", "12345 Musterstadt", "Nachname ein Buchstabe"),
("Anna", "123", "Musterweg 1", "12345 Musterstadt", "Nachname nur Ziffern"),
("Anna", "Meier", "-", "12345 Musterstadt", "Strasse nur ein Strich"),
("Anna", "Meier", "Musterweg 1", "12345", "nur PLZ, kein Ort"),
("Anna", "Meier", "Musterweg 1", "Musterstadt", "nur Ort, keine PLZ"),
("Anna", "Meier", "Musterweg 1", " ", "Ort nur Leerzeichen"),
]
for vor, nach, strasse, ort, was in faelle_schlecht:
pruefe(bool(kunde_setzen(vor, nach, strasse, ort)), f"abgelehnt: {was}")
faelle_gut = [
("Anna", "Meier", "Musterweg 1", "12345 Musterstadt", "normaler Fall"),
("Li", "Ng", "Hauptstr. 2", "04416 Markkleeberg", "kurze Namen"),
("Jan", "de Vries", "Kerkstraat 12", "1234 AB Amsterdam", "Niederlande, 4 Ziffern"),
("Hans", "Huber", "Seeweg 3", "5020 Salzburg", "Oesterreich, 4 Ziffern"),
("Anna", "Camping Nord GmbH", "Am Deich 7a", "12345 Musterstadt", "Firma"),
]
for vor, nach, strasse, ort, was in faelle_gut:
m = kunde_setzen(vor, nach, strasse, ort)
pruefe(not m, f"angenommen: {was} {m if m else ''}")
# Saeubern: Rand- und Mehrfachleerzeichen verschwinden aus den Feldern
kunde_setzen(" Anna ", " von Meier ", " Musterweg 1 ", " 12345 Musterstadt ")
pruefe(f.k_vorname.get() == "Anna", f"Vorname getrimmt ({f.k_vorname.get()!r})")
pruefe(f.k_name.get() == "von Meier", f"Doppelleerzeichen weg ({f.k_name.get()!r})")
pruefe(f.k_plzort.get() == "12345 Musterstadt", f"Ort gesaeubert ({f.k_plzort.get()!r})")
# ---- 9. Strasse ohne Hausnummer: fragen, nicht sperren ------------------
kunde_setzen("Anna", "Beispiel", "Am Seeufer", "12345 Musterstadt")
pruefe(not f.kunde_pruefen(), "Anschrift ohne Hausnummer ist kein Mangel")
vorher = sorted(os.listdir(ordner))
gefragt.clear()
antworten["Hausnummer"] = False # Nutzer sagt: nein, doch nicht
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen()
pruefe(any("Hausnummer" in t for t in gefragt), f"nach der Hausnummer wird gefragt ({gefragt})")
pruefe(sorted(os.listdir(ordner)) == vorher, "bei Nein wird nichts geschrieben")
antworten["Hausnummer"] = True # Nutzer sagt: ja, so ist es richtig
f.pdf_erstellen()
pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "bei Ja entsteht die PDF trotzdem")
# ---- 10. Altrechnung im Muster "alles in der Vornamen-Zeile" -----------
import berechnung as B # noqa: E402
import pdf_renderer as P # noqa: E402
def alt_rechnung(name, dateiname, kunde_zeilen):
"""Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten."""
rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg)
# Nummer aus dem Dateinamen, damit Kenndaten und Datei zusammenpassen
nummer = dateiname.split("_")[1].replace(".pdf", "")
kopf = {"rechnungsnummer": nummer, "datum": "12.08.2026", "anreise": "", "abreise": "",
"naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name}
ziel = os.path.join(ordner, dateiname)
P.render_rechnung(ziel, f.cfg, rr, kopf)
return ziel
# a) mit Anschrift im Text, Name komplett in der Vornamen-Zeile
alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf",
["Herr", "Andres Schäfer", "c/o Stellplatz 12",
"Bornaer Chaussee 36", "04416 Markkleeberg"])
meta_alt = BE.kenndaten_lesen(alt)
pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten")
kunde, woher = BE.kundendaten_ermitteln(meta_alt)
pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})")
pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})")
pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})")
pruefe(kunde.get("strasse") == "Bornaer Chaussee 36", f"Straße aus dem Text ({kunde.get('strasse')!r})")
pruefe(kunde.get("plzort") == "04416 Markkleeberg", f"PLZ/Ort aus dem Text ({kunde.get('plzort')!r})")
pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde.get('zusatz')!r})")
# b) sein echter Fall: NUR der Name, keine Anschrift
alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""])
kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2))
pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt")
pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden")
# c) Formular fuellen: was fehlt, wird benannt
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2))
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Formular bekommt Vor- und Nachnamen getrennt")
pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})")
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt))
pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular")
pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})")
for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
os.remove(os.path.join(ordner, name))
# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf -------------------
alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""])
meta3 = BE.kenndaten_lesen(alt3)
vorher = sorted(os.listdir(ordner))
zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"]
f._berichtigung_starten(meta3, ordner)
pruefe(f.berichtigung_stand is not None, "Flag fuer die Berichtigung gesetzt")
pruefe(f.r_nummer.get() == "2026-910", f"Rechnung behaelt ihre Nummer ({f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
"Name aufgeteilt im Formular")
# ohne Anschrift wird auch hier nichts geschrieben
warnungen.clear()
f.anzahl_vars[erste].set("3")
f.update()
f.pdf_erstellen()
pruefe(warnungen and "Kundenangaben" in warnungen[0], "Pflichtpruefung gilt auch hier")
pruefe(sorted(os.listdir(ordner)) == vorher, "nichts geschrieben")
# Anschrift ergaenzen und erstellen
f.k_strasse.set("Bornaer Chaussee 36")
f.k_plzort.set("04416 Markkleeberg")
antworten["Berichtigte Rechnung erstellen"] = True
f.update()
f.pdf_erstellen()
import datetime as _dt
ziel = os.path.join(ordner, f"Berichtigte Rechnung_{_dt.date.today():%Y-%m-%d}_2026-910.pdf")
pruefe(os.path.exists(ziel), f"Blatt geschrieben ({os.path.basename(ziel)})")
pruefe(f.berichtigung_stand is None, "Flag nach dem Erstellen wieder weg")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
pruefe(os.path.exists(alt3), "die berichtigte Rechnung liegt unangetastet da")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung,
f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer "
f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})")
meta_b = BE.kenndaten_lesen(ziel)
pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung")
pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung")
pruefe(float(meta_b.get("gesamt") or 0) == 0.0,
f"Kenndaten tragen 0 - keine zweite Einnahme ({meta_b.get('gesamt')})")
from pypdf import PdfReader as _PR # noqa: E402
text = _PR(ziel).pages[0].extract_text()
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
pruefe("31 Abs. 5 UStDV" in text and "zus" in text and "tzliche Leistung" in text,
"der Schutzsatz steht drauf (keine zusaetzliche Leistung, § 31 Abs. 5)")
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(not any("Berichtigte" in os.path.basename(e.pdf_pfad) for e in liste3),
"Steuerrechner zaehlt das Blatt nicht als Einnahme")
pruefe(not any("Berichtigte" in os.path.basename(p) for p, _ in kaputt3),
f"und meldet es nicht als Fehler ({kaputt3})")
for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)):
os.remove(os.path.join(ordner, name))
f.destroy()
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
if not fehler:
import shutil as _sh
_sh.rmtree(tmp, ignore_errors=True)
print("---")
print(f"Ordner: {ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

61
storno.py Normal file
View file

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""Den spiegelbildlichen Storno zu einer Rechnung rechnen."""
import berechnung
def storno_rechnung_bauen(meta, cfg):
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
"""
r = berechnung.Rechnung()
nummer = meta.get("rechnungsnummer", "")
bloecke = meta.get("ust_bloecke") or []
if not bloecke:
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
# die USt unter den Tisch.
brutto = float(meta.get("zwischensumme") or 0)
# Der erste Satz OHNE "extra_blatt" - also ein echter USt-Satz. Nimmt man
# blind den ersten aus der Liste, rechnet ein umsortierter Katalog die
# Steuer mit 19 % oder der Beherbergungssteuer heraus.
echte = [s for s in cfg.get("steuersaetze", []) if not s.get("extra_blatt")]
satz = int((echte or [{"satz": 7}])[0].get("satz", 7))
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
"brutto": brutto}]
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
for i, b in enumerate(bloecke, start=1):
satz = int(float(b.get("satz", 0) or 0))
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
netto = -abs(berechnung.runde(b.get("netto") or 0))
ust = -abs(berechnung.runde(b.get("ust") or 0))
r.zeilen.append(berechnung.PositionsZeile(
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
brutto=brutto, netto=netto))
info = saetze.get(satz, {})
r.bloecke.append(berechnung.Steuerblock(
satz=satz, netto=netto, ust=ust,
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
extra_blatt=bool(info.get("extra_blatt", False))))
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
# berechne() den Aufschlag baut.
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
if steuer:
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
r.aufschlaege.append(berechnung.Aufschlag(
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
return r

123
theme.py Normal file
View file

@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste.
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 sys
import tkinter as tk
from tkinter import ttk
def windows_dark_mode():
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
if not sys.platform.startswith("win"):
return False
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
except OSError:
return False
class ThemeMixin:
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
# ----------------------------------------------------------------- Theme
def _apply_theme(self):
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
dark = windows_dark_mode()
self._dark_mode = dark
if dark:
self.col_bg = "#23272e" # Fenster-Hintergrund
self.col_fg = "#e6e6e6" # Schrift
self.col_field = "#2f343c" # Eingabefelder
self.col_sel = "#3a4a44" # aktiv/hover
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
self.col_line = "#3a3f47"
self.col_scroll = "#4a505a" # Scroll-Griff
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
else:
self.col_bg = "#f3f3f3"
self.col_fg = "#1a1a1a"
self.col_field = "#ffffff"
self.col_sel = "#cfe3d8"
self.col_accent = "#1f5c3d"
self.col_line = "#cccccc"
self.col_scroll = "#b8b8b8"
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
self.configure(bg=self.col_bg)
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
self.col_sel, self.col_accent)
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg)
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
style.configure("TLabelframe.Label", background=bg, foreground=accent)
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
arrowcolor=bg, padding=(10, 3))
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
# unsichtbar im Feld.
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg,
selectbackground=field, selectforeground=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)],
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
self.option_add("*TCombobox*Listbox.background", field)
self.option_add("*TCombobox*Listbox.foreground", fg)
self.option_add("*TCombobox*Listbox.selectBackground", sel)
self.option_add("*TCombobox*Listbox.selectForeground", fg)
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
arrowcolor=fg, relief="flat", gripcount=0)
style.map(sbs, background=[("active", accent)],
troughcolor=[("disabled", bg)])
style.configure("TSeparator", background=self.col_line)
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
self.option_add("*background", bg)
self.option_add("*foreground", fg)
def _style_dialog(self, d):
"""Toplevel-Dialog an das Theme angleichen."""
d.configure(bg=self.col_bg)
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
d.after(20, lambda: self._dark_titlebar(d))
def _dark_titlebar(self, win):
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
return
try:
import ctypes
win.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
val = ctypes.c_int(1)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
except Exception as e: # noqa: BLE001
print("[theme] Titelleiste:", e)