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:
parent
51ed31b1e8
commit
991384a362
|
|
@ -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
49
main.py
|
|
@ -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 "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue