diff --git a/library/src/main/java/org/mustangproject/Item.java b/library/src/main/java/org/mustangproject/Item.java index eff70131..7abd5ab8 100644 --- a/library/src/main/java/org/mustangproject/Item.java +++ b/library/src/main/java/org/mustangproject/Item.java @@ -43,6 +43,8 @@ public class Item implements IZUGFeRDExportableItem { protected ArrayList Charges = new ArrayList<>(); protected List includedNotes = null; protected String accountingReference; + protected String parentLineID = null; + protected String lineStatusReasonCode = null; //protected HashMap attributes = new HashMap<>(); /*** @@ -91,9 +93,11 @@ public class Item implements IZUGFeRDExportableItem { }); - itemMap.getAsNodeMap("AssociatedDocumentLineDocument") - .flatMap(icnm -> icnm.getAsString("LineID")) - .ifPresent(this::setId); + itemMap.getAsNodeMap("AssociatedDocumentLineDocument").ifPresent(adld -> { + adld.getAsString("LineID").ifPresent(this::setId); + adld.getAsString("ParentLineID").ifPresent(this::setParentLineID); + adld.getAsString("LineStatusReasonCode").ifPresent(this::setLineStatusReasonCode); + }); itemMap.getAsNodeMap("Price").ifPresent(icnm -> { // ubl @@ -644,4 +648,34 @@ public class Item implements IZUGFeRDExportableItem { public String getAccountingReference() { return accountingReference; } + + @Override + public String getParentLineID() { + return parentLineID; + } + + /*** + * for sub invoice lines: set the parent line ID + * @param parentLineID the line ID of the parent line + * @return fluent setter + */ + public Item setParentLineID(String parentLineID) { + this.parentLineID = parentLineID; + return this; + } + + @Override + public String getLineStatusReasonCode() { + return lineStatusReasonCode; + } + + /*** + * for sub invoice lines: set the status reason code (DETAIL, GROUP, INFORMATION) + * @param lineStatusReasonCode the status reason code + * @return fluent setter + */ + public Item setLineStatusReasonCode(String lineStatusReasonCode) { + this.lineStatusReasonCode = lineStatusReasonCode; + return this; + } } diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/IZUGFeRDExportableItem.java b/library/src/main/java/org/mustangproject/ZUGFeRD/IZUGFeRDExportableItem.java index 05f4d437..7b48ab5f 100644 --- a/library/src/main/java/org/mustangproject/ZUGFeRD/IZUGFeRDExportableItem.java +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/IZUGFeRDExportableItem.java @@ -192,6 +192,36 @@ public interface IZUGFeRDExportableItem extends IAbsoluteValueProvider{ return null; } + /*** + * for sub invoice lines in ZUGFeRD Extended: the line ID of the parent line + * @return the parent line ID or null if this is a top-level line + */ + default String getParentLineID() { + return null; + } + + /*** + * for sub invoice lines in ZUGFeRD Extended: the status reason code + * determines if a line is relevant for calculation + * @return DETAIL, GROUP, INFORMATION or null for standard lines + */ + default String getLineStatusReasonCode() { + return null; + } + + /*** + * checks if this line should be included in sum calculation. + * GROUP and INFORMATION lines are not calculation-relevant, + * only DETAIL lines (or lines without status code) are. + * @return true if the line should be included in calculation + */ + @com.fasterxml.jackson.annotation.JsonIgnore + default boolean isCalculationRelevant() { + String status = getLineStatusReasonCode(); + // null means standard line (backwards compatible), DETAIL is explicitly relevant + return status == null || "DETAIL".equals(status); + } + /** * A grouping of business terms to indicate accounting-relevant free texts including a qualification of these. * diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/TransactionCalculator.java b/library/src/main/java/org/mustangproject/ZUGFeRD/TransactionCalculator.java index f8c80127..daf5b0e0 100644 --- a/library/src/main/java/org/mustangproject/ZUGFeRD/TransactionCalculator.java +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/TransactionCalculator.java @@ -155,12 +155,15 @@ public class TransactionCalculator implements IAbsoluteValueProvider { /*** * returns the total net value of all items, without document level - * charges/allowances + * charges/allowances. For sub invoice lines only DETAIL lines are summed, + * GROUP and INFORMATION lines are ignored. * * @return item sum */ protected BigDecimal getTotal() { - BigDecimal dec = Stream.of(trans.getZFItems()).map(LineCalculator::new) + BigDecimal dec = Stream.of(trans.getZFItems()) + .filter(IZUGFeRDExportableItem::isCalculationRelevant) + .map(LineCalculator::new) .map(LineCalculator::getItemTotalNetAmount).reduce(ZERO, BigDecimal::add); return dec; } @@ -190,6 +193,10 @@ public class TransactionCalculator implements IAbsoluteValueProvider { final String vatDueDateTypeCode = trans.getVATDueDateTypeCode(); for (IZUGFeRDExportableItem currentItem : trans.getZFItems()) { + // skip GROUP and INFORMATION lines for sub invoice lines + if (!currentItem.isCalculationRelevant()) { + continue; + } BigDecimal percent = null; if (currentItem.getProduct() != null) { percent = currentItem.getProduct().getVATPercent(); @@ -258,6 +265,10 @@ public class TransactionCalculator implements IAbsoluteValueProvider { final String vatDueDateTypeCode = this.trans.getVATDueDateTypeCode(); for (final IZUGFeRDExportableItem currentItem : this.trans.getZFItems()) { + // skip GROUP and INFORMATION lines for sub invoice lines + if (!currentItem.isCalculationRelevant()) { + continue; + } BigDecimal percent = null; if (currentItem.getProduct() != null) { diff --git a/library/src/test/java/org/mustangproject/ZUGFeRD/ZF2ZInvoiceImporterTest.java b/library/src/test/java/org/mustangproject/ZUGFeRD/ZF2ZInvoiceImporterTest.java index 8873bb0c..cdfbf6d0 100644 --- a/library/src/test/java/org/mustangproject/ZUGFeRD/ZF2ZInvoiceImporterTest.java +++ b/library/src/test/java/org/mustangproject/ZUGFeRD/ZF2ZInvoiceImporterTest.java @@ -895,4 +895,117 @@ public class ZF2ZInvoiceImporterTest extends ResourceCase { assertNull(invoice.getDespatchAdviceReferencedDocumentID()); assertNull(invoice.getInvoiceReferencedDocumentID()); } + + @Test + public void testSubInvoiceLinesImport() throws FileNotFoundException, XPathExpressionException, ParseException { + // test import of sub invoice lines with GROUP and DETAIL lines + File inputFile = getResourceAsFile("subinvoicelines/Extended_SubInvoiceLines_Hardware_Bsp2.xml"); + ZUGFeRDInvoiceImporter zii = new ZUGFeRDInvoiceImporter(); + zii.setInputStream(new FileInputStream(inputFile)); + + Invoice invoice = zii.extractInvoice(); + assertEquals(6, invoice.getZFItems().length); + + // find GROUP and DETAIL lines and verify LineStatusReasonCode and ParentLineID + Item group01 = findItemById(invoice, "01"); + assertNotNull(group01); + assertEquals("GROUP", group01.getLineStatusReasonCode()); + assertNull(group01.getParentLineID()); + assertFalse(group01.isCalculationRelevant()); + + Item detail0101 = findItemById(invoice, "0101"); + assertNotNull(detail0101); + assertEquals("DETAIL", detail0101.getLineStatusReasonCode()); + assertEquals("01", detail0101.getParentLineID()); + assertTrue(detail0101.isCalculationRelevant()); + + Item detail0102 = findItemById(invoice, "0102"); + assertNotNull(detail0102); + assertEquals("DETAIL", detail0102.getLineStatusReasonCode()); + assertEquals("01", detail0102.getParentLineID()); + assertTrue(detail0102.isCalculationRelevant()); + + // verify that only DETAIL lines are summed (not GROUP lines) + // DETAIL lines: 0101=600, 0102=450, 0201=360, 0202=90 = 1500 + // GROUP lines should NOT be added: 01=1050, 02=450 + TransactionCalculator tc = new TransactionCalculator(invoice); + assertEquals(new BigDecimal("1500.00"), tc.getTotal().setScale(2)); + assertEquals(new BigDecimal("1785.00"), tc.getGrandTotal().setScale(2)); + } + + @Test + public void testSubInvoiceLinesNestedImport() throws FileNotFoundException, XPathExpressionException, ParseException { + // test import of nested sub invoice lines (GROUP containing GROUP containing DETAIL) + File inputFile = getResourceAsFile("subinvoicelines/Extended___SubInvoiceLines_Kaffee_Bundle_Set_Bsp4__.xml"); + ZUGFeRDInvoiceImporter zii = new ZUGFeRDInvoiceImporter(); + zii.setInputStream(new FileInputStream(inputFile)); + + Invoice invoice = zii.extractInvoice(); + + // find nested structure: 1 (GROUP) -> 1.3 (GROUP) -> 1.3.1 (DETAIL) + Item group1 = findItemById(invoice, "1"); + assertNotNull(group1); + assertEquals("GROUP", group1.getLineStatusReasonCode()); + + Item group13 = findItemById(invoice, "1.3"); + assertNotNull(group13); + assertEquals("GROUP", group13.getLineStatusReasonCode()); + assertEquals("1", group13.getParentLineID()); + + Item detail131 = findItemById(invoice, "1.3.1"); + assertNotNull(detail131); + assertEquals("DETAIL", detail131.getLineStatusReasonCode()); + assertEquals("1.3", detail131.getParentLineID()); + + // verify calculation only includes DETAIL lines + // DETAIL: 1.1=30, 1.2=60, 1.3.1=90, 1.3.2=36 = 216 + TransactionCalculator tc = new TransactionCalculator(invoice); + assertEquals(new BigDecimal("216.00"), tc.getTotal().setScale(2)); + } + + @Test + public void testSubInvoiceLinesWithDiscounts() throws FileNotFoundException, XPathExpressionException, ParseException { + // test sub invoice lines with negative amounts (discounts) + File inputFile = getResourceAsFile("subinvoicelines/Extended___SubInvoiceLines_Buero_Material_Bsp3__.xml"); + ZUGFeRDInvoiceImporter zii = new ZUGFeRDInvoiceImporter(); + zii.setInputStream(new FileInputStream(inputFile)); + + Invoice invoice = zii.extractInvoice(); + + // verify calculation handles negative DETAIL lines correctly + // GROUP 01: 600 + 450 - 50 = 1000 + // GROUP 02: 360 + 90 - 45 = 405 + // Total DETAIL: 1405 + TransactionCalculator tc = new TransactionCalculator(invoice); + assertEquals(new BigDecimal("1405.00"), tc.getTotal().setScale(2)); + } + + @Test + public void testSubInvoiceLinesInformation() throws FileNotFoundException, XPathExpressionException, ParseException { + // test INFORMATION lines (should have price 0 and not affect calculation) + File inputFile = getResourceAsFile("subinvoicelines/Extended_Fallschutz-Set_SubInvoiceLine_Bsp5.xml"); + ZUGFeRDInvoiceImporter zii = new ZUGFeRDInvoiceImporter(); + zii.setInputStream(new FileInputStream(inputFile)); + + Invoice invoice = zii.extractInvoice(); + + // find INFORMATION lines + Item info0101 = findItemById(invoice, "01.01"); + assertNotNull(info0101); + assertEquals("INFORMATION", info0101.getLineStatusReasonCode()); + assertFalse(info0101.isCalculationRelevant()); + + // DETAIL line 01 = 45000 + TransactionCalculator tc = new TransactionCalculator(invoice); + assertEquals(new BigDecimal("45000.00"), tc.getTotal().setScale(2)); + } + + private Item findItemById(Invoice invoice, String id) { + for (IZUGFeRDExportableItem item : invoice.getZFItems()) { + if (item instanceof Item && id.equals(((Item) item).getId())) { + return (Item) item; + } + } + return null; + } } diff --git a/validator/src/main/java/org/mustangproject/validator/XMLValidator.java b/validator/src/main/java/org/mustangproject/validator/XMLValidator.java index 5ca50de4..61565f61 100644 --- a/validator/src/main/java/org/mustangproject/validator/XMLValidator.java +++ b/validator/src/main/java/org/mustangproject/validator/XMLValidator.java @@ -5,6 +5,7 @@ import java.io.PrintWriter; import java.io.StringReader; import java.io.StringWriter; import java.io.UncheckedIOException; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; @@ -24,6 +25,8 @@ import javax.xml.xpath.XPathFactory; import org.mustangproject.CalculatedInvoice; import org.mustangproject.XMLTools; +import org.mustangproject.ZUGFeRD.IZUGFeRDExportableItem; +import org.mustangproject.ZUGFeRD.LineCalculator; import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -454,6 +457,9 @@ public class XMLValidator extends Validator { CalculatedInvoice ci=new CalculatedInvoice(); zi.extractInto(ci); + // check sub invoice line hierarchy if present + checkSubInvoiceLineHierarchy(ci, context); + } catch ( ArithmeticException e) { try { context.addResultItem(new ValidationResultItem(ESeverity.warning, "Arithmetical issue:"+e.getMessage()).setSection(10)); @@ -469,6 +475,77 @@ public class XMLValidator extends Validator { } + /*** + * validates that GROUP line totals match the sum of their DETAIL child lines + */ + private void checkSubInvoiceLineHierarchy(CalculatedInvoice invoice, ValidationContext context) { + IZUGFeRDExportableItem[] items = invoice.getZFItems(); + if (items == null || items.length == 0) { + return; + } + + // check if we have any sub invoice lines at all + boolean hasSubInvoiceLines = false; + for (IZUGFeRDExportableItem item : items) { + if (item.getLineStatusReasonCode() != null) { + hasSubInvoiceLines = true; + break; + } + } + if (!hasSubInvoiceLines) { + return; + } + + // build a map of line ID to item for quick lookup + java.util.HashMap itemMap = new java.util.HashMap<>(); + for (IZUGFeRDExportableItem item : items) { + if (item.getId() != null) { + itemMap.put(item.getId(), item); + } + } + + // for each GROUP line, sum up the DETAIL children and compare + for (IZUGFeRDExportableItem item : items) { + if ("GROUP".equals(item.getLineStatusReasonCode())) { + String groupId = item.getId(); + if (groupId == null) { + continue; + } + + // sum up direct DETAIL children + BigDecimal childSum = BigDecimal.ZERO; + for (IZUGFeRDExportableItem child : items) { + if (groupId.equals(child.getParentLineID()) && "DETAIL".equals(child.getLineStatusReasonCode())) { + LineCalculator lc = child.getCalculation(); + childSum = childSum.add(lc.getItemTotalNetAmount()); + } + } + + // also sum up nested GROUP children (their totals should already include their DETAIL children) + for (IZUGFeRDExportableItem child : items) { + if (groupId.equals(child.getParentLineID()) && "GROUP".equals(child.getLineStatusReasonCode())) { + LineCalculator lc = child.getCalculation(); + childSum = childSum.add(lc.getItemTotalNetAmount()); + } + } + + // compare with GROUP total + LineCalculator groupLc = item.getCalculation(); + BigDecimal groupTotal = groupLc.getItemTotalNetAmount(); + if (childSum.compareTo(groupTotal) != 0) { + try { + context.addResultItem(new ValidationResultItem(ESeverity.warning, + "Sub invoice line hierarchy mismatch: GROUP line " + groupId + + " has total " + groupTotal + " but sum of child lines is " + childSum) + .setSection(10)); + } catch (IrrecoverableValidationError ie) { + LOGGER.error(ie.getMessage(), ie); + } + } + } + } + } + public void validateXR(String xml, ESeverity errorImpact) throws IrrecoverableValidationError { //Guideline ID=urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_1.2 or diff --git a/validator/src/test/java/org/mustangproject/validator/XMLValidatorTest.java b/validator/src/test/java/org/mustangproject/validator/XMLValidatorTest.java index 7ed18b8f..47989968 100644 --- a/validator/src/test/java/org/mustangproject/validator/XMLValidatorTest.java +++ b/validator/src/test/java/org/mustangproject/validator/XMLValidatorTest.java @@ -373,4 +373,28 @@ public class XMLValidatorTest extends ResourceCase { } + public void testSubInvoiceLineHierarchy() { + final ValidationContext ctx = new ValidationContext(null); + final XMLValidator xv = new XMLValidator(ctx); + final XPathEngine xpath = new JAXPXPathEngine(); + + // test invalid hierarchy: GROUP sum does not match DETAIL children sum + // GROUP 01 has LineTotalAmount=999, but children sum to 1050 (600+450) + File tempFile = getResourceAsFile("invalidSubInvoiceLineHierarchy.xml"); + try { + xv.setFilename(tempFile.getAbsolutePath()); + xv.validate(); + + String s = "" + xv.getXMLResult() + ""; + // hierarchy mismatch should produce at least one warning + assertThat(s).valueByXPath("count(//warning)") + .asInt() + .isGreaterThanOrEqualTo(1); + + } catch (final IrrecoverableValidationError e) { + // ignore, will be in XML output anyway + } + + } + }