feat(v3): BG-15 Lieferadresse (nur bei abweichendem Lieferort)
Die Lieferadresse (BG-15) wird jetzt erfasst und exportiert - aber nur, wenn sie von der Rechnungsadresse ABWEICHT (sonst wäre sie reine Dopplung; die Rechnungsadresse ist meist auch der Lieferort). - extract_delivery_address: parst den 'Lieferadresse:'-Block (Name/Straße/PLZ/Ort), E-Mail-Zeilen ignoriert. Block-Zerlegung mit extract_recipient_address geteilt. - delivery_address_differs: umbruch-/schreibweisenrobuster Vergleich (Str.=Straße, ß=ss) - so gilt z.B. ein mehrzeilig umbrochener Straßenname nicht fälschlich als Abweichung. - Header-Extraktor: delivery_* nur vorbelegt, wenn abweichend; sonst leer. - Generatoren: UBL cac:Delivery/DeliveryLocation/Address + DeliveryParty (BT-70/ 75/77/78/80), CII ShipToTradeParty (vor dem Lieferdatum). Nur wenn Name+Ort gesetzt sind. - UI: eingeklappter Bereich 'Abweichende Lieferadresse (BG-15)' im Reiter Adressen - klappt automatisch auf und ist vorbefüllt, wenn eine Abweichung erkannt wurde; sonst unsichtbar. Editierbar/leerbar. Verifiziert: Stadtwerke (Neuendorfstr. abweichend) und Gemeinde (EDV) -> BG-15; BIM/degewo/ZenIT (gleiche Adresse) -> keine BG-15. ZUGFeRD-PDF mit check_xsd=True (ShipToTradeParty-Reihenfolge korrekt). Alle 7 Beispielrechnungen unverändert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9ee56440f5
commit
bacf1798f9
|
|
@ -312,13 +312,20 @@ def extract_recipient_address(text):
|
|||
break
|
||||
# Zeile 0 (eigene Absenderadresse) ueberspringen, leere Zeilen entfernen.
|
||||
block = [ln.strip() for ln in lines[1:end] if ln.strip()]
|
||||
return _address_from_block(block)
|
||||
|
||||
|
||||
def _address_from_block(block):
|
||||
"""Zerlegt einen Adressblock (Liste nicht-leerer Zeilen) in (name, street, zip, city) anhand
|
||||
der 'PLZ Ort'-Zeile als Anker: die Zeile davor ist die Strasse, alles davor der (ggf.
|
||||
mehrzeilige) Firmenname. Zeilen hinter 'PLZ Ort' (z.B. USt-IdNr., E-Mail) werden ignoriert.
|
||||
Gibt ("", "", "", "") zurueck, wenn keine 'PLZ Ort'-Zeile gefunden wird."""
|
||||
zip_city_idx = None
|
||||
zip_val = city_val = ""
|
||||
for idx, ln in enumerate(block):
|
||||
m = _ZIP_CITY_PATTERN.match(ln)
|
||||
if m:
|
||||
zip_city_idx = idx # letzte Uebereinstimmung gewinnt (die echte Kundenadresse)
|
||||
zip_city_idx = idx # letzte Uebereinstimmung gewinnt (die echte Adresse)
|
||||
zip_val, city_val = m.group(1), m.group(2).strip()
|
||||
|
||||
if zip_city_idx is None or zip_city_idx < 1:
|
||||
|
|
@ -329,6 +336,47 @@ def extract_recipient_address(text):
|
|||
return name, street, zip_val, city_val
|
||||
|
||||
|
||||
def extract_delivery_address(text):
|
||||
"""Zerlegt den 'Lieferadresse:'-Block (falls vorhanden) in ein Dict mit name/street/zip/city
|
||||
(BG-15 Lieferadresse). Der Block steht zwischen der 'Lieferadresse:'-Zeile und der ersten
|
||||
'RECHNUNG'-Zeile; E-Mail-Zeilen (z.B. bei degewo) werden ignoriert. Gibt None zurueck, wenn
|
||||
keine Lieferadresse vorhanden ist oder der Block keine 'PLZ Ort'-Zeile enthaelt."""
|
||||
lines = text.split("\n")
|
||||
start = next((i for i, ln in enumerate(lines) if ln.strip().lower().startswith("lieferadresse")), None)
|
||||
if start is None:
|
||||
return None
|
||||
block = []
|
||||
for ln in lines[start + 1:]:
|
||||
s = ln.strip()
|
||||
if s.upper().startswith("RECHNUNG"):
|
||||
break
|
||||
if s and not _EMAIL_PATTERN.search(s):
|
||||
block.append(s)
|
||||
name, street, zip_val, city_val = _address_from_block(block)
|
||||
if not (name and city_val):
|
||||
return None
|
||||
return {"name": name, "street": street, "zip": zip_val, "city": city_val}
|
||||
|
||||
|
||||
def _normalize_address_text(*parts):
|
||||
"""Normalisiert Adressteile fuer den Vergleich: kleinschreiben, 'Str.'/'Straße' vereinheitlichen,
|
||||
dann alle Nicht-Alphanumerik entfernen. So sind Zeilenumbrueche und Schreibweisen egal."""
|
||||
s = " ".join(str(p or "") for p in parts).lower()
|
||||
s = s.replace("straße", "strasse").replace("str.", "strasse").replace("ß", "ss")
|
||||
return re.sub(r"[^a-z0-9]", "", s)
|
||||
|
||||
|
||||
def delivery_address_differs(delivery, recipient_name, recipient_street, recipient_zip, recipient_city):
|
||||
"""True, wenn die Lieferadresse (dict von extract_delivery_address) inhaltlich von der
|
||||
Rechnungsadresse abweicht - unempfindlich gegen Zeilenumbrueche und 'Str.'/'Straße'. Nur dann
|
||||
traegt BG-15 echte Information (sonst waere die Lieferadresse eine reine Dopplung)."""
|
||||
if not delivery:
|
||||
return False
|
||||
d = _normalize_address_text(delivery.get("name"), delivery.get("street"), delivery.get("zip"), delivery.get("city"))
|
||||
b = _normalize_address_text(recipient_name, recipient_street, recipient_zip, recipient_city)
|
||||
return d != b
|
||||
|
||||
|
||||
def extract_recipient_email(text):
|
||||
"""Sucht im Adressbereich (bis zur 'RECHNUNG'-Zeile) nach einer Kunden-E-Mail. Die eigene
|
||||
scitech-Adresse wird ausgeschlossen. Gibt "" zurueck, wenn keine gefunden wird."""
|
||||
|
|
@ -366,6 +414,12 @@ def extract_header_metadata_pure_python(text):
|
|||
m_vat = _VAT_RATE_PATTERN.search(text)
|
||||
vat_rate = float(m_vat.group(1).replace(",", ".")) if m_vat else 19.0
|
||||
|
||||
# BG-15 Lieferadresse nur vorbelegen, wenn sie von der Rechnungsadresse abweicht - sonst waere
|
||||
# sie reine Dopplung (die Rechnungsadresse ist meist auch der Lieferort). Die UI-Felder bleiben
|
||||
# dann leer und es wird keine BG-15 exportiert.
|
||||
_deliv = extract_delivery_address(text)
|
||||
_deliv = _deliv if delivery_address_differs(_deliv, name, street, zip_val, city_val) else None
|
||||
|
||||
return {
|
||||
"invoice_number": extract_labeled_header_value(text, "Nummer:"),
|
||||
"invoice_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Datum:")),
|
||||
|
|
@ -387,6 +441,11 @@ def extract_header_metadata_pure_python(text):
|
|||
"service_end_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Zeitraum bis:")),
|
||||
"payment_term_days": m_days.group(0) if m_days else "",
|
||||
"invoice_note": "", # wird in main.py via extract_pre_position_freetext gesetzt
|
||||
# BG-15 Lieferadresse (nur bei abweichendem Lieferort vorbelegt, sonst leer):
|
||||
"delivery_name": _deliv["name"] if _deliv else "",
|
||||
"delivery_street": _deliv["street"] if _deliv else "",
|
||||
"delivery_zip": _deliv["zip"] if _deliv else "",
|
||||
"delivery_city": _deliv["city"] if _deliv else "",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -944,9 +1003,26 @@ def generate_compliant_xrechnung(data):
|
|||
legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity")
|
||||
ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"])
|
||||
|
||||
if data.get("delivery_date"):
|
||||
# BG-15 Lieferadresse nur, wenn Name + Ort gesetzt sind (im UI nur bei abweichendem Lieferort
|
||||
# vorbelegt). UBL-Reihenfolge in cac:Delivery: ActualDeliveryDate, DeliveryLocation, DeliveryParty.
|
||||
_has_deliv_addr = bool(str(data.get("delivery_name") or "").strip() and str(data.get("delivery_city") or "").strip())
|
||||
if data.get("delivery_date") or _has_deliv_addr:
|
||||
delivery = ET.SubElement(root, f"{{{CAC}}}Delivery")
|
||||
if data.get("delivery_date"):
|
||||
ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"])
|
||||
if _has_deliv_addr:
|
||||
deliv_loc = ET.SubElement(delivery, f"{{{CAC}}}DeliveryLocation")
|
||||
deliv_addr = ET.SubElement(deliv_loc, f"{{{CAC}}}Address")
|
||||
if str(data.get("delivery_street") or "").strip():
|
||||
ET.SubElement(deliv_addr, f"{{{CBC}}}StreetName").text = str(data["delivery_street"]).strip() # BT-75
|
||||
ET.SubElement(deliv_addr, f"{{{CBC}}}CityName").text = str(data["delivery_city"]).strip() # BT-77
|
||||
if str(data.get("delivery_zip") or "").strip():
|
||||
ET.SubElement(deliv_addr, f"{{{CBC}}}PostalZone").text = str(data["delivery_zip"]).strip() # BT-78
|
||||
country = ET.SubElement(deliv_addr, f"{{{CAC}}}Country")
|
||||
ET.SubElement(country, f"{{{CBC}}}IdentificationCode").text = "DE" # BT-80
|
||||
deliv_party = ET.SubElement(delivery, f"{{{CAC}}}DeliveryParty")
|
||||
party_name_deliv = ET.SubElement(deliv_party, f"{{{CAC}}}PartyName")
|
||||
ET.SubElement(party_name_deliv, f"{{{CBC}}}Name").text = str(data["delivery_name"]).strip() # BT-70
|
||||
|
||||
payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans")
|
||||
# BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber
|
||||
|
|
@ -1227,6 +1303,18 @@ def generate_compliant_zugferd_cii(data):
|
|||
ET.SubElement(tender_ref, f"{{{RAM}}}TypeCode").text = "50"
|
||||
|
||||
header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery")
|
||||
# BG-15 Lieferadresse (ShipToTradeParty) nur bei gesetztem Name + Ort. Muss laut CII-Sequenz
|
||||
# VOR dem Lieferdatum (ActualDeliverySupplyChainEvent) stehen.
|
||||
if str(data.get("delivery_name") or "").strip() and str(data.get("delivery_city") or "").strip():
|
||||
ship_to = ET.SubElement(header_deliv, f"{{{RAM}}}ShipToTradeParty")
|
||||
ET.SubElement(ship_to, f"{{{RAM}}}Name").text = str(data["delivery_name"]).strip() # BT-70
|
||||
deliv_addr = ET.SubElement(ship_to, f"{{{RAM}}}PostalTradeAddress")
|
||||
if str(data.get("delivery_zip") or "").strip():
|
||||
ET.SubElement(deliv_addr, f"{{{RAM}}}PostcodeCode").text = str(data["delivery_zip"]).strip() # BT-78
|
||||
if str(data.get("delivery_street") or "").strip():
|
||||
ET.SubElement(deliv_addr, f"{{{RAM}}}LineOne").text = str(data["delivery_street"]).strip() # BT-75
|
||||
ET.SubElement(deliv_addr, f"{{{RAM}}}CityName").text = str(data["delivery_city"]).strip() # BT-77
|
||||
ET.SubElement(deliv_addr, f"{{{RAM}}}CountryID").text = "DE" # BT-80
|
||||
if data.get("delivery_date"):
|
||||
act_deliv = ET.SubElement(header_deliv, f"{{{RAM}}}ActualDeliverySupplyChainEvent")
|
||||
occ_date = ET.SubElement(act_deliv, f"{{{RAM}}}OccurrenceDateTime")
|
||||
|
|
|
|||
18
main.py
18
main.py
|
|
@ -370,6 +370,23 @@ with col2:
|
|||
r_zip = st.text_input("PLZ (BT-53)", value=data.get("recipient_zip", ""))
|
||||
r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", ""))
|
||||
|
||||
# BG-15 Lieferadresse: standardmäßig eingeklappt, damit die Felder im Normalfall
|
||||
# (Lieferort = Rechnungsadresse) nicht stören. Wurde eine ABWEICHENDE Lieferadresse
|
||||
# erkannt, sind die Felder vorbefüllt und der Bereich klappt automatisch auf.
|
||||
_has_deliv = bool(str(data.get("delivery_name", "")).strip())
|
||||
with st.expander("Abweichende Lieferadresse (BG-15)", expanded=_has_deliv):
|
||||
if _has_deliv:
|
||||
st.caption("ℹ️ Abweichende Lieferadresse erkannt und übernommen. "
|
||||
"Bei Bedarf korrigieren – oder die Felder leeren, wenn keine "
|
||||
"abweichende Lieferadresse gewünscht ist.")
|
||||
else:
|
||||
st.caption("Nur ausfüllen, wenn an einen **anderen Ort** geliefert wurde als "
|
||||
"die Rechnungsadresse. Leer lassen = keine gesonderte Lieferadresse.")
|
||||
d_name = st.text_input("Firma / Empfänger (BT-70)", value=data.get("delivery_name", ""))
|
||||
d_street = st.text_input("Straße (BT-75)", value=data.get("delivery_street", ""))
|
||||
d_zip = st.text_input("PLZ (BT-78)", value=data.get("delivery_zip", ""))
|
||||
d_city = st.text_input("Stadt (BT-77)", value=data.get("delivery_city", ""))
|
||||
|
||||
with tab_positionen:
|
||||
if st.button("➕ Neue Position hinzufügen"):
|
||||
data["items"].append({
|
||||
|
|
@ -501,6 +518,7 @@ with col2:
|
|||
"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_effective,
|
||||
"recipient_street": r_street, "recipient_zip": r_zip, "recipient_city": r_city,
|
||||
"delivery_name": d_name, "delivery_street": d_street, "delivery_zip": d_zip, "delivery_city": d_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,
|
||||
"payment_means_code": selected_payment_means_code,
|
||||
|
|
|
|||
Loading…
Reference in New Issue