feat: BT-41 Bearbeiter, BT-34/BT-43 split, Ihr Auftrag->BT-13, Referenznummer->Freitext

- BT-41: Bearbeiter wird aus der Rechnung erkannt (deterministisch) und als editierbares
  Kopfdaten-Feld gefuehrt; ersetzt das fixe 'Geschaeftsfuehrung' (kein Fallback, leer bleibt leer).
- BT-34 und BT-43 als zwei getrennte, editierbare Verkaeufer-Felder (elektronische Adresse vs.
  Kontakt-E-Mail), beide vorbelegt mit der office-Mail.
- 'Ihr Auftrag' deterministisch in BT-13 (statt LLM-Fehlzuordnung nach BT-14).
- 'Referenznummer' (z.B. SAP) hat kein EN16931-Feld -> in den Freitext (BT-22).
- Generatoren fallen ohne die neuen Keys auf VERKAEUFER zurueck (Golden-Verhalten unveraendert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-13 15:03:51 +02:00
parent f92c4ffac6
commit ac233baa2f
2 changed files with 70 additions and 7 deletions

View File

@ -221,6 +221,30 @@ def extract_document_references(text):
return result
def extract_labeled_header_value(text, label):
"""Liest einen Kopf-Feldwert deterministisch aus: findet die Zeile, die exakt <label> ist
(z.B. 'Ihr Auftrag:' oder 'Bearbeiter:'), und gibt den Wert der naechsten nicht-leeren Zeile
zurueck. Gibt "" zurueck, wenn das Label nicht vorkommt."""
lines = [ln.strip() for ln in text.split("\n")]
for i, ln in enumerate(lines):
if ln == label:
for j in range(i + 1, len(lines)):
if lines[j]:
return lines[j]
return ""
return ""
_REFERENZNUMMER_PATTERN = re.compile(r"\bReferenznummer\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE)
def extract_referenznummer(text):
"""Findet eine 'Referenznummer <Wert>'-Zeile (z.B. SAP-Referenz). EN16931 hat dafuer kein
eigenes Feld - der Wert wird als Freitext (BT-22) uebernommen. "" wenn nicht vorhanden."""
m = _REFERENZNUMMER_PATTERN.search(text)
return m.group(1).strip() if m else ""
def sanitize_filename_part(value):
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
@ -560,7 +584,7 @@ def generate_compliant_xrechnung(data):
# --- SENDER (SCITECH) ---
supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty")
party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party")
ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = VERKAEUFER["email"]
ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data.get("seller_endpoint", VERKAEUFER["email"]))
addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress")
ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = VERKAEUFER["street"]
@ -580,9 +604,9 @@ def generate_compliant_xrechnung(data):
ET.SubElement(legal_supp, f"{{{CBC}}}CompanyID").text = VERKAEUFER["register_id"]
contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact")
ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = VERKAEUFER["contact_name"]
ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = str(data.get("seller_contact_name", VERKAEUFER["contact_name"]))
ET.SubElement(contact_supp, f"{{{CBC}}}Telephone").text = VERKAEUFER["phone"]
ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = VERKAEUFER["email"]
ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = str(data.get("seller_contact_email", VERKAEUFER["email"]))
# --- Empfänger ---
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
@ -810,11 +834,11 @@ def generate_compliant_zugferd_cii(data):
# BG-6 SELLER CONTACT (BR-DE-2 Pflicht für XRechnung). Muss laut XSD nach
# SpecifiedLegalOrganization und vor PostalTradeAddress stehen.
supp_contact = ET.SubElement(supplier_party, f"{{{RAM}}}DefinedTradeContact")
ET.SubElement(supp_contact, f"{{{RAM}}}PersonName").text = VERKAEUFER["contact_name"]
ET.SubElement(supp_contact, f"{{{RAM}}}PersonName").text = str(data.get("seller_contact_name", VERKAEUFER["contact_name"]))
supp_contact_tel = ET.SubElement(supp_contact, f"{{{RAM}}}TelephoneUniversalCommunication")
ET.SubElement(supp_contact_tel, f"{{{RAM}}}CompleteNumber").text = VERKAEUFER["phone"]
supp_contact_mail = ET.SubElement(supp_contact, f"{{{RAM}}}EmailURIUniversalCommunication")
ET.SubElement(supp_contact_mail, f"{{{RAM}}}URIID").text = VERKAEUFER["email"]
ET.SubElement(supp_contact_mail, f"{{{RAM}}}URIID").text = str(data.get("seller_contact_email", VERKAEUFER["email"]))
supp_addr = ET.SubElement(supplier_party, f"{{{RAM}}}PostalTradeAddress")
ET.SubElement(supp_addr, f"{{{RAM}}}PostcodeCode").text = VERKAEUFER["zip"]
@ -825,7 +849,7 @@ def generate_compliant_zugferd_cii(data):
# BT-34 Elektronische Adresse des Verkäufers (PEPPOL-EN16931-R020 Pflicht). Muss laut
# XSD nach PostalTradeAddress und vor SpecifiedTaxRegistration stehen.
supp_uri = ET.SubElement(supplier_party, f"{{{RAM}}}URIUniversalCommunication")
ET.SubElement(supp_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = VERKAEUFER["email"]
ET.SubElement(supp_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = str(data.get("seller_endpoint", VERKAEUFER["email"]))
supp_tax = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedTaxRegistration")
ET.SubElement(supp_tax, f"{{{RAM}}}ID", {"schemeID": "VA"}).text = VERKAEUFER["vat_id"]

41
main.py
View File

@ -33,6 +33,8 @@ from invoice_logic import (
extract_angebot_reference,
extract_pre_position_freetext,
extract_document_references,
extract_labeled_header_value,
extract_referenznummer,
)
# ==========================================
@ -262,6 +264,23 @@ with col2:
if _doc_refs.get("tender_id") and str(final_data.get("contract_id", "")).strip() == _doc_refs["tender_id"]:
final_data["contract_id"] = ""
# 'Ihr Auftrag' deterministisch -> BT-13 (Bestellnummer/Kaeuferreferenz), damit es
# nicht (wie beim LLM gesehen) in BT-14 landet.
_ihr_auftrag = extract_labeled_header_value(pdf_text, "Ihr Auftrag:")
if _ihr_auftrag:
final_data["buyer_reference"] = _ihr_auftrag
# 'Bearbeiter' -> Verkaeufer-Kontaktperson (BT-41), als Vorbelegung fuers UI-Feld.
final_data["bearbeiter"] = extract_labeled_header_value(pdf_text, "Bearbeiter:")
# 'Referenznummer' (z.B. SAP) hat kein eigenes EN16931-Feld -> in den Freitext (BT-22).
_refnr = extract_referenznummer(pdf_text)
if _refnr:
_refline = f"Referenznummer: {_refnr}"
if _refnr not in str(final_data.get("invoice_note", "")):
_en = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline
# Angebotsbezug ("Angebot <Nr> vom <Datum>") 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
@ -322,6 +341,13 @@ with col2:
"Ihr Auftrag / Bestellnummer (BT-13)",
value=data.get("buyer_reference", "")
)
bearbeiter = st.text_input(
"Bearbeiter (BT-41)",
value=data.get("bearbeiter", ""),
help="Ansprechpartner beim Verkäufer (Verkäufer-Kontaktperson). Wird aus der "
"Rechnung erkannt und ist editierbar. Für XRechnung Pflicht (BR-DE-5) - "
"leer lassen nur, wenn wirklich kein Ansprechpartner vorhanden ist.",
)
# UI-Eingabefelder für die Daten vor Position 1 (BT-22 etc.)
st.markdown("---")
@ -364,7 +390,17 @@ with col2:
with c_s:
st.markdown("**Absender (Verkäufer):**")
st.text_input("Firma (BT-27)", value=VERKAEUFER["name"], disabled=True)
st.text_input("E-Mail (BT-34)", value=VERKAEUFER["email"], disabled=True)
seller_endpoint = st.text_input(
"Elektronische Adresse (BT-34)",
value=VERKAEUFER["email"],
help="Elektronische Zustelladresse des Verkäufers (Pflicht, PEPPOL-EN16931-R020). "
"Standard: die office-Mailadresse.",
)
seller_contact_email = st.text_input(
"Kontakt-E-Mail (BT-43)",
value=VERKAEUFER["email"],
help="E-Mail des Ansprechpartners beim Verkäufer (Verkäufer-Kontakt).",
)
st.text_input("Telefon (BT-42)", value=VERKAEUFER["phone"], disabled=True)
st.text_input("Straße (BT-35)", value=VERKAEUFER["street"], disabled=True)
st.text_input("PLZ (BT-38)", value=VERKAEUFER["zip"], disabled=True)
@ -507,6 +543,9 @@ with col2:
"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,
"payment_means_code": selected_payment_means_code,
"seller_contact_name": bearbeiter,
"seller_endpoint": seller_endpoint,
"seller_contact_email": seller_contact_email,
"tax_buckets": totals["tax_buckets"],
"line_net_total": totals["line_net_total"],
"allowance_amount": totals["allowance_amount"],