diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceAdapter.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceAdapter.java index 98ee6da..be5b253 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceAdapter.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/EInvoiceAdapter.java @@ -33,6 +33,10 @@ 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; @@ -143,51 +147,6 @@ public class EInvoiceAdapter implements SignalAdapter { return workitem; } - // /** - // * This helper method tries to find the pdf file matching the item - // * 'cargosoft.import.filename'. If not found the method returns the first .pdf - // * file form the list of attachments. The method returns null if no pdf file - // * exits - // * - // * @param workitem - // * @return the pdf FileData object - // * @throws PluginException if not pdf file was found - // */ - // private FileData loadPDF(ItemCollection workitem) throws PluginException { - // FileData pdfFileData = null; - // String cargosoftFileName = - // workitem.getItemValueString("cargosoft.import.filename"); - // if (!cargosoftFileName.isEmpty()) { - // cargosoftFileName = cargosoftFileName.replace(".xml", ".pdf"); - - // // try to load filedata.... - // pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), - // cargosoftFileName); - - // } - - // if (pdfFileData == null) { - // // take first pdf file available - // List fileNames = workitem.getFileNames(); - // for (String filename : fileNames) { - - // if (filename.toLowerCase().endsWith(".pdf")) { - // pdfFileData = snapshotService.getWorkItemFile(workitem.getUniqueID(), - // filename); - // break; - // } - // } - // } - - // if (pdfFileData == null) { - // throw new PluginException(EInvoiceAdapter.class.getSimpleName(), - // DOCUMENT_ERROR, - // "DOCUMENT_ERROR: no pdf file found!"); - // } - // return pdfFileData; - - // } - private FileData embeddXML(FileData pdfFileData, FileData xmlFileData) throws PluginException { try { PDDocument document = PDDocument.load(pdfFileData.getContent()); @@ -250,13 +209,16 @@ public class EInvoiceAdapter implements SignalAdapter { * @param workitem * @throws PluginException */ - private void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection 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! @@ -275,7 +237,7 @@ public class EInvoiceAdapter implements SignalAdapter { } // // Summenbildung - model.setNetTotalAmount(workitem.getItemValueDouble("invoice.total")); + model.setNetTotalAmount(workitem.getItemValueDouble("invoice.total.net")); model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.total")); // date @@ -292,6 +254,13 @@ public class EInvoiceAdapter implements SignalAdapter { // References>AB230321 // model.setOrderReferenceId(workitem.getItemValueString("order.number")); + // Strip all XML comments from the document... + stripComments(model.getRoot().getOwnerDocument()); + + // Remove empty template placeholder elements (e.g. ) + // that were never populated by the adapter. + // stripEmptyElements(model.getRoot()); + // finally update the template file fileDataXMLTemplate.setContent(model.getContent()); @@ -327,6 +296,12 @@ public class EInvoiceAdapter implements SignalAdapter { 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; } @@ -344,10 +319,95 @@ public class EInvoiceAdapter implements SignalAdapter { 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("vat")); + + tradeLineItem.setTaxRate(orderItem.getItemValueDouble("datev.vatrate")); return tradeLineItem; } + /** + * Recursively removes empty leaf elements from the document. + *

+ * 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. + *

+ * This is used to clean up template placeholder elements + * (e.g. {@code }) 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. + *

