421 lines
No EOL
17 KiB
Java
421 lines
No EOL
17 KiB
Java
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:
|
|
*
|
|
* <pre>
|
|
* {@code
|
|
<e-invoice name="create">
|
|
<textblock>textblock-ref</textblock>
|
|
<template>filename</template>
|
|
<debug>true</debug>
|
|
</e-invoice>
|
|
}
|
|
* </pre>
|
|
*
|
|
* <p>
|
|
* 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<String, PDComplexFileSpecification> 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<ItemCollection> 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><Reference type="cs">AB230321</Reference>
|
|
// model.setOrderReferenceId(workitem.getItemValueString("order.number"));
|
|
|
|
// Strip all XML comments from the document...
|
|
stripComments(model.getRoot().getOwnerDocument());
|
|
|
|
// Remove empty template placeholder elements (e.g. <ram:PersonName/>)
|
|
// 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.
|
|
* <p>
|
|
* 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.
|
|
* <p>
|
|
* This is used to clean up template placeholder elements
|
|
* (e.g. {@code <ram:PersonName/>}) 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.
|
|
* <p>
|
|
* 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;
|
|
}
|
|
}
|
|
} |