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)

View file

@ -168,14 +168,14 @@ def rechnungen_im_ordner(ordner):
gefunden.append(daten)
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
return gefunden
def naechste_freie_nummer_nach(ordner, cfg, vergeben):
def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None):
"""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)
kandidat = naechste_freie_nummer(ordner, cfg, belegt)
if kandidat and kandidat != vergeben:
return kandidat
ersatz = dict(cfg)
@ -184,15 +184,21 @@ def naechste_freie_nummer_nach(ordner, cfg, vergeben):
ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z)
except (ValueError, AttributeError):
return kandidat
return naechste_freie_nummer(ordner, ersatz)
return naechste_freie_nummer(ordner, ersatz, belegt)
def naechste_freie_nummer(ordner, cfg):
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
def naechste_freie_nummer(ordner, cfg, belegt=None):
"""Erste Nummer JJJJ-NNN, die weder im Ordner noch im Nummernbuch steht.
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
`belegt` sind zusaetzlich vergebene Nummern - die aus dem gemeinsamen
Nummernbuch. Ohne sie kennt das Rechnungstool nur die eigenen PDFs und
vergibt eine Nummer aus dem Steuerjournal (Altbestand aus der Excel-Mappe,
zu dem es keine PDF gibt) ein zweites Mal.
"""
belegt = set(belegt or ())
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
z = cfg.get("rechnung_zaehler", 0)
# Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends
@ -203,6 +209,6 @@ def naechste_freie_nummer(ordner, cfg):
for _ in range(1000):
z += 1
kandidat = config.format_rechnungsnummer(jahr, z)
if not vorhandene_rechnungen(ordner, kandidat):
if kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat):
return kandidat
return None

160
gemeinsam.py Normal file
View file

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""Das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal.
WARUM ES DAS GIBT
-----------------
Bis hierher wusste jedes Programm nur, was in SEINEM Ordner lag. Das
Rechnungstool sucht die naechste freie Nummer in den PDFs im Rechnungsordner -
im Steuerjournal stehen aber auch Buchungen, zu denen es gar keine PDF gibt:
der Altbestand aus der Excel-Mappe und die frueher von Hand eingetragenen
Zeilen. Deren Nummern kannte das Rechnungstool nicht und hat sie ein zweites
Mal vergeben. Jede Rechnungsnummer darf es aber nur einmal geben
(Paragraph 14 Abs. 4 Nr. 4 UStG).
Deshalb fuehren beide Programme ab jetzt EIN gemeinsames Buch: eine
SQLite-Datei in einem Ordner, den beide kennen. Wer eine Nummer vergibt oder
eine Buchung anlegt, traegt sie dort ein; wer eine neue Nummer sucht, schaut
zuerst hier nach.
Die Datei ist ein REGISTER, keine zweite Buchhaltung: die Wahrheit bleiben die
Rechnungs-PDFs. Geht das Buch verloren, fehlt keine Rechnung - es muss nur neu
gefuellt werden (beide Programme tun das beim naechsten Einlesen von selbst).
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber - sonst schreiben zwei Fassungen in dieselbe Datei.
"""
import logging
import os
import sqlite3
log = logging.getLogger("gemeinsam")
DATEINAME = "campinghof-nummernbuch.sqlite3"
SCHEMA = """
CREATE TABLE IF NOT EXISTS nummern (
nummer TEXT PRIMARY KEY, -- '2026-442', immer die volle Nummer
jahr INTEGER NOT NULL DEFAULT 0,
datum TEXT NOT NULL DEFAULT '', -- ISO JJJJ-MM-TT
nachname TEXT NOT NULL DEFAULT '',
vorname TEXT NOT NULL DEFAULT '',
naechte INTEGER NOT NULL DEFAULT 0,
zwischensumme REAL NOT NULL DEFAULT 0,
steuer_satz REAL NOT NULL DEFAULT 0,
steuer_betrag REAL NOT NULL DEFAULT 0,
gesamt REAL NOT NULL DEFAULT 0,
art TEXT NOT NULL DEFAULT 'rechnung', -- rechnung|storno|korrektur|berichtigung
vorgang TEXT NOT NULL DEFAULT '', -- Klammer um Rechnung+Storno+Korrektur
storno_zu TEXT NOT NULL DEFAULT '', -- welche Rechnung hebt dieser Storno auf
ersetzt TEXT NOT NULL DEFAULT '', -- welche Rechnung ersetzt diese hier
berichtigt_zu TEXT NOT NULL DEFAULT '', -- zu welcher Rechnung gehoert das Blatt
vorlage TEXT NOT NULL DEFAULT '', -- '' oder 'montage'
quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal
herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell
pdf_pfad TEXT NOT NULL DEFAULT '',
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
);
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
"""
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
"steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu",
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad")
def pfad_zur_datei(ordner):
"""Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner."""
return os.path.join(ordner or "", DATEINAME)
def ordner_taugt(ordner):
"""Kann in diesem Ordner wirklich gearbeitet werden? Wird ausprobiert,
nicht geraten - ein nicht verbundenes Netzlaufwerk sieht sonst gut aus,
bis die erste Rechnung geschrieben wird."""
if not ordner or not os.path.isdir(ordner):
return False, "Den Ordner gibt es nicht."
probe = os.path.join(ordner, ".schreibprobe")
try:
with open(probe, "w", encoding="ascii") as f:
f.write("x")
os.remove(probe)
except OSError as e:
return False, f"In den Ordner kann nicht geschrieben werden ({e.strerror or e})."
return True, ""
class Nummernbuch:
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
und auf einem Netzlaufwerk gibt das gesperrte Dateien."""
def __init__(self, ordner):
self.ordner = ordner
self.pfad = pfad_zur_datei(ordner)
def _verbindung(self):
con = sqlite3.connect(self.pfad, timeout=10)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
return con
# ---- Lesen -------------------------------------------------------------
def nummern(self, jahr=None):
"""Alle vergebenen Nummern (als Menge), auf Wunsch nur eines Jahres."""
with self._verbindung() as con:
if jahr:
zeilen = con.execute("SELECT nummer FROM nummern WHERE jahr=?", (int(jahr),))
else:
zeilen = con.execute("SELECT nummer FROM nummern")
return {z["nummer"] for z in zeilen}
def eintrag(self, nummer):
with self._verbindung() as con:
z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone()
return dict(z) if z else None
def alle(self):
with self._verbindung() as con:
return [dict(z) for z in con.execute("SELECT * FROM nummern ORDER BY nummer")]
def belegt(self, nummer):
return self.eintrag(nummer) is not None
# ---- Schreiben ---------------------------------------------------------
def eintragen(self, nummer, **werte):
"""Nummer eintragen oder ihre Angaben auffrischen.
Eine schon eingetragene Nummer wird NICHT als Fehler behandelt: beide
Programme sehen dieselbe Rechnung (das Rechnungstool beim Schreiben,
das Journal beim Einlesen der PDF) und sollen sich gegenseitig
ergaenzen duerfen.
"""
import datetime
nummer = str(nummer or "").strip()
if not nummer:
return False
daten = {f: werte.get(f) for f in FELDER if werte.get(f) is not None}
daten["geaendert"] = datetime.datetime.now().isoformat(timespec="seconds")
spalten = ["nummer"] + list(daten)
platzhalter = ",".join("?" * len(spalten))
setzen = ",".join(f"{k}=excluded.{k}" for k in daten)
with self._verbindung() as con:
con.execute(
f"INSERT INTO nummern ({','.join(spalten)}) VALUES ({platzhalter}) "
f"ON CONFLICT(nummer) DO UPDATE SET {setzen}",
[nummer] + list(daten.values()))
log.info("Nummernbuch: %s eingetragen (%s)", nummer, daten.get("quelle", ""))
return True
def storniert(self):
"""Nummern, die durch einen Storno aufgehoben sind."""
with self._verbindung() as con:
return {z["storno_zu"] for z in
con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")}
def entfernen(self, nummer):
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
with self._verbindung() as con:
con.execute("DELETE FROM nummern WHERE nummer=?", (str(nummer),))
log.info("Nummernbuch: %s entfernt", nummer)

View file

@ -27,7 +27,7 @@ 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 helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename
from storno import storno_rechnung_bauen
@ -106,6 +106,13 @@ class KorrekturMixin:
"Zähler nicht gespeichert",
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
# Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst
# ueberspringt das Rechnungstool sie fuer immer.
if self.buch is not None and stand.get("nummer"):
try:
self.buch.entfernen(stand["nummer"])
except Exception as e: # noqa: BLE001
print("[nummernbuch] Storno nicht ausgetragen:", e)
self.storno_stand = None
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
@ -298,7 +305,7 @@ class KorrekturMixin:
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)
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
if not nummer:
messagebox.showerror("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
@ -310,7 +317,7 @@ class KorrekturMixin:
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer)
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern())
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
kunde, woher = kundendaten_ermitteln(meta)
@ -333,10 +340,17 @@ class KorrekturMixin:
icon="warning", default="no"):
return
# Der Storno ist das Spiegelbild der Rechnung - auch bei den
# UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der
# aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten
# sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie
# doppelt gemeldet (4 - 0 + 4 = 8 statt 4).
alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0)
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "", "naechte": "",
"anreise": "", "abreise": "",
"naechte": -abs(alt_naechte) if alt_naechte else "",
"kunde_zeilen": kunde_zeilen,
"nachname": kunde.get("nachname", ""),
"vorname": kunde.get("vorname", ""),
@ -373,6 +387,11 @@ class KorrekturMixin:
}
self._zaehler_aus_nummer_speichern(nummer)
print(f"[storno] erstellt -> {pfad}")
# Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch,
# sonst vergibt sie das andere Programm noch einmal, und das Journal
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
self._ins_buch(nummer, kopf, r, pfad)
self._buch_anzeigen()
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(folge_nummer or "")

183
ordnerwahl.py Normal file
View file

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""Den gemeinsamen Ordner abfragen - in beiden Programmen gleich.
Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
(siehe gemeinsam.py). Damit sie sich finden, muss BEIDEN derselbe Ordner
genannt werden - deshalb fragt jedes Programm beim ersten Start danach und
sagt ausdruecklich dazu, dass es derselbe sein muss.
Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal
bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine
Rechnungsnummer zum zweiten Mal vergeben wurde.
Wer jetzt keinen Ordner hat, kommt mit "Spaeter einrichten" weiter - das
Programm laeuft dann ohne das gemeinsame Buch und sagt das in der Fussleiste.
⚠ Das Fenster bekommt seine Groesse ausdruecklich gesetzt. Ohne das war es zu
schmal, der Knopf zum Auswaehlen lag ausserhalb - und der Dialog war nicht zu
bedienen (gefunden am 03.09.2026, bevor es jemand benutzen musste).
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
hinueber.
"""
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import gemeinsam
BREITE = 780
class OrdnerWahl(tk.Toplevel):
"""Modaler Dialog. Nach dem Schliessen steht das Ergebnis in `.ergebnis`
(gewaehlter Ordner) oder es ist None (spaeter/abgebrochen)."""
def __init__(self, master, programm: str, vorgabe: str = ""):
super().__init__(master)
self.ergebnis = None
self.title("Gemeinsamer Ordner für das Nummernbuch")
self.transient(master)
self.pfad = tk.StringVar(value=vorgabe or "")
rahmen = ttk.Frame(self, padding=18)
rahmen.pack(fill="both", expand=True)
rahmen.columnconfigure(0, weight=1)
breite = BREITE - 60
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(rahmen, wraplength=breite, justify="left", text=(
"Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht "
"jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der "
"es gar keine PDF gibt.\n\n"
"Ohne dieses Buch kennt jedes Programm nur seine eigenen Nummern und vergibt "
"eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben "
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12))
tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000",
font=("Segoe UI", 10, "bold"),
text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten "
"der Ordner, in dem die Programme liegen.").grid(
row=2, column=0, sticky="w", pady=(0, 14))
wahl = ttk.Frame(rahmen)
wahl.grid(row=3, column=0, sticky="ew")
wahl.columnconfigure(0, weight=1)
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
self.feld.grid(row=0, column=0, sticky="ew")
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left",
font=("Segoe UI", 9))
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
knoepfe = ttk.Frame(rahmen)
knoepfe.grid(row=5, column=0, sticky="ew", pady=(18, 0))
self.ok = ttk.Button(knoepfe, text="OK", command=self.uebernehmen, width=14)
self.ok.pack(side="right")
ttk.Button(knoepfe, text="Später einrichten", command=self.spaeter,
width=18).pack(side="left")
self._stand_zeigen()
self.protocol("WM_DELETE_WINDOW", self.spaeter)
self.bind("<Escape>", lambda _e: self.spaeter())
# Groesse ausdruecklich setzen: sonst schneidet Tk das Fenster an der
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
self.update_idletasks()
hoehe = max(self.winfo_reqheight(), 300)
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
y = master.winfo_rooty() + 90
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
self.minsize(BREITE, hoehe)
self.grab_set()
self.lift()
self.focus_force()
# ------------------------------------------------------------------ Weg
def waehlen(self):
# Der Griff (grab_set) muss waehrend des Windows-Ordnerdialogs los sein:
# sonst kann der native Dialog hinter dem gesperrten Fenster landen und
# nimmt keine Eingabe an - man klickt und es passiert nichts.
try:
self.grab_release()
except tk.TclError:
pass
try:
ordner = filedialog.askdirectory(
title="Gemeinsamen Ordner wählen (in beiden Programmen derselbe!)",
initialdir=self.pfad.get() or os.path.expanduser("~"), parent=self)
finally:
try:
self.grab_set()
except tk.TclError:
pass
if ordner:
self.pfad.set(os.path.abspath(ordner))
self._stand_zeigen()
def _stand_zeigen(self):
"""Sagt, was in dem gewaehlten Ordner schon liegt.
Ein bereits vorhandenes Nummernbuch ist das beste Zeichen, dass es der
richtige Ordner ist - dann hat das andere Programm ihn schon.
"""
ordner = self.pfad.get().strip()
if not ordner:
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
self.ok.state(["disabled"])
return
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
self.stand.configure(text=grund, fg="#a00000")
self.ok.state(["disabled"])
return
self.ok.state(["!disabled"])
if os.path.exists(gemeinsam.pfad_zur_datei(ordner)):
try:
anzahl = len(gemeinsam.Nummernbuch(ordner).nummern())
except Exception: # noqa: BLE001
anzahl = "?"
self.stand.configure(
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
"das ist der Ordner des anderen Programms. Genau richtig.",
fg="#1f5c3d")
else:
self.stand.configure(
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
"anderen Programm denselben Ordner.", fg="#444444")
def uebernehmen(self):
ordner = self.pfad.get().strip()
taugt, grund = gemeinsam.ordner_taugt(ordner)
if not taugt:
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
parent=self)
return
if not messagebox.askyesno(
"Sind Sie sicher?",
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
"dieselben Rechnungsnummern.\n\n"
"Ja = Ordner übernehmen\n"
"Nein = zurück zur Auswahl",
icon="question", default="yes", parent=self):
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
self.feld.focus_set()
return
self.ergebnis = ordner
self.destroy()
def spaeter(self):
self.ergebnis = None
self.destroy()
def ordner_abfragen(fenster, programm, vorgabe=""):
"""Dialog zeigen und den gewaehlten Ordner zurueckgeben (oder None)."""
d = OrdnerWahl(fenster, programm, vorgabe)
fenster.wait_window(d)
return d.ergebnis

