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
238 lines
9.3 KiB
Python
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
|