Gemeinsames Nummernbuch, Storno kuerzt die Naechte, Testlauf-Schalter

Das Rechnungstool kannte nur die Nummern seiner eigenen PDFs. Im Steuerjournal
stehen aber Buchungen ohne PDF - der Altbestand aus der Excel-Mappe - und deren
Nummern hat es ein zweites Mal vergeben. § 14 Abs. 4 Nr. 4 UStG laesst jede
Nummer nur einmal zu.

Deshalb fuehren beide Programme jetzt EIN gemeinsames Buch (gemeinsam.py): eine
SQLite in einem Ordner, den beide kennen. Es ist ein Register, keine zweite
Buchhaltung - die Wahrheit bleiben die PDFs. Was hineinkommt: jede geschriebene
Rechnung, jeder Storno (mit storno_zu), jede Neuausstellung. Die Nummernvergabe
und die Kollisionspruefung beim Erstellen fragen es mit ab; ein verworfener
Storno gibt seine Nummer wieder frei. Faellt der Ordner aus (Netzlaufwerk weg),
laeuft alles weiter - dann steht es in der Fussleiste.

ordnerwahl.py fragt den Ordner beim ersten Start ab: auswaehlen, OK,
Sicherheitsfrage, bei Nein zurueck ins Feld. Zwei Fehler daran gleich behoben,
bevor sie jemand treffen konnte: das Fenster war zu schmal (der Auswahl-Knopf
lag ausserhalb) und der Griff (grab_set) muss waehrend des Windows-Ordner-
dialogs los sein, sonst nimmt der keine Eingabe an.

Storno: er ist das Spiegelbild der Rechnung - auch bei den UEBERNACHTUNGEN.
Vorher stand dort nichts, und das Steuerjournal meldete dem Amt die Naechte der
aufgehobenen Rechnung weiter mit. Auf dem Blatt steht "4 Naechte (aufgehoben)",
in den Kenndaten -4.

"Neue Rechnung:" auf dem Storno-Blatt heisst jetzt "Berichtigte Rechnung:" -
die alte Beschriftung las sich wie die Nummer DIESES Blattes.

--test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog. In der EXE kommt
man ohnehin nicht daran, und die Pruefstaende bleiben sonst im Dialog stehen.

Pruefstand pruef_nummernbuch.py (neu): beide Programme auf einem Ordner - das
Journal traegt drei Altbestands-Nummern ein, das Rechnungstool schlaegt danach
2026-004 statt 2026-001 vor. Alle uebrigen Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
TheMockTv 2026-09-03 20:38:57 +02:00
parent 5ef4e0a769
commit ecad4b00ea
12 changed files with 790 additions and 16 deletions

206
app.py
View file

