Statt einer Zustandsmaschine ("offener Storno" ueber den Neustart retten) traegt
das Storno jetzt einfach beide Nummern:
Storno zu Rechnung: 2026-002
Neue Rechnung: 2026-004
und im Text "2026-002 -> 2026-003 -> 2026-004". Damit ist die Kette aus dem
Beleg allein lesbar. Wird das Programm zwischendurch geschlossen, sagt der
Storno, unter welcher Nummer die berichtigte Rechnung gehoert; der Dialog
nennt sie ebenfalls, statt nur "bereits storniert" zu melden.
naechste_freie_nummer_nach() bestimmt die Folgenummer, damit sie nicht von der
Reihenfolge abhaengt, in der der Zaehler mitzieht.
Ausserdem: die Pruefstaende raeumen ihren Wegwerf-Ordner wieder weg, wenn sie
gruen sind. Ein Testlauf hatte 398 Ordner im Temp-Verzeichnis hinterlassen.
Bei Fehlern bleibt der Ordner stehen, damit man hineinsehen kann.
Weitere Funde des Code-Agenten behoben:
- "Kopie von Rechnung_...pdf" und klein geschriebene Dateinamen zaehlen jetzt
bei der Nummernvergabe mit (vorher galt die Nummer als frei).
- Strg+P feuerte auch aus einem offenen Dialog heraus (bind_all -> bind).
167 Pruefungen gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
208 lines
8.2 KiB
Python
208 lines
8.2 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):
|
|
"""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)
|
|
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)
|
|
|
|
|
|
def naechste_freie_nummer(ordner, cfg):
|
|
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
|
|
|
|
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
|
|
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
|
|
"""
|
|
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 not vorhandene_rechnungen(ordner, kandidat):
|
|
return kandidat
|
|
return None
|