Add SubInvoiceLine support for ZUGFeRD Extended profile

- Parse ParentLineID and LineStatusReasonCode from XML
  - Filter GROUP/INFORMATION lines from calculation (only DETAIL counts)
  - Add hierarchy validation for GROUP line totals
  - Add tests for sub invoice line import and validation
This commit is contained in:
Andreas Reichmann
2025-12-08 20:43:14 +01:00
parent 6900f5f433
commit 0be3e04a2a
6 changed files with 294 additions and 5 deletions

View File

@@ -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<String, IZUGFeRDExportableItem> 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

View File

@@ -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 = "<validation>" + xv.getXMLResult() + "</validation>";
// 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
}
}
}