office-alexander-logistics/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java
2026-04-28 16:44:10 +02:00

540 lines
No EOL
24 KiB
Java

package com.alexanderlogistics.einvoice;
import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.text.NumberFormat;
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 javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
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.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.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import com.alexanderlogistics.BusinessPartnerService;
import com.alexanderlogistics.InvoiceService;
import com.alexanderlogistics.InvoiceUtil;
import com.alexanderlogistics.xml.CargosoftXMLInvoiceImportService;
import jakarta.inject.Inject;
/**
* The KSeFAdapter converts a Cargosoft outbound invoice into a KSeF FA(3) XML
* e-invoice and sends the xml file to the polish KSeF API.
* <p>
* The adapter is configured via the BPMN model:
*
* <pre>
* {@code
* <ksef name="create">
* <textblock>textblock-ref</textblock>
* <template>filename</template>
* <debug>true</debug>
* </ksef>
* }
* </pre>
*
* <p>
* <b>Architecture:</b> The adapter handles invoice header data (parties,
* dates, invoice type, currency code, KOR/correction data). All logic
* concerning invoice positions ({@code <FaWiersz>}) and summary fields
* ({@code P_13_x}, {@code P_14_x}, {@code P_14_xW}, {@code P_15},
* {@code KursWalutyZ}) is delegated to {@link KSeFInvoiceLineBuilder}, which
* uses the CargoSoft VAT code per position to determine the correct target
* field in the {@code <Fa>} block.
*
* <p>
* <b>NIP:</b> 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.
*
* @version 2.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;
@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;
}
/**
* Updates the e-invoice template with the data stored in the workitem.
* <p>
* The method handles invoice header data (parties, invoice type, dates,
* currency code, KOR data) and then delegates the entire line-item and
* summary construction to {@link KSeFInvoiceLineBuilder}.
*
* @param fileDataXMLTemplate the XML template to be filled
* @param workitem the workitem holding the invoice data
* @throws PluginException if the model cannot be parsed or written
*/
public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
try {
EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent()));
model.setId(workitem.getItemValueString("invoice.number"));
// Issue date
model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
// Performance date - resync from Cargosoft XML if missing
if (workitem.getItemValueDate("invoice.performancedate") == null) {
syncPerformanceDate(workitem);
}
// CargoSoft VAT code per child item - resync from Cargosoft XML if any
// child item is missing it (legacy data created before the import was
// extended to capture cargosoft.vat.code)
if (isAnyChildMissingVatCode(workitem)) {
syncCargosoftVatCodes(workitem);
}
((EInvoiceModelKSeF) model)
.setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate"));
// Buyer address
TradeParty billingAddress = buildAddress(workitem, "buyer", model);
model.setTradeParty(billingAddress);
// Tax type derived from NIP - 1=Poland, 2=EU, 3=Other
// Still used by setTradeParty (NIP vs. NrID decision via prefix)
((EInvoiceModelKSeF) model).setTaxType(workitem.getItemValueString("partner.vat"));
workitem.setItemValue("invoice.tax.type", ((EInvoiceModelKSeF) model).getTaxType());
// Invoice type (RodzajFaktury) -> VAT or KOR
Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa");
if (isKorrekturRechnung(workitem)) {
((EInvoiceModelKSeF) model).setRodzajFaktury("KOR");
logger.info("│ ├── invoice type=KOR");
// Correction data (DaneFaKorygowanej) - required for KOR invoices
Element daneFaKorygowanej = model.findOrCreateChildNodeAfter(
elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury");
Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date");
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 - 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 - 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 {
((EInvoiceModelKSeF) model).setRodzajFaktury("VAT");
logger.info("│ ├── invoice type=VAT");
}
// Currency code
model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty",
workitem.getItemValueString("invoice.currency"));
// ================================================================
// Delegate: line items, summary fields, foreign currency fields.
// The builder uses the CargoSoft VAT code per position to write
// each amount into the correct P_13_x / P_14_x / P_14_xW field.
// ================================================================
new KSeFInvoiceLineBuilder().build((EInvoiceModelKSeF) model, workitem);
// Due date - written at the end of the XML tree
model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate"));
// Persist the modified template
fileDataXMLTemplate.setContent(model.getContent());
} catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) {
throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
}
}
/**
* Helper to resync the {@code invoice.performancedate} from the Cargosoft
* source XML if it is not yet set on the workitem.
*/
private void syncPerformanceDate(ItemCollection workitem) throws PluginException {
if (!workitem.hasItem("invoice.performancedate")) {
try {
logger.info("----Resync cargosoft performancedate....");
DocumentBuilder documentBuilder;
FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(),
workitem.getItemValueString("cargosoft.import.filename"));
if (cargoXML != null) {
InputStream inputStream = new ByteArrayInputStream(cargoXML.getContent());
InputSource inputSource = new InputSource(inputStream);
documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = documentBuilder.parse(inputSource);
CargosoftXMLInvoiceImportService.readXMLValue(doc,
"/Invoices/Invoice/InvoiceHeader/PerformanceDate",
workitem, "invoice.performancedate",
Date.class);
// Fallback to invoice date if no performance date was found
if (workitem.getItemValueDate("invoice.performancedate") == null) {
workitem.setItemValue("invoice.performancedate", workitem.getItemValueDate("invoice.date"));
}
}
} catch (ParserConfigurationException | SAXException | IOException e) {
throw new PluginException(CargosoftXMLInvoiceImportService.class.getName(), "XML_ERROR",
e.getMessage());
}
}
}
/**
* Returns {@code true} if at least one entry of {@code _childitems} does
* not yet have the item {@code cargosoft.vat.code} populated. This can
* happen for legacy workitems that were imported before the Cargosoft
* import was extended to capture the VAT code per row.
*/
private boolean isAnyChildMissingVatCode(ItemCollection workitem) {
List<ItemCollection> childItems = InvoiceUtil.explodeChildList(workitem, "_childitems");
if (childItems == null || childItems.isEmpty()) {
return false;
}
for (ItemCollection child : childItems) {
if (child.getItemValueString("cargosoft.vat.code").isBlank()) {
return true;
}
}
return false;
}
/**
* Resync of the {@code cargosoft.vat.code} item on each child of
* {@code _childitems} from the original Cargosoft import XML.
* <p>
* Strategy: parse all {@code <InvoiceRow>} elements of the source XML in
* document order, skipping rows whose {@code BillingCode} is
* {@code "WÄHRUNG"} or {@code "KURS"} (special non-position rows). The
* remaining rows are paired with the {@code _childitems} entries by their
* one-based position number ({@code numpos}). Each child item without a
* VAT code receives the value extracted from its matching XML row.
* <p>
* Rows where the VAT code cannot be extracted are silently skipped here;
* the {@link com.alexanderlogistics.einvoice.KSeFInvoiceLineBuilder} will
* later fail with a {@link PluginException} for that specific position,
* which is the desired behaviour.
*/
private void syncCargosoftVatCodes(ItemCollection workitem) throws PluginException {
try {
logger.info("----Resync cargosoft.vat.code....");
FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(),
workitem.getItemValueString("cargosoft.import.filename"));
if (cargoXML == null) {
logger.warning("│ ├── ⚠️ Cargosoft source XML not found - cannot resync vat codes");
return;
}
InputStream inputStream = new ByteArrayInputStream(cargoXML.getContent());
InputSource inputSource = new InputSource(inputStream);
DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = documentBuilder.parse(inputSource);
// Build a map: numpos (1-based position number) -> vatCode
// by iterating the InvoiceRow elements in document order, skipping
// the special rows for currency / exchange-rate metadata.
XPath xPath = XPathFactory.newInstance().newXPath();
XPathExpression billingCodeExpr = xPath.compile("BillingCode/Codes/Code");
XPathExpression vatCodeExpr = xPath.compile(
"InvoiceAmount/VATInformation/VAT/Codes/Code[@Type='cs']/text()");
Map<Integer, String> vatCodeByPos = new HashMap<>();
NodeList rowList = doc.getElementsByTagName("InvoiceRow");
int posCounter = 0;
for (int i = 0; i < rowList.getLength(); i++) {
Node rowNode = rowList.item(i);
if (rowNode.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
String billingCode = (String) billingCodeExpr.evaluate(rowNode, XPathConstants.STRING);
if ("WÄHRUNG".equals(billingCode) || "KURS".equals(billingCode)) {
continue;
}
posCounter++;
String vatCode = (String) vatCodeExpr.evaluate(rowNode, XPathConstants.STRING);
if (vatCode != null && !vatCode.isBlank()) {
vatCodeByPos.put(posCounter, vatCode);
}
}
// Patch each child item that is still missing the vat code,
// matching by numpos. Re-implode the list at the end.
List<ItemCollection> childItems = InvoiceUtil.explodeChildList(workitem, "_childitems");
boolean changed = false;
for (ItemCollection child : childItems) {
if (!child.getItemValueString("cargosoft.vat.code").isBlank()) {
continue;
}
int numpos;
try {
numpos = Integer.parseInt(child.getItemValueString("numpos"));
} catch (NumberFormatException nfe) {
logger.warning("│ ├── ⚠️ child item without numpos - cannot resync vat code");
continue;
}
String vatCode = vatCodeByPos.get(numpos);
if (vatCode != null) {
child.setItemValue("cargosoft.vat.code", vatCode);
changed = true;
logger.info("│ ├── resynced numpos=" + numpos + " cargosoft.vat.code=" + vatCode);
} else {
logger.warning("│ ├── ⚠️ no matching InvoiceRow for numpos=" + numpos);
}
}
if (changed) {
InvoiceUtil.implodeChildList(workitem, childItems);
}
} catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException e) {
throw new PluginException(CargosoftXMLInvoiceImportService.class.getName(), "XML_ERROR",
e.getMessage());
}
}
/**
* Returns {@code true} if the workitem represents a correction invoice.
*/
private boolean isKorrekturRechnung(ItemCollection workitem) {
return ("true".equals(workitem.getItemValueString("invoice.correction"))
&& !workitem.getItemValueString("invoice.CorrectionInvoiceNumber").isEmpty());
}
/**
* Builds a TradeParty (buyer/seller) from the business partner data.
* <p>
* If the business partner is missing the {@code partner.vat} item, a
* one-time migration step tries to read the VAT ID from the legacy
* {@code cargosoftkreditor} object.
*/
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: resync partner.vat from legacy cargosoftkreditor object if missing
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<ItemCollection> 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 Registration ID: " + vatID);
businessPartner.setItemValue("partner.vat", vatID);
documentService.saveByNewTransaction(businessPartner);
}
} catch (Exception e) {
logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage());
}
}
}
TradeParty tradeParty = new TradeParty(type);
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)) {
String partnerVAT = businessPartner.getItemValueString("partner.vat");
if (partnerVAT.isBlank()) {
logger.warning("│ ├── ⚠️ 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;
}
/**
* Loads the e-invoice XML template referenced by the configuration.
*/
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) {
// ignore - debug remains false
}
String targetName = "ksef.xml";
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!");
}
FileData fileData = loadTextBlockFileData(textblock, template);
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;
}
/**
* Returns a text-block FileData by name and filename.
*/
public FileData loadTextBlockFileData(String name, String fileName) {
ItemCollection textBlockItemCollection = null;
String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")";
Collection<ItemCollection> col;
try {
col = documentService.find(sQuery, 1, 0);
if (col.size() > 0) {
textBlockItemCollection = col.iterator().next();
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;
}
}