This commit is contained in:
dfuhrmann3 2026-07-06 14:09:15 +02:00
parent 076e976298
commit e80d011c88
1 changed files with 75 additions and 33 deletions

106
main.py
View File

@ -89,6 +89,24 @@ class InvoiceMetadata(BaseModel):
# ========================================== # ==========================================
# 3. MATHEMATISCHE & PARSING-FUNKTIONEN # 3. MATHEMATISCHE & PARSING-FUNKTIONEN
# ========================================== # ==========================================
# Zentrale Geschäftskonstanten - hier zentral pflegen statt an mehreren Stellen im Code.
ZAHLUNGSZIEL_TAGE = 14
ZAHLUNGSBEDINGUNG_TEXT = f"Zahlbar innerhalb {ZAHLUNGSZIEL_TAGE} Tagen ohne Abzug."
VERKAEUFER_IBAN = "DE06120700240456207000"
def berechne_faelligkeit(invoice_date_str):
"""Fälligkeitsdatum = Rechnungsdatum + ZAHLUNGSZIEL_TAGE. Gibt 'YYYY-MM-DD' zurück.
Fällt bei unlesbarem Rechnungsdatum auf heute+Ziel zurück (nur als letzter Notnagel)."""
base = parse_to_strict_xml_date(invoice_date_str)
try:
base_date = datetime.strptime(base, "%Y-%m-%d")
except (ValueError, TypeError):
base_date = datetime.today()
return (base_date + timedelta(days=ZAHLUNGSZIEL_TAGE)).strftime("%Y-%m-%d")
def kaufmaennisches_runden(betrag, nachkommastellen=2): def kaufmaennisches_runden(betrag, nachkommastellen=2):
if not betrag: if not betrag:
return 0.0 return 0.0
@ -170,13 +188,16 @@ def map_unece_unit_code(unit_str):
return "C62" return "C62"
def parse_to_strict_xml_date(date_str): def parse_to_strict_xml_date(date_str):
"""Normalisiert ein Datum auf 'YYYY-MM-DD'. Gibt leeren String zurück, wenn das Datum
fehlt ODER nicht geparst werden kann - NICHT still auf 'heute' fallen, damit fehlerhafte
Datumsangaben sichtbar werden statt unbemerkt durchzurutschen."""
if not date_str or str(date_str).strip() == "": if not date_str or str(date_str).strip() == "":
return "" return ""
clean_str = str(date_str).strip() clean_str = str(date_str).strip()
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"): for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"):
try: return datetime.strptime(clean_str, fmt).strftime("%Y-%m-%d") try: return datetime.strptime(clean_str, fmt).strftime("%Y-%m-%d")
except ValueError: continue except ValueError: continue
return datetime.today().strftime("%Y-%m-%d") return ""
def format_cii_date(date_str): def format_cii_date(date_str):
clean = parse_to_strict_xml_date(date_str) clean = parse_to_strict_xml_date(date_str)
@ -203,12 +224,7 @@ def generate_compliant_xrechnung(data):
ET.SubElement(root, f"{{{CBC}}}ID").text = str(data["invoice_number"]) ET.SubElement(root, f"{{{CBC}}}ID").text = str(data["invoice_number"])
ET.SubElement(root, f"{{{CBC}}}IssueDate").text = parse_to_strict_xml_date(data["invoice_date"]) ET.SubElement(root, f"{{{CBC}}}IssueDate").text = parse_to_strict_xml_date(data["invoice_date"])
try: ET.SubElement(root, f"{{{CBC}}}DueDate").text = berechne_faelligkeit(data["invoice_date"])
base_date = datetime.strptime(parse_to_strict_xml_date(data["invoice_date"]), "%Y-%m-%d")
computed_due = (base_date + timedelta(days=14)).strftime("%Y-%m-%d")
except Exception:
computed_due = (datetime.today() + timedelta(days=14)).strftime("%Y-%m-%d")
ET.SubElement(root, f"{{{CBC}}}DueDate").text = computed_due
ET.SubElement(root, f"{{{CBC}}}InvoiceTypeCode").text = str(data.get("invoice_type_code", "380")) ET.SubElement(root, f"{{{CBC}}}InvoiceTypeCode").text = str(data.get("invoice_type_code", "380"))
@ -282,7 +298,7 @@ def generate_compliant_xrechnung(data):
# --- Empfänger --- # --- Empfänger ---
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
party_cust = ET.SubElement(customer, f"{{{CAC}}}Party") party_cust = ET.SubElement(customer, f"{{{CAC}}}Party")
ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip()
addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress") addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress")
ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"]) ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"])
ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"]) ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"])
@ -307,7 +323,7 @@ def generate_compliant_xrechnung(data):
# CII/ZUGFeRD-Fassung. BR-CO-25 ist bereits durch das gesetzte cbc:DueDate (BT-9) erfüllt; # CII/ZUGFeRD-Fassung. BR-CO-25 ist bereits durch das gesetzte cbc:DueDate (BT-9) erfüllt;
# dieser Text ist zusätzliche Klarstellung, keine Pflicht. # dieser Text ist zusätzliche Klarstellung, keine Pflicht.
payment_terms_ubl = ET.SubElement(root, f"{{{CAC}}}PaymentTerms") payment_terms_ubl = ET.SubElement(root, f"{{{CAC}}}PaymentTerms")
ET.SubElement(payment_terms_ubl, f"{{{CBC}}}Note").text = "Zahlbar innerhalb 14 Tagen ohne Abzug." ET.SubElement(payment_terms_ubl, f"{{{CBC}}}Note").text = ZAHLUNGSBEDINGUNG_TEXT
tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal")
ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
@ -481,11 +497,10 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(cust_addr, f"{{{RAM}}}CountryID").text = "DE" ET.SubElement(cust_addr, f"{{{RAM}}}CountryID").text = "DE"
# BT-49 Elektronische Adresse des Käufers (PEPPOL-EN16931-R010 Pflicht). Muss laut # BT-49 Elektronische Adresse des Käufers (PEPPOL-EN16931-R010 Pflicht). Muss laut
# XSD nach PostalTradeAddress stehen. Fallback analog zur XRechnung(UBL)-Generierung. # XSD nach PostalTradeAddress stehen. KEIN erfundener Fallback - fehlt die Adresse, wird das
# (leere) Pflichtfeld sichtbar und die UI blockiert den Export mit Warnung.
cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication") cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication")
ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = ( ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip()
str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de"
)
if data.get("order_id") and str(data["order_id"]).strip() != "": if data.get("order_id") and str(data["order_id"]).strip() != "":
# BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer. # BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer.
@ -543,15 +558,11 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(end_pt, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["service_end_date"]) ET.SubElement(end_pt, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["service_end_date"])
# BT-9 Fälligkeitsdatum (BR-CO-25 Pflicht: bei positivem Zahlbetrag muss entweder ein # BT-9 Fälligkeitsdatum (BR-CO-25 Pflicht: bei positivem Zahlbetrag muss entweder ein
# Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Default: 14 Tage netto, # Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Zentrale Berechnung, analog
# analog zur bereits funktionierenden XRechnung(UBL)-Generierung. # zur XRechnung(UBL)-Generierung.
try: due_date_str = berechne_faelligkeit(data["invoice_date"]).replace("-", "")
base_date = datetime.strptime(format_cii_date(data["invoice_date"]), "%Y%m%d")
due_date_str = (base_date + timedelta(days=14)).strftime("%Y%m%d")
except Exception:
due_date_str = (datetime.today() + timedelta(days=14)).strftime("%Y%m%d")
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms") payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = "Zahlbar innerhalb 14 Tagen ohne Abzug." ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = ZAHLUNGSBEDINGUNG_TEXT
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime")
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = due_date_str ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = due_date_str
@ -740,7 +751,7 @@ with col2:
# --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ ---
inv_type_ui = st.selectbox( inv_type_ui = st.selectbox(
"Rechnungstyp (BT-3)", "Rechnungstyp (BT-3)",
options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)"], options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)", "326 (Teilrechnung)"],
index=0, index=0,
help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header." help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header."
) )
@ -772,7 +783,7 @@ with col2:
with c_ref1: with c_ref1:
dn_id = st.text_input("Lieferscheinnummer (BT-16)", value=data.get("delivery_note_id", "")) dn_id = st.text_input("Lieferscheinnummer (BT-16)", value=data.get("delivery_note_id", ""))
with c_ref2: with c_ref2:
o_id = st.text_input("Auftragsbestätigung / Verkaufsauftrag - unsere Nummer (BT-14)", value=data.get("order_id", "")) o_id = st.text_input("Auftragsbestätigung / Verkaufsauftrag (BT-14)", value=data.get("order_id", ""))
with c_ref3: with c_ref3:
c_id = st.text_input("Vertrag / Vergabeangebot (BT-12)", value=data.get("contract_id", "")) c_id = st.text_input("Vertrag / Vergabeangebot (BT-12)", value=data.get("contract_id", ""))
@ -797,6 +808,7 @@ with col2:
st.text_input("Stadt (Sender) (BT-37)", value="Hennigsdorf", disabled=True) st.text_input("Stadt (Sender) (BT-37)", value="Hennigsdorf", disabled=True)
st.text_input("Umsatzsteuer-ID (Sender) (BT-31)", value="DE218436692", disabled=True) st.text_input("Umsatzsteuer-ID (Sender) (BT-31)", value="DE218436692", disabled=True)
st.text_input("Registernummer (Sender) (BT-30)", value="HRB 6058 NP", disabled=True) st.text_input("Registernummer (Sender) (BT-30)", value="HRB 6058 NP", disabled=True)
st.text_input("IBAN (Sender) (BT-84)", value=VERKAEUFER_IBAN, disabled=True)
with c_r: with c_r:
st.markdown("**Empfänger (Kunde):**") st.markdown("**Empfänger (Kunde):**")
r_name = st.text_input("Firma (Kunde) (BT-44)", value=data.get("recipient_name", "")) r_name = st.text_input("Firma (Kunde) (BT-44)", value=data.get("recipient_name", ""))
@ -842,7 +854,7 @@ with col2:
with c_total: with c_total:
raw_item_total = item_quantity * item_price raw_item_total = item_quantity * item_price
item_total_amount = kaufmaennisches_runden(raw_item_total) item_total_amount = kaufmaennisches_runden(raw_item_total)
st.markdown("**Gesamt betrag (€):**") st.markdown("**Gesamtbetrag (€):**")
st.markdown(f"**{item_total_amount:.2f} €**") st.markdown(f"**{item_total_amount:.2f} €**")
edited_items.append({ edited_items.append({
@ -857,10 +869,15 @@ with col2:
st.markdown("---") st.markdown("---")
vat_rate = st.number_input("Steuersatz (BT-152, %)", value=float(data.get("vat_rate_percent") or 19.0)) vat_rate = st.number_input("Steuersatz (BT-152, %)", value=float(data.get("vat_rate_percent") or 19.0))
computed_net_amount = sum(item["total_amount"] for item in edited_items) # Summen mit Decimal bilden, damit die Netto-Summe exakt der Summe der (bereits gerundeten)
raw_vat_amount = computed_net_amount * (vat_rate / 100.0) # Positionsbeträge entspricht. Reine float-Summation kann minimal driften und dann zu
computed_vat_amount = kaufmaennisches_runden(raw_vat_amount) # BR-CO-10-Validierungsfehlern führen (Summe der Zeilen != TaxExclusiveAmount).
computed_gross_amount = kaufmaennisches_runden(computed_net_amount + computed_vat_amount) net_dec = sum((Decimal(str(item["total_amount"])) for item in edited_items), Decimal("0"))
vat_dec = (net_dec * Decimal(str(vat_rate)) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
gross_dec = (net_dec + vat_dec).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
computed_net_amount = float(net_dec.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
computed_vat_amount = float(vat_dec)
computed_gross_amount = float(gross_dec)
st.markdown("**Berechnete Summen:**") st.markdown("**Berechnete Summen:**")
c_net_ui, c_vat_ui, c_gross_ui = st.columns(3) c_net_ui, c_vat_ui, c_gross_ui = st.columns(3)
@ -882,7 +899,7 @@ with col2:
"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,
"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": "DE06120700240456207000", "vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
"delivery_note_id": dn_id, "delivery_note_id": dn_id,
"order_id": o_id, "order_id": o_id,
"contract_id": c_id, "contract_id": c_id,
@ -891,12 +908,37 @@ with col2:
"invoice_note": i_note "invoice_note": i_note
} }
# --- PFLICHTFELD-VALIDIERUNG vor dem Export ---
# Sammelt blockierende Probleme (Export nicht möglich), damit fehlerhafte/fehlende
# Pflichtangaben sichtbar werden, statt still mit Platzhaltern oder falschen Werten
# in die Rechnung zu wandern.
blocker = []
if not str(inv_num).strip():
blocker.append("Rechnungsnummer (BT-1) fehlt.")
if parse_to_strict_xml_date(inv_date) == "":
blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). "
"Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.")
if not str(r_email).strip():
blocker.append("E-Mail des Kunden (BT-49) fehlt. Diese elektronische Adresse ist für "
"XRechnung/ZUGFeRD ein Pflichtfeld (PEPPOL-EN16931-R010) und wird NICHT "
"mit einem Platzhalter befüllt. Bitte eintragen.")
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.")
if not edited_items:
blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).")
if blocker:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
+ "\n".join(f"- {b}" for b in blocker))
export_disabled = len(blocker) > 0
if "UBL XML" in export_format: if "UBL XML" in export_format:
if st.button("🔒 XRechnung exportieren", type="primary", width="stretch"): if st.button("🔒 XRechnung exportieren", type="primary", width="stretch", disabled=export_disabled):
xml_data = generate_compliant_xrechnung(final_invoice_dict) xml_data = generate_compliant_xrechnung(final_invoice_dict)
st.download_button(label="📥 Download XRechnung XML", data=xml_data, file_name=f"XRechnung_{inv_num}.xml", mime="text/xml") st.download_button(label="📥 Download XRechnung XML", data=xml_data, file_name=f"XRechnung_{inv_num}.xml", mime="text/xml")
else: else:
if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"): if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch", disabled=export_disabled):
try: try:
cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict)
@ -944,7 +986,7 @@ st.markdown("---")
st.caption( st.caption(
"📝 **Hinweise:**\n\n" "📝 **Hinweise:**\n\n"
"**Human-in-the-Loop:** " "**Human-in-the-Loop:** "
"Das Tool dient as Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten " "Das Tool dient als Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten "
"(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich.\n\n" "(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich.\n\n"
"**GoBD-Konformität:** " "**GoBD-Konformität:** "
"Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien "