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