Compare commits
No commits in common. "a0d80c5d08aae14c209893930d5583927790bc12" and "ae5126898e280d513eb4213c6e7e23b03c25770b" have entirely different histories.
a0d80c5d08
...
ae5126898e
597
main.py
597
main.py
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
import io
|
||||
import streamlit as st
|
||||
import json
|
||||
import re
|
||||
|
|
@ -10,8 +9,6 @@ from decimal import Decimal, ROUND_HALF_UP
|
|||
import fitz
|
||||
import requests
|
||||
from pydantic import BaseModel, Field
|
||||
import pikepdf
|
||||
from facturx import generate_from_binary as facturx_generate_from_binary
|
||||
|
||||
# ==========================================
|
||||
# 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING)
|
||||
|
|
@ -69,8 +66,7 @@ class InvoiceMetadata(BaseModel):
|
|||
invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R26-219)")
|
||||
invoice_date: str = Field(description="Das Rechnungsdatum nach 'Datum:' im Format YYYY-MM-DD")
|
||||
delivery_date: str = Field(description="Das Lieferdatum nach 'Lieferdatum:' im Format YYYY-MM-DD")
|
||||
customer_number: str = Field(default="", description="Kundennummer, wie sie beim Empfänger hinterlegt ist (z.B. der exakte Wert nach 'Kunden-Nr:'). Wird als Käuferreferenz (BT-10) verwendet. Falls im Text keine Kundennummer erkennbar ist: leer lassen.")
|
||||
buyer_reference: str = Field(default="", description="Bestellnummer des KÄUFERS für diesen Auftrag (BT-13): der exakte Wert nach 'Ihr Auftrag:' o.ä. (z.B. 05.06.2026-Herr.Knospe). NICHT die Kundennummer (die gehört in customer_number) und NICHT unsere eigene Auftragsbestätigungsnummer (die gehört in order_id). Wenn im Text keine solche Käufer-Bestellnummer erkennbar ist: leer lassen.")
|
||||
buyer_reference: str = Field(description="Der exakte Wert nach 'Ihr Auftrag:' (z.B. 05.06.2026-Herr.Knospe)")
|
||||
recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Stadtwerke Hennigsdorf GmbH)")
|
||||
recipient_street: str = Field(description="Straße und Hausnummer des Kunden")
|
||||
recipient_zip: str = Field(description="Postleitzahl (PLZ) des Kunden")
|
||||
|
|
@ -79,58 +75,16 @@ class InvoiceMetadata(BaseModel):
|
|||
vat_rate_percent: float = Field(description="Der primäre Umsatzsteuersatz in Prozent (z.B. 19.0)")
|
||||
|
||||
delivery_note_id: str = Field(default="", description="Gefundene Lieferscheinnummer (z.B. LS26-035) VOR Position 1. Sonst leer lassen.")
|
||||
order_id: str = Field(default="", description="Gefundene EIGENE Auftragsbestätigungs-/Verkaufsauftragsnummer (BT-14, z.B. AB26-080) VOR Position 1. Das ist unsere eigene Nummer, NICHT die Bestellnummer des Käufers. Sonst leer lassen.")
|
||||
order_id: str = Field(default="", description="Gefundene Nummer der Auftragsbestätigung oder Bestellung (z.B. AB26-080) 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_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.")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 3. MATHEMATISCHE & PARSING-FUNKTIONEN
|
||||
# ==========================================
|
||||
|
||||
# Zentrale Geschäftskonstanten - hier zentral pflegen statt an mehreren Stellen im Code.
|
||||
VERKAEUFER_IBAN = "DE06120700240456207000"
|
||||
|
||||
# Verkäufer-Stammdaten (SCITECH). Werte bleiben fest hinterlegt, sind aber an EINER Stelle
|
||||
# gepflegt und werden in beiden XML-Generatoren sowie in der UI referenziert - so bleibt bei
|
||||
# einer Adress-/Kontaktänderung nichts inkonsistent zurück.
|
||||
VERKAEUFER = {
|
||||
"name": "scitech it solutions GmbH", # BT-27 (Firmenname)
|
||||
"email": "office@scitech-gmbh.de", # BT-34 (elektronische Adresse) / Kontakt
|
||||
"phone": "+49 3302 4940537", # BT-42 (Kontakttelefon)
|
||||
"contact_name": "Geschäftsführung", # BG-6 (Ansprechpartner)
|
||||
"street": "Hermann-Schumann-Str. 4", # BT-35
|
||||
"zip": "16761", # BT-38
|
||||
"city": "Hennigsdorf", # BT-37
|
||||
"country": "DE", # BT-40
|
||||
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID)
|
||||
"register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-ID)
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
return ""
|
||||
return (base_date + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def kaufmaennisches_runden(betrag, nachkommastellen=2):
|
||||
if not betrag:
|
||||
return 0.0
|
||||
|
|
@ -212,25 +166,14 @@ def map_unece_unit_code(unit_str):
|
|||
return "C62"
|
||||
|
||||
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() == "":
|
||||
return ""
|
||||
clean_str = str(date_str).strip()
|
||||
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"):
|
||||
try: return datetime.strptime(clean_str, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError: continue
|
||||
return ""
|
||||
return datetime.today().strftime("%Y-%m-%d")
|
||||
|
||||
def format_cii_date(date_str):
|
||||
clean = parse_to_strict_xml_date(date_str)
|
||||
return clean.replace("-", "")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GENERATOR A: XRECHNUNG (UBL FORMAT)
|
||||
# ==========================================
|
||||
def generate_compliant_xrechnung(data):
|
||||
INVOICE_NS = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
||||
CBC = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
|
||||
|
|
@ -248,9 +191,12 @@ 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"])
|
||||
|
||||
_ubl_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
|
||||
if _ubl_due:
|
||||
ET.SubElement(root, f"{{{CBC}}}DueDate").text = _ubl_due
|
||||
try:
|
||||
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"))
|
||||
|
||||
|
|
@ -258,30 +204,16 @@ def generate_compliant_xrechnung(data):
|
|||
ET.SubElement(root, f"{{{CBC}}}Note").text = str(data["invoice_note"]).strip()
|
||||
|
||||
ET.SubElement(root, f"{{{CBC}}}DocumentCurrencyCode").text = "EUR"
|
||||
# BT-10 Käuferreferenz: NUR eintragen, wenn tatsächlich ein Wert vorliegt. Für private/
|
||||
# geschäftliche Empfänger (B2B) reicht die Kundennummer, die der Kunde bei euch hat; nur bei
|
||||
# Behörden (B2G) ist eine echte Leitweg-ID vorgesehen - die kommt dann vom Empfänger selbst.
|
||||
if data.get("customer_number") and str(data["customer_number"]).strip() != "":
|
||||
ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["customer_number"]).strip()
|
||||
ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["buyer_reference"])
|
||||
|
||||
if data.get("service_start_date") and data.get("service_end_date"):
|
||||
period_elem = ET.SubElement(root, f"{{{CAC}}}InvoicePeriod")
|
||||
ET.SubElement(period_elem, f"{{{CBC}}}StartDate").text = parse_to_strict_xml_date(data["service_start_date"])
|
||||
ET.SubElement(period_elem, f"{{{CBC}}}EndDate").text = parse_to_strict_xml_date(data["service_end_date"])
|
||||
|
||||
# BT-13 "Purchase order reference" (Bestellnummer des KÄUFERS, z.B. nach "Ihr Auftrag:") und
|
||||
# BT-14 "Sales order reference" (unsere EIGENE Auftrags-/Verkaufsauftragsnummer) leben beide
|
||||
# unter demselben cac:OrderReference-Element. cac:OrderReference/cbc:ID ist laut UBL-Schema
|
||||
# zwingend, sobald OrderReference verwendet wird - liegt keine Käufer-Bestellnummer (BT-13)
|
||||
# vor, aber eine eigene Nummer (BT-14), ist laut Peppol-Spezifikation der Platzhalter "NA"
|
||||
# vorgeschrieben.
|
||||
has_bt13 = data.get("buyer_reference") and str(data["buyer_reference"]).strip() != ""
|
||||
has_bt14 = data.get("order_id") and str(data["order_id"]).strip() != ""
|
||||
if has_bt13 or has_bt14:
|
||||
if data.get("order_id") and str(data["order_id"]).strip() != "":
|
||||
order_ref = ET.SubElement(root, f"{{{CAC}}}OrderReference")
|
||||
ET.SubElement(order_ref, f"{{{CBC}}}ID").text = str(data["buyer_reference"]).strip() if has_bt13 else "NA"
|
||||
if has_bt14:
|
||||
ET.SubElement(order_ref, f"{{{CBC}}}SalesOrderID").text = str(data["order_id"]).strip()
|
||||
ET.SubElement(order_ref, f"{{{CBC}}}ID").text = str(data["order_id"]).strip()
|
||||
|
||||
if data.get("delivery_note_id") and str(data["delivery_note_id"]).strip() != "":
|
||||
despatch_ref = ET.SubElement(root, f"{{{CAC}}}DespatchDocumentReference")
|
||||
|
|
@ -297,34 +229,36 @@ def generate_compliant_xrechnung(data):
|
|||
# --- SENDER (SCITECH) ---
|
||||
supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty")
|
||||
party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party")
|
||||
ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = VERKAEUFER["email"]
|
||||
ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = "office@scitech-gmbh.de"
|
||||
party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName")
|
||||
ET.SubElement(party_name_supp, f"{{{CBC}}}Name").text = "scitech it solutions GmbH"
|
||||
|
||||
addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress")
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = VERKAEUFER["street"]
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}CityName").text = VERKAEUFER["city"]
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = VERKAEUFER["zip"]
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = "Hermann-Schumann-Str. 4"
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}CityName").text = "Hennigsdorf"
|
||||
ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = "16761"
|
||||
country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country")
|
||||
ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = VERKAEUFER["country"]
|
||||
ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE"
|
||||
|
||||
tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme")
|
||||
ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = VERKAEUFER["vat_id"]
|
||||
ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692"
|
||||
scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme")
|
||||
ET.SubElement(scheme_supp, f"{{{CBC}}}ID").text = "VAT"
|
||||
|
||||
legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity")
|
||||
ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = VERKAEUFER["name"]
|
||||
# BT-30 Registernummer (Handelsregister) - NICHT identisch mit der Umsatzsteuer-ID (BT-31)!
|
||||
ET.SubElement(legal_supp, f"{{{CBC}}}CompanyID").text = VERKAEUFER["register_id"]
|
||||
ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH"
|
||||
|
||||
contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact")
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = VERKAEUFER["contact_name"]
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}Telephone").text = VERKAEUFER["phone"]
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = VERKAEUFER["email"]
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = "Geschäftsführung"
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}Telephone").text = "+49 3302 4940537"
|
||||
ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = "office@scitech-gmbh.de"
|
||||
|
||||
# --- Empfänger ---
|
||||
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
|
||||
party_cust = ET.SubElement(customer, f"{{{CAC}}}Party")
|
||||
ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip()
|
||||
ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de"
|
||||
party_name_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyName")
|
||||
ET.SubElement(party_name_cust, f"{{{CBC}}}Name").text = str(data["recipient_name"])
|
||||
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}}}CityName").text = str(data["recipient_city"])
|
||||
|
|
@ -339,9 +273,7 @@ def generate_compliant_xrechnung(data):
|
|||
ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"])
|
||||
|
||||
payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans")
|
||||
# BT-81 Zahlungsart: Code 58 = SEPA-Überweisung (statt 30, das für Überweisungen
|
||||
# außerhalb des SEPA-Raums gedacht ist - VD-Valitool-169-Hinweis).
|
||||
ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "58"
|
||||
ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "30"
|
||||
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
|
||||
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
|
||||
|
||||
|
|
@ -399,274 +331,6 @@ def generate_compliant_xrechnung(data):
|
|||
return minidom.parseString(xml_str).toprettyxml(indent=" ")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GENERATOR B: ZUGFeRD 2.4 (CII FORMAT UN/CEFACT)
|
||||
# ==========================================
|
||||
def generate_compliant_zugferd_cii(data):
|
||||
RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||
UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
||||
QDT = "urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
||||
|
||||
ET.register_namespace('rsm', RSM)
|
||||
ET.register_namespace('ram', RAM)
|
||||
ET.register_namespace('udt', UDT)
|
||||
ET.register_namespace('qdt', QDT)
|
||||
|
||||
root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice")
|
||||
# VD-Valitool-14: Der qdt-Namespace wird in dieser Instanz nicht benötigt, soll aber laut
|
||||
# Spezifikation trotzdem am Root-Element deklariert werden.
|
||||
root.set("xmlns:qdt", QDT)
|
||||
|
||||
ex_doc_ctx = ET.SubElement(root, f"{{{RSM}}}ExchangedDocumentContext")
|
||||
|
||||
# BT-23 Geschäftsprozess (PEPPOL-EN16931-R001 Pflicht). Muss laut XSD VOR
|
||||
# GuidelineSpecifiedDocumentContextParameter stehen.
|
||||
biz_process = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}BusinessProcessSpecifiedDocumentContextParameter")
|
||||
ET.SubElement(biz_process, f"{{{RAM}}}ID").text = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
|
||||
|
||||
guideline = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}GuidelineSpecifiedDocumentContextParameter")
|
||||
ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
|
||||
|
||||
ex_doc = ET.SubElement(root, f"{{{RSM}}}ExchangedDocument")
|
||||
ET.SubElement(ex_doc, f"{{{RAM}}}ID").text = str(data["invoice_number"])
|
||||
ET.SubElement(ex_doc, f"{{{RAM}}}TypeCode").text = str(data.get("invoice_type_code", "380"))
|
||||
|
||||
issue_date = ET.SubElement(ex_doc, f"{{{RAM}}}IssueDateTime")
|
||||
ET.SubElement(issue_date, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["invoice_date"])
|
||||
|
||||
if data.get("invoice_note") and str(data["invoice_note"]).strip() != "":
|
||||
note = ET.SubElement(ex_doc, f"{{{RAM}}}IncludedNote")
|
||||
ET.SubElement(note, f"{{{RAM}}}Content").text = str(data["invoice_note"]).strip()
|
||||
|
||||
trade_trans = ET.SubElement(root, f"{{{RSM}}}SupplyChainTradeTransaction")
|
||||
|
||||
vat_rate = float(data['vat_rate_percent'])
|
||||
vat_category = "S" if vat_rate > 0.0 else "E"
|
||||
|
||||
for index, item in enumerate(data["items"], start=1):
|
||||
line_item = ET.SubElement(trade_trans, f"{{{RAM}}}IncludedSupplyChainTradeLineItem")
|
||||
|
||||
doc_line = ET.SubElement(line_item, f"{{{RAM}}}AssociatedDocumentLineDocument")
|
||||
ET.SubElement(doc_line, f"{{{RAM}}}LineID").text = str(index)
|
||||
|
||||
trade_prod = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedTradeProduct")
|
||||
ET.SubElement(trade_prod, f"{{{RAM}}}Name").text = str(item.get("name", "Dienstleistung"))
|
||||
if item.get("description") and str(item["description"]).strip() != "":
|
||||
ET.SubElement(trade_prod, f"{{{RAM}}}Description").text = str(item["description"]).strip()
|
||||
|
||||
line_agree = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeAgreement")
|
||||
net_price = ET.SubElement(line_agree, f"{{{RAM}}}NetPriceProductTradePrice")
|
||||
ET.SubElement(net_price, f"{{{RAM}}}ChargeAmount").text = f"{item['unit_price']:.2f}"
|
||||
|
||||
line_deliv = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeDelivery")
|
||||
u_code = map_unece_unit_code(item.get("unit", "Stk."))
|
||||
ET.SubElement(line_deliv, f"{{{RAM}}}BilledQuantity", {"unitCode": u_code}).text = f"{item['quantity']:.4f}"
|
||||
|
||||
line_settle = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeSettlement")
|
||||
trade_tax = ET.SubElement(line_settle, f"{{{RAM}}}ApplicableTradeTax")
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}TypeCode").text = "VAT"
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}CategoryCode").text = vat_category
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{vat_rate:.2f}"
|
||||
|
||||
mon_sum = ET.SubElement(line_settle, f"{{{RAM}}}SpecifiedTradeSettlementLineMonetarySummation")
|
||||
ET.SubElement(mon_sum, f"{{{RAM}}}LineTotalAmount").text = f"{item['total_amount']:.2f}"
|
||||
|
||||
header_agree = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeAgreement")
|
||||
# BT-10 Käuferreferenz: NUR eintragen, wenn tatsächlich ein Wert vorliegt (siehe UBL-Generator
|
||||
# für die ausführliche Begründung).
|
||||
if data.get("customer_number") and str(data["customer_number"]).strip() != "":
|
||||
ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["customer_number"]).strip()
|
||||
|
||||
supplier_party = ET.SubElement(header_agree, f"{{{RAM}}}SellerTradeParty")
|
||||
ET.SubElement(supplier_party, f"{{{RAM}}}Name").text = VERKAEUFER["name"]
|
||||
|
||||
supp_legal = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedLegalOrganization")
|
||||
# BT-30 Registernummer (Handelsregister) - NICHT identisch mit der Umsatzsteuer-ID (BT-31)!
|
||||
# Fest eingetragen, analog zu den übrigen fixen Verkäufer-Stammdaten.
|
||||
ET.SubElement(supp_legal, f"{{{RAM}}}ID").text = VERKAEUFER["register_id"]
|
||||
|
||||
# BG-6 SELLER CONTACT (BR-DE-2 Pflicht für XRechnung). Muss laut XSD nach
|
||||
# SpecifiedLegalOrganization und vor PostalTradeAddress stehen.
|
||||
supp_contact = ET.SubElement(supplier_party, f"{{{RAM}}}DefinedTradeContact")
|
||||
ET.SubElement(supp_contact, f"{{{RAM}}}PersonName").text = VERKAEUFER["contact_name"]
|
||||
supp_contact_tel = ET.SubElement(supp_contact, f"{{{RAM}}}TelephoneUniversalCommunication")
|
||||
ET.SubElement(supp_contact_tel, f"{{{RAM}}}CompleteNumber").text = VERKAEUFER["phone"]
|
||||
supp_contact_mail = ET.SubElement(supp_contact, f"{{{RAM}}}EmailURIUniversalCommunication")
|
||||
ET.SubElement(supp_contact_mail, f"{{{RAM}}}URIID").text = VERKAEUFER["email"]
|
||||
|
||||
supp_addr = ET.SubElement(supplier_party, f"{{{RAM}}}PostalTradeAddress")
|
||||
ET.SubElement(supp_addr, f"{{{RAM}}}PostcodeCode").text = VERKAEUFER["zip"]
|
||||
ET.SubElement(supp_addr, f"{{{RAM}}}LineOne").text = VERKAEUFER["street"]
|
||||
ET.SubElement(supp_addr, f"{{{RAM}}}CityName").text = VERKAEUFER["city"]
|
||||
ET.SubElement(supp_addr, f"{{{RAM}}}CountryID").text = VERKAEUFER["country"]
|
||||
|
||||
# BT-34 Elektronische Adresse des Verkäufers (PEPPOL-EN16931-R020 Pflicht). Muss laut
|
||||
# XSD nach PostalTradeAddress und vor SpecifiedTaxRegistration stehen.
|
||||
supp_uri = ET.SubElement(supplier_party, f"{{{RAM}}}URIUniversalCommunication")
|
||||
ET.SubElement(supp_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = VERKAEUFER["email"]
|
||||
|
||||
supp_tax = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedTaxRegistration")
|
||||
ET.SubElement(supp_tax, f"{{{RAM}}}ID", {"schemeID": "VA"}).text = VERKAEUFER["vat_id"]
|
||||
|
||||
customer_party = ET.SubElement(header_agree, f"{{{RAM}}}BuyerTradeParty")
|
||||
ET.SubElement(customer_party, f"{{{RAM}}}Name").text = str(data["recipient_name"])
|
||||
|
||||
cust_addr = ET.SubElement(customer_party, f"{{{RAM}}}PostalTradeAddress")
|
||||
ET.SubElement(cust_addr, f"{{{RAM}}}PostcodeCode").text = str(data["recipient_zip"])
|
||||
ET.SubElement(cust_addr, f"{{{RAM}}}LineOne").text = str(data["recipient_street"])
|
||||
ET.SubElement(cust_addr, f"{{{RAM}}}CityName").text = str(data["recipient_city"])
|
||||
ET.SubElement(cust_addr, f"{{{RAM}}}CountryID").text = "DE"
|
||||
|
||||
# BT-49 Elektronische Adresse des Käufers (PEPPOL-EN16931-R010 Pflicht). Muss laut
|
||||
# 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")
|
||||
ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip()
|
||||
|
||||
if data.get("order_id") and str(data["order_id"]).strip() != "":
|
||||
# BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer.
|
||||
# In CII ein eigenständiges Element. Muss laut XSD VOR BuyerOrderReferencedDocument stehen.
|
||||
order_ref = ET.SubElement(header_agree, f"{{{RAM}}}SellerOrderReferencedDocument")
|
||||
ET.SubElement(order_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["order_id"]).strip()
|
||||
|
||||
if data.get("buyer_reference") and str(data["buyer_reference"]).strip() != "":
|
||||
# BT-13 "Purchase order reference" - die Bestellnummer des KÄUFERS (z.B. nach
|
||||
# "Ihr Auftrag:"), NICHT unsere eigene Nummer (die ist BT-14/order_id oben).
|
||||
buyer_order_ref = ET.SubElement(header_agree, f"{{{RAM}}}BuyerOrderReferencedDocument")
|
||||
ET.SubElement(buyer_order_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["buyer_reference"]).strip()
|
||||
|
||||
if data.get("contract_id") and str(data["contract_id"]).strip() != "":
|
||||
contract_ref = ET.SubElement(header_agree, f"{{{RAM}}}ContractReferencedDocument")
|
||||
ET.SubElement(contract_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["contract_id"]).strip()
|
||||
|
||||
header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery")
|
||||
if data.get("delivery_date"):
|
||||
act_deliv = ET.SubElement(header_deliv, f"{{{RAM}}}ActualDeliverySupplyChainEvent")
|
||||
occ_date = ET.SubElement(act_deliv, f"{{{RAM}}}OccurrenceDateTime")
|
||||
ET.SubElement(occ_date, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["delivery_date"])
|
||||
|
||||
# HINWEIS: "DeliveryNoteReferencedDocument" existiert im EN16931/Factur-X-Schema NICHT
|
||||
# (im offiziellen XSD nicht vorhanden). Das korrekte, schema-konforme Element für eine
|
||||
# Lieferschein-/Lieferavis-Referenz ist "DespatchAdviceReferencedDocument" und muss laut
|
||||
# XSD-Sequenz NACH ActualDeliverySupplyChainEvent stehen.
|
||||
if data.get("delivery_note_id") and str(data["delivery_note_id"]).strip() != "":
|
||||
dn_ref = ET.SubElement(header_deliv, f"{{{RAM}}}DespatchAdviceReferencedDocument")
|
||||
ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip()
|
||||
|
||||
header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement")
|
||||
ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR"
|
||||
|
||||
pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans")
|
||||
# BT-81 Zahlungsart: Code 58 = SEPA-Überweisung. Code 30 ist für Überweisungen AUSSERHALB
|
||||
# des SEPA-Raums gedacht (VD-Valitool-169-Hinweis) - da hier immer eine IBAN verwendet wird,
|
||||
# ist 58 der korrekte Code.
|
||||
ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "58"
|
||||
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
||||
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
|
||||
|
||||
header_tax = ET.SubElement(header_settle, f"{{{RAM}}}ApplicableTradeTax")
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CalculatedAmount").text = f"{data['total_vat_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}TypeCode").text = "VAT"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}BasisAmount").text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CategoryCode").text = vat_category
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{vat_rate:.2f}"
|
||||
|
||||
if data.get("service_start_date") and data.get("service_end_date"):
|
||||
period = ET.SubElement(header_settle, f"{{{RAM}}}BillingSpecifiedPeriod")
|
||||
start_pt = ET.SubElement(period, f"{{{RAM}}}StartDateTime")
|
||||
ET.SubElement(start_pt, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = format_cii_date(data["service_start_date"])
|
||||
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, abgeleitet aus dem tatsächlichen Zahlungsziel der Rechnung. Erfüllt
|
||||
# allein BR-CO-25 (Fälligkeitsdatum ODER Zahlungsbedingungen - wir nutzen nur das Datum).
|
||||
# Kein fester Fallback: ohne gültiges Zahlungsziel wird der Block weggelassen (die UI blockiert
|
||||
# dann den Export).
|
||||
_cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
|
||||
if _cii_due:
|
||||
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
|
||||
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}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}"
|
||||
|
||||
xml_str = ET.tostring(root, encoding="utf-8")
|
||||
return minidom.parseString(xml_str).toprettyxml(indent=" ")
|
||||
|
||||
|
||||
# Pfad zum sRGB-ICC-Profil, das für ein PDF/A-3-konformes OutputIntent benötigt wird.
|
||||
# (PDF/A verlangt zwingend ein eingebettetes Farbprofil - ohne das gilt die PDF nicht als PDF/A,
|
||||
# selbst wenn XMP-Metadaten und eingebettetes XML korrekt sind.)
|
||||
SRGB_ICC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sRGB.icc")
|
||||
|
||||
|
||||
def add_pdfa_output_intent(pdf_bytes: bytes, icc_path: str = SRGB_ICC_PATH) -> bytes:
|
||||
"""
|
||||
Fügt der PDF ein OutputIntent mit sRGB-ICC-Profil hinzu, falls noch keines vorhanden ist.
|
||||
Das ist zwingend erforderlich, damit eine PDF als PDF/A-3(B) gilt (ZUGFeRD-Pflicht).
|
||||
"""
|
||||
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
|
||||
try:
|
||||
if "/OutputIntents" not in pdf.Root or len(pdf.Root.get("/OutputIntents", [])) == 0:
|
||||
if not os.path.isfile(icc_path):
|
||||
raise FileNotFoundError(
|
||||
f"sRGB-ICC-Profil nicht gefunden unter '{icc_path}'. "
|
||||
"Für echte PDF/A-3-Konformität muss diese Datei mit ausgeliefert werden."
|
||||
)
|
||||
with open(icc_path, "rb") as f:
|
||||
icc_bytes = f.read()
|
||||
icc_stream = pikepdf.Stream(pdf, icc_bytes)
|
||||
icc_stream["/N"] = 3
|
||||
icc_stream["/Alternate"] = pikepdf.Name("/DeviceRGB")
|
||||
output_intent = pdf.make_indirect(pikepdf.Dictionary({
|
||||
"/Type": pikepdf.Name("/OutputIntent"),
|
||||
"/S": pikepdf.Name("/GTS_PDFA1"),
|
||||
"/OutputConditionIdentifier": pikepdf.String("sRGB IEC61966-2.1"),
|
||||
"/Info": pikepdf.String("sRGB IEC61966-2.1"),
|
||||
"/DestOutputProfile": icc_stream,
|
||||
}))
|
||||
pdf.Root["/OutputIntents"] = pikepdf.Array([output_intent])
|
||||
buf = io.BytesIO()
|
||||
pdf.save(buf)
|
||||
return buf.getvalue()
|
||||
finally:
|
||||
pdf.close()
|
||||
|
||||
|
||||
def fix_xrechnung_xmp_conformance(pdf_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Die factur-x-Bibliothek schreibt standardmäßig fx:ConformanceLevel = 'EN 16931' und
|
||||
fx:Version = '1.0' in die XMP-Metadaten (generischer Factur-X-Standard). Da unsere CII-XML
|
||||
aber explizit das KoSIT-XRechnung-Profil deklariert (GuidelineID .../xrechnung_3.0), erwarten
|
||||
Validatoren hier stattdessen 'XRECHNUNG' und '3.0' - sonst schlagen z. B.
|
||||
VD-Valitool-H-022/H-024 fehl, weil PDF-Metadaten und eingebettetes XML nicht zusammenpassen.
|
||||
"""
|
||||
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
|
||||
try:
|
||||
if "/Metadata" in pdf.Root:
|
||||
xmp_bytes = pdf.Root.Metadata.read_bytes()
|
||||
xmp_str = xmp_bytes.decode("utf-8")
|
||||
xmp_str = xmp_str.replace(
|
||||
"<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>",
|
||||
"<fx:ConformanceLevel>XRECHNUNG</fx:ConformanceLevel>",
|
||||
).replace(
|
||||
"<fx:Version>1.0</fx:Version>",
|
||||
"<fx:Version>3.0</fx:Version>",
|
||||
)
|
||||
pdf.Root.Metadata.write(xmp_str.encode("utf-8"))
|
||||
buf = io.BytesIO()
|
||||
pdf.save(buf)
|
||||
return buf.getvalue()
|
||||
finally:
|
||||
pdf.close()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 4. API CLIENT INITIALISIERUNG
|
||||
# ==========================================
|
||||
|
|
@ -696,8 +360,6 @@ with col1:
|
|||
for page in doc:
|
||||
pdf_text += page.get_text("text") + "\n"
|
||||
st.success("PDF eingelesen!")
|
||||
uploaded_file.seek(0)
|
||||
st.pdf(uploaded_file)
|
||||
with st.expander("Gefundener Text"): st.text(pdf_text)
|
||||
|
||||
with col2:
|
||||
|
|
@ -718,7 +380,6 @@ with col2:
|
|||
"- invoice_date (im Format YYYY-MM-DD)\n"
|
||||
"- delivery_date (im Format YYYY-MM-DD)\n"
|
||||
"- buyer_reference\n"
|
||||
"- customer_number\n"
|
||||
"- recipient_name\n"
|
||||
"- recipient_street\n"
|
||||
"- recipient_zip\n"
|
||||
|
|
@ -730,7 +391,6 @@ 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. "
|
||||
|
|
@ -763,7 +423,7 @@ with col2:
|
|||
st.code(res_meta_text)
|
||||
st.stop()
|
||||
|
||||
if final_data.get("recipient_email") == VERKAEUFER["email"]:
|
||||
if final_data.get("recipient_email") == "office@scitech-gmbh.de":
|
||||
final_data["recipient_email"] = ""
|
||||
|
||||
final_data["items"] = extract_invoice_items_pure_python(pdf_text)
|
||||
|
|
@ -774,30 +434,19 @@ with col2:
|
|||
if st.session_state.extracted_data:
|
||||
data = st.session_state.extracted_data
|
||||
|
||||
st.caption("Mit :red[*] markierte Felder sind EN16931-Pflichtfelder und müssen für einen gültigen Export befüllt sein.")
|
||||
|
||||
# --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ ---
|
||||
inv_type_ui = st.selectbox(
|
||||
"Rechnungstyp (BT-3)",
|
||||
options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)", "326 (Teilrechnung)"],
|
||||
options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)"],
|
||||
index=0,
|
||||
help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header."
|
||||
)
|
||||
selected_type_code = inv_type_ui.split(" ")[0]
|
||||
|
||||
inv_num = st.text_input("Rechnungsnummer (BT-1) :red[*]", value=data.get("invoice_number", ""))
|
||||
inv_date = st.text_input("Rechnungsdatum (BT-2, YYYY-MM-DD) :red[*]", 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)",
|
||||
value=data.get("customer_number", ""),
|
||||
help="Eure Kundennummer beim Empfänger (Standardfall für B2B) oder eine Leitweg-ID, falls "
|
||||
"der Empfänger eine Behörde ist."
|
||||
)
|
||||
buyer_ref = st.text_input(
|
||||
"Ihr Auftrag / Bestellnummer (BT-13)",
|
||||
value=data.get("buyer_reference", "")
|
||||
)
|
||||
inv_num = st.text_input("Rechnungsnummer", value=data.get("invoice_number", ""))
|
||||
inv_date = st.text_input("Rechnungsdatum (YYYY-MM-DD)", value=data.get("invoice_date", ""))
|
||||
inv_delivery = st.text_input("Lieferdatum (YYYY-MM-DD)", value=data.get("delivery_date", ""))
|
||||
buyer_ref = st.text_input("Ihr Auftrag / Referenz", value=data.get("buyer_reference", ""))
|
||||
|
||||
# UI-Eingabefelder für die Daten vor Position 1 (BT-22 etc.)
|
||||
st.markdown("---")
|
||||
|
|
@ -806,20 +455,15 @@ with col2:
|
|||
with c_ref1:
|
||||
dn_id = st.text_input("Lieferscheinnummer (BT-16)", value=data.get("delivery_note_id", ""))
|
||||
with c_ref2:
|
||||
o_id = st.text_input("Auftragsbestätigung / Verkaufsauftrag (BT-14)", value=data.get("order_id", ""))
|
||||
o_id = st.text_input("Auftragsbestätigung / Bestellung (BT-13)", value=data.get("order_id", ""))
|
||||
with c_ref3:
|
||||
c_id = st.text_input("Vertrag / Vergabeangebot (BT-12)", value=data.get("contract_id", ""))
|
||||
|
||||
c_p1, c_p2 = st.columns(2)
|
||||
with c_p1:
|
||||
s_start = st.text_input("Leistungszeitraum Start (BT-73, YYYY-MM-DD)", value=data.get("service_start_date", ""))
|
||||
s_start = st.text_input("Leistungszeitraum Start (YYYY-MM-DD)", value=data.get("service_start_date", ""))
|
||||
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) :red[*]",
|
||||
value=str(data.get("payment_term_days", "") or "")
|
||||
)
|
||||
s_end = st.text_input("Leistungszeitraum Ende (YYYY-MM-DD)", value=data.get("service_end_date", ""))
|
||||
|
||||
i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150)
|
||||
st.markdown("---")
|
||||
|
|
@ -828,22 +472,19 @@ with col2:
|
|||
c_s, c_r = st.columns(2)
|
||||
with c_s:
|
||||
st.markdown("**Absender (Verkäufer):**")
|
||||
st.text_input("Firma (BT-27)", value=VERKAEUFER["name"], disabled=True)
|
||||
st.text_input("E-Mail (BT-34)", value=VERKAEUFER["email"], disabled=True)
|
||||
st.text_input("Telefon (BT-42)", value=VERKAEUFER["phone"], disabled=True)
|
||||
st.text_input("Straße (BT-35)", value=VERKAEUFER["street"], disabled=True)
|
||||
st.text_input("PLZ (BT-38)", value=VERKAEUFER["zip"], disabled=True)
|
||||
st.text_input("Stadt (BT-37)", value=VERKAEUFER["city"], disabled=True)
|
||||
st.text_input("Umsatzsteuer-ID (BT-31)", value=VERKAEUFER["vat_id"], disabled=True)
|
||||
st.text_input("Registernummer (BT-30)", value=VERKAEUFER["register_id"], disabled=True)
|
||||
st.text_input("IBAN (BT-84)", value=VERKAEUFER_IBAN, disabled=True)
|
||||
st.text_input("Firma (Sender)", value="scitech it solutions GmbH", disabled=True)
|
||||
st.text_input("E-Mail (Sender)", value="office@scitech-gmbh.de", disabled=True)
|
||||
st.text_input("Telefon (Sender)", value="+49 3302 4940537", disabled=True)
|
||||
st.text_input("Straße (Sender)", value="Hermann-Schumann-Str. 4", disabled=True)
|
||||
st.text_input("PLZ (Sender)", value="16761", disabled=True)
|
||||
st.text_input("Stadt (Sender)", value="Hennigsdorf", disabled=True)
|
||||
with c_r:
|
||||
st.markdown("**Empfänger (Kunde):**")
|
||||
r_name = st.text_input("Firma (BT-44) :red[*]", value=data.get("recipient_name", ""))
|
||||
r_email = st.text_input("E-Mail (BT-49) :red[*]", value=data.get("recipient_email", ""))
|
||||
r_street = st.text_input("Straße (BT-50)", value=data.get("recipient_street", ""))
|
||||
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", ""))
|
||||
r_name = st.text_input("Firma (Kunde)", value=data.get("recipient_name", ""))
|
||||
r_email = st.text_input("E-Mail (Kunde)", value=data.get("recipient_email", ""))
|
||||
r_street = st.text_input("Straße (Kunde)", value=data.get("recipient_street", ""))
|
||||
r_zip = st.text_input("PLZ (Kunde)", value=data.get("recipient_zip", ""))
|
||||
r_city = st.text_input("Stadt (Kunde)", value=data.get("recipient_city", ""))
|
||||
|
||||
st.markdown("### 3. Positionen")
|
||||
|
||||
|
|
@ -852,6 +493,7 @@ with col2:
|
|||
st.rerun()
|
||||
|
||||
edited_items = []
|
||||
|
||||
inv_id = st.session_state.invoice_counter
|
||||
|
||||
for idx, item in enumerate(data.get("items", [])):
|
||||
|
|
@ -865,24 +507,25 @@ with col2:
|
|||
|
||||
c_name, c_desc = st.columns([1, 1])
|
||||
with c_name:
|
||||
item_name = st.text_input("Artikelname (BT-153) :red[*]", 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
|
||||
calc_height = max(68, min(400, estimated_lines * 24))
|
||||
|
||||
item_description = st.text_area("Zusatzbeschreibung (BT-154)", value=desc_text, key=f"item_desc_{inv_id}_{idx}", height=calc_height)
|
||||
|
||||
c_qty, c_unit, c_price, c_total = st.columns(4)
|
||||
with c_qty:
|
||||
item_quantity = st.number_input("Menge (BT-129) :red[*]", 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", 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) :red[*]", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}")
|
||||
item_unit = st.text_input("Einheit", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}")
|
||||
with c_price:
|
||||
item_price = st.number_input("Einzelpreis (BT-146, €) :red[*]", value=float(item.get("unit_price") or 0.0), format="%.2f", key=f"item_price_{inv_id}_{idx}")
|
||||
item_price = st.number_input("Einzelpreis (€)", 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)
|
||||
st.markdown("**Gesamtbetrag (€):**")
|
||||
st.markdown("**Gesamt betrag (€):**")
|
||||
st.markdown(f"**{item_total_amount:.2f} €**")
|
||||
|
||||
edited_items.append({
|
||||
|
|
@ -895,17 +538,12 @@ with col2:
|
|||
})
|
||||
|
||||
st.markdown("---")
|
||||
vat_rate = st.number_input("Steuersatz (BT-152, %) :red[*]", value=float(data.get("vat_rate_percent") or 19.0))
|
||||
vat_rate = st.number_input("Steuersatz (%)", 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
|
||||
# BR-CO-10-Validierungsfehlern führen (Summe der Zeilen != TaxExclusiveAmount).
|
||||
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)
|
||||
computed_net_amount = sum(item["total_amount"] for item in edited_items)
|
||||
raw_vat_amount = computed_net_amount * (vat_rate / 100.0)
|
||||
computed_vat_amount = kaufmaennisches_runden(raw_vat_amount)
|
||||
computed_gross_amount = kaufmaennisches_runden(computed_net_amount + computed_vat_amount)
|
||||
|
||||
st.markdown("**Berechnete Summen:**")
|
||||
c_net_ui, c_vat_ui, c_gross_ui = st.columns(3)
|
||||
|
|
@ -913,106 +551,23 @@ with col2:
|
|||
with c_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}")
|
||||
with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_amount:.2f}")
|
||||
|
||||
# --- EXPORT-SEKTION DROPDOWN-AUSWAHL ---
|
||||
st.markdown("---")
|
||||
export_format = st.selectbox(
|
||||
"💾 Export-Format wählen",
|
||||
options=["XRechnung v3.0.2 (Reines UBL XML)", "ZUGFeRD v2.4 (Hybrid-PDF mit CII XML)"],
|
||||
help="XRechnung generiert ein reines, valides UBL-XML. ZUGFeRD bettet ein schema-konformes CII-XML (factur-x.xml) direkt in deine PDF ein."
|
||||
)
|
||||
|
||||
final_invoice_dict = {
|
||||
"invoice_type_code": selected_type_code,
|
||||
"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,
|
||||
"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,
|
||||
"vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
|
||||
"delivery_note_id": dn_id,
|
||||
"order_id": o_id,
|
||||
"contract_id": c_id,
|
||||
"service_start_date": s_start,
|
||||
"service_end_date": s_end,
|
||||
"payment_term_days": pay_term,
|
||||
"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).")
|
||||
_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"
|
||||
+ "\n".join(f"- {b}" for b in blocker))
|
||||
|
||||
export_disabled = len(blocker) > 0
|
||||
|
||||
if "UBL XML" in export_format:
|
||||
if st.button("🔒 XRechnung exportieren", type="primary", width="stretch", disabled=export_disabled):
|
||||
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")
|
||||
else:
|
||||
if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch", disabled=export_disabled):
|
||||
try:
|
||||
cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict)
|
||||
|
||||
uploaded_file.seek(0)
|
||||
original_pdf_bytes = uploaded_file.read()
|
||||
|
||||
# 1) factur-x übernimmt: XSD-Validierung der CII-XML, korrektes Einbetten
|
||||
# (Filespec mit AFRelationship=/Data, /F + /UF, Subtype text/xml) und die
|
||||
# komplette PDF/A-3-konforme XMP-Metadatenstruktur (pdfaid:part/conformance,
|
||||
# fx:-Schema-Erweiterung). Bricht die XML-Validierung fehl, wird hier ein
|
||||
# klarer Fehler geworfen statt einer stillen, ungültigen PDF.
|
||||
zugferd_pdf_bytes = facturx_generate_from_binary(
|
||||
original_pdf_bytes,
|
||||
cii_xml_string.encode("utf-8"),
|
||||
flavor="factur-x",
|
||||
level="en16931",
|
||||
check_xsd=True,
|
||||
check_schematron=False, # benötigt optional 'saxonche' für Schematron-Regeln
|
||||
afrelationship="data",
|
||||
)
|
||||
|
||||
# 2) Zusätzliches OutputIntent (sRGB-ICC-Profil) ergänzen. Das ist für echte
|
||||
# PDF/A-3-Konformität zwingend, wird von factur-x aber nicht selbst gesetzt.
|
||||
zugferd_pdf_bytes = add_pdfa_output_intent(zugferd_pdf_bytes)
|
||||
zugferd_pdf_bytes = fix_xrechnung_xmp_conformance(zugferd_pdf_bytes)
|
||||
|
||||
st.download_button(
|
||||
label="📥 Download ZUGFeRD Hybrid-PDF",
|
||||
data=zugferd_pdf_bytes,
|
||||
file_name=f"ZUGFeRD_{inv_num}.pdf",
|
||||
mime="application/pdf"
|
||||
)
|
||||
st.info(
|
||||
"ℹ️ Hinweis: Die eingebettete CII-XML wurde gegen das offizielle EN16931-XSD-Schema "
|
||||
"validiert. Für eine vollständige PDF/A-3-Zertifizierung (z. B. Schriftarten-Einbettung "
|
||||
"der Ursprungs-PDF) empfiehlt sich zusätzlich eine Prüfung mit veraPDF."
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}")
|
||||
if st.button("🔒 XRechnung exportieren", type="primary", width="stretch"):
|
||||
final_invoice_dict = {
|
||||
"invoice_type_code": selected_type_code,
|
||||
"invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery,
|
||||
"buyer_reference": buyer_ref, "recipient_name": r_name, "recipient_email": r_email,
|
||||
"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,
|
||||
"vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": "DE06120700240456207000",
|
||||
"delivery_note_id": dn_id,
|
||||
"order_id": o_id,
|
||||
"contract_id": c_id,
|
||||
"service_start_date": s_start,
|
||||
"service_end_date": s_end,
|
||||
"invoice_note": i_note
|
||||
}
|
||||
st.download_button(label="📥 Download XML", data=generate_compliant_xrechnung(final_invoice_dict), file_name=f"XRechnung_{inv_num}.xml", mime="text/xml")
|
||||
|
||||
# ==========================================
|
||||
# FOOTER
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
streamlit[pdf]==1.58.0
|
||||
streamlit==1.58.0
|
||||
pydantic==2.13.4
|
||||
pymupdf==1.28.0
|
||||
requests==2.32.3
|
||||
factur-x==5.1
|
||||
pikepdf==10.5.1
|
||||
|
||||
Loading…
Reference in New Issue