This commit is contained in:
dfuhrmann3 2026-07-07 09:49:28 +02:00
parent e80d011c88
commit ebf172276a
1 changed files with 77 additions and 32 deletions

107
main.py
View File

@ -83,6 +83,7 @@ class InvoiceMetadata(BaseModel):
contract_id: str = Field(default="", description="Gefundene Nummer von Vergabeangebot, Vertrag oder Ausschreibung (z.B. AN26-130) VOR Position 1. Sonst leer lassen.") contract_id: str = Field(default="", description="Gefundene Nummer von Vergabeangebot, Vertrag oder Ausschreibung (z.B. AN26-130) VOR Position 1. Sonst leer lassen.")
service_start_date: str = Field(default="", description="Startdatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.") service_start_date: str = Field(default="", description="Startdatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.")
service_end_date: str = Field(default="", description="Enddatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.") service_end_date: str = Field(default="", description="Enddatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.")
payment_term_days: str = Field(default="", description="Das Zahlungsziel in TAGEN als reine Zahl (z.B. '14' oder '30'). Suche im Text nach Formulierungen wie 'zahlbar innerhalb X Tagen', 'Zahlungsziel X Tage', 'X Tage netto'. Steht stattdessen ein konkretes Fälligkeitsdatum (z.B. 'zahlbar bis 31.07.2026'), berechne die Anzahl Tage zwischen Rechnungsdatum und diesem Datum und gib NUR diese Zahl zurück. Wenn im Text KEIN Zahlungsziel und KEIN Fälligkeitsdatum erkennbar ist: leer lassen (NICHT raten, NICHT 14 annehmen).")
invoice_note: str = Field(default="", description="Sämtlicher unstrukturierter Fließtext, Beschreibungen, Abnahmekriterien (Acceptance Criteria) oder Projektklauseln, die sich VOR Position 1 befinden. Vollständig mit Zeilenumbrüchen sichern.") invoice_note: str = Field(default="", description="Sämtlicher unstrukturierter Fließtext, Beschreibungen, Abnahmekriterien (Acceptance Criteria) oder Projektklauseln, die sich VOR Position 1 befinden. Vollständig mit Zeilenumbrüchen sichern.")
@ -91,20 +92,38 @@ class InvoiceMetadata(BaseModel):
# ========================================== # ==========================================
# Zentrale Geschäftskonstanten - hier zentral pflegen statt an mehreren Stellen im Code. # 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" VERKAEUFER_IBAN = "DE06120700240456207000"
def berechne_faelligkeit(invoice_date_str): def berechne_faelligkeit(invoice_date_str, term_days):
"""Fälligkeitsdatum = Rechnungsdatum + ZAHLUNGSZIEL_TAGE. Gibt 'YYYY-MM-DD' zurück. """Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer,
Fällt bei unlesbarem Rechnungsdatum auf heute+Ziel zurück (nur als letzter Notnagel).""" wenn Rechnungsdatum unlesbar oder kein gültiges Zahlungsziel angegeben ist. KEIN fester
Fallback - fehlt das Zahlungsziel, bleibt das Feld leer und die UI blockiert den Export."""
base = parse_to_strict_xml_date(invoice_date_str) base = parse_to_strict_xml_date(invoice_date_str)
if not base:
return ""
try:
days = int(term_days)
except (ValueError, TypeError):
return ""
if days < 0:
return ""
try: try:
base_date = datetime.strptime(base, "%Y-%m-%d") base_date = datetime.strptime(base, "%Y-%m-%d")
except (ValueError, TypeError): except (ValueError, TypeError):
base_date = datetime.today() return ""
return (base_date + timedelta(days=ZAHLUNGSZIEL_TAGE)).strftime("%Y-%m-%d") return (base_date + timedelta(days=days)).strftime("%Y-%m-%d")
def zahlungsbedingung_text(term_days):
"""Freitext-Zahlungsbedingung (BT-20) aus dem Zahlungsziel. Leer, wenn kein gültiger Wert."""
try:
days = int(term_days)
except (ValueError, TypeError):
return ""
if days < 0:
return ""
return f"Zahlbar innerhalb {days} Tagen ohne Abzug."
def kaufmaennisches_runden(betrag, nachkommastellen=2): def kaufmaennisches_runden(betrag, nachkommastellen=2):
@ -224,7 +243,9 @@ 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"])
ET.SubElement(root, f"{{{CBC}}}DueDate").text = berechne_faelligkeit(data["invoice_date"]) _ubl_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
if _ubl_due:
ET.SubElement(root, f"{{{CBC}}}DueDate").text = _ubl_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"))
@ -319,11 +340,13 @@ def generate_compliant_xrechnung(data):
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount") payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
# BT-20 Zahlungsbedingungen (Freitext) - für menschliche Lesbarkeit, analog zur # BT-20 Zahlungsbedingungen (Freitext), abgeleitet aus dem tatsächlichen Zahlungsziel der
# CII/ZUGFeRD-Fassung. BR-CO-25 ist bereits durch das gesetzte cbc:DueDate (BT-9) erfüllt; # Rechnung. Zusammen mit BT-9 (cbc:DueDate) erfüllt das BR-CO-25. Kein fester Fallback:
# dieser Text ist zusätzliche Klarstellung, keine Pflicht. # ohne gültiges Zahlungsziel wird der Text weggelassen (die UI blockiert dann den Export).
payment_terms_ubl = ET.SubElement(root, f"{{{CAC}}}PaymentTerms") _ubl_terms_text = zahlungsbedingung_text(data.get("payment_term_days"))
ET.SubElement(payment_terms_ubl, f"{{{CBC}}}Note").text = ZAHLUNGSBEDINGUNG_TEXT if _ubl_terms_text:
payment_terms_ubl = ET.SubElement(root, f"{{{CAC}}}PaymentTerms")
ET.SubElement(payment_terms_ubl, f"{{{CBC}}}Note").text = _ubl_terms_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}"
@ -557,14 +580,18 @@ def generate_compliant_zugferd_cii(data):
end_pt = ET.SubElement(period, f"{{{RAM}}}EndDateTime") end_pt = ET.SubElement(period, f"{{{RAM}}}EndDateTime")
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 + BT-20 Zahlungsbedingungen, abgeleitet aus dem tatsächlichen
# Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Zentrale Berechnung, analog # Zahlungsziel der Rechnung (BR-CO-25). Kein fester Fallback: ohne gültiges Zahlungsziel wird
# zur XRechnung(UBL)-Generierung. # der komplette SpecifiedTradePaymentTerms-Block weggelassen (die UI blockiert dann den Export).
due_date_str = berechne_faelligkeit(data["invoice_date"]).replace("-", "") _cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms") _cii_terms_text = zahlungsbedingung_text(data.get("payment_term_days"))
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = ZAHLUNGSBEDINGUNG_TEXT if _cii_due or _cii_terms_text:
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = due_date_str if _cii_terms_text:
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _cii_terms_text
if _cii_due:
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime")
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "")
header_sum = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementHeaderMonetarySummation") header_sum = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementHeaderMonetarySummation")
ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data['total_net_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data['total_net_amount']:.2f}"
@ -705,6 +732,7 @@ with col2:
"- contract_id\n" "- contract_id\n"
"- service_start_date\n" "- service_start_date\n"
"- service_end_date\n" "- service_end_date\n"
"- payment_term_days\n"
"- invoice_note\n\n" "- invoice_note\n\n"
"WICHTIGE REGEL: Analysiere den Text direkt VOR der ersten Tabellenposition '1'. " "WICHTIGE REGEL: Analysiere den Text direkt VOR der ersten Tabellenposition '1'. "
"Sortiere alle dort vorlaufenden Zeilen vollständig in die dafür vorgesehenen ID-, Datums- und Freitext-Felder ein. " "Sortiere alle dort vorlaufenden Zeilen vollständig in die dafür vorgesehenen ID-, Datums- und Freitext-Felder ein. "
@ -757,8 +785,9 @@ with col2:
) )
selected_type_code = inv_type_ui.split(" ")[0] selected_type_code = inv_type_ui.split(" ")[0]
inv_num = st.text_input("Rechnungsnummer (BT-1)", value=data.get("invoice_number", "")) st.caption("Mit * markierte Felder sind EN16931-Pflichtfelder und müssen für einen gültigen Export befüllt sein.")
inv_date = st.text_input("Rechnungsdatum (BT-2, YYYY-MM-DD)", value=data.get("invoice_date", "")) inv_num = st.text_input("Rechnungsnummer (BT-1) *", value=data.get("invoice_number", ""))
inv_date = st.text_input("Rechnungsdatum (BT-2, YYYY-MM-DD) *", value=data.get("invoice_date", ""))
inv_delivery = st.text_input("Lieferdatum (BT-72, YYYY-MM-DD)", value=data.get("delivery_date", "")) inv_delivery = st.text_input("Lieferdatum (BT-72, YYYY-MM-DD)", value=data.get("delivery_date", ""))
cust_num = st.text_input( cust_num = st.text_input(
"Kundennummer / Leitweg-ID (BT-10)", "Kundennummer / Leitweg-ID (BT-10)",
@ -793,6 +822,15 @@ with col2:
with c_p2: with c_p2:
s_end = st.text_input("Leistungszeitraum Ende (BT-74, YYYY-MM-DD)", value=data.get("service_end_date", "")) s_end = st.text_input("Leistungszeitraum Ende (BT-74, YYYY-MM-DD)", value=data.get("service_end_date", ""))
pay_term = st.text_input(
"Zahlungsziel in Tagen (BT-9 / BT-20) *",
value=str(data.get("payment_term_days", "") or ""),
help="Zahlungsziel in Tagen ab Rechnungsdatum (z.B. 14 oder 30), so wie es auf der Rechnung steht. "
"Daraus werden Fälligkeitsdatum (BT-9) und Zahlungsbedingung (BT-20) berechnet. "
"Wird KEIN Wert extrahiert, bleibt das Feld leer - der Export wird dann blockiert, bis ein "
"Zahlungsziel eingetragen ist (es wird NICHT automatisch 14 angenommen)."
)
i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150)
st.markdown("---") st.markdown("---")
@ -811,11 +849,11 @@ with col2:
st.text_input("IBAN (Sender) (BT-84)", value=VERKAEUFER_IBAN, 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", ""))
r_email = st.text_input("E-Mail (Kunde) (BT-49)", value=data.get("recipient_email", "")) r_email = st.text_input("E-Mail (Kunde) (BT-49) *", value=data.get("recipient_email", ""))
r_street = st.text_input("Straße (Kunde) (BT-50)", value=data.get("recipient_street", "")) r_street = st.text_input("Straße (Kunde) (BT-50)", value=data.get("recipient_street", ""))
r_zip = st.text_input("PLZ (Kunde) (BT-53)", value=data.get("recipient_zip", "")) r_zip = st.text_input("PLZ (Kunde) (BT-53)", value=data.get("recipient_zip", ""))
r_city = st.text_input("Stadt (Kunde) (BT-52)", value=data.get("recipient_city", "")) r_city = st.text_input("Stadt (Kunde) (BT-52) *", value=data.get("recipient_city", ""))
st.markdown("### 3. Positionen") st.markdown("### 3. Positionen")
@ -837,7 +875,7 @@ with col2:
c_name, c_desc = st.columns([1, 1]) c_name, c_desc = st.columns([1, 1])
with c_name: with c_name:
item_name = st.text_input("Artikelname (BT-153)", value=item.get("name", ""), key=f"item_name_{inv_id}_{idx}") item_name = st.text_input("Artikelname (BT-153) *", value=item.get("name", ""), key=f"item_name_{inv_id}_{idx}")
with c_desc: with c_desc:
desc_text = item.get("description", "") desc_text = item.get("description", "")
estimated_lines = desc_text.count("\n") + (len(desc_text) // 45) + 1 estimated_lines = desc_text.count("\n") + (len(desc_text) // 45) + 1
@ -846,11 +884,11 @@ with col2:
c_qty, c_unit, c_price, c_total = st.columns(4) c_qty, c_unit, c_price, c_total = st.columns(4)
with c_qty: with c_qty:
item_quantity = st.number_input("Menge (BT-129)", value=float(item.get("quantity") or 0.0), format="%.3f", step=0.1, key=f"item_qty_{inv_id}_{idx}") item_quantity = st.number_input("Menge (BT-129) *", value=float(item.get("quantity") or 0.0), format="%.3f", step=0.1, key=f"item_qty_{inv_id}_{idx}")
with c_unit: with c_unit:
item_unit = st.text_input("Einheit (BT-130)", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}") item_unit = st.text_input("Einheit (BT-130) *", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}")
with c_price: with c_price:
item_price = st.number_input("Einzelpreis (BT-146, €)", value=float(item.get("unit_price") or 0.0), format="%.2f", key=f"item_price_{inv_id}_{idx}") item_price = st.number_input("Einzelpreis (BT-146, €) *", value=float(item.get("unit_price") or 0.0), format="%.2f", key=f"item_price_{inv_id}_{idx}")
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)
@ -867,7 +905,7 @@ 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))
# Summen mit Decimal bilden, damit die Netto-Summe exakt der Summe der (bereits gerundeten) # Summen mit Decimal bilden, damit die Netto-Summe exakt der Summe der (bereits gerundeten)
# Positionsbeträge entspricht. Reine float-Summation kann minimal driften und dann zu # Positionsbeträge entspricht. Reine float-Summation kann minimal driften und dann zu
@ -905,6 +943,7 @@ with col2:
"contract_id": c_id, "contract_id": c_id,
"service_start_date": s_start, "service_start_date": s_start,
"service_end_date": s_end, "service_end_date": s_end,
"payment_term_days": pay_term,
"invoice_note": i_note "invoice_note": i_note
} }
@ -926,6 +965,12 @@ with col2:
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:
blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).") blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).")
_pt = str(pay_term).strip()
if _pt == "":
blocker.append("Zahlungsziel (BT-9/BT-20) fehlt. Es wird KEIN Standardwert (z.B. 14 Tage) "
"angenommen - bitte das tatsächliche Zahlungsziel der Rechnung in Tagen eintragen.")
elif not _pt.isdigit():
blocker.append("Zahlungsziel (BT-9/BT-20) muss eine Zahl in Tagen sein (z.B. 14 oder 30).")
if blocker: if blocker:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"