From 991384a362dae3aa5e3df39b6cd04cb2f7a8ea9b Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 14 Jul 2026 15:13:29 +0200 Subject: [PATCH] feat(v3): Angebot -> BT-17 und BT-49 aus Leitweg-ID ableiten 1) BT-17 (tender_id): extract_document_references sortiert jetzt nicht nur ein Vergabeangebot, sondern auch ein einfaches 'Angebot vom ' in BT-17 ein (Vergabeangebot hat Vorrang; pro Rechnung kommt nur eines von beiden vor). Der bisherige Angebot->Freitext-Block in main.py entfaellt. 2) BT-49 (elektronische Adresse) aus BT-10 ableiten: neue Funktion resolve_buyer_endpoint - ist das E-Mail-Feld leer, aber eine Leitweg-ID (BT-10) vorhanden, wird die Leitweg-ID mit Schema 0204 als BT-49 verwendet. So muss die Leitweg-ID nur einmal eingegeben werden. Das Schema bleibt an den Wert gekoppelt (E-Mail -> EM, Leitweg-ID -> 0204), Default bleibt EM. Blocker + ZUGFeRD-Platzhalter nutzen die abgeleitete Adresse; UI-Hilfen und das BT-17-Label ('Angebot / Vergabeangebot') entsprechend angepasst. Verifiziert gegen alle 7 Beispielrechnungen: die 3 Angebot-Rechnungen landen in BT-17 (im XML), die 2 Vergabeangebote bleiben, Freitext ohne Dopplung. Co-Authored-By: Claude Opus 4.8 --- invoice_logic.py | 39 +++++++++++++++++++++++++++++++++----- main.py | 49 ++++++++++++++++++++++++------------------------ 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 1b0ec4c..1653a9d 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -80,6 +80,22 @@ BUYER_ENDPOINT_SCHEMES = { "0088": "GLN (Global Location Number)", } + +def resolve_buyer_endpoint(recipient_email, recipient_scheme, leitweg_id): + """Ermittelt die effektive elektronische Adresse des Kaeufers (BT-49) samt Schema (BT-49-1). + + Ist eine E-Mail/Adresse eingetragen, wird sie mit dem gewaehlten Schema uebernommen. Ist das + Feld leer, aber eine Leitweg-ID (BT-10) vorhanden, wird die Leitweg-ID als BT-49 mit Schema + '0204' verwendet - so muss die Leitweg-ID nur einmal (in BT-10) eingegeben werden. Ist beides + leer, bleibt die Adresse leer (Schema unveraendert). Gibt (adresse, schema) zurueck.""" + email = str(recipient_email or "").strip() + if email: + return email, recipient_scheme + leitweg = str(leitweg_id or "").strip() + if leitweg: + return leitweg, "0204" + return "", recipient_scheme + # Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein # Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den # Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81). @@ -199,25 +215,38 @@ def extract_pre_position_freetext(text): return "\n".join(freetext_lines) -# Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als die +# Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als eine # LLM-Zuordnung, die z.B. ein Vergabeangebot faelschlich in BT-12 (Vertrag) statt BT-17 einsortiert. _DOC_REF_PATTERNS = { "order_id": re.compile(r"\bAUFTRAGSBESTÄTIGUNG\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), "delivery_note_id": re.compile(r"\bLIEFERSCHEIN\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), - "tender_id": re.compile(r"\bVERGABEANGEBOT\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), } +# BT-17: Vergabeangebot. '\bVERGABEANGEBOT' greift dank Wortgrenze NICHT in ein blosses 'ANGEBOT'. +_TENDER_VERGABE_PATTERN = re.compile(r"\bVERGABEANGEBOT\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE) def extract_document_references(text): """Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts: - Auftragsbestaetigung (BT-14), Lieferschein (BT-16), Vergabeangebot (BT-17). Gibt ein Dict nur - mit den gefundenen Feldern zurueck - so lassen sich die (teils falschen) LLM-Zuordnungen gezielt - korrigieren, ohne fehlende Felder zu ueberschreiben.""" + Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER, + falls keines vorhanden ist, ein einfaches Angebot ('Angebot vom '). Pro Rechnung + kommt nur eines von beiden vor; beide gehoeren fachlich in BT-17. Gibt ein Dict nur mit den + gefundenen Feldern zurueck, damit fehlende Felder nicht ueberschrieben werden.""" result = {} for field, pattern in _DOC_REF_PATTERNS.items(): m = pattern.search(text) if m: result[field] = m.group(1).strip() + + # BT-17: Vergabeangebot bevorzugt; sonst ein einfaches 'Angebot vom ' + # (ANGEBOT_PATTERN erzwingt die 'vom '-Form und vermeidet so Fehltreffer auf + # Fliesstext wie 'Angebot erstellen'). + m_vergabe = _TENDER_VERGABE_PATTERN.search(text) + if m_vergabe: + result["tender_id"] = m_vergabe.group(1).strip() + else: + m_angebot = ANGEBOT_PATTERN.search(text) + if m_angebot: + result["tender_id"] = m_angebot.group(1).strip() return result diff --git a/main.py b/main.py index 61dd660..002910d 100644 --- a/main.py +++ b/main.py @@ -23,10 +23,10 @@ from invoice_logic import ( add_pdfa_output_intent, fix_xrechnung_xmp_conformance, compute_invoice_totals, + resolve_buyer_endpoint, extract_service_period, detect_contract_type, detect_invoice_type_code, - extract_angebot_reference, extract_pre_position_freetext, extract_document_references, extract_labeled_header_value, @@ -183,19 +183,9 @@ with col2: _en = str(final_data.get("invoice_note", "")).strip() final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline - # Angebotsbezug ("Angebot vom ") hat kein eigenes EN16931-Feld - als - # Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die - # Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt - # hat, dort leeren - ein Angebot ist weder ein Vertrag noch ein Vergabeangebot. - _angebot = extract_angebot_reference(pdf_text) - if _angebot: - _angebot_nr = _angebot.split()[1] - for _k in ("contract_id", "tender_id"): - if str(final_data.get(_k, "")).strip() == _angebot_nr: - final_data[_k] = "" - if _angebot.lower() not in str(final_data.get("invoice_note", "")).lower(): - _existing_note = str(final_data.get("invoice_note", "")).strip() - final_data["invoice_note"] = f"{_existing_note}\n{_angebot}".strip() if _existing_note else _angebot + # Hinweis: Ein Angebotsbezug ('Angebot vom ') wird - genau wie ein + # Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe + # extract_document_references oben), nicht mehr in den Freitext. st.session_state.invoice_counter += 1 st.session_state.extracted_data = final_data @@ -284,7 +274,12 @@ with col2: with c_ref3: c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", "")) with c_ref4: - tender_id = st.text_input("Vergabeangebot / Ausschreibung (BT-17)", value=data.get("tender_id", "")) + tender_id = st.text_input( + "Angebot / Vergabeangebot (BT-17)", + value=data.get("tender_id", ""), + help="Nummer eines Angebots, Vergabeangebots oder einer Ausschreibung. Wird aus der " + "Rechnung erkannt ('ANGEBOT'/'VERGABEANGEBOT vom ').", + ) c_p1, c_p2 = st.columns(2) with c_p1: @@ -338,7 +333,9 @@ with col2: "Elektronische Adresse (BT-49)", value=data.get("recipient_email", ""), help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " - "Für Behörden ggf. Leitweg-ID (0204) oder GLN (0088) - dann rechts das passende Schema wählen.", + "Leer lassen genügt bei Behörden-Rechnungen: Ist oben eine Leitweg-ID (BT-10) " + "eingetragen, wird sie automatisch als elektronische Adresse mit Schema 0204 " + "übernommen (keine Doppeleingabe nötig). Für eine GLN das Schema 0088 wählen.", ) _scheme_codes = list(BUYER_ENDPOINT_SCHEMES.keys()) _scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes] @@ -458,10 +455,15 @@ with col2: help="XRechnung generiert ein reines, valides UBL-XML. ZUGFeRD bettet ein schema-konformes CII-XML (factur-x.xml) direkt in Ihre PDF ein." ) + # BT-49 (elektronische Adresse) + BT-49-1 (Schema) aufloesen: ist das E-Mail-Feld leer, + # aber eine Leitweg-ID (BT-10) vorhanden, wird die Leitweg-ID mit Schema '0204' als BT-49 + # verwendet - so muss die Leitweg-ID nicht doppelt (in BT-10 und BT-49) eingegeben werden. + r_email_effective, r_scheme_effective = resolve_buyer_endpoint(r_email, r_endpoint_scheme, leitweg_id) + final_invoice_dict = { "invoice_type_code": selected_type_code, "invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery, - "buyer_reference": buyer_ref, "customer_number": cust_num, "recipient_name": r_name, "recipient_email": r_email, + "buyer_reference": buyer_ref, "customer_number": cust_num, "recipient_name": r_name, "recipient_email": r_email_effective, "recipient_street": r_street, "recipient_zip": r_zip, "recipient_city": r_city, "items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount, "vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN, @@ -476,7 +478,7 @@ with col2: "skonto_percent": skonto_percent, "skonto_days": skonto_days, "vorkasse": vorkasse, - "recipient_endpoint_scheme": r_endpoint_scheme, + "recipient_endpoint_scheme": r_scheme_effective, "leitweg_id": leitweg_id, "delivery_note_id": dn_id, "order_id": o_id, @@ -506,12 +508,11 @@ with col2: blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie " "Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins " "Feld 'Leitweg-ID (BT-10)' eintragen.") - if is_xrechnung and not str(r_email).strip(): + if is_xrechnung and not str(r_email_effective).strip(): blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie " - "Pflicht (PEPPOL-EN16931-R010). Da XRechnung meist an Behörden geht, gehört " - "hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld " - "'Elektronische Adresse (BT-49)' eintragen und als Schema '0204 (Leitweg-ID)' " - "wählen. Alternativ die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088).") + "Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, " + "wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter " + "'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.") if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.") if not edited_items: @@ -532,7 +533,7 @@ with col2: # ZUGFeRD (B2B) ohne Kunden-Adresse: KEIN Blocker, aber statt eines leeren Pflichtfelds wird # ein Platzhalter "X" in BT-49 gesetzt, damit das Feld nicht komplett fehlt - dazu eine # orange Warnung (st.warning), dass eine echte E-Mail besser waere. - if not is_xrechnung and not str(r_email).strip(): + if not is_xrechnung and not str(r_email_effective).strip(): final_invoice_dict["recipient_email"] = "X" st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird " "nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett "