package com.alexanderlogistics.einvoice; import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.logging.Logger; import javax.xml.transform.TransformerException; 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.EInvoiceModelKSeF; import org.imixs.einvoice.EInvoiceNS; 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 org.w3c.dom.Element; import com.alexanderlogistics.BusinessPartnerService; import com.alexanderlogistics.InvoiceService; 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
            
            true
         

   }
 * 
* *

* NIP: The adapter needs the NIP (vat-id). If we do not find the partner.vat in * the business object, we do a lookup on the D-Cargosoft object and try the vat * id from there. This is because the partner.vat is a field which was not * defined before. * *

* 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 API_ERROR = "API_ERROR"; public static final String BUSINESSPARTNER_ERROR = "BUSINESSPARTNER_ERROR"; 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 SnapshotService snapshotService; @Inject BusinessPartnerService businessPartnerService; @Inject InvoiceService invoiceService; /** * 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, "ksef", workitem, false); if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("CREATE")) { throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR, "missing e-invoice/ksef configuration in model event - please check model configuration"); } ItemCollection ksefCreateDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE")); try { // Load the e-invoice template.... FileData xmlFileData = loadXMLTemplate(workitem, ksefCreateDefinition); updateEInvoice(xmlFileData, workitem); // append XML document logger.info("β”‚ β”œβ”€β”€ attach KSeF e-invoice..."); workitem.addFileData(xmlFileData); } catch (PluginException e) { throw new AdapterException(e); } return workitem; } /** * 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")); // date model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date")); model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate")); // Update Addresses TradeParty billingAddress = buildAddress(workitem, "buyer", model); model.setTradeParty(billingAddress); // set Tax Type based on vatID ((EInvoiceModelKSeF) model).setTaxType(workitem.getItemValueString("partner.vat")); workitem.setItemValue("invoice.tax.type", ((EInvoiceModelKSeF) model).getTaxType()); // Update Invoice Type (RodzajFaktury) -> VAT | KOR Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa"); if (isKorrekturRechnung(workitem)) { ((EInvoiceModelKSeF) model).setRodzajFaktury("KOR"); logger.info("β”‚ β”œβ”€β”€ invoice type=KOR"); // Set correction data (DaneFaKorygowanej) - required for KOR invoices Element daneFaKorygowanej = model.findOrCreateChildNodeAfter( elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury"); Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date"); // try to lookup original invoice by invoice.CorrectionInvoiceNumber ItemCollection correctionInvoice = invoiceService .findOutboundInvoiceByNumber(workitem.getItemValueString("invoice.CorrectionInvoiceNumber")); if (correctionInvoice != null) { correctionInvoiceDate = correctionInvoice.getItemValueDate("invoice.date"); logger.info("β”‚ β”œβ”€β”€ found original invoice, date: " + correctionInvoiceDate); } else { logger.info("β”‚ β”œβ”€β”€ ⚠️ original invoice not found, using current date as fallback"); } // Original invoice date (DataWystFaKorygowanej) - required if (correctionInvoiceDate != null) { SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); String sCorrectionDate = formatter.format(correctionInvoiceDate); model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "DataWystFaKorygowanej", sCorrectionDate); } // Original invoice number (NrFaKorygowanej) - required model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrFaKorygowanej", workitem.getItemValueString("invoice.CorrectionInvoiceNumber")); // NrKSeFN = 1 means the original invoice was issued outside KSeF model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrKSeFN", "1"); } else { // normale rechnung ((EInvoiceModelKSeF) model).setRodzajFaktury("VAT"); logger.info("β”‚ β”œβ”€β”€ invoice type=VAT"); } // set currency model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty", workitem.getItemValueString("invoice.currency")); // Update Invoice Items // Here we compute the pos number independent if the _childitems hold a posnum. // This is because a new imported cargosoft invoice does not provide numPos // items in the child list. So it is important to generate the pos numbers here // one by one List invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); double lineTotalAmount = 0.00; int pos = 1; for (ItemCollection invoiceItem : invoiceItems) { TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem, pos); model.setTradeLineItem(tradeLineItem); lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal(); pos++; } // Summenbildung // Netto model.setNetTotalAmount(workitem.getItemValueDouble("invoice.total.net")); // Tax model.setTaxTotalAmount(InvoiceUtil.round(workitem.getItemValueDouble("invoice.total") - workitem.getItemValueDouble("invoice.total.net"))); model.setTaxRate(workitem.getItemValueDouble("invoice.total.tax")); // Brutto model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.total")); // finally update the template file fileDataXMLTemplate.setContent(model.getContent()); } catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) { throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e); } } /** * Gibt True zurΓΌck wenn es eine korrektur rechnung ist * * InvoiceHeader/Correction=true * InvoiceHeader/CorrectionInvoiceNumber !empty * * * @param workitem * @return */ private boolean isKorrekturRechnung(ItemCollection workitem) { return ("true".equals(workitem.getItemValueString("invoice.correction")) && !workitem.getItemValueString("invoice.CorrectionInvoiceNumber").isEmpty()); } /** * 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(ItemCollection workitem, String type, EInvoiceModel model) throws PluginException { String partnerID = workitem.getItemValueString("partner.id"); if (partnerID == null || partnerID.isEmpty()) { throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, "Missing partnerID"); } ItemCollection businessPartner = businessPartnerService.getBusinessPartnerByID(partnerID); if (businessPartner == null) { throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, "Business Partner ID '" + partnerID + "' does not exist"); } /** * Migration: * If the business partner does not yet have the item "partner.vat" we try here * to resync this information from the cargosoftkreditor object. This is needed * because the partner.vat was not initially defined by the synch processor and * so many partner objects do not yet provide this information. */ if (businessPartner.getItemValueString("partner.vat").isEmpty()) { logger.info("β”‚ β”œβ”€β”€ BusinessPartner " + partnerID + " does not provide vat-id - try to migrate...."); String dNumber = businessPartner.getItemValueString("dbtr.number"); if (!dNumber.isEmpty()) { try { String query = "(type:cargosoftkreditor) AND (name:" + dNumber + ")"; List result; result = documentService.find(query, 1, 0); if (result != null && result.size() > 0) { ItemCollection creditorData = result.get(0); String vatID = creditorData.getItemValueString("_vendor_vat_registration_id"); logger.info("β”‚ β”œβ”€β”€ synchronize VAT Registrytion ID: " + vatID); // update business partner businessPartner.setItemValue("partner.vat", vatID); documentService.saveByNewTransaction(businessPartner); } } catch (Exception e) { // should not happen! logger.info("β”‚ β”œβ”€β”€ ⚠️ Failed to sync BusinessPartner : " + e.getMessage()); } } } 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")); if ("buyer".equals(type)) { // if we do NOT have a partner.vat we can not upload the invoice! String partnerVAT = businessPartner.getItemValueString("partner.vat"); if (partnerVAT.isBlank()) { // Just a warning logger.warning("β”‚ β”œβ”€β”€ ⚠️ Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); // throw new PluginException(this.getClass().getName(), BUSINESSPARTNER_ERROR, // "Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); } workitem.setItemValue("partner.vat", partnerVAT); tradeParty.setVatNumber(partnerVAT); // Update or create NrKlienta element directly under Podmiot2 Element podmiot2 = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Podmiot2"); model.updateElementValue(podmiot2, EInvoiceNS.KSEF, "NrKlienta", businessPartner.getItemValueString("dbtr.number")); } 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, int pos) { if (orderItem == null) { return null; } // TradeLineItem tradeLineItem = new // TradeLineItem(orderItem.getItemValueString("numpos")); // We do not trust the item 'numPos' here because it can be empty for new // invoices! TradeLineItem tradeLineItem = new TradeLineItem(pos + ""); tradeLineItem.setName(orderItem.getItemValueString("datev.text")); tradeLineItem.setQuantity(1); double vat = orderItem.getItemValueDouble("datev.vatrate"); // 23.00 double netto = orderItem.getItemValueDouble("datev.umsatz"); // 970.00 double brutto = netto * (1 + (vat / 100)); double steuer = brutto - netto; tradeLineItem.setTaxRate(vat); tradeLineItem.setNetPrice(netto); tradeLineItem.setTotal(brutto); return tradeLineItem; } /** * 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 = "ksef.xml"; // adapt text.... sourceName = workflowService.adaptText(sourceName, workitem); if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) { throw new PluginException(EInvoiceAdapter.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(EInvoiceAdapter.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; } }