From bacf1798f913df30390e93b8d41816c0231d21b8 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Wed, 15 Jul 2026 12:54:56 +0200 Subject: [PATCH] feat(v3): BG-15 Lieferadresse (nur bei abweichendem Lieferort) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- invoice_logic.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++-- main.py | 18 ++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 2f7b7a8..99b6e47 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -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") - ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) + 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") diff --git a/main.py b/main.py index 2fe4479..20f7b8c 100644 --- a/main.py +++ b/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,