diff --git a/invoice_logic.py b/invoice_logic.py index bf6eb6f..161db51 100644 --- a/invoice_logic.py +++ b/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: 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" diff --git a/main.py b/main.py index fa12271..634975d 100644 --- a/main.py +++ b/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(