REGRESSION aus der Modulaufteilung (Code-Review-Agent): - einstellungen.py fehlten "berechnung" und "datetime". Menue > Leistungskatalog und > Rechnungsnummer stuerzten mit NameError ab und liessen ein leeres, modales Fenster stehen. Preise und Nummernkreis waren nicht mehr einstellbar. KEIN Pruefstand hat das gemerkt - keiner oeffnete je einen Dialog. Neu: pruef_dialoge.py oeffnet ALLE Dialoge und schliesst sie wieder. HAENGENDE ZWISCHENSPEICHER (derselbe Agent, gemessen): - "Zuruecksetzen" liess storno_stand/berichtigung_stand stehen. Danach gehoerten die Daten eines FREMDEN Gastes zum Storno der alten Rechnung, und die Berichtigung schrieb ein Blatt mit Nummer A im Kenndatensatz und Nummer B auf dem Blatt. reset_formular fragt jetzt nach und verwirft sauber. - _neue_nummer_vorschlagen fasst die Nummer nicht mehr an, solange ein Vorgang laeuft (traf auch dlg_nummer). - Fenster schliessen mit offenem Storno fragt jetzt nach (WM_DELETE_WINDOW). - _storno_verwerfen bricht ab, wenn das PDF nicht geloescht werden kann, statt den Zaehler trotzdem zurueckzudrehen. - Der Storno prueft die Kundenangaben und fragt, wenn die alte Rechnung sie nicht hergibt. - Zaehler nicht speicherbar -> sichtbare Meldung statt Traceback ins Nichts. - storno.py: v1-Rueckfall nimmt den ersten Satz OHNE extra_blatt. - EINE Quelle fuer die naechste Nummer: _neue_nummer_vorschlagen benutzt jetzt naechste_freie_nummer, der Jahreswechsel steht nur noch dort. Vorher schlug das Formular 2026-001 vor, waehrend der Storno-Weg 2025-088 nahm. PRUEFSTAENDE (Audit-Agent: 16 von 27 eingebauten Fehlern blieben unbemerkt): - Neu pruef_kernregeln.py (27 Pruefungen) fuer die zwei Regeln, um die es geht: Nummer von HAND auf eine vergebene setzen, Zaehler hinter dem Ordner, Jahreswechsel, Zaehler nur vorwaerts, Storno ueber den DIALOGKNOPF statt der internen Methode, zweite Berichtigung am selben Tag. Der Bestand wird ueber SHA256 verglichen - "Datei ist noch da" heisst nicht "unveraendert". - Der Text des Storno-PDFs wird gelesen: Positionszeile negativ, nicht nur der Summenblock. - Tautologien raus: Selbstvergleich beim storno_datum, all() ueber eine leere Liste, "nicht" in einem deutschen Text. - Unangemeldeter Dialog laesst den Lauf scheitern, statt still "Nein" zu sagen. - pruef_nummern.py: .PDF gross geschrieben, Rechnung_*.txt, echte Praefix-Falle. - Gegenprobe: sechs Mutationen eingebaut, die vorher gruen blieben - alle sechs werden jetzt rot. RECHT (Gegenpruefungs-Agent): Zitate praezisiert. § 146 Abs. 4 AO verbietet nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr feststellbar ist - ein protokollierter Storno-Vermerk waere erlaubt, wir verzichten trotzdem bewusst darauf. Der § 14c-Hinweis bleibt, gilt aber gegenueber Endverbrauchern nicht (EuGH C-378/21, BMF v. 27.02.2024) - er zielt auf Firmengaeste. 142 Pruefungen gruen (100 + 27 + 8 + 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
176 lines
6.9 KiB
Python
176 lines
6.9 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 []
|
|
treffer = []
|
|
for name in namen:
|
|
if not name.lower().endswith(".pdf"):
|
|
continue
|
|
stamm = name[:-4]
|
|
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
|
|
if any(stamm == k or stamm.startswith(k + "_") for k in koepfe):
|
|
treffer.append(os.path.join(ordner, name))
|
|
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(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
|