office-alexander-logistics/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceAdapter.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;
}
}
}