rechnungstool/bestand.py
TheMockTv d884cff6f8 Der Rechnungszaehler kann nicht mehr hinter das Vergebene zurueck
Sein Einwand: 'er kann jetzt bescheissen, indem er das in den einstellungen
aendert' - und sein Vorschlag: 'dann koennte man noch die pdfs nutzen als
referenz, die haben auch die nummern'.

Der hoechste je vergebene Stand kommt jetzt aus ZWEI Quellen:
* den PDFs im Rechnungsordner (der Dateiname traegt die Nummer - kein Oeffnen
  noetig)
* dem gemeinsamen Nummernbuch (kennt auch Nummern OHNE PDF, den Altbestand)

Der Zaehler in der config taugt nicht als Wahrheit, er ist ja gerade das, was
sich aendern laesst. Deshalb: beim Nummernvorschlag wird ein zurueckgedrehter
Zaehler stillschweigend wieder angehoben, und die Einstellungen nehmen keinen
Wert mehr an, der hinter dem Vergebenen liegt - mit Meldung, welcher Stand
schon erreicht ist und woher er kommt. Nach VORN bleibt er frei: eine
lueckenlose Folge verlangt das Gesetz nicht, jede Nummer aber nur einmal
(§ 14 Abs. 4 Nr. 4 UStG).

Pruefstand pruef_nummernsperre.py: 17 Pruefungen - zurueckgedrehter Zaehler,
Einstellungen vor und zurueck, nur Ordner, nur Buch, und beides weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 22:43:23 +02:00

238 lines
9.3 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 re
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 hoechster_zaehler_im_ordner(ordner, jahr):
"""Groesster Zaehler dieses Jahres, den die PDFs im Ordner hergeben.
Gelesen wird der DATEINAME (Rechnung_2026-442_..., Storno_2026-443_...) -
das geht ohne jede PDF zu oeffnen und reicht: das Programm schreibt die
Nummer immer in den Namen. Was jemand von Hand umbenannt hat, faellt hier
durch; dafuer gibt es das gemeinsame Nummernbuch als zweite Quelle.
"""
hoch = 0
muster = re.compile(rf"(?:Rechnung|Storno)_{int(jahr)}-(\d+)", re.IGNORECASE)
try:
namen = os.listdir(ordner)
except OSError:
return 0
for name in namen:
if not name.lower().endswith(".pdf"):
continue
m = muster.search(name)
if m:
hoch = max(hoch, int(m.group(1)))
return hoch
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