@ -27,9 +27,12 @@ from tkinter import ttk, messagebox
import config
import berechnung
import gemeinsam
import ordnerwahl
import pdf_renderer
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
from bestand import naechste_freie_nummer, vorhandene_rechnungen
from bestand import (naechste_freie_nummer, rechnungen_im_ordner,
vorhandene_rechnungen)
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
laufende_pid_lesen)
from theme import ThemeMixin
@ -37,6 +40,13 @@ from korrektur import KorrekturMixin
from einstellungen import EinstellungenMixin
# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein
# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den
# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente).
# python app.py --test oder CAMPINGHOF_TEST=1
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
def __init__(self):
super().__init__()
@ -53,6 +63,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
self.storno_stand = None
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
self.buch = None
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
self.berichtigung_stand = None
@ -74,6 +87,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
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)
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
self.after(400, self._frage_gemeinsam_falls_noetig)
# ---------------------------------------------------------------- Menue
@ -96,6 +111,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
("Firmendaten, Logo & Texte…", self.dlg_firma),
("Rechnungsnummer…", self.dlg_nummer),
("Speicherort der Rechnungen…", self.dlg_speicherort),
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
],
"Hilfe": [("Info", self._info)],
}
@ -484,6 +500,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
status.pack(side="bottom", fill="x")
ttk.Label(status, text="powered by ravokk", foreground=self.col_muted,
font=("Segoe UI", 8)).pack(side="right")
# Steht der gemeinsame Ordner? Das gehoert sichtbar hin - ohne ihn kennt
# das Programm die Nummern des Steuerjournals nicht.
self.buch_lbl = ttk.Label(status, text="", foreground=self.col_muted,
font=("Segoe UI", 8))
self.buch_lbl.pack(side="left")
f = ttk.Frame(self, padding=(12, 6, 12, 10))
f.pack(side="bottom", fill="x")
@ -502,6 +523,161 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
width=14).pack(side="left", padx=4)
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
# --------------------------------------------------- Gemeinsames Buch
def _buch_oeffnen(self):
"""Nummernbuch aus dem eingestellten Ordner - oder None."""
ordner = str(self.cfg.get("gemeinsam_ordner", "") or "")
if not ordner:
self.buch = None
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.buch = None
print("[nummernbuch] nicht erreichbar:", grund)
return
self.buch = gemeinsam.Nummernbuch(ordner)
def _frage_gemeinsam_falls_noetig(self):
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
Wer abbricht, kann weiterarbeiten - dann steht aber in der Fussleiste,
dass die Nummern des Steuerjournals unbekannt sind. Beim naechsten Start
wird wieder gefragt.
"""
self._buch_oeffnen()
if self.buch is None and not TESTLAUF:
self.dlg_gemeinsam(erststart=True)
self._buch_anzeigen()
def dlg_gemeinsam(self, erststart=False):
ordner = ordnerwahl.ordner_abfragen(
self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or ""))
if not ordner:
if erststart:
messagebox.showwarning(
"Ohne gemeinsamen Ordner",
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
"Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen "
"PDFs - eine Buchung, die nur im Steuerjournal steht, kann eine "
"Nummer ein zweites Mal bekommen.\n\n"
"Nachholen: Einstellungen -> Gemeinsamer Ordner (Nummernbuch).")
return
self.cfg["gemeinsam_ordner"] = ordner
config.save_config(self.cfg)
self._buch_oeffnen()
self._nachtragen()
self._buch_anzeigen()
self._neue_nummer_vorschlagen()
def _buch_anzeigen(self):
"""Fussleiste: welcher Ordner, wie viele Nummern - oder die Warnung."""
if not hasattr(self, "buch_lbl"):
return
if self.buch is None:
self.buch_lbl.configure(
text="kein gemeinsamer Ordner - Nummern des Steuerjournals unbekannt",
foreground="#c04040")
return
try:
anzahl = len(self.buch.nummern())
except Exception as e: # noqa: BLE001
self.buch_lbl.configure(text=f"Nummernbuch nicht lesbar: {e}",
foreground="#c04040")
return
self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern",
foreground=self.col_muted)
def _belegte_nummern(self):
"""Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge
leer - eine Rechnung muss auch dann geschrieben werden koennen."""
if self.buch is None:
return set()
try:
return self.buch.nummern()
except Exception as e: # noqa: BLE001
print("[nummernbuch] Lesen fehlgeschlagen:", e)
return set()
def _ins_buch(self, nummer, kopf, rechnung, pdf_pfad=""):
"""Eine geschriebene Rechnung ins gemeinsame Buch eintragen."""
if self.buch is None or not nummer:
return
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
datum = parse_date(str(kopf.get("datum", "")))
try:
jahr = int(str(nummer).split("-")[0])
except (ValueError, IndexError):
jahr = datum.year if datum else datetime.date.today().year
try:
self.buch.eintragen(
nummer,
jahr=jahr,
datum=datum.isoformat() if datum else "",
nachname=str(kopf.get("nachname", "") or ""),
vorname=str(kopf.get("vorname", "") or ""),
naechte=int(parse_num(str(kopf.get("naechte") or 0)) or 0),
zwischensumme=berechnung.runde(rechnung.brutto),
steuer_satz=float(aufschlag.satz) if aufschlag else 0.0,
steuer_betrag=berechnung.runde(aufschlag.betrag) if aufschlag else 0.0,
gesamt=berechnung.runde(rechnung.gesamt),
art=str(kopf.get("art", "rechnung") or "rechnung"),
vorgang=str(kopf.get("vorgang", "") or ""),
storno_zu=str(kopf.get("storno_zu", "") or ""),
ersetzt=str(kopf.get("ersetzt", "") or ""),
berichtigt_zu=str(kopf.get("berichtigt_zu", "") or ""),
vorlage=str(kopf.get("vorlage", "") or ""),
quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad)
except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben
print("[nummernbuch] Eintragen fehlgeschlagen:", e)
messagebox.showwarning(
"Nummernbuch nicht erreichbar",
"Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame "
f"Nummernbuch eingetragen werden:\n{e}\n\n"
"Das Steuerjournal sieht sie trotzdem - es liest die PDFs.")
def _nachtragen(self):
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
Laeuft beim Einrichten des Ordners einmal durch - sonst faengt das Buch
bei null an und kennt die bisherigen Nummern nicht.
"""
if self.buch is None:
return
anzahl = 0
for meta in rechnungen_im_ordner(config.ensure_output_dir(self.cfg)):
nummer = str(meta.get("rechnungsnummer") or "")
if not nummer:
continue
datum = parse_date(str(meta.get("datum", "")))
try:
jahr = int(nummer.split("-")[0])
except (ValueError, IndexError):
jahr = datum.year if datum else 0
try:
self.buch.eintragen(
nummer, jahr=jahr,
datum=datum.isoformat() if datum else "",
nachname=str(meta.get("nachname", "") or ""),
vorname=str(meta.get("vorname", "") or ""),
naechte=int(parse_num(str(meta.get("naechte") or 0)) or 0),
zwischensumme=float(meta.get("zwischensumme") or 0),
steuer_satz=float(meta.get("steuer_satz") or 0),
steuer_betrag=float(meta.get("steuer_betrag") or 0),
gesamt=float(meta.get("gesamt") or 0),
art=str(meta.get("art", "rechnung") or "rechnung"),
vorgang=str(meta.get("vorgang", "") or ""),
storno_zu=str(meta.get("storno_zu", "") or ""),
ersetzt=str(meta.get("ersetzt", "") or ""),
berichtigt_zu=str(meta.get("berichtigt_zu", "") or ""),
vorlage=str(meta.get("vorlage", "") or ""),
quelle="rechnungstool", herkunft="pdf")
anzahl += 1
except Exception as e: # noqa: BLE001
print("[nummernbuch] Nachtragen fehlgeschlagen:", e)
break
if anzahl:
print(f"[nummernbuch] {anzahl} vorhandene Rechnungen nachgetragen")
# --------------------------------------------------------- Berechnung
def _eingaben(self):
e = {}
@ -569,7 +745,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"""
if self.storno_stand or self.berichtigung_stand:
return
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg,
self._belegte_nummern())
if frei:
self.r_nummer.set(frei)
@ -770,6 +947,17 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
print("[berichtigung] FEHLER:", e)
return
print(f"[berichtigung] erstellt -> {pfad}")
# Das Blatt verbraucht keine Nummer und bucht nichts - im Buch wird
# nur der berichtigte Name der Rechnung nachgezogen. Wuerde man hier
# den ganzen Eintrag schreiben, stuenden dort die Nullen des Blattes.
if self.buch is not None:
try:
self.buch.eintragen(ber["zu"],
nachname=kopf.get("nachname", ""),
vorname=kopf.get("vorname", ""),
berichtigt_zu=ber["zu"])
except Exception as e: # noqa: BLE001
print("[nummernbuch] Berichtigung nicht eingetragen:", e)
self.berichtigung_stand = None
self._storno_anzeigen()
if messagebox.askyesno("Fertig",
@ -784,9 +972,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
vorhanden = vorhandene_rechnungen(out_dir, nummer)
if vorhanden:
# Auch das gemeinsame Nummernbuch zaehlt als vergeben: dort stehen die
# Nummern des Steuerjournals, zu denen es hier gar keine PDF gibt.
im_buch = nummer in self._belegte_nummern()
if vorhanden or im_buch:
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
frei = naechste_freie_nummer(out_dir, self.cfg)
if im_buch and not vorhanden:
liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)"
frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not frei:
messagebox.showerror(
"Rechnungsnummer schon vergeben",
@ -823,6 +1016,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
print("[pdf] FEHLER:", e)
return
# Ins gemeinsame Nummernbuch, damit das Steuerjournal die Nummer kennt -
# auch wenn es die PDF (noch) nicht eingelesen hat.
self._ins_buch(nummer, kopf, r, pfad)
self._buch_anzeigen()
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
self._zaehler_aus_nummer_speichern(nummer)