feat(v3): Bundle-Parser für konfigurierte Positionen (z.B. Dell) + Summen-Check
Rechnungen mit konfigurierten Bündel-Positionen (Signal: 'Menge Komponenten') haben einen völlig anderen Aufbau: Produktname + Dutzende Komponentenzeilen über viele Seiten, Preis als 3-Zeilen-Block mittendrin, Service-Tags erst danach. Der bisherige Parser scheitert daran (falsche Namen, Fehltreffer). Neu in invoice_logic.py: - is_bundle_invoice / extract_bundle_items: eigener Parser, verankert am 3-Zeilen-Preisblock (Menge+Einheit / Einzelpreis / Zeilensumme), segmentiert an den Positions-Headern; entfernt Seitenmöblierung (Kopf RECHNUNG..Betrag €, Bank-/Registerzeilen, Übertrag/Vortrag). Beschreibung (BT-154) = volle Komponentenliste + Service-Tags. - extract_invoice_items: Dispatcher (Bundle vs. Standardparser) - die bekannten Service-Rechnungen bleiben unverändert. - extract_stated_tax_basis: liest die ausgewiesene Bemessungsgrundlage aus der Steuerzeile. main.py: nutzt den Dispatcher; Summen-Abgleich als Sicherheitsnetz - weicht die Summe der erkannten Positionen von der ausgewiesenen Bemessungsgrundlage ab, erscheint eine deutliche Warnung. Verifiziert an R26-252 (9 Seiten): 4 Positionen korrekt, Summe 217.500,55 = Bemessungsgrundlage, Brutto 258.825,65 = Endbetrag, valides XRechnung/ZUGFeRD mit Service-Tags in BT-154. Alle 7 bekannten Rechnungen unverändert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
23968bb911
commit
cd187585dd
152
invoice_logic.py
152
invoice_logic.py
|
|
@ -495,6 +495,158 @@ def extract_invoice_items_pure_python(text):
|
|||
|
||||
return items
|
||||
|
||||
|
||||
# ==========================================
|
||||
# BUNDLE-RECHNUNGEN (konfigurierte Positionen mit Komponentenliste, z.B. Dell-Notebooks)
|
||||
# ==========================================
|
||||
# Solche Rechnungen (Signal: "Menge Komponenten") haben einen voellig anderen Aufbau als die
|
||||
# ueblichen Service-Rechnungen: jede Position ist ein konfiguriertes Buendel aus Produktname +
|
||||
# Dutzenden Komponentenzeilen, verteilt ueber viele Seiten mit wiederholten Seitenkoepfen und
|
||||
# "Uebertrag/Vortrag"-Zeilen. Der Preis steht als 3-Zeilen-Block (Menge+Einheit / Einzelpreis /
|
||||
# Zeilensumme) mittendrin; die Service-Tags (Seriennummern) folgen erst danach. Dafuer gibt es
|
||||
# diesen eigenen Parser - der uebrige (haeufigere) Rechnungstyp bleibt beim Standardparser.
|
||||
|
||||
_BUNDLE_MARKER = "Menge Komponenten"
|
||||
_BUNDLE_MONEY_LINE = re.compile(r"^[\d.]+,\d{2}$")
|
||||
_BUNDLE_UNIT_LINE = re.compile(
|
||||
r"^(\d+(?:[.,]\d+)?)\s*(Stk\.|pausch\.|pauschal|Monat\(e\)|Tag\(e\)|Std\.|Stück)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STATED_TAX_BASIS_PATTERN = re.compile(r"%\s*USt\.?\s*auf\s*€\s*([\d.]+,\d{2})", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_bundle_invoice(text):
|
||||
"""True, wenn die Rechnung dem Buendel-Layout entspricht (konfigurierte Positionen mit
|
||||
Komponentenliste) - erkennbar am Marker 'Menge Komponenten'. Nur dann greift der Bundle-Parser."""
|
||||
return _BUNDLE_MARKER in text
|
||||
|
||||
|
||||
def _is_bundle_position_number(line):
|
||||
"""Eine alleinstehende kleine Ganzzahl (Positions- oder Komponentennummer)."""
|
||||
return line.isdigit() and len(line) <= 3
|
||||
|
||||
|
||||
def _strip_bundle_furniture(lines):
|
||||
"""Entfernt die zwischen den Seiten wiederholte Moeblierung aus dem Zeilenstrom: den ganzen
|
||||
Seitenkopf ('RECHNUNG' ... 'Betrag €'), die Bank-/Registerzeilen sowie 'Uebertrag/Vortrag'
|
||||
samt der jeweils angrenzenden laufenden Uebertragssumme (Geldzeile)."""
|
||||
out = []
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
ln = lines[i]
|
||||
if ln == "RECHNUNG": # kompletten Seitenkopf bis 'Betrag €' ueberspringen
|
||||
j = i + 1
|
||||
while j < n and lines[j] != "Betrag €":
|
||||
j += 1
|
||||
i = j + 1
|
||||
continue
|
||||
if ln.startswith("Deutsche Bank ") or ln.startswith("Registergericht"):
|
||||
i += 1
|
||||
continue
|
||||
if ln == "Übertrag:":
|
||||
if out and _BUNDLE_MONEY_LINE.match(out[-1]):
|
||||
out.pop() # laufende Uebertragssumme davor entfernen
|
||||
i += 1
|
||||
continue
|
||||
if ln == "Vortrag:":
|
||||
i += 1
|
||||
if i < n and _BUNDLE_MONEY_LINE.match(lines[i]):
|
||||
i += 1 # laufende Vortragssumme danach entfernen
|
||||
continue
|
||||
out.append(ln)
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def extract_bundle_items(text):
|
||||
"""Parst eine Buendel-Rechnung: liefert je Position (Produkt) name, description (volle
|
||||
Komponentenliste + Service-Tags = BT-154), quantity, unit, unit_price. Ankerpunkt ist der
|
||||
3-Zeilen-Preisblock; die Positionsgrenzen sind die Positions-Header (Name vor 'Menge
|
||||
Komponenten' bzw. die Zeile hinter der Positionsnummer bei Positionen ohne Komponentenliste)."""
|
||||
raw = [l.strip() for l in text.split("\n") if l.strip()]
|
||||
lines = _strip_bundle_furniture(raw)
|
||||
end = next((i for i, l in enumerate(lines) if l.startswith("Zwischensumme")), len(lines))
|
||||
lines = lines[:end]
|
||||
|
||||
# 1) Preisbloecke: <Menge Einheit> gefolgt von 2 Geldzeilen (Einzelpreis, Zeilensumme).
|
||||
price_blocks = []
|
||||
for i in range(len(lines) - 2):
|
||||
m = _BUNDLE_UNIT_LINE.match(lines[i])
|
||||
if m and _BUNDLE_MONEY_LINE.match(lines[i + 1]) and _BUNDLE_MONEY_LINE.match(lines[i + 2]):
|
||||
price_blocks.append((i, clean_german_float(m.group(1)), m.group(2),
|
||||
clean_german_float(lines[i + 1])))
|
||||
if not price_blocks:
|
||||
return []
|
||||
|
||||
marker_idxs = [i for i, l in enumerate(lines) if l == _BUNDLE_MARKER]
|
||||
|
||||
# 2) Fuer jeden Preisblock den Positions-Header (Startindex + Name) bestimmen.
|
||||
headers = [] # (start_idx, name, pb_idx, qty, unit, unit_price)
|
||||
prev_boundary = 0
|
||||
for (pb_idx, qty, unit, up) in price_blocks:
|
||||
mk = next((m for m in reversed(marker_idxs) if prev_boundary <= m < pb_idx), None)
|
||||
if mk is not None:
|
||||
# Name = Zeilen direkt vor 'Menge Komponenten' bis zur Positionsnummer.
|
||||
k = mk - 1
|
||||
name_lines = []
|
||||
while k >= prev_boundary and not _is_bundle_position_number(lines[k]):
|
||||
name_lines.insert(0, lines[k])
|
||||
k -= 1
|
||||
start = k if (k >= prev_boundary and _is_bundle_position_number(lines[k])) else k + 1
|
||||
name = " ".join(name_lines).strip()
|
||||
else:
|
||||
# Position ohne Komponentenliste (z.B. Serviceleistung): rueckwaerts vom Preisblock die
|
||||
# Positionsnummer suchen; die Zeile dahinter ist der Name.
|
||||
k = pb_idx
|
||||
while k > prev_boundary and not _is_bundle_position_number(lines[k - 1]):
|
||||
k -= 1
|
||||
start = k - 1 if (k - 1 >= prev_boundary and _is_bundle_position_number(lines[k - 1])) else k
|
||||
name = lines[k] if k < pb_idx else ""
|
||||
headers.append((start, name, pb_idx, qty, unit, up))
|
||||
prev_boundary = pb_idx + 3
|
||||
|
||||
# 3) Positionen bauen: Beschreibung = Segment vom Header bis zum naechsten Header,
|
||||
# ohne Preisblock-Zeilen, fuehrende Positionsnummer und verirrte Geldzeilen.
|
||||
items = []
|
||||
for h_i, (start, name, pb_idx, qty, unit, up) in enumerate(headers):
|
||||
seg_end = headers[h_i + 1][0] if h_i + 1 < len(headers) else len(lines)
|
||||
seg = lines[start:seg_end]
|
||||
skip = {pb_idx - start, pb_idx - start + 1, pb_idx - start + 2}
|
||||
desc_lines = []
|
||||
for idx, l in enumerate(seg):
|
||||
if idx in skip:
|
||||
continue
|
||||
if idx == 0 and _is_bundle_position_number(l):
|
||||
continue
|
||||
if _BUNDLE_MONEY_LINE.match(l):
|
||||
continue
|
||||
desc_lines.append(l)
|
||||
items.append({
|
||||
"name": name,
|
||||
"description": "\n".join(desc_lines).strip(),
|
||||
"quantity": qty,
|
||||
"unit": unit,
|
||||
"unit_price": up,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def extract_invoice_items(text):
|
||||
"""Dispatcher: Buendel-Rechnungen (konfigurierte Positionen mit Komponentenliste) ueber den
|
||||
Bundle-Parser, alle uebrigen ueber den Standardparser (unveraendertes Verhalten)."""
|
||||
if is_bundle_invoice(text):
|
||||
return extract_bundle_items(text)
|
||||
return extract_invoice_items_pure_python(text)
|
||||
|
||||
|
||||
def extract_stated_tax_basis(text):
|
||||
"""Liest die auf der Rechnung ausgewiesene Bemessungsgrundlage aus der Steuerzeile
|
||||
('19 % USt. auf € 217.500,55' -> 217500.55). Fuer den Summen-Abgleich als Sicherheitsnetz.
|
||||
Gibt None zurueck, wenn keine solche Zeile gefunden wird."""
|
||||
m = _STATED_TAX_BASIS_PATTERN.search(text)
|
||||
return clean_german_float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def map_unece_unit_code(unit_str):
|
||||
u = str(unit_str).lower()
|
||||
if "monat" in u: return "MON"
|
||||
|
|
|
|||
26
main.py
26
main.py
|
|
@ -12,7 +12,8 @@ from invoice_logic import (
|
|||
berechne_faelligkeit,
|
||||
kaufmaennisches_runden,
|
||||
clean_german_float,
|
||||
extract_invoice_items_pure_python,
|
||||
extract_invoice_items,
|
||||
extract_stated_tax_basis,
|
||||
map_unece_unit_code,
|
||||
parse_to_strict_xml_date,
|
||||
format_cii_date,
|
||||
|
|
@ -134,7 +135,11 @@ with col2:
|
|||
if final_data.get("recipient_email") == VERKAEUFER["email"]:
|
||||
final_data["recipient_email"] = ""
|
||||
|
||||
final_data["items"] = extract_invoice_items_pure_python(pdf_text)
|
||||
final_data["items"] = extract_invoice_items(pdf_text)
|
||||
# Sicherheitsnetz fuer komplexe (Buendel-)Rechnungen: die auf der Rechnung
|
||||
# ausgewiesene Bemessungsgrundlage merken, um sie spaeter gegen die Summe der
|
||||
# erkannten Positionen abzugleichen (siehe Summen-Check unten).
|
||||
final_data["stated_tax_basis"] = extract_stated_tax_basis(pdf_text)
|
||||
|
||||
# IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden
|
||||
# Felder nicht bereits geliefert hat.
|
||||
|
|
@ -460,6 +465,23 @@ with col2:
|
|||
with c_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}")
|
||||
with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_amount:.2f}")
|
||||
|
||||
# --- SUMMEN-ABGLEICH (Sicherheitsnetz) ---
|
||||
# Die auf der Rechnung ausgewiesene Bemessungsgrundlage (z.B. '19 % USt. auf € 217.500,55')
|
||||
# gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die
|
||||
# Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der
|
||||
# Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen).
|
||||
_stated_basis = data.get("stated_tax_basis")
|
||||
if _stated_basis is not None and abs(computed_net_amount - float(_stated_basis)) > 0.01:
|
||||
st.warning(
|
||||
f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen "
|
||||
f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen "
|
||||
f"Bemessungsgrundlage (**{float(_stated_basis):.2f} €**) ab. Bitte die Positionen im "
|
||||
f"Reiter *Positionen* prüfen und korrigieren, bevor Sie exportieren."
|
||||
)
|
||||
elif _stated_basis is not None:
|
||||
st.caption(f"✅ Summen-Abgleich ok: Positionen = ausgewiesene Bemessungsgrundlage "
|
||||
f"({float(_stated_basis):.2f} €).")
|
||||
|
||||
# --- EXPORT-SEKTION DROPDOWN-AUSWAHL ---
|
||||
st.markdown("---")
|
||||
export_format = st.selectbox(
|
||||
|
|
|
|||
Loading…
Reference in New Issue