package com.alexanderlogistics.einvoice; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.logging.Logger; import javax.xml.transform.TransformerException; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary; import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; import org.imixs.einvoice.EInvoiceFormatException; import org.imixs.einvoice.EInvoiceModel; import org.imixs.einvoice.EInvoiceModelCII; import org.imixs.einvoice.EInvoiceModelFactory; import org.imixs.einvoice.TradeLineItem; import org.imixs.einvoice.TradeParty; import org.imixs.workflow.FileData; import org.imixs.workflow.ItemCollection; import org.imixs.workflow.SignalAdapter; import org.imixs.workflow.engine.DocumentService; import org.imixs.workflow.engine.WorkflowService; import org.imixs.workflow.exceptions.AdapterException; import org.imixs.workflow.exceptions.PluginException; import org.imixs.workflow.util.XMLParser; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import com.alexanderlogistics.BusinessPartnerService; import com.alexanderlogistics.InvoiceUtil; import com.alexanderlogistics.ksef.api.KSeFAPIService; import jakarta.inject.Inject; /** * The EInvoiceConverterAdapter converts a Cargsoft outbound invoice into an * e-invoice and embeds the xml file into the pdf file to produce a Zugferd * Invoice. * * The adapter fetches the .pdf file matching the name of * 'cargosoft.import.filename'. If no such an item exits, the adapter takes the * first PDF file from the list of attachments. * * This adapter can only be run on Invoice data provided by Cargosoft. * * The adapter extracts a template XML from the text block, then inserts invoice * information into the XML file. In the end, * it inserts the XML file into the PDF. * * The adapter can be configured by the model: * *
 * {@code
        
            textblock-ref
            
            true
         
   }
 * 
* *

* Wenn Debug = true gibt der Adapter alle einzelschritte auf der console aus * und erzeugt zusätzlich die factur-x xml und txt dateien. * * * * @version 1.0 * @author rsoika */ public class EInvoiceAdapter implements SignalAdapter { public static final String LINE_ITEMS_PROPERTY = "invoice.items"; private static Logger logger = Logger.getLogger(EInvoiceAdapter.class.getName()); public static SimpleDateFormat dateFormatter = new SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN); public static NumberFormat numberFormat = NumberFormat.getInstance(Locale.GERMANY); boolean debug = false; @Inject WorkflowService workflowService; @Inject DocumentService documentService; @Inject BusinessPartnerService businessPartnerService; @Inject KSeFAPIService kSeFAPIService; /** * This method * * @throws PluginException */ @Override public ItemCollection execute(ItemCollection workitem, ItemCollection event) throws AdapterException, PluginException { logger.info("├── 🔜 Convert Invoice to E-Invoice..."); // read configuration.... ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "e-invoice", workitem, false); if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("create")) { throw new PluginException(EInvoiceAdapter.class.getSimpleName(), KSeFAPIService.CONFIG_ERROR, "missing e-invoice configuration in model event - please check model configuration"); } ItemCollection createDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE")); try { // Load the e-invoice template.... FileData xmlFileData = kSeFAPIService.loadXMLTemplate(workitem, createDefinition); FileData pdfFileData = kSeFAPIService.loadPDF(workitem); logger.info("│ ├── Source File: " + pdfFileData.getName()); updateEInvoice(xmlFileData, workitem); // append document logger.info("│ ├── append new e-invoice"); workitem.addFileData(xmlFileData); // Embedd XML..... logger.info("│ ├── embed e-invoice into PDF"); FileData eInvoice = embeddXML(pdfFileData, xmlFileData); workitem.removeFile(pdfFileData.getName()); // set new pdf file... workitem.addFileData(eInvoice); } catch (PluginException e) { throw new AdapterException(e); } return workitem; } private FileData embeddXML(FileData pdfFileData, FileData xmlFileData) throws PluginException { try { PDDocument document = PDDocument.load(pdfFileData.getContent()); byte[] xmlBytes = xmlFileData.getContent(); // Erstelle einen InputStream aus dem XML Content InputStream inputStream = new ByteArrayInputStream(xmlBytes); // Erstelle eine Datei-Spezifikation für die XML PDComplexFileSpecification fileSpec = new PDComplexFileSpecification(); fileSpec.setFile(xmlFileData.getName()); // Erstelle einen PDEmbeddedFile mit dem InputStream PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, inputStream); embeddedFile.setSubtype("application/xml"); embeddedFile.setSize(xmlBytes.length); // Schließe den InputStream inputStream.close(); // Setze den PDEmbeddedFile fileSpec.setEmbeddedFile(embeddedFile); // Erstelle einen Map-Eintrag für die eingebettete Datei Map filesMap = new HashMap<>(); filesMap.put(xmlFileData.getName(), fileSpec); // Erstelle einen Eingebettete-Dateien-Namensbaum und füge die Datei hinzu PDEmbeddedFilesNameTreeNode efTree = new PDEmbeddedFilesNameTreeNode(); efTree.setNames(filesMap); // Erstelle ein Namens-Dictionary und füge den Baum hinzu PDDocumentNameDictionary names = new PDDocumentNameDictionary(document.getDocumentCatalog()); names.setEmbeddedFiles(efTree); document.getDocumentCatalog().setNames(names); // Speichere das PDF-Dokument ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); byte[] eInvoiceData = baos.toByteArray(); document.close(); baos.close(); FileData eInvoiceFileData = new FileData(pdfFileData.getName(), eInvoiceData, pdfFileData.getContentType(), pdfFileData.getAttributes()); return eInvoiceFileData; } catch (IOException e) { // TODO Auto-generated catch block throw new PluginException(EInvoiceAdapter.class.getSimpleName(), KSeFAPIService.CONFIG_ERROR, "Failed to generate e-invoice: " + e.getMessage()); } } /** * THis method updates an e-invoice template with the data stored in the * workitem. * * First the method loads an EInvoiceModel based on the provided XML Template * and than updates the e-invoice data based on the items stored in the given * workitem. * * @param workitem * @throws PluginException */ public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException { try { EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent())); model.setId(workitem.getItemValueString("invoice.number")); // Currency model.setCurrency(workitem.getItemValueString("invoice.currency")); // BR-DE-15: BuyerReference (BT-10) is mandatory, but we have no real reference model.setBuyerReference("n/a"); // Update Rechnungssummen... if (workitem.getItemValueDouble("invoice.total.tax") > 0) { // wir haben eine Steuer! model.setTaxTotalAmount(InvoiceUtil.roundBigDecimal(workitem.getItemValueDouble("invoice.total") - workitem.getItemValueDouble("invoice.total.net"))); model.setTaxRate(InvoiceUtil.roundBigDecimal(workitem.getItemValueDouble("invoice.total.tax"))); } // Update Invoice Items List invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); double lineTotalAmount = 0.00; for (ItemCollection invoiceItem : invoiceItems) { TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem); model.addTradeLineItem(tradeLineItem); lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal(); } // // Summenbildung model.setNetTotalAmount(InvoiceUtil.roundBigDecimal(workitem.getItemValueDouble("invoice.total.net"))); model.setGrandTotalAmount(InvoiceUtil.roundBigDecimal(workitem.getItemValueDouble("invoice.total"))); // date model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date")); model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate")); // BT-20: Payment terms description, derived from the due date Date dueDate = workitem.getItemValueDate("invoice.duedate"); if (dueDate != null) { String formattedDueDate = dateFormatter.format(dueDate); ((EInvoiceModelCII) model).setPaymentTermsDescription("Payment due by " + formattedDueDate); } // Update Addresses TradeParty billingAddress = buildAddress(workitem.getItemValueString("partner.id"), "buyer"); model.setTradeParty(billingAddress); TradeParty shippingAddress = buildAddress(workitem.getItemValueString("partner.id"), "ship_to"); model.setTradeParty(shippingAddress); // References>AB230321 // model.setOrderReferenceId(workitem.getItemValueString("order.number")); // Strip all XML comments from the document... stripComments(model.getRoot().getOwnerDocument()); // Remove empty template placeholder elements (e.g. ) // that were never populated by the adapter. // stripEmptyElements(model.getRoot()); // finally update the template file fileDataXMLTemplate.setContent(model.getContent()); } catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) { throw new PluginException(this.getClass().getName(), KSeFAPIService.DOCUMENT_ERROR, e.getMessage(), e); } } /** * Erstellt ein TradeParty Objekt aus einer Liste von Adresszeilen. * * @param addressLines Liste der Adresszeilen * @param type Der Typ der TradeParty * @return TradeParty */ public TradeParty buildAddress(String partnerID, String type) throws PluginException { if (partnerID == null || partnerID.isEmpty()) { throw new PluginException(this.getClass().getName(), KSeFAPIService.DOCUMENT_ERROR, "Missing partnerID"); } ItemCollection businessPartner = businessPartnerService.getBusinessPartnerByID(partnerID); if (businessPartner == null) { throw new PluginException(this.getClass().getName(), KSeFAPIService.DOCUMENT_ERROR, "Business Partner ID '" + partnerID + "' does not exist"); } TradeParty tradeParty = new TradeParty(type); // Name ist immer die erste Zeile tradeParty.setName(businessPartner.getItemValueString("partner.name")); tradeParty.setCountryId(businessPartner.getItemValueString("partner.country")); tradeParty.setCityName(businessPartner.getItemValueString("partner.city")); tradeParty.setPostcodeCode(businessPartner.getItemValueString("partner.zip")); tradeParty.setStreetAddress(businessPartner.getItemValueString("partner.address")); // CII-SR-314: SpecifiedTaxRegistration should not be present on // ShipToTradeParty if (!"ship_to".equals(type)) { tradeParty.setVatNumber(businessPartner.getItemValueString("partner.vat")); } return tradeParty; } /** * Parses the data list of an invoice line and returns a TradeLineItem object. * The order of the list items must be exactly! */ private TradeLineItem buildTradeLineItem(ItemCollection orderItem) { if (orderItem == null) { return null; } TradeLineItem tradeLineItem = new TradeLineItem(orderItem.getItemValueString("numpos")); tradeLineItem.setName(orderItem.getItemValueString("datev.text")); tradeLineItem.setQuantity(1); tradeLineItem.setNetPrice(orderItem.getItemValueDouble("datev.umsatz")); tradeLineItem.setGrossPrice(orderItem.getItemValueDouble("datev.umsatz")); tradeLineItem.setTotal(orderItem.getItemValueDouble("datev.umsatz")); tradeLineItem.setTaxRate(orderItem.getItemValueDouble("datev.vatrate")); return tradeLineItem; } /** * Recursively removes empty leaf elements from the document. *

* An element is considered "empty" if it has no text content, no * attributes, and no child elements. Because removing a child can make * its parent empty as well (e.g. an empty {@code DefinedTradeContact} * once all of its empty grandchildren are gone), the method processes * children first (bottom-up) and then re-checks the current node. *

* This is used to clean up template placeholder elements * (e.g. {@code }) that were never populated by the * adapter and should not appear in the final invoice. */ private void stripEmptyElements(Element element) { // First, recurse into child elements (bottom-up) NodeList children = element.getChildNodes(); for (int i = children.getLength() - 1; i >= 0; i--) { Node child = children.item(i); if (child.getNodeType() == Node.ELEMENT_NODE) { stripEmptyElements((Element) child); } } // Then remove this element itself if it is now empty Node parent = element.getParentNode(); if (parent != null && isEmptyElement(element)) { parent.removeChild(element); } } /** * Returns true if the given element has no text content, no attributes, * and no remaining child elements. */ private boolean isEmptyElement(Element element) { if (element.hasAttributes()) { return false; } if (element.getTextContent() != null && !element.getTextContent().trim().isEmpty()) { return false; } NodeList children = element.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { return false; } } return true; } /** * Removes all XML comment nodes from the given document. *

* The KSeF template carries comments to help template maintainers * (field explanations, fixed-value markers, business rules). These * comments must not appear in the final invoice that is submitted * to KSeF or reviewed by the tax advisor - they only add noise. */ private void stripComments(Document doc) { if (doc == null) { return; } removeCommentsRecursive(doc); } /** * Recursively walks a node and removes every direct child that is an * XML comment. Iterates from the last child backwards so that * removing a node does not affect the index of the still-to-visit * children. */ private void removeCommentsRecursive(Node node) { Node child = node.getLastChild(); while (child != null) { Node previous = child.getPreviousSibling(); if (child.getNodeType() == Node.COMMENT_NODE) { node.removeChild(child); } else if (child.hasChildNodes()) { removeCommentsRecursive(child); } child = previous; } } }