+ * 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; + } + } } \ No newline at end of file diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/einvoice/AGLEInvoiceAdapterTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/einvoice/AGLEInvoiceAdapterTest.java new file mode 100644 index 0000000..5591e89 --- /dev/null +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/einvoice/AGLEInvoiceAdapterTest.java @@ -0,0 +1,174 @@ +package com.alexanderlogistics.einvoice; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.logging.Logger; + +import org.imixs.workflow.FileData; +import org.imixs.workflow.ItemCollection; +import org.imixs.workflow.engine.DocumentService; +import org.imixs.workflow.engine.WorkflowService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.alexanderlogistics.BusinessPartnerService; +import com.alexanderlogistics.InvoiceService; +import com.alexanderlogistics.TestLoggerConfig; + +/** + * Der AGLEInvoiceAdapterTest prüft die Umwandlung einer Cargosoft Rechnung in + * eine E-Rechnung + * + * + */ +@ExtendWith(MockitoExtension.class) +public class AGLEInvoiceAdapterTest { + + private static Logger logger = Logger.getLogger(AGLEInvoiceAdapterTest.class.getName()); + + private static final String TEMPLATE_FILE = "pl/e-invoice/templates/factur-x.xml"; + + ItemCollection businessPartner; + + @Mock + private BusinessPartnerService businessPartnerService; + + @Mock + private DocumentService documentService; + + @Mock + private WorkflowService workflowService; + + @Mock + private InvoiceService invoiceService; + + @InjectMocks + private EInvoiceAdapter adapter; + + @BeforeEach + void setup() throws Exception { + + TestLoggerConfig.setupTestLogger(); + + // adapter = new EInvoiceAdapter(); + + // Prepare mock business partner + businessPartner = new ItemCollection(); + businessPartner.setItemValue("partner.name", "Test Sp. z o.o."); + businessPartner.setItemValue("partner.country", "PL"); + businessPartner.setItemValue("partner.city", "Warszawa"); + businessPartner.setItemValue("partner.zip", "00-001"); + businessPartner.setItemValue("partner.address", "ul. Testowa 1"); + businessPartner.setItemValue("partner.vat", "PL1234567890"); + businessPartner.setItemValue("dbtr.number", "D-12345"); + } + + /** + * Simple PLN invoice with one tax rate (23%). + * Expected: P_13_1, P_14_1, P_15. + */ + @Test + @DisplayName("Test Simple Invoice (PLN, 23%)") + public void testSimpleInvoice() throws Exception { + logger.info("==> Test: Simple Invoice"); + + ItemCollection workitem = new ItemCollection(); + workitem.setItemValue("invoice.number", "FV/2025/001"); + workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10)); + workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10)); + workitem.setItemValue("invoice.currency", "PLN"); + workitem.setItemValue("invoice.total.net", 10000.00); + workitem.setItemValue("invoice.total.tax", 23.0); + workitem.setItemValue("invoice.total", 12300.00); + workitem.setItemValue("invoice.correction", "false"); + workitem.setItemValue("invoice.CorrectionInvoiceNumber", ""); + workitem.setItemValue("partner.id", "BP-001"); + workitem.setItemValue("partner.vat", "PL1234567890"); + workitem.setItemValue("invoice.performancedate", new Date()); + + // Prepare child items (invoice line items) + List childItems = new ArrayList<>(); + + ItemCollection lineItem1 = new ItemCollection(); + lineItem1.setItemValue("numpos", "1"); + lineItem1.setItemValue("datev.text", "Transport Berlin - Warsaw"); + lineItem1.setItemValue("billingtext", "Transport Berlin - Warsaw"); + lineItem1.setItemValue("datev.umsatz", 6000.00); + lineItem1.setItemValue("datev.vatrate", 23.0); + lineItem1.setItemValue("cargosoft.vat.code", "23"); + childItems.add(lineItem1.getAllItems()); + + ItemCollection lineItem2 = new ItemCollection(); + lineItem2.setItemValue("numpos", "2"); + lineItem2.setItemValue("datev.text", "Customs handling"); + lineItem2.setItemValue("billingtext", "Customs handling"); + lineItem2.setItemValue("datev.umsatz", 4000.00); + lineItem2.setItemValue("datev.vatrate", 23.0); + lineItem2.setItemValue("cargosoft.vat.code", "23"); + childItems.add(lineItem2.getAllItems()); + + workitem.setItemValue("_childitems", childItems); + + when(businessPartnerService.getBusinessPartnerByID("BP-001")) + .thenReturn(businessPartner); + + FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); + + adapter.updateEInvoice(xmlTemplate, workitem); + + assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); + assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); + + writeOutputToResources(xmlTemplate, "einvoice-simple.xml"); + } + + // ── Helper methods ────────────────────────────────────────────── + + /** + * Load the KSeF XML template from src/test/resources. + */ + private FileData loadTemplateFromResources(String filename) { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(filename)) { + if (is == null) { + fail("Template file not found in test resources: " + filename + + "\nPlease place your KSeF XML template at: src/test/resources/" + + filename); + } + byte[] content = is.readAllBytes(); + return new FileData(filename, content, "application/xml", null); + } catch (IOException e) { + fail("Failed to read template: " + e.getMessage()); + return null; + } + } + + /** + * Write the resulting XML to src/test/resources/output/ for manual inspection. + */ + private void writeOutputToResources(FileData fileData, String filename) throws IOException { + Path outputDir = Paths.get("src", "test", "resources", "einvoice/output"); + Files.createDirectories(outputDir); + Path outputPath = outputDir.resolve(filename); + Files.write(outputPath, fileData.getContent()); + System.out.println("──────────────────────────────────────────────"); + System.out.println("Output written to: " + outputPath.toAbsolutePath()); + System.out.println("──────────────────────────────────────────────"); + } +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/test/resources/einvoice/output/einvoice-simple.xml b/office-alexander-logistics-app/src/test/resources/einvoice/output/einvoice-simple.xml new file mode 100644 index 0000000..553adfb --- /dev/null +++ b/office-alexander-logistics-app/src/test/resources/einvoice/output/einvoice-simple.xml @@ -0,0 +1,181 @@ + + + + + urn:cen.eu:en16931:2017 + + + + FV/2025/001 + 380 + + 20250210 + + + Payment Instructions: + + + Please ensure that the payment reference includes the invoice number, BL or + AWB number, container or shipment number and place of loading and discharge. + The full invoice amount must be transferred without any deductions and all bank charges + must be covered by the sender. + Otherwise, the beneficiary’s bank will be unable to process the incoming payment. + + + Alexander Global Logistics + + + Numer NIP: PL9552521552 + + + + + + 1 + + + Transport Berlin - Warsaw + + + + 6000.0 + + + 6000.0 + + + + 1.0 + + + + VAT + S + 23.0 + + + 6000.0 + + + + + + 2 + + + Customs handling + + + + 4000.0 + + + 4000.0 + + + + 1.0 + + + + VAT + S + 23.0 + + + 4000.0 + + + + + n/a + + Alexander Global Logistics + + n/a + + +49 421 566 46 0 + + + info@alexander-logistics.com + + + + 70-660 + Gdanska 36 + Szczecin + PL + + + info@alexander-logistics.com + + + PL9552521552 + + + + Test Sp. z o.o. + + 00-001 + ul. Testowa 1 + Warszawa + PL + + + PL1234567890 + + + + + + Test Sp. z o.o. + + 00-001 + ul. Testowa 1 + Warszawa + PL + + + + + PLN + + 30 + + PL79116022020000000654306674 + + + BIGBPLPWXXX + + + + 2300.0 + VAT + 10000.0 + S + 23.0 + + + + + 20250310 + + + + 10000.0 + 0.00 + 0.00 + 10000.0 + 2300.0 + 12300.0 + 0.00 + 12300.0 + + + + diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-npt.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-npt.xml index e715fe6..7414f47 100644 --- a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-npt.xml +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-npt.xml @@ -6,7 +6,7 @@ FA 3 - 2026-05-28T19:18:53.850338633Z + 2026-07-23T14:06:32.328007Z Imixs eInvoice @@ -42,7 +42,7 @@ 2025-02-10 Szczecin FV/2025/001 - 2026-05-28 + 2026-07-23 10000.00 10000.00 diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml index 14b7978..1dc8f47 100644 --- a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml @@ -6,7 +6,7 @@ FA 3 - 2026-05-28T19:18:53.948427821Z + 2026-07-23T15:09:14.896034Z Imixs eInvoice @@ -42,7 +42,7 @@ 2025-02-10 Szczecin FV/2025/001 - 2026-05-28 + 2026-07-23 10000.00 2300.00 12300.00 diff --git a/workflow/pl/e-invoice/templates/factur-x.xml b/workflow/pl/e-invoice/templates/factur-x.xml index 44859a4..9feacc3 100644 --- a/workflow/pl/e-invoice/templates/factur-x.xml +++ b/workflow/pl/e-invoice/templates/factur-x.xml @@ -40,12 +40,12 @@ Alexander Global Logistics - + n/a - + +49 421 566 46 0 - + info@alexander-logistics.com