Test
This commit is contained in:
parent
076e976298
commit
e80d011c88
104
main.py
104
main.py
|
|
@ -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", ""))
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue