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 <Nr> vom <Datum>'
   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 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-14 15:13:29 +02:00
parent 51ed31b1e8
commit 991384a362
2 changed files with 59 additions and 29 deletions

View File

@ -80,6 +80,22 @@ BUYER_ENDPOINT_SCHEMES = {
"0088": "GLN (Global Location Number)", "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 # 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 # Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den
# Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81). # 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) 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. # LLM-Zuordnung, die z.B. ein Vergabeangebot faelschlich in BT-12 (Vertrag) statt BT-17 einsortiert.
_DOC_REF_PATTERNS = { _DOC_REF_PATTERNS = {
"order_id": re.compile(r"\bAUFTRAGSBESTÄTIGUNG\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), "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), "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): def extract_document_references(text):
"""Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts: """Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts:
Auftragsbestaetigung (BT-14), Lieferschein (BT-16), Vergabeangebot (BT-17). Gibt ein Dict nur Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER,
mit den gefundenen Feldern zurueck - so lassen sich die (teils falschen) LLM-Zuordnungen gezielt falls keines vorhanden ist, ein einfaches Angebot ('Angebot <Nr> vom <Datum>'). Pro Rechnung
korrigieren, ohne fehlende Felder zu ueberschreiben.""" 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 = {} result = {}
for field, pattern in _DOC_REF_PATTERNS.items(): for field, pattern in _DOC_REF_PATTERNS.items():
m = pattern.search(text) m = pattern.search(text)
if m: if m:
result[field] = m.group(1).strip() result[field] = m.group(1).strip()
# BT-17: Vergabeangebot bevorzugt; sonst ein einfaches 'Angebot <Nr> vom <Datum>'
# (ANGEBOT_PATTERN erzwingt die 'vom <Datum>'-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 return result

49
main.py
View File

@ -23,10 +23,10 @@ from invoice_logic import (
add_pdfa_output_intent, add_pdfa_output_intent,
fix_xrechnung_xmp_conformance, fix_xrechnung_xmp_conformance,
compute_invoice_totals, compute_invoice_totals,
resolve_buyer_endpoint,
extract_service_period, extract_service_period,
detect_contract_type, detect_contract_type,
detect_invoice_type_code, detect_invoice_type_code,
extract_angebot_reference,
extract_pre_position_freetext, extract_pre_position_freetext,
extract_document_references, extract_document_references,
extract_labeled_header_value, extract_labeled_header_value,
@ -183,19 +183,9 @@ with col2:
_en = str(final_data.get("invoice_note", "")).strip() _en = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline
# Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld - als # Hinweis: Ein Angebotsbezug ('Angebot <Nr> vom <Datum>') wird - genau wie ein
# Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die # Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe
# Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt # extract_document_references oben), nicht mehr in den Freitext.
# 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
st.session_state.invoice_counter += 1 st.session_state.invoice_counter += 1
st.session_state.extracted_data = final_data st.session_state.extracted_data = final_data
@ -284,7 +274,12 @@ with col2:
with c_ref3: with c_ref3:
c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", "")) c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", ""))
with c_ref4: 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 <Nr> vom <Datum>').",
)
c_p1, c_p2 = st.columns(2) c_p1, c_p2 = st.columns(2)
with c_p1: with c_p1:
@ -338,7 +333,9 @@ with col2:
"Elektronische Adresse (BT-49)", "Elektronische Adresse (BT-49)",
value=data.get("recipient_email", ""), value=data.get("recipient_email", ""),
help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " 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_codes = list(BUYER_ENDPOINT_SCHEMES.keys())
_scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes] _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." 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 = { final_invoice_dict = {
"invoice_type_code": selected_type_code, "invoice_type_code": selected_type_code,
"invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery, "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, "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, "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, "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_percent": skonto_percent,
"skonto_days": skonto_days, "skonto_days": skonto_days,
"vorkasse": vorkasse, "vorkasse": vorkasse,
"recipient_endpoint_scheme": r_endpoint_scheme, "recipient_endpoint_scheme": r_scheme_effective,
"leitweg_id": leitweg_id, "leitweg_id": leitweg_id,
"delivery_note_id": dn_id, "delivery_note_id": dn_id,
"order_id": o_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 " 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 " "Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins "
"Feld 'Leitweg-ID (BT-10)' eintragen.") "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 " 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 " "Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, "
"hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld " "wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter "
"'Elektronische Adresse (BT-49)' eintragen und als Schema '0204 (Leitweg-ID)' " "'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.")
"wählen. Alternativ die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088).")
if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": 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.") blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.")
if not edited_items: if not edited_items:
@ -532,7 +533,7 @@ with col2:
# ZUGFeRD (B2B) ohne Kunden-Adresse: KEIN Blocker, aber statt eines leeren Pflichtfelds wird # 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 # 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. # 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" final_invoice_dict["recipient_email"] = "X"
st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird " st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird "
"nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett " "nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett "