diff --git a/imixs-archive-documents/src/main/java/org/imixs/archive/documents/EInvoiceMetaAdapter.java b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/EInvoiceMetaAdapter.java new file mode 100644 index 0000000..1aa60bd --- /dev/null +++ b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/EInvoiceMetaAdapter.java @@ -0,0 +1,84 @@ +package org.imixs.archive.documents; + +import java.util.logging.Logger; + +import org.imixs.workflow.FileData; +import org.imixs.workflow.ItemCollection; +import org.imixs.workflow.exceptions.AdapterException; +import org.imixs.workflow.exceptions.PluginException; + +/** + * The EInvoiceAutoAdapter can detect and extract content from e-invoice + * documents + * in different formats. This Adapter class extends the {@link EInvoiceAdapter} + * and resolves pre defined Items according to the Factur-X/ZUGFeRD 2.0 + * standard. + * + * @author rsoika + * @version 2.0 + * + */ +public class EInvoiceMetaAdapter extends EInvoiceAdapter { + private static Logger logger = Logger.getLogger(EInvoiceAdapter.class.getName()); + + /** + * Executes the e-invoice detection process on the given workitem. + * It attempts to detect the e-invoice format from attached files and + * updates the workitem with the result. + * + * @param workitem The workitem to process + * @param event The event triggering this execution + * @return The updated workitem + * @throws AdapterException If there's an error in the adapter execution + * @throws PluginException If there's an error in plugin processing + */ + @Override + public ItemCollection execute(ItemCollection workitem, ItemCollection event) + throws AdapterException, PluginException { + + // Detect and read E-Invoice Data + FileData eInvoiceFileData = detectEInvoice(workitem); + + if (eInvoiceFileData == null) { + logger.info("No e-invoice type detected."); + return workitem; + } else { + String einvoiceType = detectEInvoiceType(eInvoiceFileData); + workitem.setItemValue(FILE_ATTRIBUTE_EINVOICE_TYPE, einvoiceType); + logger.info("Detected e-invoice type: " + einvoiceType); + + // readEInvoiceContent(eInvoiceFileData, workitem); + } + + return workitem; + } + + /** + * This method resolves the content of a factur-x e-invocie file and extracts + * all invoice and customer fields. + * + * This is the variant without Mustang Project + * + * + * @param xmlData + * @return + * @throws PluginException + */ + private void readEInvoiceContentNativeXML(FileData eInvoiceFileData, + ItemCollection workitem) throws PluginException { + byte[] xmlData = readXMLContent(eInvoiceFileData); + logger.info("Autodetect e-invoice data..."); + + createXMLDoc(xmlData); + + readItem(workitem, "//rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:ID", "text", "invoice.number"); + readItem(workitem, "//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString/text()", "date", + "invoice.date"); + readItem(workitem, "//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount", "double", + "invoice.total"); + readItem(workitem, "//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:Name/text()", "text", + "cdtr.name"); + + } + +} diff --git a/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModel.java b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModel.java new file mode 100644 index 0000000..3b4aeea --- /dev/null +++ b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModel.java @@ -0,0 +1,446 @@ +package org.imixs.archive.documents.einvoice; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * A EInvoiceModel represents the dom tree of a e-invoice. + *
+ * The model elements can be read only.
+ *
+ * @author rsoika
+ *
+ */
+public class EInvoiceModel {
+ protected static Logger logger = Logger.getLogger(EInvoiceModel.class.getName());
+
+ private static final SecureRandom random = new SecureRandom();
+ private static final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+
+ private Document doc;
+ private Element crossIndustryInvoice;
+ private Element exchangedDocumentContext;
+ private Element exchangedDocument;
+ private Element supplyChainTradeTransaction;
+
+ // elements
+ protected String id = null;
+ protected String buyerReference = null;
+ protected LocalDate issueDateTime = null;
+ protected BigDecimal grandTotalAmount = new BigDecimal("0.00");
+ protected BigDecimal taxTotalAmount = new BigDecimal("0.00");
+ protected BigDecimal netTotalAmount = new BigDecimal("0.00");
+ protected Set
+ *
+ *
+ *
+ * @param doc
+ */
+ public EInvoiceModel(Document doc) {
+ this();
+ tradeParties = new LinkedHashSet<>();
+
+ if (doc != null) {
+ this.doc = doc;
+
+ crossIndustryInvoice = doc.getDocumentElement();
+
+ // parse the BPMN namespaces
+ NamedNodeMap defAttributes = crossIndustryInvoice.getAttributes();
+ for (int j = 0; j < defAttributes.getLength(); j++) {
+ Node node = defAttributes.item(j);
+
+ if (getPrefix(EInvoiceNS.A).equals(node.getLocalName())
+ && !getUri(EInvoiceNS.A).equals(node.getNodeValue())) {
+ logger.fine("...set A namespace URI: " + node.getNodeValue());
+ setUri(EInvoiceNS.A, node.getNodeValue());
+ }
+ if (getPrefix(EInvoiceNS.RSM).equals(node.getLocalName())
+ && !getUri(EInvoiceNS.RSM).equals(node.getNodeValue())) {
+ logger.fine("...set RSM namespace URI: " + node.getNodeValue());
+ setUri(EInvoiceNS.RSM, node.getNodeValue());
+ }
+ if (getPrefix(EInvoiceNS.QDT).equals(node.getLocalName())
+ && !getUri(EInvoiceNS.QDT).equals(node.getNodeValue())) {
+ logger.fine("...set QDT namespace URI: " + node.getNodeValue());
+ setUri(EInvoiceNS.QDT, node.getNodeValue());
+ }
+ if (getPrefix(EInvoiceNS.RAM).equals(node.getLocalName())
+ && !getUri(EInvoiceNS.RAM).equals(node.getNodeValue())) {
+ logger.fine("...set RAM namespace URI: " + node.getNodeValue());
+ setUri(EInvoiceNS.RAM, node.getNodeValue());
+ }
+ if (getPrefix(EInvoiceNS.UDT).equals(node.getLocalName())
+ && !getUri(EInvoiceNS.UDT).equals(node.getNodeValue())) {
+ logger.fine("...set UDT namespace URI: " + node.getNodeValue());
+ setUri(EInvoiceNS.UDT, node.getNodeValue());
+ }
+
+ }
+
+ exchangedDocumentContext = findChildNodeByName(crossIndustryInvoice, EInvoiceNS.RSM,
+ "ExchangedDocumentContext");
+ exchangedDocument = findChildNodeByName(crossIndustryInvoice, EInvoiceNS.RSM, "ExchangedDocument");
+ supplyChainTradeTransaction = findChildNodeByName(crossIndustryInvoice, EInvoiceNS.RSM,
+ "SupplyChainTradeTransaction");
+
+ // Load e-invoice standard data
+ loadDocumentCoreData();
+
+ }
+ }
+
+ public Document getDoc() {
+ return doc;
+ }
+
+ public Element getCrossIndustryInvoice() {
+ return crossIndustryInvoice;
+ }
+
+ public Element getExchangedDocumentContext() {
+ return exchangedDocumentContext;
+ }
+
+ public Element getExchangedDocument() {
+ return exchangedDocument;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public LocalDate getIssueDateTime() {
+ return issueDateTime;
+ }
+
+ public BigDecimal getGrandTotalAmount() {
+ return grandTotalAmount;
+ }
+
+ public BigDecimal getTaxTotalAmount() {
+ return taxTotalAmount;
+ }
+
+ public BigDecimal getNetTotalAmount() {
+ return netTotalAmount;
+ }
+
+ /**
+ * Returns all trade parties
+ *
+ * @return
+ */
+ public Set
+ * The method compares the Name including the namespace of the child elements.
+ *
+ * See also {@link #findChildNodeByName(Element parent, String nodeName)
+ * findChildNodeByName}
+ *
+ * @param parent
+ * @param nodeName
+ * @return - list of nodes. If no nodes were found, the method returns an empty
+ * list
+ */
+ public Set
+ * This is necessary because a model can work with the default namespace
+ * prefix. The model instance automatically detects the used
+ * namespace prefix and updates the prefix when loading a model file.
+ *
+ * @param ns
+ * @return
+ */
+ public String getPrefix(EInvoiceNS ns) {
+ return PREFIX_BY_NAMESPACE.get(ns);
+ }
+
+ /**
+ * Updates the namespace prefix for a given BPMN namespace - e.g. 'bpmn2' or
+ * 'bpmndi'
+ * The method automatically adds the prefix separator ':' if the prefix is not
+ * empty. This is necessary to handle default namespaces without a prefix
+ * correctly
+ */
+ public void setPrefix(EInvoiceNS ns, String prefix) {
+ if (prefix == null) {
+ prefix = "";
+ }
+ if (prefix.isEmpty()) {
+ PREFIX_BY_NAMESPACE.put(ns, prefix);
+ } else {
+ PREFIX_BY_NAMESPACE.put(ns, prefix + ":");
+ }
+
+ }
+
+}
diff --git a/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModelFactory.java b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModelFactory.java
new file mode 100644
index 0000000..19724dc
--- /dev/null
+++ b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceModelFactory.java
@@ -0,0 +1,106 @@
+package org.imixs.archive.documents.einvoice;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.logging.Logger;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.xml.sax.SAXException;
+
+/**
+ * The BPMNModelFactory can be used to load or create a BPMNModel instance.
+ *
+ * @author rsoika
+ *
+ */
+public class EInvoiceModelFactory {
+ private static Logger logger = Logger.getLogger(EInvoiceModelFactory.class.getName());
+
+ /**
+ * Reads a EInvoiceModel instance from an java.io.File
+ *
+ * @param modelFile
+ * @return a EInvoiceModel instance
+ * @throws FileNotFoundException
+ *
+ */
+ public static EInvoiceModel read(File modelFile) throws FileNotFoundException {
+
+ return read(new FileInputStream(modelFile));
+
+ }
+
+ /**
+ * Reads a EInvoiceModel instance from an given file path
+ *
+ * @param modelFile
+ * @return a EInvoiceModel instance
+ * @throws FileNotFoundException
+ */
+ public static EInvoiceModel read(String modelFilePath) throws FileNotFoundException {
+ return read(EInvoiceModel.class.getResourceAsStream(modelFilePath));
+ }
+
+ /**
+ * Reads a EInvoiceModel instance from an InputStream.
+ *
+ *
+ *
+ * @param modelFile
+ * @return a EInvoiceModel instance
+ * @throws FileNotFoundException
+ */
+ public static EInvoiceModel read(InputStream is) throws FileNotFoundException {
+ logger.fine("read from inputStream...");
+ if (is == null) {
+ throw new NullPointerException(
+ "Model can not be parsed: InputStream is null");
+ }
+ DocumentBuilderFactory docFactory = DocumentBuilderFactory
+ .newInstance();
+ docFactory.setIgnoringElementContentWhitespace(true); // because of a bug this does not have
+ // any effect!
+ docFactory.setNamespaceAware(true);
+
+ try {
+ if (is.available() == 0) {
+ logger.warning("Empty BPMN file - creating a default process");
+ throw new IOException(
+ "Model can not be parsed: No Content");
+ }
+
+ // parse XML file
+ DocumentBuilder db = docFactory.newDocumentBuilder();
+ // read from a project's resources folder
+ Document doc = db.parse(is);
+ Element root = doc.getDocumentElement();
+
+ // explicit remove whitespace
+
+ EInvoiceModel model = new EInvoiceModel(doc);
+
+ return model;
+ } catch (ParserConfigurationException | SAXException | IOException e) {
+ logger.severe(e.getMessage());
+ // create a runtimeException to show a error message in the client
+ throw new RuntimeException(e);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+}
diff --git a/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceNS.java b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceNS.java
new file mode 100644
index 0000000..7122c33
--- /dev/null
+++ b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/EInvoiceNS.java
@@ -0,0 +1,27 @@
+package org.imixs.archive.documents.einvoice;
+
+/**
+ * Defines the valid BPMN namespaces.
+ *
+ * NOTE: The primary namespace can be either 'bpmn2' or 'bpmn' ! But 'bpmn2' is
+ * the default.
+ *
+ *
+ *
+ * // xmlns:a='urn:un:unece:uncefact:data:standard:QualifiedDataType:100'
+ * // xmlns:rsm='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'
+ * // xmlns:qdt='urn:un:unece:uncefact:data:standard:QualifiedDataType:10'
+ * //
+ * xmlns:ram='urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
+ * // xmlns:xs='http://www.w3.org/2001/XMLSchema'
+ * // xmlns:udt='urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'>
+ *
+ * @author rsoika
+ */
+public enum EInvoiceNS {
+ A, // default
+ RSM, //
+ QDT, //
+ RAM, //
+ UDT;
+}
diff --git a/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/TradeParty.java b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/TradeParty.java
new file mode 100644
index 0000000..ce5bb72
--- /dev/null
+++ b/imixs-archive-documents/src/main/java/org/imixs/archive/documents/einvoice/TradeParty.java
@@ -0,0 +1,135 @@
+package org.imixs.archive.documents.einvoice;
+
+/**
+ * A TradeParty is a container for a TradeParty element
+ *
+ * @author rsoika
+ *
+ */
+public class TradeParty {
+
+ private String type; // type of trade party
+ private String name;
+ private String postcodeCode;
+ private String streetAddress;
+ private String cityName;
+ private String countryId;
+ private String vatNumber;
+
+ public TradeParty(String type) {
+ this.type = type;
+ }
+ // public TradeParty(Element sellerElement, String type) {
+ // this.type = type;
+ // // Parse name
+ // Element element=sellerElement.getElementsByTagNameNS( EInvoiceNS.RAM.name(),
+ // "name");
+ // this.name =
+ // .getElementsByTagName("ram:Name")
+ // .item(0)
+ // .getTextContent();
+
+ // // Get PostalTradeAddress element
+ // Element postalAddress = (Element) sellerElement
+ // .getElementsByTagName("ram:PostalTradeAddress")
+ // .item(0);
+
+ // // Parse address details
+ // this.postcodeCode = postalAddress
+ // .getElementsByTagName("ram:PostcodeCode")
+ // .item(0)
+ // .getTextContent();
+
+ // this.streetAddress = postalAddress
+ // .getElementsByTagName("ram:LineOne")
+ // .item(0)
+ // .getTextContent();
+
+ // this.cityName = postalAddress
+ // .getElementsByTagName("ram:CityName")
+ // .item(0)
+ // .getTextContent();
+
+ // this.countryId = postalAddress
+ // .getElementsByTagName("ram:CountryID")
+ // .item(0)
+ // .getTextContent();
+
+ // // // Parse VAT number
+ // // Element taxRegistration = (Element) sellerElement
+ // // .getElementsByTagName("ram:SpecifiedTaxRegistration")
+ // // .item(0);
+
+ // // this.vatNumber = taxRegistration
+ // // .getElementsByTagName("ram:ID")
+ // // .item(0)
+ // // .getTextContent();
+ // }
+
+ // Getters
+ public String getType() {
+ return type;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getPostcodeCode() {
+ return postcodeCode;
+ }
+
+ public String getStreetAddress() {
+ return streetAddress;
+ }
+
+ public String getCityName() {
+ return cityName;
+ }
+
+ public String getCountryId() {
+ return countryId;
+ }
+
+ public String getVatNumber() {
+ return vatNumber;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public void setPostcodeCode(String postcodeCode) {
+ this.postcodeCode = postcodeCode;
+ }
+
+ public void setStreetAddress(String streetAddress) {
+ this.streetAddress = streetAddress;
+ }
+
+ public void setCityName(String cityName) {
+ this.cityName = cityName;
+ }
+
+ public void setCountryId(String countryId) {
+ this.countryId = countryId;
+ }
+
+ public void setVatNumber(String vatNumber) {
+ this.vatNumber = vatNumber;
+ }
+
+ // toString method for easy debugging
+ @Override
+ public String toString() {
+ return "SellerTradeParty{" +
+ "name='" + name + '\'' +
+ ", postcodeCode='" + postcodeCode + '\'' +
+ ", streetAddress='" + streetAddress + '\'' +
+ ", cityName='" + cityName + '\'' +
+ ", countryId='" + countryId + '\'' +
+ ", vatNumber='" + vatNumber + '\'' +
+ '}';
+ }
+
+}
diff --git a/imixs-archive-documents/src/test/java/org/imixs/archive/documents/EInvoiceModelTest.java b/imixs-archive-documents/src/test/java/org/imixs/archive/documents/EInvoiceModelTest.java
new file mode 100644
index 0000000..252c832
--- /dev/null
+++ b/imixs-archive-documents/src/test/java/org/imixs/archive/documents/EInvoiceModelTest.java
@@ -0,0 +1,104 @@
+package org.imixs.archive.documents;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.imixs.archive.documents.einvoice.EInvoiceModel;
+import org.imixs.archive.documents.einvoice.EInvoiceModelFactory;
+import org.imixs.archive.documents.einvoice.TradeParty;
+import org.imixs.workflow.FileData;
+import org.imixs.workflow.exceptions.AdapterException;
+import org.imixs.workflow.exceptions.ModelException;
+import org.imixs.workflow.exceptions.PluginException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * This test class is testing the EInvoiceModel and tests different
+ * kind of files
+ *
+ */
+class EInvoiceModelTest {
+
+ @BeforeEach
+ public void setUp() throws PluginException, ModelException {
+
+ }
+
+ @Test
+ void testStandaloneXML() throws AdapterException, PluginException, IOException {
+ // Prepare test data
+
+ EInvoiceModel eInvoiceModel = loadEInvoice("e-invoice/Rechnung_R_00010.xml", "application/xml");
+
+ // Verify the result
+ assertNotNull(eInvoiceModel);
+ assertEquals("R-00010", eInvoiceModel.getId());
+
+ LocalDate invoiceDate = eInvoiceModel.getIssueDateTime();
+ assertEquals(LocalDate.of(2021, 7, 28), invoiceDate);
+
+ assertEquals(new BigDecimal("4380.9"), eInvoiceModel.getGrandTotalAmount());
+ assertEquals(new BigDecimal("510.9"), eInvoiceModel.getTaxTotalAmount());
+ assertEquals(new BigDecimal("3870.00"), eInvoiceModel.getNetTotalAmount());
+
+ // Test SellerTradeParty
+ TradeParty seller = eInvoiceModel.findTradeParty("seller");
+ assertNotNull(seller);
+ assertEquals("Max Mustermann", seller.getName());
+ assertEquals("DE111111111", seller.getVatNumber());
+
+ TradeParty buyer = eInvoiceModel.findTradeParty("buyer");
+ assertNotNull(buyer);
+ assertEquals("Viborg Metall GbR", buyer.getName());
+
+ }
+
+ /**
+ * Creates a FileData object from a file stored under /test/resources/
+ *
+ * @param fileName
+ * @param contentType
+ * @return
+ * @throws IOException
+ */
+ private FileData createFileData(String fileName, String contentType) throws IOException {
+ byte[] content = null;
+ ClassLoader classLoader = getClass().getClassLoader();
+ try (InputStream is = classLoader.getResourceAsStream(fileName)) {
+ if (is == null) {
+ throw new IOException("Resource not found: " + fileName);
+ }
+ content = is.readAllBytes();
+ }
+ Map