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

@@ -43,6 +43,8 @@ public class Item implements IZUGFeRDExportableItem {
protected ArrayList<IZUGFeRDAllowanceCharge> Charges = new ArrayList<>();
protected List<IncludedNote> includedNotes = null;
protected String accountingReference;
protected String parentLineID = null;
protected String lineStatusReasonCode = null;
//protected HashMap<String, String> 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;
}
}

View File

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

View File

@@ -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)
{

View File

@@ -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;
}
}