diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 4d896aa..8ce7fef 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -1,6 +1,12 @@
# Versionen
-## 1.3.4 (Development)
+## 1.3.5 (Development)
+
+- Verbesserter Cargosoft Export (Eingangsrechnungen)
+- Polen KSeF Schnittstelle
+- E-Rechnungs Adapter
+
+## 1.3.4
- Finalisierung Business Partner Interface
|- Neue Plugin Logik (Aktualisierung der BP Nummer und Aktivierung von BP Objekten falls diese archiviert waren)
diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceConverterAdapter.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceConverterAdapter.java
new file mode 100644
index 0000000..4b2431d
--- /dev/null
+++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceConverterAdapter.java
@@ -0,0 +1,556 @@
+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.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+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 java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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.archive.core.SnapshotService;
+import org.imixs.einvoice.EInvoiceFormatException;
+import org.imixs.einvoice.EInvoiceModel;
+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.exceptions.QueryException;
+import org.imixs.workflow.util.XMLParser;
+
+import com.alexanderlogistics.InvoiceUtil;
+
+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
+ filename
+ 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 EInvoiceConverterAdapter implements SignalAdapter {
+
+ final String TYPE_TEXTBLOCK = "textblock";
+ public static final String DOCUMENT_ERROR = "DOCUMENT_ERROR";
+ public static final String CONFIG_ERROR = "CONFIG_ERROR";
+ public static final String LINE_ITEMS_PROPERTY = "invoice.items";
+ private static Logger logger = Logger.getLogger(EInvoiceConverterAdapter.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
+ SnapshotService snapshotService;
+
+ /**
+ * 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(EInvoiceConverterAdapter.class.getSimpleName(), 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 = loadXMLTemplate(workitem, createDefinition);
+ FileData pdfFileData = loadPDF(workitem);
+
+ logger.info("│ ├── Source File: " + pdfFileData.getName());
+
+ } catch (PluginException e) {
+ throw new AdapterException(e);
+ // throw new AdapterException(
+ // EInvoiceConverterAdapter.class.getSimpleName(), e.getErrorCode(),
+ // e.getMessage());
+
+ }
+
+ return workitem;
+ }
+
+ /**
+ * This helper method tries to find the pdf file matching the item
+ * 'cargosoft.import.filename'. If not found the method returns the first .pdf
+ * file form the list of attachments. The method returns null if no pdf file
+ * exits
+ *
+ * @param workitem
+ * @return the pdf FileData object
+ * @throws PluginException if not pdf file was found
+ */
+ private FileData loadPDF(ItemCollection workitem) throws PluginException {
+ FileData pdfFileData = null;
+ String cargosoftFileName = workitem.getItemValueString("cargosoft.import.filename");
+ if (!cargosoftFileName.isEmpty()) {
+ cargosoftFileName = cargosoftFileName.replace(".xml", ".pdf");
+
+ // try to load filedata....
+ pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), cargosoftFileName);
+
+ }
+
+ if (pdfFileData == null) {
+ // take first pdf file available
+ List fileNames = workitem.getFileNames();
+ for (String filename : fileNames) {
+
+ if (filename.toLowerCase().endsWith(".pdf")) {
+ pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), filename);
+ break;
+ }
+ }
+ }
+
+ if (pdfFileData == null) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ DOCUMENT_ERROR,
+ "DOCUMENT_ERROR: no pdf file found!");
+ }
+ return pdfFileData;
+
+ }
+
+ 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(EInvoiceConverterAdapter.class.getSimpleName(), 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
+ */
+ @SuppressWarnings("unchecked")
+ private void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
+
+ try {
+ EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent()));
+
+ model.setId(workitem.getItemValueString("invoice.number"));
+
+ // Update Rechnungssummen...
+ if (workitem.getItemValueDouble("invoice.vat.rate") > 0) {
+ // wir haben eine Steuer!
+ model.setTaxTotalAmount(InvoiceUtil.round(workitem.getItemValueDouble("invoice.amount.total")
+ - workitem.getItemValueDouble("invoice.amount.net")));
+ model.setTaxRate(workitem.getItemValueDouble("invoice.vat.rate"));
+ }
+
+ // // Update Invoice Items
+ // List invoiceItems = explodeChildList(workitem);
+ // double lineTotalAmount = 0.00;
+ // for (ItemCollection invoiceItem : invoiceItems) {
+ // TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem);
+ // model.setTradeLineItem(tradeLineItem);
+ // lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal();
+ // }
+
+ // // Summenbildung
+ // model.setNetTotalAmount(workitem.getItemValueDouble("invoice.amount.net"));
+ // model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.amount.total"));
+ // model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
+
+ // // Update Addresses
+ // TradeParty billingAddress =
+ // parseAddress(workitem.getItemValue("dbtr.address"), "buyer");
+ // model.setTradeParty(billingAddress);
+
+ // TradeParty shippingAddress =
+ // parseAddress(workitem.getItemValue("dbtr.address.shipping"), "ship_to");
+ // model.setTradeParty(shippingAddress);
+
+ // model.setOrderReferenceId(workitem.getItemValueString("order.number"));
+
+ // // finally update the template file
+ // fileDataXMLTemplate.setContent(model.getContent());
+
+ } catch (FileNotFoundException | EInvoiceFormatException e) {
+ throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
+ }
+
+ }
+
+ /**
+ * Parses a list of address lines into a TradeParty object using best-guess
+ * approach
+ */
+ /**
+ * Erstellt ein TradeParty Objekt aus einer Liste von Adresszeilen.
+ *
+ * @param addressLines Liste der Adresszeilen
+ * @param type Der Typ der TradeParty
+ * @return TradeParty
+ */
+ public static TradeParty parseAddress(List addressLines, String type) {
+ if (addressLines == null || addressLines.isEmpty()) {
+ throw new IllegalArgumentException("Address lines cannot be null or empty");
+ }
+
+ TradeParty tradeParty = new TradeParty(type);
+
+ // Name ist immer die erste Zeile
+ tradeParty.setName(addressLines.get(0).trim());
+
+ // Letzte Zeile analysieren
+ String lastLine = addressLines.get(addressLines.size() - 1).trim();
+
+ // Prüfen ob die letzte Zeile ein PLZ + Stadt Format hat
+ if (hasPostcodeAndCity(lastLine)) {
+ tradeParty.setCountryId("DE");
+ parsePostcodeAndCity(lastLine, tradeParty);
+
+ // Straßenadresse aus den Zeilen zwischen Name und PLZ/Stadt
+ buildStreetAddress(addressLines.subList(1, addressLines.size() - 1), tradeParty);
+ } else {
+ // Wenn die letzte Zeile kein PLZ + Stadt Format hat, dann ist es ein Land
+ String countryCode = resolveISOCountryCodes(lastLine.toUpperCase());
+ tradeParty.setCountryId(countryCode);
+ // Stadt und PLZ sind in der vorletzten Zeile
+ if (addressLines.size() > 2) {
+ parsePostcodeAndCity(addressLines.get(addressLines.size() - 2), tradeParty);
+ // Straßenadresse aus den Zeilen zwischen Name und PLZ/Stadt/Land
+ buildStreetAddress(addressLines.subList(1, addressLines.size() - 2), tradeParty);
+ }
+ }
+
+ return tradeParty;
+ }
+
+ /**
+ * Hilfemethode zum übersetzen von ISO Country codes
+ *
+ * @param country
+ * @return
+ */
+ private static String resolveISOCountryCodes(String country) {
+ String countryCode = country.toUpperCase().substring(0, 2);
+
+ // Bulgarien -> BG
+ countryCode = countryCode.replace("BU", "BG");
+ return countryCode;
+ }
+
+ /**
+ * Baut die Straßenadresse aus den gegebenen Zeilen
+ */
+ private static void buildStreetAddress(List streetLines, TradeParty tradeParty) {
+ if (streetLines.isEmpty())
+ return;
+
+ StringBuilder streetAddress = new StringBuilder();
+ for (int i = 0; i < streetLines.size(); i++) {
+ if (i > 0) {
+ streetAddress.append(", ");
+ }
+ streetAddress.append(streetLines.get(i).trim());
+ }
+ tradeParty.setStreetAddress(streetAddress.toString());
+ }
+
+ /**
+ * Prüft ob eine Zeile PLZ und Stadt enthält.
+ * Eine PLZ wird als 4-5 stellige Zahl am Anfang der Zeile erkannt.
+ */
+ private static boolean hasPostcodeAndCity(String line) {
+ String[] parts = line.trim().split("\\s+", 2);
+ if (parts.length < 2)
+ return false;
+
+ String potentialPostcode = parts[0];
+ return potentialPostcode.matches("\\d{4,5}");
+ }
+
+ /**
+ * Analysiert die PLZ und Stadt aus einer Zeile.
+ */
+ private static void parsePostcodeAndCity(String line, TradeParty tradeParty) {
+ String[] parts = line.trim().split("\\s+", 2);
+ if (parts.length >= 2) {
+ tradeParty.setPostcodeCode(parts[0].trim());
+ tradeParty.setCityName(parts[1].trim());
+ } else {
+ tradeParty.setCityName(line.trim());
+ }
+ }
+
+ /**
+ * 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("id"));
+
+ tradeLineItem.setName(orderItem.getItemValueString("name"));
+ tradeLineItem.setQuantity(orderItem.getItemValueDouble("quantity"));
+ tradeLineItem.setNetPrice(orderItem.getItemValueDouble("price"));
+ tradeLineItem.setTotal(orderItem.getItemValueDouble("total"));
+ tradeLineItem.setTaxRate(orderItem.getItemValueDouble("vat"));
+
+ // if (orderItem.hasItem("orderid")) {
+ // tradeLineItem.setOrderReferenceId(orderItem.getItemValueString("orderid"));
+ // }
+ return tradeLineItem;
+ }
+
+ /**
+ * Generic method to set a item value by a regex
+ *
+ * @param text
+ * @param regex
+ * @return
+ */
+ @SuppressWarnings("rawtypes")
+ private void setItemValueByRegex(ItemCollection workitem, String itemName, String content, String regex,
+ Class itemType) {
+
+ String value = getValueByRegex(content, regex);
+ logger.info(itemName + "=" + value);
+ if (value.isEmpty()) {
+ return; // no value found
+ }
+
+ // Parse Double?
+ if (itemType == Double.class) {
+ if (value.contains(",")) {
+ value = value.replace(".", "");
+ }
+ value = value.replace(",", ".");
+ workitem.setItemValue(itemName, Double.parseDouble(value));
+ } else
+ // Parse Integer
+ if (itemType == Integer.class) {
+ workitem.setItemValue(itemName, Integer.parseInt(value));
+ } else
+ // Parse Date
+ if (itemType == Date.class) {
+ try {
+ workitem.setItemValue(itemName, dateFormatter.parse(value));
+ } catch (ParseException e) {
+ logger.warning("Date value " + value + " not parsable: " + e.getMessage());
+ workitem.setItemValue(itemName, value);
+ }
+ } else {
+ workitem.setItemValue(itemName, value);
+ }
+
+ }
+
+ /**
+ * Generic method to extract a text fragment by regex
+ *
+ * @param text
+ * @param regex
+ * @return
+ */
+ private String getValueByRegex(String text, String regex) {
+ String result = "";
+ Pattern datumPattern = Pattern.compile(regex);
+ Matcher datumMatcher = datumPattern.matcher(text);
+ if (datumMatcher.find()) {
+ result = datumMatcher.group(1);
+ }
+ return result;
+ }
+
+ /**
+ * This method loads a text-block for a specified ref and appends the named
+ * fileData object of this document.
+ *
+ * @param document
+ * @throws PluginException
+ */
+
+ private FileData loadXMLTemplate(ItemCollection workitem, ItemCollection config)
+ throws PluginException {
+
+ String textblock = config.getItemValueString("textblock");
+ String template = config.getItemValueString("template");
+ String sourceName = config.getItemValueString("source");
+
+ try {
+ debug = Boolean.parseBoolean(config.getItemValueString("debug"));
+ } catch (Exception e) {
+ }
+ String targetName = "factur-x.xml";
+
+ // adapt text....
+ sourceName = workflowService.adaptText(sourceName, workitem);
+
+ if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ CONFIG_ERROR,
+ "invalid e-invoice configuration in model event - textblock/template reference not defined!");
+ }
+
+ // load the text block
+ FileData fileData = loadTextBlockFileData(textblock, template);
+
+ // do we found the document?
+ if (fileData == null) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ CONFIG_ERROR,
+ "invalid e-invoice configuration in model event - textblock/template: " + textblock + "/" + template
+ + " not found!");
+ }
+ fileData.setName(targetName);
+
+ return fileData;
+ }
+
+ /**
+ * This method returns a text-block ItemCollection for a specified name.
+ *
+ * @param name in attribute txtname
+ *
+ *
+ */
+ public FileData loadTextBlockFileData(String name, String fileName) {
+ ItemCollection textBlockItemCollection = null;
+
+ // load text-block by name....
+ String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")";
+ Collection col;
+ try {
+ // find the textblock...
+ col = documentService.find(sQuery, 1, 0);
+ if (col.size() > 0) {
+ textBlockItemCollection = col.iterator().next();
+ // fetch the fileData...
+ return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName);
+
+ } else {
+ logger.warning("Missing text-block : '" + name + "'");
+ }
+ } catch (QueryException e) {
+ logger.warning("getTextBlock - invalid query: " + e.getMessage());
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java
new file mode 100644
index 0000000..4e04a87
--- /dev/null
+++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java
@@ -0,0 +1,548 @@
+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.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+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 java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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.archive.core.SnapshotService;
+import org.imixs.einvoice.EInvoiceFormatException;
+import org.imixs.einvoice.EInvoiceModel;
+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.exceptions.QueryException;
+import org.imixs.workflow.util.XMLParser;
+
+import com.alexanderlogistics.InvoiceUtil;
+
+import jakarta.inject.Inject;
+
+/**
+ * The KSeFAdapter converts a Cargsoft outbound invoice into an KSeF XML
+ * e-invoice and sends the xml file to the polish KSeF API
+ *
+ * The adapter can be configured by the model:
+ *
+ *
+ * {@code
+
+ textblock-ref
+ filename
+ 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 KSeFAdapter implements SignalAdapter {
+
+ final String TYPE_TEXTBLOCK = "textblock";
+ public static final String DOCUMENT_ERROR = "DOCUMENT_ERROR";
+ public static final String CONFIG_ERROR = "CONFIG_ERROR";
+ public static final String LINE_ITEMS_PROPERTY = "invoice.items";
+ private static Logger logger = Logger.getLogger(EInvoiceConverterAdapter.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
+ SnapshotService snapshotService;
+
+ /**
+ * This method
+ *
+ * @throws PluginException
+ */
+ @Override
+ public ItemCollection execute(ItemCollection workitem, ItemCollection event)
+ throws AdapterException, PluginException {
+
+ logger.info("├── 🔜 Convert Invoice to KSeF...");
+ // read configuration....
+ ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "e-invoice",
+ workitem,
+ false);
+ if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("ksef")) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(), CONFIG_ERROR,
+ "missing e-invoice/ksef configuration in model event - please check model configuration");
+ }
+ ItemCollection ksefDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("ksef"));
+ try {
+
+ // Load the e-invoice template....
+ FileData xmlFileData = loadXMLTemplate(workitem, ksefDefinition);
+ FileData pdfFileData = loadPDF(workitem);
+
+ logger.info("│ ├── Source File: " + pdfFileData.getName());
+
+ if (true)
+ throw new AdapterException(KSeFAdapter.class.getSimpleName(), CONFIG_ERROR, "NOT YET IMPLEMENTED");
+ } catch (PluginException e) {
+ throw new AdapterException(e);
+ // throw new AdapterException(
+ // EInvoiceConverterAdapter.class.getSimpleName(), e.getErrorCode(),
+ // e.getMessage());
+
+ }
+
+ return workitem;
+ }
+
+ /**
+ * This helper method tries to find the pdf file matching the item
+ * 'cargosoft.import.filename'. If not found the method returns the first .pdf
+ * file form the list of attachments. The method returns null if no pdf file
+ * exits
+ *
+ * @param workitem
+ * @return the pdf FileData object
+ * @throws PluginException if not pdf file was found
+ */
+ private FileData loadPDF(ItemCollection workitem) throws PluginException {
+ FileData pdfFileData = null;
+ String cargosoftFileName = workitem.getItemValueString("cargosoft.import.filename");
+ if (!cargosoftFileName.isEmpty()) {
+ cargosoftFileName = cargosoftFileName.replace(".xml", ".pdf");
+
+ // try to load filedata....
+ pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), cargosoftFileName);
+
+ }
+
+ if (pdfFileData == null) {
+ // take first pdf file available
+ List fileNames = workitem.getFileNames();
+ for (String filename : fileNames) {
+
+ if (filename.toLowerCase().endsWith(".pdf")) {
+ pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), filename);
+ break;
+ }
+ }
+ }
+
+ if (pdfFileData == null) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ DOCUMENT_ERROR,
+ "DOCUMENT_ERROR: no pdf file found!");
+ }
+ return pdfFileData;
+
+ }
+
+ 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(EInvoiceConverterAdapter.class.getSimpleName(), 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
+ */
+ @SuppressWarnings("unchecked")
+ private void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
+
+ try {
+ EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent()));
+
+ model.setId(workitem.getItemValueString("invoice.number"));
+
+ // Update Rechnungssummen...
+ if (workitem.getItemValueDouble("invoice.vat.rate") > 0) {
+ // wir haben eine Steuer!
+ model.setTaxTotalAmount(InvoiceUtil.round(workitem.getItemValueDouble("invoice.amount.total")
+ - workitem.getItemValueDouble("invoice.amount.net")));
+ model.setTaxRate(workitem.getItemValueDouble("invoice.vat.rate"));
+ }
+
+ // // Update Invoice Items
+ // List invoiceItems = explodeChildList(workitem);
+ // double lineTotalAmount = 0.00;
+ // for (ItemCollection invoiceItem : invoiceItems) {
+ // TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem);
+ // model.setTradeLineItem(tradeLineItem);
+ // lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal();
+ // }
+
+ // // Summenbildung
+ // model.setNetTotalAmount(workitem.getItemValueDouble("invoice.amount.net"));
+ // model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.amount.total"));
+ // model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
+
+ // // Update Addresses
+ // TradeParty billingAddress =
+ // parseAddress(workitem.getItemValue("dbtr.address"), "buyer");
+ // model.setTradeParty(billingAddress);
+
+ // TradeParty shippingAddress =
+ // parseAddress(workitem.getItemValue("dbtr.address.shipping"), "ship_to");
+ // model.setTradeParty(shippingAddress);
+
+ // model.setOrderReferenceId(workitem.getItemValueString("order.number"));
+
+ // // finally update the template file
+ // fileDataXMLTemplate.setContent(model.getContent());
+
+ } catch (FileNotFoundException | EInvoiceFormatException e) {
+ throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
+ }
+
+ }
+
+ /**
+ * Parses a list of address lines into a TradeParty object using best-guess
+ * approach
+ */
+ /**
+ * Erstellt ein TradeParty Objekt aus einer Liste von Adresszeilen.
+ *
+ * @param addressLines Liste der Adresszeilen
+ * @param type Der Typ der TradeParty
+ * @return TradeParty
+ */
+ public static TradeParty parseAddress(List addressLines, String type) {
+ if (addressLines == null || addressLines.isEmpty()) {
+ throw new IllegalArgumentException("Address lines cannot be null or empty");
+ }
+
+ TradeParty tradeParty = new TradeParty(type);
+
+ // Name ist immer die erste Zeile
+ tradeParty.setName(addressLines.get(0).trim());
+
+ // Letzte Zeile analysieren
+ String lastLine = addressLines.get(addressLines.size() - 1).trim();
+
+ // Prüfen ob die letzte Zeile ein PLZ + Stadt Format hat
+ if (hasPostcodeAndCity(lastLine)) {
+ tradeParty.setCountryId("DE");
+ parsePostcodeAndCity(lastLine, tradeParty);
+
+ // Straßenadresse aus den Zeilen zwischen Name und PLZ/Stadt
+ buildStreetAddress(addressLines.subList(1, addressLines.size() - 1), tradeParty);
+ } else {
+ // Wenn die letzte Zeile kein PLZ + Stadt Format hat, dann ist es ein Land
+ String countryCode = resolveISOCountryCodes(lastLine.toUpperCase());
+ tradeParty.setCountryId(countryCode);
+ // Stadt und PLZ sind in der vorletzten Zeile
+ if (addressLines.size() > 2) {
+ parsePostcodeAndCity(addressLines.get(addressLines.size() - 2), tradeParty);
+ // Straßenadresse aus den Zeilen zwischen Name und PLZ/Stadt/Land
+ buildStreetAddress(addressLines.subList(1, addressLines.size() - 2), tradeParty);
+ }
+ }
+
+ return tradeParty;
+ }
+
+ /**
+ * Hilfemethode zum übersetzen von ISO Country codes
+ *
+ * @param country
+ * @return
+ */
+ private static String resolveISOCountryCodes(String country) {
+ String countryCode = country.toUpperCase().substring(0, 2);
+
+ // Bulgarien -> BG
+ countryCode = countryCode.replace("BU", "BG");
+ return countryCode;
+ }
+
+ /**
+ * Baut die Straßenadresse aus den gegebenen Zeilen
+ */
+ private static void buildStreetAddress(List streetLines, TradeParty tradeParty) {
+ if (streetLines.isEmpty())
+ return;
+
+ StringBuilder streetAddress = new StringBuilder();
+ for (int i = 0; i < streetLines.size(); i++) {
+ if (i > 0) {
+ streetAddress.append(", ");
+ }
+ streetAddress.append(streetLines.get(i).trim());
+ }
+ tradeParty.setStreetAddress(streetAddress.toString());
+ }
+
+ /**
+ * Prüft ob eine Zeile PLZ und Stadt enthält.
+ * Eine PLZ wird als 4-5 stellige Zahl am Anfang der Zeile erkannt.
+ */
+ private static boolean hasPostcodeAndCity(String line) {
+ String[] parts = line.trim().split("\\s+", 2);
+ if (parts.length < 2)
+ return false;
+
+ String potentialPostcode = parts[0];
+ return potentialPostcode.matches("\\d{4,5}");
+ }
+
+ /**
+ * Analysiert die PLZ und Stadt aus einer Zeile.
+ */
+ private static void parsePostcodeAndCity(String line, TradeParty tradeParty) {
+ String[] parts = line.trim().split("\\s+", 2);
+ if (parts.length >= 2) {
+ tradeParty.setPostcodeCode(parts[0].trim());
+ tradeParty.setCityName(parts[1].trim());
+ } else {
+ tradeParty.setCityName(line.trim());
+ }
+ }
+
+ /**
+ * 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("id"));
+
+ tradeLineItem.setName(orderItem.getItemValueString("name"));
+ tradeLineItem.setQuantity(orderItem.getItemValueDouble("quantity"));
+ tradeLineItem.setNetPrice(orderItem.getItemValueDouble("price"));
+ tradeLineItem.setTotal(orderItem.getItemValueDouble("total"));
+ tradeLineItem.setTaxRate(orderItem.getItemValueDouble("vat"));
+
+ // if (orderItem.hasItem("orderid")) {
+ // tradeLineItem.setOrderReferenceId(orderItem.getItemValueString("orderid"));
+ // }
+ return tradeLineItem;
+ }
+
+ /**
+ * Generic method to set a item value by a regex
+ *
+ * @param text
+ * @param regex
+ * @return
+ */
+ @SuppressWarnings("rawtypes")
+ private void setItemValueByRegex(ItemCollection workitem, String itemName, String content, String regex,
+ Class itemType) {
+
+ String value = getValueByRegex(content, regex);
+ logger.info(itemName + "=" + value);
+ if (value.isEmpty()) {
+ return; // no value found
+ }
+
+ // Parse Double?
+ if (itemType == Double.class) {
+ if (value.contains(",")) {
+ value = value.replace(".", "");
+ }
+ value = value.replace(",", ".");
+ workitem.setItemValue(itemName, Double.parseDouble(value));
+ } else
+ // Parse Integer
+ if (itemType == Integer.class) {
+ workitem.setItemValue(itemName, Integer.parseInt(value));
+ } else
+ // Parse Date
+ if (itemType == Date.class) {
+ try {
+ workitem.setItemValue(itemName, dateFormatter.parse(value));
+ } catch (ParseException e) {
+ logger.warning("Date value " + value + " not parsable: " + e.getMessage());
+ workitem.setItemValue(itemName, value);
+ }
+ } else {
+ workitem.setItemValue(itemName, value);
+ }
+
+ }
+
+ /**
+ * Generic method to extract a text fragment by regex
+ *
+ * @param text
+ * @param regex
+ * @return
+ */
+ private String getValueByRegex(String text, String regex) {
+ String result = "";
+ Pattern datumPattern = Pattern.compile(regex);
+ Matcher datumMatcher = datumPattern.matcher(text);
+ if (datumMatcher.find()) {
+ result = datumMatcher.group(1);
+ }
+ return result;
+ }
+
+ /**
+ * This method loads a text-block for a specified ref and appends the named
+ * fileData object of this document.
+ *
+ * @param document
+ * @throws PluginException
+ */
+
+ private FileData loadXMLTemplate(ItemCollection workitem, ItemCollection config)
+ throws PluginException {
+
+ String textblock = config.getItemValueString("textblock");
+ String template = config.getItemValueString("template");
+ String sourceName = config.getItemValueString("source");
+
+ try {
+ debug = Boolean.parseBoolean(config.getItemValueString("debug"));
+ } catch (Exception e) {
+ }
+ String targetName = "factur-x.xml";
+
+ // adapt text....
+ sourceName = workflowService.adaptText(sourceName, workitem);
+
+ if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ CONFIG_ERROR,
+ "invalid e-invoice configuration in model event - textblock/template reference not defined!");
+ }
+
+ // load the text block
+ FileData fileData = loadTextBlockFileData(textblock, template);
+
+ // do we found the document?
+ if (fileData == null) {
+ throw new PluginException(EInvoiceConverterAdapter.class.getSimpleName(),
+ CONFIG_ERROR,
+ "invalid e-invoice configuration in model event - textblock/template: " + textblock + "/" + template
+ + " not found!");
+ }
+ fileData.setName(targetName);
+
+ return fileData;
+ }
+
+ /**
+ * This method returns a text-block ItemCollection for a specified name.
+ *
+ * @param name in attribute txtname
+ *
+ *
+ */
+ public FileData loadTextBlockFileData(String name, String fileName) {
+ ItemCollection textBlockItemCollection = null;
+
+ // load text-block by name....
+ String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")";
+ Collection col;
+ try {
+ // find the textblock...
+ col = documentService.find(sQuery, 1, 0);
+ if (col.size() > 0) {
+ textBlockItemCollection = col.iterator().next();
+ // fetch the fileData...
+ return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName);
+
+ } else {
+ logger.warning("Missing text-block : '" + name + "'");
+ }
+ } catch (QueryException e) {
+ logger.warning("getTextBlock - invalid query: " + e.getMessage());
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/workflow/pl/e-invoice/factur-x.xml b/workflow/pl/e-invoice/factur-x.xml
new file mode 100644
index 0000000..29f33c1
--- /dev/null
+++ b/workflow/pl/e-invoice/factur-x.xml
@@ -0,0 +1,113 @@
+
+
+
+
+ urn:cen.eu:en16931:2017
+
+
+
+
+ 380
+
+
+
+
+ Wir berechnen heute unter Zugrundelegung unserer allgemeinen
+ Geschäftsbedingungen, die Ihnen bereits
+ übersendet wurden oder die Sie unter www.mueller-ahlhorn.com nachlesen können, wie
+ folgt:
+
+
+ Bitte zahlen Sie ab sofort alle Rechnungen auf unser Konto bei der
+ Landessparkasse zu Oldenburg, BIC
+ SLZODE22XXX, IBAN DE04 2805 0100 0092 2828 70. Etwaig anfallende Bankspesen gehen zu
+ Ihren Lasten.
+
+
+ Dr. Dietrich Müller GmbH
+
+
+ Geschäftsführer: Dr. Michael Müller
+
+
+ Amtsgericht Oldenburg HRB 209026
+
+
+ info@mueller-ahlhorn.com
+
+
+
+
+
+
+
+ Dr. Dietrich Müller GmbH
+
+ Müller, Michael
+
+ +4944359710261
+
+
+ info@mueller-ahlhorn.com
+
+
+
+ 26197
+ Zeppelinring 18
+ Ahlhorn
+ DE
+
+
+ info@mueller-ahlhorn.com
+
+
+ DE295969093
+
+
+
+
+
+
+
+
+
+ EUR
+
+ 30
+
+ DE04 2805 0100 0092 2828 70
+
+
+ SLZODE22XXX
+
+
+
+ 0.00
+ VAT
+ 0.00
+ Z
+ 0.00
+
+
+ Wir berechnen heute unter Zugrundelegung unserer allgemeinen
+ Geschäftsbedingungen, die Ihnen bereits
+ übersendet wurden oder die Sie unter www.mueller-ahlhorn.com nachlesen können.
+
+
+ 0.00
+ 0.00
+ 0.00
+ 0.00
+ 0.00
+ 0.00
+ 0.00
+ 0.00
+
+
+
+
\ No newline at end of file
diff --git a/workflow/pl/rechnungsausgang-pl-1.1.0.bpmn b/workflow/pl/rechnungsausgang-pl-1.1.0.bpmn
new file mode 100644
index 0000000..3ffeffa
--- /dev/null
+++ b/workflow/pl/rechnungsausgang-pl-1.1.0.bpmn
@@ -0,0 +1,2628 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+$workflowstatus
+
+]]>
+
+
+ In case you have already made the payment, please disregard this notice. If you require any information regarding your account, please do not hesitate to contact us.
+Thank you for your cooperation.
+
+Kind regards
Alexander Global Logistics GmbH
+
+