From 068e6627d9b5314d38f492825fed52b80f532b15 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:07:33 +0200 Subject: [PATCH 01/26] ZUGFeRD --- main.py | 79 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index 45f9e42..a5019a6 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ if "invoice_counter" not in st.session_state: # 1. API-CLIENT FÜR DAS vLLM-BACKEND # ========================================== class LLMClient: + """Client für das vLLM-Backend (OpenAI-kompatibel) mit JSON-Schema-Unterstützung.""" def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"): self.model = model @@ -350,7 +351,9 @@ with col1: uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) pdf_text = "" if uploaded_file is not None: - doc = fitz.open(stream=uploaded_file.read(), filetype="pdf") + # Optimierung: Nutze getvalue(), damit die PDF-Bytes später für ZUGFeRD erhalten bleiben + pdf_data = uploaded_file.getvalue() + doc = fitz.open(stream=pdf_data, filetype="pdf") for page in doc: pdf_text += page.get_text("text") + "\n" st.success("PDF eingelesen!") @@ -545,23 +548,65 @@ 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}") + # --- NEU: DYNAMISCHE EXPORT-SEKTION FÜR ZUGFeRD ODER XRECHNUNG --- st.markdown("---") - 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") + export_format = st.selectbox( + "💾 Export-Format wählen", + options=["XRechnung (Reines XML)", "ZUGFeRD (Hybrid-PDF mit eingebettetem XML)"], + help="XRechnung generiert eine reine XML-Datei. ZUGFeRD bettet dieses XML direkt in dein originales PDF-Dokument 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, "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 + } + + xml_string = generate_compliant_xrechnung(final_invoice_dict) + + if export_format == "XRechnung (Reines XML)": + st.download_button( + label="📥 XRechnung XML herunterladen", + data=xml_string, + file_name=f"XRechnung_{inv_num}.xml", + mime="text/xml", + type="primary", + use_container_width=True + ) + else: + try: + # ZUGFeRD: Nutzt PyMuPDF, um das XML direkt in die hochgeladene PDF einzubetten + original_pdf_bytes = uploaded_file.getvalue() + doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") + doc.embedded_file_add( + "factur-x.xml", + xml_string.encode("utf-8"), + filename="factur-x.xml", + desc="Factur-X/ZUGFeRD E-Invoice", + mimetype="text/xml" + ) + zugferd_pdf_bytes = doc.write() + doc.close() + + st.download_button( + label="📥 ZUGFeRD Hybrid-PDF herunterladen", + data=zugferd_pdf_bytes, + file_name=f"ZUGFeRD_{inv_num}.pdf", + mime="application/pdf", + type="primary", + use_container_width=True + ) + except Exception as e: + st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}") # ========================================== # FOOTER From 3f516ed6515c1fd44ed1d9e691e28f1e890029c7 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:15:25 +0200 Subject: [PATCH 02/26] ZUGFeRD fix --- main.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index a5019a6..155262c 100644 --- a/main.py +++ b/main.py @@ -351,7 +351,6 @@ with col1: uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) pdf_text = "" if uploaded_file is not None: - # Optimierung: Nutze getvalue(), damit die PDF-Bytes später für ZUGFeRD erhalten bleiben pdf_data = uploaded_file.getvalue() doc = fitz.open(stream=pdf_data, filetype="pdf") for page in doc: @@ -392,7 +391,7 @@ with col2: "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. " "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" - "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." + "Antworte ausschließlich with dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) prompt_meta = f"Rechnungstext:\n{pdf_text}" @@ -548,7 +547,7 @@ 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}") - # --- NEU: DYNAMISCHE EXPORT-SEKTION FÜR ZUGFeRD ODER XRECHNUNG --- + # --- EXPORT-SEKTION FÜR ZUGFeRD ODER XRECHNUNG --- st.markdown("---") export_format = st.selectbox( "💾 Export-Format wählen", @@ -584,15 +583,15 @@ with col2: ) else: try: - # ZUGFeRD: Nutzt PyMuPDF, um das XML direkt in die hochgeladene PDF einzubetten + # 🔥 KORREKTUR: Nutzt die echten PyMuPDF-Funktionen 'embfile_add' und 'mime=' original_pdf_bytes = uploaded_file.getvalue() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - doc.embedded_file_add( + doc.embfile_add( "factur-x.xml", xml_string.encode("utf-8"), filename="factur-x.xml", desc="Factur-X/ZUGFeRD E-Invoice", - mimetype="text/xml" + mime="text/xml" ) zugferd_pdf_bytes = doc.write() doc.close() From d8e90b119e6356e6d5568f7cde20c1f6e4537dc0 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:19:12 +0200 Subject: [PATCH 03/26] ZUGFeRD fix 2 --- main.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 155262c..e7ea1a7 100644 --- a/main.py +++ b/main.py @@ -391,7 +391,7 @@ with col2: "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. " "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" - "Antworte ausschließlich with dem reinen JSON-Objekt. Schreibe keinen anderen Text." + "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) prompt_meta = f"Rechnungstext:\n{pdf_text}" @@ -583,17 +583,16 @@ with col2: ) else: try: - # 🔥 KORREKTUR: Nutzt die echten PyMuPDF-Funktionen 'embfile_add' und 'mime=' + # 🔥 FIX: mime-Parameter entfernt, da von PyMuPDF nicht unterstützt original_pdf_bytes = uploaded_file.getvalue() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") doc.embfile_add( "factur-x.xml", xml_string.encode("utf-8"), filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice", - mime="text/xml" + desc="Factur-X/ZUGFeRD E-Invoice" ) - zugferd_pdf_bytes = doc.write() + zugferd_pdf_bytes = doc.tobytes() doc.close() st.download_button( From d1604b038875dc0ccd1012b4b7b1453a3c222594 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:25:51 +0200 Subject: [PATCH 04/26] ZUGFeRD fix 3 --- main.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index e7ea1a7..f822153 100644 --- a/main.py +++ b/main.py @@ -583,15 +583,72 @@ with col2: ) else: try: - # 🔥 FIX: mime-Parameter entfernt, da von PyMuPDF nicht unterstützt original_pdf_bytes = uploaded_file.getvalue() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") + + # 🔥 OPTIMIERUNG 1: Setze das zwingend erforderliche PDF/A-3 Beziehungs-Flag 'Alternative' doc.embfile_add( "factur-x.xml", xml_string.encode("utf-8"), filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice" + desc="Factur-X/ZUGFeRD E-Invoice", + relationship="Alternative" ) + + # 🔥 OPTIMIERUNG 2: Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header + xmp_xml = """ + + + + + + + Factur-X PDFA Extension Schema + fx + urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# + + + + DocumentType + Text + external + The type of the document, usually INVOICE + + + DocumentFileName + Text + external + The name of the embedded file + + + Version + Text + external + The version of the document type schema + + + ConformanceLevel + Text + external + The conformance level of the document + + + + + + + + + INVOICE + factur-x.xml + 1.0 + XRECHNUNG + + + +""" + + doc.set_xmp_metadata(xmp_xml) zugferd_pdf_bytes = doc.tobytes() doc.close() From f1f8a5b97d67a1efc1f709f0e1e63ead3a741d26 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:29:29 +0200 Subject: [PATCH 05/26] ZUGFeRD fix 4 --- main.py | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index f822153..ff17e22 100644 --- a/main.py +++ b/main.py @@ -282,7 +282,7 @@ def generate_compliant_xrechnung(data): ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" - ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" @@ -371,26 +371,7 @@ with col2: sys_prompt = ( "Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. " "Extrahiere die Metadaten fehlerfrei als valides JSON-Objekt. " - "Das JSON MUSS zwingend exakt folgende Schlüssel (Keys) enthalten:\n" - "- invoice_number\n" - "- invoice_date (im Format YYYY-MM-DD)\n" - "- delivery_date (im Format YYYY-MM-DD)\n" - "- buyer_reference\n" - "- recipient_name\n" - "- recipient_street\n" - "- recipient_zip\n" - "- recipient_city\n" - "- recipient_email\n" - "- vat_rate_percent (als Zahl, z.B. 19.0)\n" - "- delivery_note_id\n" - "- order_id\n" - "- contract_id\n" - "- service_start_date\n" - "- service_end_date\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. " - "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" + "Du musst zwingend exakt das vorgegebene JSON-Schema einhalten. " "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) prompt_meta = f"Rechnungstext:\n{pdf_text}" @@ -583,19 +564,18 @@ with col2: ) else: try: + # 🔥 FIX: Alle erfundenen Parameter entfernt. Nur valide Argumente verwendet. original_pdf_bytes = uploaded_file.getvalue() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - # 🔥 OPTIMIERUNG 1: Setze das zwingend erforderliche PDF/A-3 Beziehungs-Flag 'Alternative' doc.embfile_add( "factur-x.xml", xml_string.encode("utf-8"), filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice", - relationship="Alternative" + desc="Factur-X/ZUGFeRD E-Invoice" ) - # 🔥 OPTIMIERUNG 2: Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header + # Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header xmp_xml = """ From f1a63b7457b818e14e91e318bf84abd8eaee22b9 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:32:45 +0200 Subject: [PATCH 06/26] ZUGFeRD fix 5 --- main.py | 267 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 157 insertions(+), 110 deletions(-) diff --git a/main.py b/main.py index ff17e22..8c92759 100644 --- a/main.py +++ b/main.py @@ -17,14 +17,18 @@ if "invoice_counter" not in st.session_state: st.session_state.invoice_counter = 0 # ========================================== -# 1. API-CLIENT FÜR DAS vLLM-BACKEND +# 1. API-CLIENT FÜR EXTERNE BACKENDS # ========================================== class LLMClient: - """Client für das vLLM-Backend (OpenAI-kompatibel) mit JSON-Schema-Unterstützung.""" + """Client für Ollama und vLLM (OpenAI-kompatibel) mit JSON-Schema-Unterstützung.""" - def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"): + def __init__(self, backend_type="vllm", model="gemma431b", base_url=None): + self.backend_type = backend_type.lower() self.model = model + if not base_url: + base_url = "http://10.192.0.140:8002" if self.backend_type == "vllm" else "http://localhost:11434" + base_url = base_url.strip() if base_url.startswith("[") and ")" in base_url: match = re.search(r"\((https?://.*?)\)", base_url) @@ -38,7 +42,31 @@ class LLMClient: {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ] - return self._openai_compatible_request(messages, temperature, json_schema) + + if self.backend_type == "ollama": + return self._ollama_request(messages, temperature, json_schema) + else: + return self._openai_compatible_request(messages, temperature, json_schema) + + def _ollama_request(self, messages, temperature, json_schema): + url = f"{self.base_url}/api/chat" + payload = { + "model": self.model, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature + } + } + if json_schema: + payload["format"] = json_schema + + try: + response = requests.post(url, json=payload, timeout=120) + response.raise_for_status() + return response.json().get("message", {}).get("content", "") + except Exception as e: + return f"Ollama Error: {e}" def _openai_compatible_request(self, messages, temperature, json_schema): url = f"{self.base_url}/v1/chat/completions" @@ -224,105 +252,105 @@ def generate_compliant_xrechnung(data): contract_ref = ET.SubElement(root, f"{{{CAC}}}ContractDocumentReference") ET.SubElement(contract_ref, f"{{{CBC}}}ID").text = str(data["contract_id"]).strip() - vat_rate = float(data['vat_rate_percent']) - vat_category = "S" if vat_rate > 0.0 else "E" + `vat_rate` = float(data['vat_rate_percent']) + `vat_category` = "S" if vat_rate > 0.0 else "E" # --- SENDER (SCITECH) --- - supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty") - party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party") + `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 = "office@scitech-gmbh.de" - party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName") + `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") + `addr_supp` = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress") 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") + `country_supp` = ET.SubElement(addr_supp, f"{{{CAC}}}Country") ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" - tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") + `tax_scheme_supp` = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" - scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme") + `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") + `legal_supp` = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" - contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact") + `contact_supp` = ET.SubElement(party_supp, f"{{{CAC}}}Contact") 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") + `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["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" - party_name_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyName") + `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") + `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"]) ET.SubElement(addr_cust, f"{{{CBC}}}PostalZone").text = str(data["recipient_zip"]) - country_cust = ET.SubElement(addr_cust, f"{{{CAC}}}Country") + `country_cust` = ET.SubElement(addr_cust, f"{{{CAC}}}Country") ET.SubElement(country_cust, f"{{{CBC}}}IdentificationCode").text = "DE" - legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") + `legal_cust` = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"]) if data.get("delivery_date"): - delivery = ET.SubElement(root, f"{{{CAC}}}Delivery") + `delivery` = ET.SubElement(root, f"{{{CAC}}}Delivery") ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) - payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") + `payment_means` = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "30" - 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(" ", "") - 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}" - tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") + `tax_subtotal` = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" - ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" - tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") + ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + `tax_cat` = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" - tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") + `tax_scheme` = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT" - monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") + `monetary_total` = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" for index, item in enumerate(data["items"], start=1): - line = ET.SubElement(root, f"{{{CAC}}}InvoiceLine") + `line` = ET.SubElement(root, f"{{{CAC}}}InvoiceLine") ET.SubElement(line, f"{{{CBC}}}ID").text = str(index) - u_code = map_unece_unit_code(item.get("unit", "C62")) - q_val = float(item.get("quantity") or 0.0) - qty_formatted = f"{q_val:.4f}".rstrip('0').rstrip('.') if not q_val.is_integer() else f"{q_val:.2f}" + `u_code` = map_unece_unit_code(item.get("unit", "C62")) + `q_val` = float(item.get("quantity") or 0.0) + `qty_formatted` = f"{q_val:.4f}".rstrip('0').rstrip('.') if not q_val.is_integer() else f"{q_val:.2f}" if qty_formatted == "" or qty_formatted == "0": qty_formatted = "0.00" ET.SubElement(line, f"{{{CBC}}}InvoicedQuantity", {"unitCode": u_code}).text = qty_formatted ET.SubElement(line, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{item['total_amount']:.2f}" - item_elem = ET.SubElement(line, f"{{{CAC}}}Item") + `item_elem` = ET.SubElement(line, f"{{{CAC}}}Item") ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item.get("name", "Dienstleistung")) if item.get("description") and str(item["description"]).strip() != "": ET.SubElement(item_elem, f"{{{CBC}}}Description").text = str(item["description"]).strip() - item_tax = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") + `item_tax` = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") ET.SubElement(item_tax, f"{{{CBC}}}ID").text = vat_category ET.SubElement(item_tax, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" - item_scheme = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme") + `item_scheme` = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme") ET.SubElement(item_scheme, f"{{{CBC}}}ID").text = "VAT" - price_elem = ET.SubElement(line, f"{{{CAC}}}Price") + `price_elem` = ET.SubElement(line, f"{{{CAC}}}Price") ET.SubElement(price_elem, f"{{{CBC}}}PriceAmount", {"currencyID": "EUR"}).text = f"{item['unit_price']:.2f}" - xml_str = ET.tostring(root, encoding="utf-8") + `xml_str` = ET.tostring(root, encoding="utf-8") return minidom.parseString(xml_str).toprettyxml(indent=" ") @@ -331,9 +359,10 @@ def generate_compliant_xrechnung(data): # ========================================== def get_cached_llm(): if "llm_instance" not in st.session_state: - llm_url = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') - llm_model = os.environ.get('LLM_MODEL', 'gemma431b') - st.session_state.llm_instance = LLMClient(model=llm_model, base_url=llm_url) + `llm_url` = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') + `llm_model` = os.environ.get('LLM_MODEL', 'gemma431b') + `llm_backend` = os.environ.get('LLM_BACKEND', 'vllm') + st.session_state.llm_instance = LLMClient(backend_type=llm_backend, model=llm_model, base_url=llm_url) return st.session_state.llm_instance @@ -343,16 +372,16 @@ def get_cached_llm(): st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") st.title("📄 PDF zu E-Rechnung Konverter") -llm = get_cached_llm() -col1, col2 = st.columns(2) +`llm` = get_cached_llm() +`col1`, `col2` = st.columns(2) with col1: st.subheader("1. Dokument hochladen") - uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) - pdf_text = "" + `uploaded_file` = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) + `pdf_text` = "" if uploaded_file is not None: - pdf_data = uploaded_file.getvalue() - doc = fitz.open(stream=pdf_data, filetype="pdf") + `pdf_data` = uploaded_file.getvalue() + `doc` = fitz.open(stream=pdf_data, filetype="pdf") for page in doc: pdf_text += page.get_text("text") + "\n" st.success("PDF eingelesen!") @@ -366,35 +395,54 @@ with col2: if st.button("Daten extrahieren", width="stretch"): with st.spinner("Extrahiere Daten über vLLM-API..."): - schema_dict = InvoiceMetadata.model_json_schema() + `schema_dict` = InvoiceMetadata.model_json_schema() - sys_prompt = ( + `sys_prompt` = ( "Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. " "Extrahiere die Metadaten fehlerfrei als valides JSON-Objekt. " - "Du musst zwingend exakt das vorgegebene JSON-Schema einhalten. " + "Das JSON MUSS zwingend exakt folgende Schlüssel (Keys) enthalten:\n" + "- invoice_number\n" + "- invoice_date (im Format YYYY-MM-DD)\n" + "- delivery_date (im Format YYYY-MM-DD)\n" + "- buyer_reference\n" + "- recipient_name\n" + "- recipient_street\n" + "- recipient_zip\n" + "- recipient_city\n" + "- recipient_email\n" + "- vat_rate_percent (als Zahl, z.B. 19.0)\n" + "- delivery_note_id\n" + "- order_id\n" + "- contract_id\n" + "- service_start_date\n" + "- service_end_date\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. " + "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) - prompt_meta = f"Rechnungstext:\n{pdf_text}" + `prompt_meta` = f"Rechnungstext:\n{pdf_text}" - res_meta_text = llm.chat( + `res_meta_text` = llm.chat( prompt=prompt_meta, system_prompt=sys_prompt, temperature=0.0, json_schema=schema_dict ) - clean_json_text = res_meta_text.strip() - backtick_trigger = chr(96) * 3 + `clean_json_text` = res_meta_text.strip() + `backtick_trigger` = chr(96) * 3 if clean_json_text.startswith(backtick_trigger): - lines = clean_json_text.splitlines() + `lines` = clean_json_text.splitlines() if lines[0].startswith(backtick_trigger): - lines = lines[1:] + `lines` = lines[1:] if lines and lines[-1].startswith(backtick_trigger): - lines = lines[:-1] - clean_json_text = "\n".join(lines).strip() + `lines` = lines[:-1] + `clean_json_text` = "\n".join(lines).strip() try: - final_data = json.loads(clean_json_text) + `final_data` = json.loads(clean_json_text) except Exception as e: st.error(f"Fehler beim Parsen der API-Antwort: {e}") st.code(res_meta_text) @@ -409,44 +457,44 @@ with col2: st.session_state.extracted_data = final_data if st.session_state.extracted_data: - data = st.session_state.extracted_data + `data` = st.session_state.extracted_data # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- - inv_type_ui = st.selectbox( + `inv_type_ui` = st.selectbox( "Rechnungstyp (BT-3)", 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] + `selected_type_code` = inv_type_ui.split(" ")[0] - 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", "")) + `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("---") st.markdown("**Globale Referenzen & Vorlauftexte:**") - c_ref1, c_ref2, c_ref3 = st.columns(3) + `c_ref1`, `c_ref2`, `c_ref3` = st.columns(3) 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: - o_id = st.text_input("Auftragsbestätigung / Bestellung (BT-13)", 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_id` = st.text_input("Vertrag / Vergabeangebot (BT-12)", value=data.get("contract_id", "")) - c_p1, c_p2 = st.columns(2) + `c_p1`, `c_p2` = st.columns(2) with c_p1: - s_start = st.text_input("Leistungszeitraum Start (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 (YYYY-MM-DD)", value=data.get("service_end_date", "")) + `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) + `i_note` = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) st.markdown("---") # Adressdaten - c_s, c_r = st.columns(2) + `c_s`, `c_r` = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") st.text_input("Firma (Sender)", value="scitech it solutions GmbH", disabled=True) @@ -457,11 +505,11 @@ with col2: st.text_input("Stadt (Sender)", value="Hennigsdorf", disabled=True) with c_r: st.markdown("**Empfänger (Kunde):**") - 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", "")) + `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") @@ -469,39 +517,39 @@ with col2: data["items"].append({"name": "Neue Dienstleistung", "description": "", "quantity": 1.0, "unit": "Stk.", "unit_price": 0.0}) st.rerun() - edited_items = [] + `edited_items` = [] - inv_id = st.session_state.invoice_counter + `inv_id` = st.session_state.invoice_counter for idx, item in enumerate(data.get("items", [])): with st.expander(f"📋 Position {idx+1}: {item.get('name', 'Dienstleistung')}", expanded=True): - c_del, _ = st.columns([1, 4]) + `c_del`, _ = st.columns([1, 4]) with c_del: if st.button("🗑️ Position löschen", key=f"del_btn_{inv_id}_{idx}"): data["items"].pop(idx) st.rerun() - c_name, c_desc = st.columns([1, 1]) + `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 - calc_height = max(68, min(400, estimated_lines * 24)) + `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) + `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) + `c_qty`, `c_unit`, `c_price`, `c_total` = st.columns(4) with c_qty: - 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}") + `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", 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 (€)", 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) + `raw_item_total` = item_quantity * item_price + `item_total_amount` = kaufmaennisches_runden(raw_item_total) st.markdown("**Gesamt betrag (€):**") st.markdown(f"**{item_total_amount:.2f} €**") @@ -515,28 +563,28 @@ with col2: }) st.markdown("---") - vat_rate = st.number_input("Steuersatz (%)", 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)) - 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) + `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) + `c_net_ui`, `c_vat_ui`, `c_gross_ui` = st.columns(3) with c_net_ui: st.metric("Netto-Gesamt (€)", f"{computed_net_amount:.2f}") 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 FÜR ZUGFeRD ODER XRECHNUNG --- st.markdown("---") - export_format = st.selectbox( + `export_format` = st.selectbox( "💾 Export-Format wählen", options=["XRechnung (Reines XML)", "ZUGFeRD (Hybrid-PDF mit eingebettetem XML)"], help="XRechnung generiert eine reine XML-Datei. ZUGFeRD bettet dieses XML direkt in dein originales PDF-Dokument ein." ) - final_invoice_dict = { + `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, @@ -551,7 +599,7 @@ with col2: "invoice_note": i_note } - xml_string = generate_compliant_xrechnung(final_invoice_dict) + `xml_string` = generate_compliant_xrechnung(final_invoice_dict) if export_format == "XRechnung (Reines XML)": st.download_button( @@ -564,9 +612,8 @@ with col2: ) else: try: - # 🔥 FIX: Alle erfundenen Parameter entfernt. Nur valide Argumente verwendet. - original_pdf_bytes = uploaded_file.getvalue() - doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") + `original_pdf_bytes` = uploaded_file.getvalue() + `doc` = fitz.open(stream=original_pdf_bytes, filetype="pdf") doc.embfile_add( "factur-x.xml", @@ -576,7 +623,7 @@ with col2: ) # Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header - xmp_xml = """ + `xmp_xml` = """ @@ -629,7 +676,7 @@ with col2: """ doc.set_xmp_metadata(xmp_xml) - zugferd_pdf_bytes = doc.tobytes() + `zugferd_pdf_bytes` = doc.tobytes() doc.close() st.download_button( From 7094f80f89f11c0a1063caec7c7495dfaf80eff3 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 13:36:20 +0200 Subject: [PATCH 07/26] ZUGFeRD fix 5 --- main.py | 208 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/main.py b/main.py index 8c92759..09227fd 100644 --- a/main.py +++ b/main.py @@ -69,7 +69,7 @@ class LLMClient: return f"Ollama Error: {e}" def _openai_compatible_request(self, messages, temperature, json_schema): - url = f"{self.base_url}/v1/chat/completions" + url = f"{self.base_url}/v1/chat/com completions" payload = { "model": self.model, "messages": messages, @@ -252,105 +252,105 @@ def generate_compliant_xrechnung(data): contract_ref = ET.SubElement(root, f"{{{CAC}}}ContractDocumentReference") ET.SubElement(contract_ref, f"{{{CBC}}}ID").text = str(data["contract_id"]).strip() - `vat_rate` = float(data['vat_rate_percent']) - `vat_category` = "S" if vat_rate > 0.0 else "E" + vat_rate = float(data['vat_rate_percent']) + vat_category = "S" if vat_rate > 0.0 else "E" # --- SENDER (SCITECH) --- - `supplier` = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty") - `party_supp` = ET.SubElement(supplier, f"{{{CAC}}}Party") + 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 = "office@scitech-gmbh.de" - `party_name_supp` = ET.SubElement(party_supp, f"{{{CAC}}}PartyName") + 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") + addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress") 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") + country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country") ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" - `tax_scheme_supp` = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") + tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" - `scheme_supp` = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme") + 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") + legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" - `contact_supp` = ET.SubElement(party_supp, f"{{{CAC}}}Contact") + contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact") 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") + 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["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" - `party_name_cust` = ET.SubElement(party_cust, f"{{{CAC}}}PartyName") + 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") + 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"]) ET.SubElement(addr_cust, f"{{{CBC}}}PostalZone").text = str(data["recipient_zip"]) - `country_cust` = ET.SubElement(addr_cust, f"{{{CAC}}}Country") + country_cust = ET.SubElement(addr_cust, f"{{{CAC}}}Country") ET.SubElement(country_cust, f"{{{CBC}}}IdentificationCode").text = "DE" - `legal_cust` = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") + legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"]) if data.get("delivery_date"): - `delivery` = ET.SubElement(root, f"{{{CAC}}}Delivery") + delivery = ET.SubElement(root, f"{{{CAC}}}Delivery") ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) - `payment_means` = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") + payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "30" - `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(" ", "") - `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}" - `tax_subtotal` = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") + tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" - `tax_cat` = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") + tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" - `tax_scheme` = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") + tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT" - `monetary_total` = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") + monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" for index, item in enumerate(data["items"], start=1): - `line` = ET.SubElement(root, f"{{{CAC}}}InvoiceLine") + line = ET.SubElement(root, f"{{{CAC}}}InvoiceLine") ET.SubElement(line, f"{{{CBC}}}ID").text = str(index) - `u_code` = map_unece_unit_code(item.get("unit", "C62")) - `q_val` = float(item.get("quantity") or 0.0) - `qty_formatted` = f"{q_val:.4f}".rstrip('0').rstrip('.') if not q_val.is_integer() else f"{q_val:.2f}" + u_code = map_unece_unit_code(item.get("unit", "C62")) + q_val = float(item.get("quantity") or 0.0) + qty_formatted = f"{q_val:.4f}".rstrip('0').rstrip('.') if not q_val.is_integer() else f"{q_val:.2f}" if qty_formatted == "" or qty_formatted == "0": qty_formatted = "0.00" ET.SubElement(line, f"{{{CBC}}}InvoicedQuantity", {"unitCode": u_code}).text = qty_formatted ET.SubElement(line, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{item['total_amount']:.2f}" - `item_elem` = ET.SubElement(line, f"{{{CAC}}}Item") + item_elem = ET.SubElement(line, f"{{{CAC}}}Item") ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item.get("name", "Dienstleistung")) if item.get("description") and str(item["description"]).strip() != "": ET.SubElement(item_elem, f"{{{CBC}}}Description").text = str(item["description"]).strip() - `item_tax` = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") + item_tax = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") ET.SubElement(item_tax, f"{{{CBC}}}ID").text = vat_category ET.SubElement(item_tax, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" - `item_scheme` = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme") + item_scheme = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme") ET.SubElement(item_scheme, f"{{{CBC}}}ID").text = "VAT" - `price_elem` = ET.SubElement(line, f"{{{CAC}}}Price") + price_elem = ET.SubElement(line, f"{{{CAC}}}Price") ET.SubElement(price_elem, f"{{{CBC}}}PriceAmount", {"currencyID": "EUR"}).text = f"{item['unit_price']:.2f}" - `xml_str` = ET.tostring(root, encoding="utf-8") + xml_str = ET.tostring(root, encoding="utf-8") return minidom.parseString(xml_str).toprettyxml(indent=" ") @@ -359,9 +359,9 @@ def generate_compliant_xrechnung(data): # ========================================== def get_cached_llm(): if "llm_instance" not in st.session_state: - `llm_url` = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') - `llm_model` = os.environ.get('LLM_MODEL', 'gemma431b') - `llm_backend` = os.environ.get('LLM_BACKEND', 'vllm') + llm_url = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') + llm_model = os.environ.get('LLM_MODEL', 'gemma431b') + llm_backend = os.environ.get('LLM_BACKEND', 'vllm') st.session_state.llm_instance = LLMClient(backend_type=llm_backend, model=llm_model, base_url=llm_url) return st.session_state.llm_instance @@ -372,16 +372,16 @@ def get_cached_llm(): st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") st.title("📄 PDF zu E-Rechnung Konverter") -`llm` = get_cached_llm() -`col1`, `col2` = st.columns(2) +llm = get_cached_llm() +col1, col2 = st.columns(2) with col1: st.subheader("1. Dokument hochladen") - `uploaded_file` = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) - `pdf_text` = "" + uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) + pdf_text = "" if uploaded_file is not None: - `pdf_data` = uploaded_file.getvalue() - `doc` = fitz.open(stream=pdf_data, filetype="pdf") + pdf_data = uploaded_file.getvalue() + doc = fitz.open(stream=pdf_data, filetype="pdf") for page in doc: pdf_text += page.get_text("text") + "\n" st.success("PDF eingelesen!") @@ -395,9 +395,9 @@ with col2: if st.button("Daten extrahieren", width="stretch"): with st.spinner("Extrahiere Daten über vLLM-API..."): - `schema_dict` = InvoiceMetadata.model_json_schema() + schema_dict = InvoiceMetadata.model_json_schema() - `sys_prompt` = ( + sys_prompt = ( "Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. " "Extrahiere die Metadaten fehlerfrei als valides JSON-Objekt. " "Das JSON MUSS zwingend exakt folgende Schlüssel (Keys) enthalten:\n" @@ -422,27 +422,27 @@ with col2: "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) - `prompt_meta` = f"Rechnungstext:\n{pdf_text}" + prompt_meta = f"Rechnungstext:\n{pdf_text}" - `res_meta_text` = llm.chat( + res_meta_text = llm.chat( prompt=prompt_meta, system_prompt=sys_prompt, temperature=0.0, json_schema=schema_dict ) - `clean_json_text` = res_meta_text.strip() - `backtick_trigger` = chr(96) * 3 + clean_json_text = res_meta_text.strip() + backtick_trigger = chr(96) * 3 if clean_json_text.startswith(backtick_trigger): - `lines` = clean_json_text.splitlines() + lines = clean_json_text.splitlines() if lines[0].startswith(backtick_trigger): - `lines` = lines[1:] + lines = lines[1:] if lines and lines[-1].startswith(backtick_trigger): - `lines` = lines[:-1] - `clean_json_text` = "\n".join(lines).strip() + lines = lines[:-1] + clean_json_text = "\n".join(lines).strip() try: - `final_data` = json.loads(clean_json_text) + final_data = json.loads(clean_json_text) except Exception as e: st.error(f"Fehler beim Parsen der API-Antwort: {e}") st.code(res_meta_text) @@ -457,44 +457,44 @@ with col2: st.session_state.extracted_data = final_data if st.session_state.extracted_data: - `data` = st.session_state.extracted_data + data = st.session_state.extracted_data # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- - `inv_type_ui` = st.selectbox( + inv_type_ui = st.selectbox( "Rechnungstyp (BT-3)", 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] + selected_type_code = inv_type_ui.split(" ")[0] - `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", "")) + 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("---") st.markdown("**Globale Referenzen & Vorlauftexte:**") - `c_ref1`, `c_ref2`, `c_ref3` = st.columns(3) + c_ref1, c_ref2, c_ref3 = st.columns(3) 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: - `o_id = st.text_input("Auftragsbestätigung / Bestellung (BT-13)", 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_id = st.text_input("Vertrag / Vergabeangebot (BT-12)", value=data.get("contract_id", "")) - `c_p1`, `c_p2` = st.columns(2) + c_p1, c_p2 = st.columns(2) with c_p1: - `s_start` = st.text_input("Leistungszeitraum Start (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 (YYYY-MM-DD)", value=data.get("service_end_date", "")) + 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) + i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) st.markdown("---") # Adressdaten - `c_s`, `c_r` = st.columns(2) + c_s, c_r = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") st.text_input("Firma (Sender)", value="scitech it solutions GmbH", disabled=True) @@ -505,11 +505,11 @@ with col2: st.text_input("Stadt (Sender)", value="Hennigsdorf", disabled=True) with c_r: st.markdown("**Empfänger (Kunde):**") - `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", "")) + 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") @@ -517,39 +517,39 @@ with col2: data["items"].append({"name": "Neue Dienstleistung", "description": "", "quantity": 1.0, "unit": "Stk.", "unit_price": 0.0}) st.rerun() - `edited_items` = [] + edited_items = [] - `inv_id` = st.session_state.invoice_counter + inv_id = st.session_state.invoice_counter for idx, item in enumerate(data.get("items", [])): with st.expander(f"📋 Position {idx+1}: {item.get('name', 'Dienstleistung')}", expanded=True): - `c_del`, _ = st.columns([1, 4]) + c_del, _ = st.columns([1, 4]) with c_del: if st.button("🗑️ Position löschen", key=f"del_btn_{inv_id}_{idx}"): data["items"].pop(idx) st.rerun() - `c_name`, `c_desc` = st.columns([1, 1]) + 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 - `calc_height` = max(68, min(400, estimated_lines * 24)) + 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) + 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) + c_qty, c_unit, c_price, c_total = st.columns(4) with c_qty: - `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}") + 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", 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 (€)", 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) + raw_item_total = item_quantity * item_price + item_total_amount = kaufmaennisches_runden(raw_item_total) st.markdown("**Gesamt betrag (€):**") st.markdown(f"**{item_total_amount:.2f} €**") @@ -563,28 +563,28 @@ with col2: }) st.markdown("---") - `vat_rate` = st.number_input("Steuersatz (%)", 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)) - `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) + 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) + c_net_ui, c_vat_ui, c_gross_ui = st.columns(3) with c_net_ui: st.metric("Netto-Gesamt (€)", f"{computed_net_amount:.2f}") 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 FÜR ZUGFeRD ODER XRECHNUNG --- st.markdown("---") - `export_format` = st.selectbox( + export_format = st.selectbox( "💾 Export-Format wählen", options=["XRechnung (Reines XML)", "ZUGFeRD (Hybrid-PDF mit eingebettetem XML)"], help="XRechnung generiert eine reine XML-Datei. ZUGFeRD bettet dieses XML direkt in dein originales PDF-Dokument ein." ) - `final_invoice_dict` = { + 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, @@ -599,7 +599,7 @@ with col2: "invoice_note": i_note } - `xml_string` = generate_compliant_xrechnung(final_invoice_dict) + xml_string = generate_compliant_xrechnung(final_invoice_dict) if export_format == "XRechnung (Reines XML)": st.download_button( @@ -612,8 +612,8 @@ with col2: ) else: try: - `original_pdf_bytes` = uploaded_file.getvalue() - `doc` = fitz.open(stream=original_pdf_bytes, filetype="pdf") + original_pdf_bytes = uploaded_file.getvalue() + doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") doc.embfile_add( "factur-x.xml", @@ -623,7 +623,7 @@ with col2: ) # Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header - `xmp_xml` = """ + xmp_xml = """ @@ -676,7 +676,7 @@ with col2: """ doc.set_xmp_metadata(xmp_xml) - `zugferd_pdf_bytes` = doc.tobytes() + zugferd_pdf_bytes = doc.tobytes() doc.close() st.download_button( From 6f74a040e76cbf37655cc58a8954f37fe96d1d23 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 14:03:47 +0200 Subject: [PATCH 08/26] Stuff --- main.py | 299 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 217 insertions(+), 82 deletions(-) diff --git a/main.py b/main.py index 09227fd..520bbe7 100644 --- a/main.py +++ b/main.py @@ -17,18 +17,13 @@ if "invoice_counter" not in st.session_state: st.session_state.invoice_counter = 0 # ========================================== -# 1. API-CLIENT FÜR EXTERNE BACKENDS +# 1. API-CLIENT FÜR DAS vLLM-BACKEND # ========================================== class LLMClient: - """Client für Ollama und vLLM (OpenAI-kompatibel) mit JSON-Schema-Unterstützung.""" - def __init__(self, backend_type="vllm", model="gemma431b", base_url=None): - self.backend_type = backend_type.lower() + def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"): self.model = model - if not base_url: - base_url = "http://10.192.0.140:8002" if self.backend_type == "vllm" else "http://localhost:11434" - base_url = base_url.strip() if base_url.startswith("[") and ")" in base_url: match = re.search(r"\((https?://.*?)\)", base_url) @@ -42,34 +37,10 @@ class LLMClient: {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ] - - if self.backend_type == "ollama": - return self._ollama_request(messages, temperature, json_schema) - else: - return self._openai_compatible_request(messages, temperature, json_schema) - - def _ollama_request(self, messages, temperature, json_schema): - url = f"{self.base_url}/api/chat" - payload = { - "model": self.model, - "messages": messages, - "stream": False, - "options": { - "temperature": temperature - } - } - if json_schema: - payload["format"] = json_schema - - try: - response = requests.post(url, json=payload, timeout=120) - response.raise_for_status() - return response.json().get("message", {}).get("content", "") - except Exception as e: - return f"Ollama Error: {e}" + return self._openai_compatible_request(messages, temperature, json_schema) def _openai_compatible_request(self, messages, temperature, json_schema): - url = f"{self.base_url}/v1/chat/com completions" + url = f"{self.base_url}/v1/chat/completions" payload = { "model": self.model, "messages": messages, @@ -203,6 +174,14 @@ def parse_to_strict_xml_date(date_str): except ValueError: continue 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) - UNBERÜHRT +# ========================================== 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" @@ -308,12 +287,15 @@ def generate_compliant_xrechnung(data): tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" + tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT" @@ -336,10 +318,11 @@ def generate_compliant_xrechnung(data): ET.SubElement(line, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{item['total_amount']:.2f}" item_elem = ET.SubElement(line, f"{{{CAC}}}Item") - ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item.get("name", "Dienstleistung")) if item.get("description") and str(item["description"]).strip() != "": ET.SubElement(item_elem, f"{{{CBC}}}Description").text = str(item["description"]).strip() + + ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item.get("name", "Dienstleistung")) item_tax = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") ET.SubElement(item_tax, f"{{{CBC}}}ID").text = vat_category @@ -354,6 +337,164 @@ def generate_compliant_xrechnung(data): return minidom.parseString(xml_str).toprettyxml(indent=" ") +# ========================================== +# GENERATOR B: ZUGFeRD 2.4 (CII FORMAT UN/CEFACT) - NEU +# ========================================== +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" + + ET.register_namespace('rsm', RSM) + ET.register_namespace('ram', RAM) + ET.register_namespace('udt', UDT) + + root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice") + + # Context + ex_doc_ctx = ET.SubElement(root, f"{{{RAM}}}ExchangedDocumentContext") + 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" + + # Header Document + 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 Transaction + 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" + + # --- POSITIONS-SCHLEIFE --- + for index, item in enumerate(data["items"], start=1): + line_item = ET.SubElement(trade_trans, f"{{{RAM}}}IncludedSupplyChainTradeLineItem") + + # Document Line + doc_line = ET.SubElement(line_item, f"{{{RAM}}}AssociatedDocumentLineDocument") + ET.SubElement(doc_line, f"{{{RAM}}}LineID").text = str(index) + + # Product / Item + 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() + + # Agreement (Price) + line_agree = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeAgreement") + net_price = ET.SubElement(line_agree, f"{{{RAM}}}GrossPriceProductTradePrice") + ET.SubElement(net_price, f"{{{RAM}}}ChargeAmount").text = f"{item['unit_price']:.2f}" + + # Delivery (Quantity) + 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}" + + # Settlement (Financials per Item) + 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 Trade Agreement (Sender & Customer & Global IDs) + header_agree = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeAgreement") + ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["buyer_reference"]) + + # Supplier + supplier_party = ET.SubElement(header_agree, f"{{{RAM}}}SellerTradeParty") + ET.SubElement(supplier_party, f"{{{RAM}}}Name").text = "scitech it solutions GmbH" + + supp_legal = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedLegalOrganization") + ET.SubElement(supp_legal, f"{{{RAM}}}ID").text = "DE218436692" + + supp_addr = ET.SubElement(supplier_party, f"{{{RAM}}}PostalTradeAddress") + ET.SubElement(supp_addr, f"{{{RAM}}}PostcodeCode").text = "16761" + ET.SubElement(supp_addr, f"{{{RAM}}}LineOne").text = "Hermann-Schumann-Str. 4" + ET.SubElement(supp_addr, f"{{{RAM}}}CityName").text = "Hennigsdorf" + ET.SubElement(supp_addr, f"{{{RAM}}}CountryID").text = "DE" + + supp_tax = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedTaxRegistration") + ET.SubElement(supp_tax, f"{{{RAM}}}ID", {"schemeID": "VA"}).text = "DE218436692" + + # Customer + 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" + + # Global References (BT-12, BT-13, BT-16) + if data.get("order_id") and str(data["order_id"]).strip() != "": + order_ref = ET.SubElement(header_agree, f"{{{RAM}}}BuyerOrderReferencedDocument") + ET.SubElement(order_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["order_id"]).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 Trade Delivery + header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery") + if data.get("delivery_note_id") and str(data["delivery_note_id"]).strip() != "": + dn_ref = ET.SubElement(header_deliv, f"{{{RAM}}}DeliveryNoteReferencedDocument") + ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip() + + 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"]) + + # Header Trade Settlement + 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}}}SpecifiedTradePaymentMeans") + ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "30" + payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount") + ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "") + + # Global Tax Total + 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}" + + # Billing Period (BT-26) + 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"]) + + # Monetary Summation + 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=" ") + + # ========================================== # 4. API CLIENT INITIALISIERUNG # ========================================== @@ -361,8 +502,7 @@ def get_cached_llm(): if "llm_instance" not in st.session_state: llm_url = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') llm_model = os.environ.get('LLM_MODEL', 'gemma431b') - llm_backend = os.environ.get('LLM_BACKEND', 'vllm') - st.session_state.llm_instance = LLMClient(backend_type=llm_backend, model=llm_model, base_url=llm_url) + st.session_state.llm_instance = LLMClient(model=llm_model, base_url=llm_url) return st.session_state.llm_instance @@ -380,8 +520,7 @@ with col1: uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) pdf_text = "" if uploaded_file is not None: - pdf_data = uploaded_file.getvalue() - doc = fitz.open(stream=pdf_data, filetype="pdf") + doc = fitz.open(stream=uploaded_file.read(), filetype="pdf") for page in doc: pdf_text += page.get_text("text") + "\n" st.success("PDF eingelesen!") @@ -518,7 +657,6 @@ with col2: st.rerun() edited_items = [] - inv_id = st.session_state.invoice_counter for idx, item in enumerate(data.get("items", [])): @@ -537,7 +675,6 @@ with col2: 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) @@ -576,12 +713,12 @@ 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 FÜR ZUGFeRD ODER XRECHNUNG --- + # --- EXPORT-SEKTION DROPDOWN-AUSWAHL --- st.markdown("---") export_format = st.selectbox( "💾 Export-Format wählen", - options=["XRechnung (Reines XML)", "ZUGFeRD (Hybrid-PDF mit eingebettetem XML)"], - help="XRechnung generiert eine reine XML-Datei. ZUGFeRD bettet dieses XML direkt in dein originales PDF-Dokument ein." + 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 = { @@ -599,31 +736,31 @@ with col2: "invoice_note": i_note } - xml_string = generate_compliant_xrechnung(final_invoice_dict) - - if export_format == "XRechnung (Reines XML)": - st.download_button( - label="📥 XRechnung XML herunterladen", - data=xml_string, - file_name=f"XRechnung_{inv_num}.xml", - mime="text/xml", - type="primary", - use_container_width=True - ) + if "UBL XML" in export_format: + if st.button("🔒 XRechnung exportieren", type="primary", width="stretch"): + 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: - try: - original_pdf_bytes = uploaded_file.getvalue() - doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - - doc.embfile_add( - "factur-x.xml", - xml_string.encode("utf-8"), - filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice" - ) - - # Injiziere das offizielle Factur-X / ZUGFeRD 2.x XMP-Schema in den PDF-Header - xmp_xml = """ + if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"): + try: + # CII XML aus dem zentralen Objekt bauen + cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) + + # Original-PDF laden + uploaded_file.seek(0) + original_pdf_bytes = uploaded_file.read() + doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") + + # Datei einbetten nach ZUGFeRD 2.4 Standard + doc.embfile_add( + "factur-x.xml", + cii_xml_string.encode("utf-8"), + filename="factur-x.xml", + desc="Factur-X/ZUGFeRD E-Invoice" + ) + + # Offizielles ZUGFeRD 2.4/Factur-X Extension Schema für XMP injizieren + xmp_xml = """ @@ -674,21 +811,19 @@ with col2: """ - - doc.set_xmp_metadata(xmp_xml) - zugferd_pdf_bytes = doc.tobytes() - doc.close() - - st.download_button( - label="📥 ZUGFeRD Hybrid-PDF herunterladen", - data=zugferd_pdf_bytes, - file_name=f"ZUGFeRD_{inv_num}.pdf", - mime="application/pdf", - type="primary", - use_container_width=True - ) - except Exception as e: - st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}") + + doc.set_xmp_metadata(xmp_xml) + zugferd_pdf_bytes = doc.tobytes() + doc.close() + + st.download_button( + label="📥 Download ZUGFeRD Hybrid-PDF", + data=zugferd_pdf_bytes, + file_name=f"ZUGFeRD_{inv_num}.pdf", + mime="application/pdf" + ) + except Exception as e: + st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}") # ========================================== # FOOTER From c71d632e83b31da05bba8b68594b1858428d593e Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 14:11:22 +0200 Subject: [PATCH 09/26] Stuff --- main.py | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/main.py b/main.py index 520bbe7..15a8c8f 100644 --- a/main.py +++ b/main.py @@ -180,7 +180,7 @@ def format_cii_date(date_str): # ========================================== -# GENERATOR A: XRECHNUNG (UBL FORMAT) - UNBERÜHRT +# GENERATOR A: XRECHNUNG (UBL FORMAT) # ========================================== def generate_compliant_xrechnung(data): INVOICE_NS = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" @@ -338,7 +338,7 @@ def generate_compliant_xrechnung(data): # ========================================== -# GENERATOR B: ZUGFeRD 2.4 (CII FORMAT UN/CEFACT) - NEU +# GENERATOR B: ZUGFeRD 2.4 (CII FORMAT UN/CEFACT) # ========================================== def generate_compliant_zugferd_cii(data): RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" @@ -351,12 +351,10 @@ def generate_compliant_zugferd_cii(data): root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice") - # Context ex_doc_ctx = ET.SubElement(root, f"{{{RAM}}}ExchangedDocumentContext") 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" - # Header Document 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")) @@ -368,37 +366,30 @@ def generate_compliant_zugferd_cii(data): note = ET.SubElement(ex_doc, f"{{{RAM}}}IncludedNote") ET.SubElement(note, f"{{{RAM}}}Content").text = str(data["invoice_note"]).strip() - # Trade Transaction 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" - # --- POSITIONS-SCHLEIFE --- for index, item in enumerate(data["items"], start=1): line_item = ET.SubElement(trade_trans, f"{{{RAM}}}IncludedSupplyChainTradeLineItem") - # Document Line doc_line = ET.SubElement(line_item, f"{{{RAM}}}AssociatedDocumentLineDocument") ET.SubElement(doc_line, f"{{{RAM}}}LineID").text = str(index) - # Product / Item 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() - # Agreement (Price) line_agree = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeAgreement") net_price = ET.SubElement(line_agree, f"{{{RAM}}}GrossPriceProductTradePrice") ET.SubElement(net_price, f"{{{RAM}}}ChargeAmount").text = f"{item['unit_price']:.2f}" - # Delivery (Quantity) 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}" - # Settlement (Financials per Item) 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" @@ -408,11 +399,9 @@ def generate_compliant_zugferd_cii(data): mon_sum = ET.SubElement(line_settle, f"{{{RAM}}}SpecifiedTradeSettlementLineMonetarySummation") ET.SubElement(mon_sum, f"{{{RAM}}}LineTotalAmount").text = f"{item['total_amount']:.2f}" - # Header Trade Agreement (Sender & Customer & Global IDs) header_agree = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeAgreement") ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["buyer_reference"]) - # Supplier supplier_party = ET.SubElement(header_agree, f"{{{RAM}}}SellerTradeParty") ET.SubElement(supplier_party, f"{{{RAM}}}Name").text = "scitech it solutions GmbH" @@ -428,7 +417,6 @@ def generate_compliant_zugferd_cii(data): supp_tax = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedTaxRegistration") ET.SubElement(supp_tax, f"{{{RAM}}}ID", {"schemeID": "VA"}).text = "DE218436692" - # Customer customer_party = ET.SubElement(header_agree, f"{{{RAM}}}BuyerTradeParty") ET.SubElement(customer_party, f"{{{RAM}}}Name").text = str(data["recipient_name"]) @@ -438,7 +426,6 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(cust_addr, f"{{{RAM}}}CityName").text = str(data["recipient_city"]) ET.SubElement(cust_addr, f"{{{RAM}}}CountryID").text = "DE" - # Global References (BT-12, BT-13, BT-16) if data.get("order_id") and str(data["order_id"]).strip() != "": order_ref = ET.SubElement(header_agree, f"{{{RAM}}}BuyerOrderReferencedDocument") ET.SubElement(order_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["order_id"]).strip() @@ -447,7 +434,6 @@ def generate_compliant_zugferd_cii(data): contract_ref = ET.SubElement(header_agree, f"{{{RAM}}}ContractReferencedDocument") ET.SubElement(contract_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["contract_id"]).strip() - # Header Trade Delivery header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery") if data.get("delivery_note_id") and str(data["delivery_note_id"]).strip() != "": dn_ref = ET.SubElement(header_deliv, f"{{{RAM}}}DeliveryNoteReferencedDocument") @@ -458,7 +444,6 @@ def generate_compliant_zugferd_cii(data): 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"]) - # Header Trade Settlement header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement") ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR" @@ -467,7 +452,6 @@ def generate_compliant_zugferd_cii(data): payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount") ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "") - # Global Tax Total 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" @@ -475,7 +459,6 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(header_tax, f"{{{RAM}}}CategoryCode").text = vat_category ET.SubElement(header_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{vat_rate:.2f}" - # Billing Period (BT-26) 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") @@ -483,7 +466,6 @@ 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"]) - # Monetary Summation 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}" @@ -743,15 +725,12 @@ with col2: else: if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"): try: - # CII XML aus dem zentralen Objekt bauen cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) - # Original-PDF laden uploaded_file.seek(0) original_pdf_bytes = uploaded_file.read() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - # Datei einbetten nach ZUGFeRD 2.4 Standard doc.embfile_add( "factur-x.xml", cii_xml_string.encode("utf-8"), @@ -759,7 +738,6 @@ with col2: desc="Factur-X/ZUGFeRD E-Invoice" ) - # Offizielles ZUGFeRD 2.4/Factur-X Extension Schema für XMP injizieren xmp_xml = """ @@ -812,7 +790,8 @@ with col2: """ - doc.set_xmp_metadata(xmp_xml) + # 🔥 FIX: Benutze doc.set_metadata mit dem xmp-Keyword anstelle von set_xmp_metadata + doc.set_metadata(doc.metadata, xmp=xmp_xml) zugferd_pdf_bytes = doc.tobytes() doc.close() @@ -832,7 +811,7 @@ st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**Human-in-the-Loop:** " - "Das Tool dient als Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten " + "Das Tool dient as 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" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " From 26293f1e1e12d469715941c541b0f1884402c24c Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 14:23:20 +0200 Subject: [PATCH 10/26] Stuff --- main.py | 2 ++ requirements.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 15a8c8f..2a66f78 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,8 @@ from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP import fitz import requests +import tempfile +from facturx import generate_from_file from pydantic import BaseModel, Field # ========================================== diff --git a/requirements.txt b/requirements.txt index fddbe14..f044702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ streamlit==1.58.0 pydantic==2.13.4 pymupdf==1.28.0 requests==2.32.3 - \ No newline at end of file +factur-x>=3.8 \ No newline at end of file From 91fd15621c1b8fd8b40ebcdf172ee9cf7a73a57b Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Fri, 3 Jul 2026 15:20:25 +0200 Subject: [PATCH 11/26] Stuff --- main.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main.py b/main.py index 2a66f78..e80d074 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,6 @@ from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP import fitz import requests -import tempfile -from facturx import generate_from_file from pydantic import BaseModel, Field # ========================================== @@ -792,8 +790,7 @@ with col2: """ - # 🔥 FIX: Benutze doc.set_metadata mit dem xmp-Keyword anstelle von set_xmp_metadata - doc.set_metadata(doc.metadata, xmp=xmp_xml) + doc.set_xml_metadata(xmp_xml) zugferd_pdf_bytes = doc.tobytes() doc.close() From 2384b27ae1ffcf42872addbeacde0378128b05b9 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 09:16:26 +0200 Subject: [PATCH 12/26] Test --- main.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index e80d074..a3df05c 100644 --- a/main.py +++ b/main.py @@ -470,6 +470,7 @@ def generate_compliant_zugferd_cii(data): 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}" + header_sum.find(f"{{{RAM}}}TaxTotalAmount").set("currencyID", "EUR") 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}" @@ -731,6 +732,7 @@ with col2: original_pdf_bytes = uploaded_file.read() doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") + # 1. XML-Datei einbetten nach ZUGFeRD Standard doc.embfile_add( "factur-x.xml", cii_xml_string.encode("utf-8"), @@ -738,9 +740,14 @@ with col2: desc="Factur-X/ZUGFeRD E-Invoice" ) + # 2. XMP-Metadaten inkl. PDF/A-3-Identifikation & ZUGFeRD-Schema aufbauen xmp_xml = """ + + 3 + B + @@ -790,7 +797,37 @@ with col2: """ - doc.set_xml_metadata(xmp_xml) + doc.set_metadata(doc.metadata, xmp=xmp_xml) + + # 3. Low-Level XREF-Injektion für strikte PDF/A-3 & ZUGFeRD Konformität + filespec_xref = None + for xref in range(1, doc.xref_length()): + obj_str = doc.xref_object(xref) + if "/Type /Filespec" in obj_str and "factur-x.xml" in obj_str: + filespec_xref = xref + break + + if filespec_xref: + # 3.1 Erzwinge das /AFRelationship Attribut am eingebetteten FileSpec + doc.xref_set_key(filespec_xref, "AFRelationship", "/Alternative") + + # 3.2 Ermittle den Catalog-Knoten und verknüpfe das XML im globalen /AF Array + cat_xref = doc.pdf_catalog() + doc.xref_set_key(cat_xref, "AF", f"[{filespec_xref} 0 R]") + + # 3.3 Standard-sRGB OutputIntent erstellen & injizieren (Farbprofil-Marker für PDF/A) + oi_xref = doc.xref_create() + oi_dict = ( + "<< /Type /OutputIntent " + "/S /GTS_PDFA1 " + "/OutputConditionIdentifier (sRGB) " + "/RegistryName (http://www.color.org) " + "/Info (sRGB IEC61966-2.1) >>" + ) + doc.xref_update(oi_xref, oi_dict) + doc.xref_set_key(cat_xref, "OutputIntents", f"[{oi_xref} 0 R]") + + # PDF finalisieren und umwandeln zugferd_pdf_bytes = doc.tobytes() doc.close() @@ -810,7 +847,7 @@ st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**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" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " From aae15f96dba87873ed033d1b76ffde66139a9c9b Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 09:23:01 +0200 Subject: [PATCH 13/26] Test --- main.py | 174 ++++++++++++++++++----------------------------- requirements.txt | 3 +- sRGB.icc | Bin 0 -> 3268 bytes 3 files changed, 70 insertions(+), 107 deletions(-) create mode 100644 sRGB.icc diff --git a/main.py b/main.py index a3df05c..f5f8274 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import os +import io import streamlit as st import json import re @@ -9,6 +10,8 @@ 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) @@ -351,7 +354,7 @@ def generate_compliant_zugferd_cii(data): root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice") - ex_doc_ctx = ET.SubElement(root, f"{{{RAM}}}ExchangedDocumentContext") + ex_doc_ctx = ET.SubElement(root, f"{{{RSM}}}ExchangedDocumentContext") 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" @@ -383,7 +386,7 @@ def generate_compliant_zugferd_cii(data): 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}}}GrossPriceProductTradePrice") + 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") @@ -447,7 +450,7 @@ def generate_compliant_zugferd_cii(data): 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}}}SpecifiedTradePaymentMeans") + pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans") ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "30" payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount") ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "") @@ -470,7 +473,6 @@ def generate_compliant_zugferd_cii(data): 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}" - header_sum.find(f"{{{RAM}}}TaxTotalAmount").set("currencyID", "EUR") 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}" @@ -478,7 +480,43 @@ def generate_compliant_zugferd_cii(data): 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()# ========================================== # 4. API CLIENT INITIALISIERUNG # ========================================== def get_cached_llm(): @@ -727,116 +765,40 @@ with col2: if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"): try: cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) - + uploaded_file.seek(0) original_pdf_bytes = uploaded_file.read() - doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - - # 1. XML-Datei einbetten nach ZUGFeRD Standard - doc.embfile_add( - "factur-x.xml", + + # 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"), - filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice" + flavor="factur-x", + level="en16931", + check_xsd=True, + check_schematron=False, # benötigt optional 'saxonche' für Schematron-Regeln + afrelationship="data", ) - - # 2. XMP-Metadaten inkl. PDF/A-3-Identifikation & ZUGFeRD-Schema aufbauen - xmp_xml = """ - - - - 3 - B - - - - - - Factur-X PDFA Extension Schema - fx - urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# - - - - DocumentType - Text - external - The type of the document, usually INVOICE - - - DocumentFileName - Text - external - The name of the embedded file - - - Version - Text - external - The version of the document type schema - - - ConformanceLevel - Text - external - The conformance level of the document - - - - - - - - - INVOICE - factur-x.xml - 1.0 - XRECHNUNG - - - -""" - - doc.set_metadata(doc.metadata, xmp=xmp_xml) - - # 3. Low-Level XREF-Injektion für strikte PDF/A-3 & ZUGFeRD Konformität - filespec_xref = None - for xref in range(1, doc.xref_length()): - obj_str = doc.xref_object(xref) - if "/Type /Filespec" in obj_str and "factur-x.xml" in obj_str: - filespec_xref = xref - break - - if filespec_xref: - # 3.1 Erzwinge das /AFRelationship Attribut am eingebetteten FileSpec - doc.xref_set_key(filespec_xref, "AFRelationship", "/Alternative") - - # 3.2 Ermittle den Catalog-Knoten und verknüpfe das XML im globalen /AF Array - cat_xref = doc.pdf_catalog() - doc.xref_set_key(cat_xref, "AF", f"[{filespec_xref} 0 R]") - - # 3.3 Standard-sRGB OutputIntent erstellen & injizieren (Farbprofil-Marker für PDF/A) - oi_xref = doc.xref_create() - oi_dict = ( - "<< /Type /OutputIntent " - "/S /GTS_PDFA1 " - "/OutputConditionIdentifier (sRGB) " - "/RegistryName (http://www.color.org) " - "/Info (sRGB IEC61966-2.1) >>" - ) - doc.xref_update(oi_xref, oi_dict) - doc.xref_set_key(cat_xref, "OutputIntents", f"[{oi_xref} 0 R]") - - # PDF finalisieren und umwandeln - zugferd_pdf_bytes = doc.tobytes() - doc.close() - + + # 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) + 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}") @@ -847,7 +809,7 @@ st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**Human-in-the-Loop:** " - "Das Tool dient als Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten " + "Das Tool dient as 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" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " diff --git a/requirements.txt b/requirements.txt index f044702..2158e8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ streamlit==1.58.0 pydantic==2.13.4 pymupdf==1.28.0 requests==2.32.3 -factur-x>=3.8 \ No newline at end of file +factur-x==5.1 +pikepdf==10.5.1 \ No newline at end of file diff --git a/sRGB.icc b/sRGB.icc new file mode 100644 index 0000000000000000000000000000000000000000..59b4507045831e10007301bab6a8b6e3c0c47ac3 GIT binary patch literal 3268 zcmb`J2T&7f8^_<>O(&rxL^?}v5}H5&k={h4NEelmgb+vqgoI)*94c_4h=`z~$f<}2 zo}Pf56;Ui4f(5%5O^IGj??mt|w|aaKw_HS-J4RI(J?G^R%?Sun}U zn2gj(F8Jb!iT1}lWdM+gevyMnfXeY-v`SP{g3lR>o+=V@QC^mqBb2bv zxmjpDM=Iq=WQEhw*jzr!k)efIaw(dXFF~ck+)Ne#Q*yvm)=s)imhEA0uiUMW&t)lJ z_@aL4;xF{|1eaq7e|-+Tiu%&)5VPl-_SFo&X<54fV2@!vxbd5ox)p%7 zwE$?}{-&AL0iad{Kx-FQF3p>aq-<4y2Na+N7(fRY0#jfG?0^$+1D?PK1cKQh0>pqs zun=%TIuHXX$OlWoDo_kc!A7tJ)POqB02)CvI1Jjrac~Bl2bVz~xB>101sDd;z$kbF z#vuqILNtgD=|D!1Ib;htL2PIi6bQ|MqM<}61rk6KNDeK9)a^(NHfxbbR*Xg1@atuk0apdI231x z^TdVVVsTtt4sJQF3|E8Ok88zs;jZBZaih4;cp6?GZ;SW9hvF0P0(?Hc7+;0oi$97# zhrfXz#=jvD2uy+{!Hp0?NF-zsmJrGaI|wa=(}aG)5Mhi+B1*f>^mFuK27zJ82xVk2N*O;gdKsfynp!io z;WhldYP1U-HP$x{ zF_s(eHtsbZH?cH{HCb)aVshV#4;`*g|l`sp|AsCK?~d3H^9gDfU%4r?{5jrGFb%s$C}lYO`SX9pJt zkwb&ST}MsF*^a9nI~@OVvT@=%)jC~wra1>WuW)X6emTQ-27kuR8Mj>MF5xa~T{>Mp zxw^SZU7KA;+|1lk+_t;jWHZ>2>@xNRcf7lw`wI8t?(aQZJ!Bq-J^u8x^UU--;Q3^x zn=-@|{I|LFiEAUL2Tpf^x6FgCCz@IjDCkRYfjXf)U*cuDZ-5PV2j$fl5+p$4Jc z(8kcw*>1C!&F%`LhRqAB341ulW{z}DM>rflJA8Bay}9OdC3D*%KtyQ7=7{@|mXSG; zzeEwDBBN@f9z{DwFOBY=N1wN7-hp{NP4?X=U<)?weVTU`9u#YtVZ2t51qx7RCtx#)W>u6hgo1#6jz5m$kW1StI z9Y=p*{j%@4@$uRdS|`d+Qcf0~oH(`k)Y$3l)6dTc&J1-f>b!e4;q3LUsIJ~~VdpNK z4?2Ig+qe7F1+NRoFM3=&c8Ptd?Xv6T)*hFhqrbZRdbHQIxAltKmG(aOzK;Hx{U@(_ zUp;dz;M)1?q1P|pn0uptAa>x^&E%VkTfAFOZi{Zeypwll{O-zo_ zoAqxk3RlI6!GOV@hw~oZ9pVl>`#tyfkHbZeXpgEM8$E7(;`HSBNYF^%)A>(_pJhKA ze_s5D#vj{XSiSi9PoF<~MiWMdUrJw2{8jc!=heQ~GhTO&MUFjqBYrdfw&b18yZ!Ik z@4G+5e|S8e|B?8y`jhpij?ZDA?@fp&CUVo9ohH8phRp#8aGsbLXqlMsehr}3{Q!{p P*fBOS@uC1g#LB+`$e<%Z literal 0 HcmV?d00001 From a5c27f5475d1a305eb30cc355d98f9df1f6acaad Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 10:13:30 +0200 Subject: [PATCH 14/26] Test --- main.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index f5f8274..6abf6b0 100644 --- a/main.py +++ b/main.py @@ -438,15 +438,19 @@ def generate_compliant_zugferd_cii(data): 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_note_id") and str(data["delivery_note_id"]).strip() != "": - dn_ref = ET.SubElement(header_deliv, f"{{{RAM}}}DeliveryNoteReferencedDocument") - ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip() - 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" From 00fe916c06b3f89368bf5ba3da5290ac4e9ca59b Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 10:30:35 +0200 Subject: [PATCH 15/26] Test --- main.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 6abf6b0..b905914 100644 --- a/main.py +++ b/main.py @@ -355,6 +355,12 @@ def generate_compliant_zugferd_cii(data): root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice") 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" @@ -410,13 +416,27 @@ def generate_compliant_zugferd_cii(data): supp_legal = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedLegalOrganization") ET.SubElement(supp_legal, f"{{{RAM}}}ID").text = "DE218436692" - + + # 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 = "Geschäftsführung" + supp_contact_tel = ET.SubElement(supp_contact, f"{{{RAM}}}TelephoneUniversalCommunication") + ET.SubElement(supp_contact_tel, f"{{{RAM}}}CompleteNumber").text = "+49 3302 4940537" + supp_contact_mail = ET.SubElement(supp_contact, f"{{{RAM}}}EmailURIUniversalCommunication") + ET.SubElement(supp_contact_mail, f"{{{RAM}}}URIID").text = "office@scitech-gmbh.de" + supp_addr = ET.SubElement(supplier_party, f"{{{RAM}}}PostalTradeAddress") ET.SubElement(supp_addr, f"{{{RAM}}}PostcodeCode").text = "16761" ET.SubElement(supp_addr, f"{{{RAM}}}LineOne").text = "Hermann-Schumann-Str. 4" ET.SubElement(supp_addr, f"{{{RAM}}}CityName").text = "Hennigsdorf" ET.SubElement(supp_addr, f"{{{RAM}}}CountryID").text = "DE" - + + # 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 = "office@scitech-gmbh.de" + supp_tax = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedTaxRegistration") ET.SubElement(supp_tax, f"{{{RAM}}}ID", {"schemeID": "VA"}).text = "DE218436692" @@ -428,6 +448,13 @@ def generate_compliant_zugferd_cii(data): 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. Fallback analog zur XRechnung(UBL)-Generierung. + cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication") + ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = ( + str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" + ) if data.get("order_id") and str(data["order_id"]).strip() != "": order_ref = ET.SubElement(header_agree, f"{{{RAM}}}BuyerOrderReferencedDocument") @@ -472,7 +499,20 @@ def generate_compliant_zugferd_cii(data): 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 (BR-CO-25 Pflicht: bei positivem Zahlbetrag muss entweder ein + # Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Default: 14 Tage netto, + # analog zur bereits funktionierenden XRechnung(UBL)-Generierung. + try: + 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") + ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = "Zahlbar innerhalb 14 Tagen ohne Abzug." + due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") + ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = due_date_str + 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}" @@ -520,7 +560,38 @@ def add_pdfa_output_intent(pdf_bytes: bytes, icc_path: str = SRGB_ICC_PATH) -> b pdf.save(buf) return buf.getvalue() finally: - pdf.close()# ========================================== + 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.2' - 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( + "EN 16931", + "XRECHNUNG", + ).replace( + "1.0", + "3.0.2", + ) + 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 # ========================================== def get_cached_llm(): @@ -791,6 +862,7 @@ with col2: # 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", From 381216d619bfa508dfbd0179ab5e20ffda5b068d Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 10:51:36 +0200 Subject: [PATCH 16/26] Test --- main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index b905914..a612ddc 100644 --- a/main.py +++ b/main.py @@ -415,7 +415,9 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(supplier_party, f"{{{RAM}}}Name").text = "scitech it solutions GmbH" supp_legal = ET.SubElement(supplier_party, f"{{{RAM}}}SpecifiedLegalOrganization") - ET.SubElement(supp_legal, f"{{{RAM}}}ID").text = "DE218436692" + # 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 = "HRB 6058 NP" # BG-6 SELLER CONTACT (BR-DE-2 Pflicht für XRechnung). Muss laut XSD nach # SpecifiedLegalOrganization und vor PostalTradeAddress stehen. From ec8352682ea42accdac86dd3bb6666f86b88fe82 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 10:54:43 +0200 Subject: [PATCH 17/26] Test --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index a612ddc..11419bc 100644 --- a/main.py +++ b/main.py @@ -740,6 +740,8 @@ with col2: 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) + st.text_input("Umsatzsteuer-ID (Sender)", value="DE218436692", disabled=True) + st.text_input("Registernummer (Sender)", value="HRB 6058 NP", disabled=True) with c_r: st.markdown("**Empfänger (Kunde):**") r_name = st.text_input("Firma (Kunde)", value=data.get("recipient_name", "")) From 37b7986831e0aa95ce6d147d9388ddddfcba2799 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 11:12:39 +0200 Subject: [PATCH 18/26] Test --- main.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 11419bc..0e5149c 100644 --- a/main.py +++ b/main.py @@ -347,12 +347,17 @@ 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") @@ -484,7 +489,10 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR" pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans") - ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "30" + # 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(" ", "") @@ -570,7 +578,7 @@ 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.2' - sonst schlagen z. B. + 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)) @@ -583,7 +591,7 @@ def fix_xrechnung_xmp_conformance(pdf_bytes: bytes) -> bytes: "XRECHNUNG", ).replace( "1.0", - "3.0.2", + "3.0", ) pdf.Root.Metadata.write(xmp_str.encode("utf-8")) buf = io.BytesIO() From 3e59cd188e6158db1732ae4f0d85e61917dc0318 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 11:22:11 +0200 Subject: [PATCH 19/26] Test --- main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 0e5149c..57467e1 100644 --- a/main.py +++ b/main.py @@ -258,6 +258,8 @@ def generate_compliant_xrechnung(data): legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" + # BT-30 Registernummer (Handelsregister) - NICHT identisch mit der Umsatzsteuer-ID (BT-31)! + ET.SubElement(legal_supp, f"{{{CBC}}}CompanyID").text = "HRB 6058 NP" contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact") ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = "Geschäftsführung" @@ -284,7 +286,9 @@ 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") - ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "30" + # 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" payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount") ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") From 75c9d113bc5c4a0425c801b55a29b25df05e486c Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 12:53:48 +0200 Subject: [PATCH 20/26] Test --- main.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 57467e1..12ac34c 100644 --- a/main.py +++ b/main.py @@ -69,7 +69,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") - buyer_reference: str = Field(description="Der exakte Wert nach 'Ihr Auftrag:' (z.B. 05.06.2026-Herr.Knospe)") + buyer_reference: str = Field(default="", description="Käuferreferenz/Leitweg-ID: NUR eintragen, wenn im Text explizit ein Wert nach 'Ihr Auftrag:' o.ä. als eigene Referenz des Käufers/Empfängers steht (z.B. 05.06.2026-Herr.Knospe). NIEMALS die Nummer einer Auftragsbestätigung/Bestellung hier eintragen - die gehört ausschließlich in order_id. Wenn keine separate Käuferreferenz im Text erkennbar ist: leer lassen.") 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") @@ -215,7 +215,12 @@ 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" - ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["buyer_reference"]) + # BT-10 Käuferreferenz: NUR eintragen, wenn tatsächlich ein Wert vorliegt. Für private/ + # geschäftliche Empfänger (B2B) ist dieses Feld optional (0..1); nur bei Behörden (B2G) + # ist eine echte Leitweg-ID zwingend (BR-DE-15) - die kommt dann aber vom Empfänger selbst + # und wird nicht erfunden. + if data.get("buyer_reference") and str(data["buyer_reference"]).strip() != "": + ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["buyer_reference"]).strip() if data.get("service_start_date") and data.get("service_end_date"): period_elem = ET.SubElement(root, f"{{{CAC}}}InvoicePeriod") @@ -241,8 +246,6 @@ def generate_compliant_xrechnung(data): 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 = "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 = "Hermann-Schumann-Str. 4" @@ -270,8 +273,6 @@ def generate_compliant_xrechnung(data): 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["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"]) @@ -292,6 +293,12 @@ 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 = "Zahlbar innerhalb 14 Tagen ohne Abzug." + tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" @@ -418,7 +425,10 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(mon_sum, f"{{{RAM}}}LineTotalAmount").text = f"{item['total_amount']:.2f}" header_agree = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeAgreement") - ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["buyer_reference"]) + # 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("buyer_reference") and str(data["buyer_reference"]).strip() != "": + ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["buyer_reference"]).strip() supplier_party = ET.SubElement(header_agree, f"{{{RAM}}}SellerTradeParty") ET.SubElement(supplier_party, f"{{{RAM}}}Name").text = "scitech it solutions GmbH" From 076e9762982f9b8e28bb4bd22b7032ea70445a99 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 13:42:28 +0200 Subject: [PATCH 21/26] Test --- main.py | 109 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/main.py b/main.py index 12ac34c..a76ca91 100644 --- a/main.py +++ b/main.py @@ -69,7 +69,8 @@ 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") - buyer_reference: str = Field(default="", description="Käuferreferenz/Leitweg-ID: NUR eintragen, wenn im Text explizit ein Wert nach 'Ihr Auftrag:' o.ä. als eigene Referenz des Käufers/Empfängers steht (z.B. 05.06.2026-Herr.Knospe). NIEMALS die Nummer einer Auftragsbestätigung/Bestellung hier eintragen - die gehört ausschließlich in order_id. Wenn keine separate Käuferreferenz im Text erkennbar ist: leer lassen.") + 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.") 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") @@ -78,7 +79,7 @@ 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 Nummer der Auftragsbestätigung oder Bestellung (z.B. AB26-080) 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.") 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.") @@ -216,20 +217,29 @@ def generate_compliant_xrechnung(data): 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) ist dieses Feld optional (0..1); nur bei Behörden (B2G) - # ist eine echte Leitweg-ID zwingend (BR-DE-15) - die kommt dann aber vom Empfänger selbst - # und wird nicht erfunden. - if data.get("buyer_reference") and str(data["buyer_reference"]).strip() != "": - ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["buyer_reference"]).strip() + # 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() 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"]) - - if data.get("order_id") and str(data["order_id"]).strip() != "": + + # 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: order_ref = ET.SubElement(root, f"{{{CAC}}}OrderReference") - ET.SubElement(order_ref, f"{{{CBC}}}ID").text = str(data["order_id"]).strip() + 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() if data.get("delivery_note_id") and str(data["delivery_note_id"]).strip() != "": despatch_ref = ET.SubElement(root, f"{{{CAC}}}DespatchDocumentReference") @@ -427,8 +437,8 @@ def generate_compliant_zugferd_cii(data): 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("buyer_reference") and str(data["buyer_reference"]).strip() != "": - ET.SubElement(header_agree, f"{{{RAM}}}BuyerReference").text = str(data["buyer_reference"]).strip() + 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 = "scitech it solutions GmbH" @@ -478,8 +488,16 @@ def generate_compliant_zugferd_cii(data): ) if data.get("order_id") and str(data["order_id"]).strip() != "": - order_ref = ET.SubElement(header_agree, f"{{{RAM}}}BuyerOrderReferencedDocument") + # 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") @@ -664,6 +682,7 @@ 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" @@ -727,10 +746,24 @@ with col2: ) selected_type_code = inv_type_ui.split(" ")[0] - 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", "")) + 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)", + 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. Sonst leer lassen - das Feld wird dann im XML komplett " + "weggelassen." + ) + buyer_ref = st.text_input( + "Ihr Auftrag / Bestellnummer (BT-13)", + value=data.get("buyer_reference", ""), + help="Die vom KÄUFER vorgegebene Bestellnummer für diesen Auftrag (z.B. nach 'Ihr Auftrag:'). " + "NICHT die Kundennummer (die gehört ins Feld oben) und NICHT die eigene Auftragsbestätigungs-" + "/Verkaufsauftragsnummer (die gehört ins Feld 'Auftragsbestätigung / Verkaufsauftrag (BT-14)' " + "weiter unten). Sonst leer lassen." + ) # UI-Eingabefelder für die Daten vor Position 1 (BT-22 etc.) st.markdown("---") @@ -739,15 +772,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 / Bestellung (BT-13)", value=data.get("order_id", "")) + o_id = st.text_input("Auftragsbestätigung / Verkaufsauftrag - unsere Nummer (BT-14)", 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 (YYYY-MM-DD)", value=data.get("service_start_date", "")) + s_start = st.text_input("Leistungszeitraum Start (BT-73, YYYY-MM-DD)", value=data.get("service_start_date", "")) with c_p2: - s_end = st.text_input("Leistungszeitraum Ende (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", "")) i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) st.markdown("---") @@ -756,21 +789,21 @@ with col2: c_s, c_r = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") - 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) - st.text_input("Umsatzsteuer-ID (Sender)", value="DE218436692", disabled=True) - st.text_input("Registernummer (Sender)", value="HRB 6058 NP", disabled=True) + st.text_input("Firma (Sender) (BT-27)", value="scitech it solutions GmbH", disabled=True) + st.text_input("E-Mail (Sender) (BT-34)", value="office@scitech-gmbh.de", disabled=True) + st.text_input("Telefon (Sender) (BT-42)", value="+49 3302 4940537", disabled=True) + st.text_input("Straße (Sender) (BT-35)", value="Hermann-Schumann-Str. 4", disabled=True) + st.text_input("PLZ (Sender) (BT-38)", value="16761", 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("Registernummer (Sender) (BT-30)", value="HRB 6058 NP", disabled=True) with c_r: st.markdown("**Empfänger (Kunde):**") - 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", "")) + 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", "")) st.markdown("### 3. Positionen") @@ -801,11 +834,11 @@ with col2: c_qty, c_unit, c_price, c_total = st.columns(4) with c_qty: - 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}") + 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", 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 (€)", 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) @@ -822,7 +855,7 @@ with col2: }) st.markdown("---") - vat_rate = st.number_input("Steuersatz (%)", 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) raw_vat_amount = computed_net_amount * (vat_rate / 100.0) @@ -846,7 +879,7 @@ with col2: 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, + "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": "DE06120700240456207000", From e80d011c88495a26e3aa63546fb55f348cf9a8dc Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 14:09:15 +0200 Subject: [PATCH 22/26] Test --- main.py | 108 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/main.py b/main.py index a76ca91..079a655 100644 --- a/main.py +++ b/main.py @@ -89,6 +89,24 @@ class InvoiceMetadata(BaseModel): # ========================================== # 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): if not betrag: return 0.0 @@ -170,13 +188,16 @@ 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 datetime.today().strftime("%Y-%m-%d") + return "" def format_cii_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}}}IssueDate").text = parse_to_strict_xml_date(data["invoice_date"]) - 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}}}DueDate").text = berechne_faelligkeit(data["invoice_date"]) 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 --- 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["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") ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"]) 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; # 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 = "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") 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" # 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") - ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = ( - str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" - ) + 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. @@ -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"]) # BT-9 Fälligkeitsdatum (BR-CO-25 Pflicht: bei positivem Zahlbetrag muss entweder ein - # Fälligkeitsdatum ODER Zahlungsbedingungen angegeben werden). Default: 14 Tage netto, - # analog zur bereits funktionierenden XRechnung(UBL)-Generierung. - try: - 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") + # 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 = "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") 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Ü --- inv_type_ui = st.selectbox( "Rechnungstyp (BT-3)", - options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)"], + options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)", "326 (Teilrechnung)"], index=0, help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header." ) @@ -772,7 +783,7 @@ 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 - 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: 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("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("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", "")) @@ -842,7 +854,7 @@ with col2: with c_total: raw_item_total = item_quantity * item_price item_total_amount = kaufmaennisches_runden(raw_item_total) - st.markdown("**Gesamt betrag (€):**") + st.markdown("**Gesamtbetrag (€):**") st.markdown(f"**{item_total_amount:.2f} €**") edited_items.append({ @@ -857,10 +869,15 @@ with col2: st.markdown("---") 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) - 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) + # 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) st.markdown("**Berechnete Summen:**") 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, "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", + "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, @@ -890,13 +907,38 @@ with col2: "service_end_date": s_end, "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 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) 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"): + if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch", disabled=export_disabled): try: cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) @@ -944,7 +986,7 @@ st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**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" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " From ebf172276a1da45420cdac6bef7190e281d872d6 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 7 Jul 2026 09:49:28 +0200 Subject: [PATCH 23/26] Test --- main.py | 109 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/main.py b/main.py index 079a655..c478de8 100644 --- a/main.py +++ b/main.py @@ -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)", @@ -792,7 +821,16 @@ with col2: s_start = st.text_input("Leistungszeitraum Start (BT-73, 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 / 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" From aad83afd5454d14e144d93f7c8b50d1f8175aadf Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 7 Jul 2026 10:03:41 +0200 Subject: [PATCH 24/26] Test --- main.py | 98 ++++++++++++++++++++------------------------------------- 1 file changed, 34 insertions(+), 64 deletions(-) diff --git a/main.py b/main.py index c478de8..7ae6f23 100644 --- a/main.py +++ b/main.py @@ -115,17 +115,6 @@ def berechne_faelligkeit(invoice_date_str, term_days): 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): if not betrag: return 0.0 @@ -340,14 +329,6 @@ 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), 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}" @@ -580,18 +561,15 @@ 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 + 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). + # 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")) - _cii_terms_text = zahlungsbedingung_text(data.get("payment_term_days")) - if _cii_due or _cii_terms_text: + if _cii_due: 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("-", "") + 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}" @@ -776,6 +754,8 @@ 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)", @@ -785,24 +765,18 @@ with col2: ) selected_type_code = inv_type_ui.split(" ")[0] - 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_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. Sonst leer lassen - das Feld wird dann im XML komplett " - "weggelassen." + "der Empfänger eine Behörde ist." ) buyer_ref = st.text_input( "Ihr Auftrag / Bestellnummer (BT-13)", - value=data.get("buyer_reference", ""), - help="Die vom KÄUFER vorgegebene Bestellnummer für diesen Auftrag (z.B. nach 'Ihr Auftrag:'). " - "NICHT die Kundennummer (die gehört ins Feld oben) und NICHT die eigene Auftragsbestätigungs-" - "/Verkaufsauftragsnummer (die gehört ins Feld 'Auftragsbestätigung / Verkaufsauftrag (BT-14)' " - "weiter unten). Sonst leer lassen." + value=data.get("buyer_reference", "") ) # UI-Eingabefelder für die Daten vor Position 1 (BT-22 etc.) @@ -823,12 +797,8 @@ with col2: 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)." + "Zahlungsziel in Tagen (BT-9) :red[*]", + value=str(data.get("payment_term_days", "") or "") ) i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) @@ -838,22 +808,22 @@ with col2: c_s, c_r = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") - st.text_input("Firma (Sender) (BT-27)", value="scitech it solutions GmbH", disabled=True) - st.text_input("E-Mail (Sender) (BT-34)", value="office@scitech-gmbh.de", disabled=True) - st.text_input("Telefon (Sender) (BT-42)", value="+49 3302 4940537", disabled=True) - st.text_input("Straße (Sender) (BT-35)", value="Hermann-Schumann-Str. 4", disabled=True) - st.text_input("PLZ (Sender) (BT-38)", value="16761", 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("Registernummer (Sender) (BT-30)", value="HRB 6058 NP", disabled=True) - st.text_input("IBAN (Sender) (BT-84)", value=VERKAEUFER_IBAN, disabled=True) + st.text_input("Firma (BT-27)", value="scitech it solutions GmbH", disabled=True) + st.text_input("E-Mail (BT-34)", value="office@scitech-gmbh.de", disabled=True) + st.text_input("Telefon (BT-42)", value="+49 3302 4940537", disabled=True) + st.text_input("Straße (BT-35)", value="Hermann-Schumann-Str. 4", disabled=True) + st.text_input("PLZ (BT-38)", value="16761", disabled=True) + st.text_input("Stadt (BT-37)", value="Hennigsdorf", disabled=True) + st.text_input("Umsatzsteuer-ID (BT-31)", value="DE218436692", disabled=True) + st.text_input("Registernummer (BT-30)", value="HRB 6058 NP", disabled=True) + st.text_input("IBAN (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_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_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", "")) st.markdown("### 3. Positionen") @@ -875,7 +845,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) :red[*]", 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 @@ -884,11 +854,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) :red[*]", 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) :red[*]", 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, €) :red[*]", 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) @@ -905,7 +875,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, %) :red[*]", 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 From fa977594eb3a72e44c70c1fda598f582832133b1 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 7 Jul 2026 10:23:51 +0200 Subject: [PATCH 25/26] Test --- main.py | 88 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/main.py b/main.py index 7ae6f23..659855d 100644 --- a/main.py +++ b/main.py @@ -94,6 +94,22 @@ class InvoiceMetadata(BaseModel): # 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, @@ -281,29 +297,29 @@ 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 = "office@scitech-gmbh.de" - + ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = VERKAEUFER["email"] + addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress") - 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" + 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"] country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country") - ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" - + ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = VERKAEUFER["country"] + tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") - ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" + ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = VERKAEUFER["vat_id"] 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 = "scitech it solutions GmbH" + 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 = "HRB 6058 NP" - + ET.SubElement(legal_supp, f"{{{CBC}}}CompanyID").text = VERKAEUFER["register_id"] + contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact") - 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" + 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"] # --- Empfänger --- customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") @@ -461,35 +477,35 @@ def generate_compliant_zugferd_cii(data): 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 = "scitech it solutions GmbH" - + 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 = "HRB 6058 NP" + 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 = "Geschäftsführung" + 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 = "+49 3302 4940537" + 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 = "office@scitech-gmbh.de" + 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 = "16761" - ET.SubElement(supp_addr, f"{{{RAM}}}LineOne").text = "Hermann-Schumann-Str. 4" - ET.SubElement(supp_addr, f"{{{RAM}}}CityName").text = "Hennigsdorf" - ET.SubElement(supp_addr, f"{{{RAM}}}CountryID").text = "DE" + 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 = "office@scitech-gmbh.de" + 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 = "DE218436692" + 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"]) @@ -743,7 +759,7 @@ with col2: st.code(res_meta_text) st.stop() - if final_data.get("recipient_email") == "office@scitech-gmbh.de": + if final_data.get("recipient_email") == VERKAEUFER["email"]: final_data["recipient_email"] = "" final_data["items"] = extract_invoice_items_pure_python(pdf_text) @@ -808,14 +824,14 @@ with col2: c_s, c_r = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") - st.text_input("Firma (BT-27)", value="scitech it solutions GmbH", disabled=True) - st.text_input("E-Mail (BT-34)", value="office@scitech-gmbh.de", disabled=True) - st.text_input("Telefon (BT-42)", value="+49 3302 4940537", disabled=True) - st.text_input("Straße (BT-35)", value="Hermann-Schumann-Str. 4", disabled=True) - st.text_input("PLZ (BT-38)", value="16761", disabled=True) - st.text_input("Stadt (BT-37)", value="Hennigsdorf", disabled=True) - st.text_input("Umsatzsteuer-ID (BT-31)", value="DE218436692", disabled=True) - st.text_input("Registernummer (BT-30)", value="HRB 6058 NP", disabled=True) + 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) with c_r: st.markdown("**Empfänger (Kunde):**") From 7b5cd86974efeeef8c568d4ba8cd59bab948d710 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Tue, 7 Jul 2026 10:52:43 +0200 Subject: [PATCH 26/26] Test --- main.py | 2 ++ requirements.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 659855d..872eb7e 100644 --- a/main.py +++ b/main.py @@ -694,6 +694,8 @@ 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: diff --git a/requirements.txt b/requirements.txt index 2158e8f..86c387c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -streamlit==1.58.0 +streamlit[pdf]==1.58.0 pydantic==2.13.4 pymupdf==1.28.0 requests==2.32.3