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.")
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.")
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.")
@ -91,20 +92,38 @@ class InvoiceMetadata(BaseModel):
# ==========================================
# 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)."""
def berechne_faelligkeit(invoice_date_str, term_days):
"""Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer,
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)
if not base:
return ""
try:
days = int(term_days)
except (ValueError, TypeError):
return ""
if days < 0:
return ""
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")
return ""
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):
@ -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}}}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"))
@ -319,11 +340,13 @@ def generate_compliant_xrechnung(data):
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
# BT-20 Zahlungsbedingungen (Freitext) - für menschliche Lesbarkeit, analog zur
# 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.
payment_terms_ubl = ET.SubElement(root, f"{{{CAC}}}PaymentTerms")
ET.SubElement(payment_terms_ubl, f"{{{CBC}}}Note").text = ZAHLUNGSBEDINGUNG_TEXT
# BT-20 Zahlungsbedingungen (Freitext), abgeleitet aus dem tatsächlichen Zahlungsziel der
# Rechnung. Zusammen mit BT-9 (cbc:DueDate) erfüllt das BR-CO-25. Kein fester Fallback:
# ohne gültiges Zahlungsziel wird der Text weggelassen (die UI blockiert dann den Export).
_ubl_terms_text = zahlungsbedingung_text(data.get("payment_term_days"))
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")
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")
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
# Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Zentrale Berechnung, analog
# zur XRechnung(UBL)-Generierung.
due_date_str = berechne_faelligkeit(data["invoice_date"]).replace("-", "")
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = ZAHLUNGSBEDINGUNG_TEXT
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime")
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = due_date_str
# BT-9 Fälligkeitsdatum + BT-20 Zahlungsbedingungen, abgeleitet aus dem tatsächlichen
# Zahlungsziel der Rechnung (BR-CO-25). Kein fester Fallback: ohne gültiges Zahlungsziel wird
# der komplette SpecifiedTradePaymentTerms-Block weggelassen (die UI blockiert dann den Export).
_cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
_cii_terms_text = zahlungsbedingung_text(data.get("payment_term_days"))
if _cii_due or _cii_terms_text:
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
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")
ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data['total_net_amount']:.2f}"
@ -705,6 +732,7 @@ with col2:
"- contract_id\n"
"- service_start_date\n"
"- service_end_date\n"
"- payment_term_days\n"
"- invoice_note\n\n"
"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. "
@ -757,8 +785,9 @@ with col2:
)
selected_type_code = inv_type_ui.split(" ")[0]
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", ""))
st.caption("Mit * markierte Felder sind EN16931-Pflichtfelder und müssen für einen gültigen Export befüllt sein.")
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", ""))
cust_num = st.text_input(
"Kundennummer / Leitweg-ID (BT-10)",
@ -793,6 +822,15 @@ with col2:
with c_p2:
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)
st.markdown("---")
@ -811,11 +849,11 @@ with col2:
st.text_input("IBAN (Sender) (BT-84)", value=VERKAEUFER_IBAN, disabled=True)
with c_r:
st.markdown("**Empfänger (Kunde):**")
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_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_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_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")
@ -837,7 +875,7 @@ with col2:
c_name, c_desc = st.columns([1, 1])
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:
desc_text = item.get("description", "")
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)
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:
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:
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:
raw_item_total = item_quantity * item_price
item_total_amount = kaufmaennisches_runden(raw_item_total)
@ -867,7 +905,7 @@ with col2:
})
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)
# Positionsbeträge entspricht. Reine float-Summation kann minimal driften und dann zu
@ -905,6 +943,7 @@ with col2:
"contract_id": c_id,
"service_start_date": s_start,
"service_end_date": s_end,
"payment_term_days": pay_term,
"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.")
if not edited_items:
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:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"