View file

@ -301,7 +301,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# 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"]))
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt.
info_rows.append(("Berichtigte Rechnung:", kopf["korrektur_nummer"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
@ -315,7 +317,17 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
if kopf.get("abreise"):
info_rows.append(("Abreise:", kopf.get("abreise", "")))
if kopf.get("naechte") not in (None, "", 0, "0"):
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
# Auf dem Storno stehen die Naechte negativ in den Kenndaten (das
# Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4
# Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird.
try:
anzahl = int(float(str(kopf.get("naechte")).replace(",", ".")))
except (TypeError, ValueError):
anzahl = 0
if anzahl < 0:
info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)"))
else:
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
info_tbl = Table(
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
colWidths=[39 * mm, 25 * mm])

View file

@ -29,6 +29,8 @@ 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)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402

View file

@ -37,6 +37,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)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402

View file

@ -36,6 +36,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)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402

View file

@ -40,6 +40,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)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402

186
pruef_nummernbuch.py Normal file
View file

@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""Pruefstand: das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal.
Der Fall, um den es geht (sein Satz vom 03.09.2026):
"er hat manuelle eingetragen die das andere programm die rechnungs nummer
nicht kennt - mit der sqlite beheben wir das"
Im Steuerjournal stehen Buchungen aus der alten Excel-Mappe, zu denen es keine
PDF gibt. Das Rechnungstool sucht die naechste freie Nummer bisher nur in
seinem eigenen Ordner - und vergibt so eine Nummer zum zweiten Mal
(§ 14 Abs. 4 Nr. 4 UStG: jede Nummer nur einmal).
Geprueft wird mit BEIDEN echten Programmen auf EINEM gemeinsamen Ordner.
"""
import json
import os
import shutil
import sys
import tempfile
import threading
TOOL = r"C:\claude\rechnungstool"
JOURNAL = r"C:\claude\beherbergungssteuer"
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
kodierung = sys.stdout.encoding or "utf-8"
print(zeile.encode(kodierung, "replace").decode(kodierung))
if not bedingung:
fehler.append(text)
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
os._exit(2)
_wecker = threading.Timer(150, _reissleine)
_wecker.daemon = True
_wecker.start()
os.environ["CAMPINGHOF_TEST"] = "1" # kein Erststart-Dialog
tmp = tempfile.mkdtemp(prefix="buch_pruef_")
gemeinsam_ordner = os.path.join(tmp, "install") # hier liegt das Buch
rechnungen = os.path.join(tmp, "rechnungen")
journal_daten = os.path.join(tmp, "journal_daten")
for d in (gemeinsam_ordner, rechnungen, journal_daten):
os.makedirs(d)
# ---- Erst das Steuerjournal (eigener Datenordner) --------------------------
sys.path.insert(0, JOURNAL)
import ablage # noqa: E402
ablage.daten_ordner = lambda: journal_daten
import app as JA # noqa: E402 - das Journal
from modell import Buchung # noqa: E402
import gemeinsam # noqa: E402
del sys.modules["app"]
sys.path.remove(JOURNAL)
from datetime import date # noqa: E402
from tkinter import messagebox # noqa: E402
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: None
journal = JA.App()
journal.withdraw()
# Altbestand wie aus der Excel-Mappe: Nummern OHNE PDF.
for nr, name in (("001", "Alt Eins"), ("002", "Alt Zwei"), ("003", "Alt Drei")):
journal.journal.speichern(Buchung(date(2026, 5, 1), nr, 2026, name, 2, 50.0,
quelle="xlsx"))
journal.jahr.set(2026)
journal.aktualisieren()
journal.journal.setze("gemeinsam_ordner", gemeinsam_ordner)
journal._buch_oeffnen()
journal._buch_fuellen()
buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
pruefe(os.path.exists(gemeinsam.pfad_zur_datei(gemeinsam_ordner)),
"das Journal legt das Nummernbuch im gemeinsamen Ordner an")
nummern = buch.nummern()
pruefe(nummern == {"2026-001", "2026-002", "2026-003"},
f"die drei Altbestands-Nummern stehen drin ({sorted(nummern)})")
eintrag = buch.eintrag("2026-002")
pruefe(eintrag and eintrag["herkunft"] == "xlsx" and eintrag["quelle"] == "journal",
f"mit Herkunft und Quelle ({eintrag and (eintrag['quelle'], eintrag['herkunft'])})")
# ---- Jetzt das Rechnungstool auf denselben Ordner --------------------------
sys.path.insert(0, TOOL)
import config # noqa: E402
shutil.copy(os.path.join(TOOL, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = rechnungen
roh["gemeinsam_ordner"] = gemeinsam_ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402 - das Rechnungstool
import bestand as BE # noqa: E402
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
"Storno wirklich": True}
def askyesno(titel, text, **kw):
for schlue, wert in antworten.items():
if schlue in titel:
return wert
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
messagebox.askyesno = askyesno
tool = A.RechnungsApp()
tool.cfg["output_dir"] = rechnungen
tool._buch_oeffnen()
pruefe(tool.buch is not None, "das Rechnungstool findet dasselbe Buch")
tool._neue_nummer_vorschlagen()
pruefe(tool.r_nummer.get() == "2026-004",
f"es schlaegt 2026-004 vor und ueberspringt den Altbestand ({tool.r_nummer.get()})")
# ---- Eine Rechnung schreiben: sie muss im Buch landen ---------------------
tool.k_vorname.set("Anna")
tool.k_name.set("Neu")
tool.k_strasse.set("Musterweg 1")
tool.k_plzort.set("12345 Musterstadt")
tool.r_anreise.set("01.09.2026")
tool.r_abreise.set("03.09.2026")
tool.anzahl_vars[sorted(tool.anzahl_vars)[0]].set("2")
tool.update()
tool.pdf_erstellen()
pruefe(os.path.exists(os.path.join(rechnungen, "Rechnung_2026-004_Neu.pdf")),
"die Rechnung 2026-004 ist geschrieben")
e4 = buch.eintrag("2026-004")
pruefe(e4 is not None, "und steht im gemeinsamen Buch")
pruefe(e4 and e4["quelle"] == "rechnungstool" and e4["nachname"] == "Neu",
f"mit Quelle rechnungstool und Namen ({e4 and (e4['quelle'], e4['nachname'])})")
pruefe(e4 and abs(float(e4["zwischensumme"]) - 26.0) < 0.005,
f"und dem Betrag ({e4 and e4['zwischensumme']})")
# ---- Storno: eigene Nummer ins Buch, beim Verwerfen wieder raus -----------
offen = [m for m in BE.rechnungen_im_ordner(rechnungen) if m.get("art") != "storno"]
tool._storno_anlegen(offen[0], rechnungen)
storno_nr = tool.storno_stand["nummer"] if tool.storno_stand else ""
pruefe(buch.belegt(storno_nr), f"der Storno {storno_nr} steht im Buch")
e_storno = buch.eintrag(storno_nr)
pruefe(e_storno and e_storno["storno_zu"] == "2026-004",
f"und sagt, welche Rechnung er aufhebt ({e_storno and e_storno['storno_zu']})")
tool._storno_verwerfen("Pruefstand")
pruefe(not buch.belegt(storno_nr),
f"beim Verwerfen ist die Nummer wieder frei ({storno_nr})")
# ---- Das Journal sieht die neue Rechnung im Buch --------------------------
journal._buch_oeffnen()
pruefe("2026-004" in journal.buch.nummern(),
"das Journal sieht die Nummer des Rechnungstools im Buch")
# ---- Faellt das Buch aus, laeuft trotzdem alles weiter --------------------
tool.cfg["gemeinsam_ordner"] = os.path.join(tmp, "gibtsnicht")
tool._buch_oeffnen()
pruefe(tool.buch is None, "ein nicht erreichbarer Ordner schaltet das Buch ab")
pruefe(tool._belegte_nummern() == set(), "die belegten Nummern sind dann leer")
tool._neue_nummer_vorschlagen()
pruefe(bool(tool.r_nummer.get()), f"und es wird trotzdem eine Nummer vorgeschlagen "
f"({tool.r_nummer.get()})")
tool.destroy()
journal.journal.schliessen()
journal.destroy()
if not fehler:
shutil.rmtree(tmp, ignore_errors=True)
else:
print(f" (Wegwerf-Ordner bleibt: {tmp})")
print("---")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)

View file

@ -32,6 +32,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)
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
import bestand as BE # noqa: E402
from tkinter import messagebox # noqa: E402