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