Test
This commit is contained in:
parent
2384b27ae1
commit
aae15f96db
174
main.py
174
main.py
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -9,6 +10,8 @@ from decimal import Decimal, ROUND_HALF_UP
|
||||||
import fitz
|
import fitz
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel, Field
|
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)
|
# 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")
|
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")
|
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"
|
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()
|
ET.SubElement(trade_prod, f"{{{RAM}}}Description").text = str(item["description"]).strip()
|
||||||
|
|
||||||
line_agree = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeAgreement")
|
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}"
|
ET.SubElement(net_price, f"{{{RAM}}}ChargeAmount").text = f"{item['unit_price']:.2f}"
|
||||||
|
|
||||||
line_deliv = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeDelivery")
|
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")
|
header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement")
|
||||||
ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR"
|
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"
|
ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "30"
|
||||||
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
||||||
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
|
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}}}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}}}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}}}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}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}"
|
||||||
ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").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=" ")
|
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
|
# 4. API CLIENT INITIALISIERUNG
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def get_cached_llm():
|
def get_cached_llm():
|
||||||
|
|
@ -727,116 +765,40 @@ with col2:
|
||||||
if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"):
|
if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"):
|
||||||
try:
|
try:
|
||||||
cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict)
|
cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict)
|
||||||
|
|
||||||
uploaded_file.seek(0)
|
uploaded_file.seek(0)
|
||||||
original_pdf_bytes = uploaded_file.read()
|
original_pdf_bytes = uploaded_file.read()
|
||||||
doc = fitz.open(stream=original_pdf_bytes, filetype="pdf")
|
|
||||||
|
# 1) factur-x übernimmt: XSD-Validierung der CII-XML, korrektes Einbetten
|
||||||
# 1. XML-Datei einbetten nach ZUGFeRD Standard
|
# (Filespec mit AFRelationship=/Data, /F + /UF, Subtype text/xml) und die
|
||||||
doc.embfile_add(
|
# komplette PDF/A-3-konforme XMP-Metadatenstruktur (pdfaid:part/conformance,
|
||||||
"factur-x.xml",
|
# 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"),
|
cii_xml_string.encode("utf-8"),
|
||||||
filename="factur-x.xml",
|
flavor="factur-x",
|
||||||
desc="Factur-X/ZUGFeRD E-Invoice"
|
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
|
# 2) Zusätzliches OutputIntent (sRGB-ICC-Profil) ergänzen. Das ist für echte
|
||||||
xmp_xml = """<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
# PDF/A-3-Konformität zwingend, wird von factur-x aber nicht selbst gesetzt.
|
||||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
zugferd_pdf_bytes = add_pdfa_output_intent(zugferd_pdf_bytes)
|
||||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
||||||
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
|
|
||||||
<pdfaid:part>3</pdfaid:part>
|
|
||||||
<pdfaid:conformance>B</pdfaid:conformance>
|
|
||||||
</rdf:Description>
|
|
||||||
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema/" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
|
||||||
<pdfaExtension:schemas>
|
|
||||||
<rdf:Bag>
|
|
||||||
<rdf:li rdf:parseType="Resource">
|
|
||||||
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
|
||||||
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
|
||||||
<pdfaSchema:namespace>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespace>
|
|
||||||
<pdfaSchema:properties>
|
|
||||||
<rdf:Seq>
|
|
||||||
<rdf:li rdf:parseType="Resource">
|
|
||||||
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
|
||||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
||||||
<pdfaProperty:category>external</pdfaProperty:category>
|
|
||||||
<pdfaProperty:description>The type of the document, usually INVOICE</pdfaProperty:description>
|
|
||||||
</rdf:li>
|
|
||||||
<rdf:li rdf:parseType="Resource">
|
|
||||||
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
|
||||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
||||||
<pdfaProperty:category>external</pdfaProperty:category>
|
|
||||||
<pdfaProperty:description>The name of the embedded file</pdfaProperty:description>
|
|
||||||
</rdf:li>
|
|
||||||
<rdf:li rdf:parseType="Resource">
|
|
||||||
<pdfaProperty:name>Version</pdfaProperty:name>
|
|
||||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
||||||
<pdfaProperty:category>external</pdfaProperty:category>
|
|
||||||
<pdfaProperty:description>The version of the document type schema</pdfaProperty:description>
|
|
||||||
</rdf:li>
|
|
||||||
<rdf:li rdf:parseType="Resource">
|
|
||||||
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
|
||||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
||||||
<pdfaProperty:category>external</pdfaProperty:category>
|
|
||||||
<pdfaProperty:description>The conformance level of the document</pdfaProperty:description>
|
|
||||||
</rdf:li>
|
|
||||||
</rdf:Seq>
|
|
||||||
</pdfaSchema:properties>
|
|
||||||
</rdf:li>
|
|
||||||
</rdf:Bag>
|
|
||||||
</pdfaExtension:schemas>
|
|
||||||
</rdf:Description>
|
|
||||||
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
|
|
||||||
<fx:DocumentType>INVOICE</fx:DocumentType>
|
|
||||||
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
|
|
||||||
<fx:Version>1.0</fx:Version>
|
|
||||||
<fx:ConformanceLevel>XRECHNUNG</fx:ConformanceLevel>
|
|
||||||
</rdf:Description>
|
|
||||||
</rdf:RDF>
|
|
||||||
</x:xmpmeta>
|
|
||||||
<?xpacket end="w"?>"""
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
label="📥 Download ZUGFeRD Hybrid-PDF",
|
label="📥 Download ZUGFeRD Hybrid-PDF",
|
||||||
data=zugferd_pdf_bytes,
|
data=zugferd_pdf_bytes,
|
||||||
file_name=f"ZUGFeRD_{inv_num}.pdf",
|
file_name=f"ZUGFeRD_{inv_num}.pdf",
|
||||||
mime="application/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:
|
except Exception as e:
|
||||||
st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}")
|
st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}")
|
||||||
|
|
||||||
|
|
@ -847,7 +809,7 @@ st.markdown("---")
|
||||||
st.caption(
|
st.caption(
|
||||||
"📝 **Hinweise:**\n\n"
|
"📝 **Hinweise:**\n\n"
|
||||||
"**Human-in-the-Loop:** "
|
"**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"
|
"(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich.\n\n"
|
||||||
"**GoBD-Konformität:** "
|
"**GoBD-Konformität:** "
|
||||||
"Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien "
|
"Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien "
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@ streamlit==1.58.0
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
pymupdf==1.28.0
|
pymupdf==1.28.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
factur-x>=3.8
|
factur-x==5.1
|
||||||
|
pikepdf==10.5.1
|
||||||
Loading…
Reference in New Issue