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
214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
# -*- 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, 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, belegt)
|
|
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, belegt)
|
|
|
|
|
|
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
|
|
# 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 kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat):
|
|
return kandidat
|
|
return None
|