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
|
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):
|
def map_unece_unit_code(unit_str):
|
||||||
u = str(unit_str).lower()
|
u = str(unit_str).lower()
|
||||||
if "monat" in u: return "MON"
|
if "monat" in u: return "MON"
|
||||||
|
|
|
||||||
26
main.py
26
main.py
|
|
@ -12,7 +12,8 @@ from invoice_logic import (
|
||||||
berechne_faelligkeit,
|
berechne_faelligkeit,
|
||||||
kaufmaennisches_runden,
|
kaufmaennisches_runden,
|
||||||
clean_german_float,
|
clean_german_float,
|
||||||
extract_invoice_items_pure_python,
|
extract_invoice_items,
|
||||||
|
extract_stated_tax_basis,
|
||||||
map_unece_unit_code,
|
map_unece_unit_code,
|
||||||
parse_to_strict_xml_date,
|
parse_to_strict_xml_date,
|
||||||
format_cii_date,
|
format_cii_date,
|
||||||
|
|
@ -134,7 +135,11 @@ with col2:
|
||||||
if final_data.get("recipient_email") == VERKAEUFER["email"]:
|
if final_data.get("recipient_email") == VERKAEUFER["email"]:
|
||||||
final_data["recipient_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
|
# IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden
|
||||||
# Felder nicht bereits geliefert hat.
|
# 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_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}")
|
||||||
with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_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 ---
|
# --- EXPORT-SEKTION DROPDOWN-AUSWAHL ---
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
export_format = st.selectbox(
|
export_format = st.selectbox(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue