fix: extract tax number, price basis quantity, spaced VAT IDs and contact email
- supplier/buyer: extract FC tax registration (BT-32/BT-49) into new tax_number field; previously only VA (USt-IdNr.) was read - pflichtfelder: critical error only when neither vat_id nor tax_number is present (EN 16931 BR-CO-26) - ustid: tolerate formatting whitespace inside VAT IDs (e.g. 'DE 140 978 617') - line items: normalize unit price by basis quantity (BT-149), so a price per 100 units no longer inflates line totals by factor 100 - supplier email: also look in DefinedTradeContact (BT-42) - rewrite xrechnung fallback test to assert only our own behavior, independent of factur-x version specifics
This commit is contained in:
+173
-13
@@ -307,12 +307,11 @@ class TestXRechnungExtraction:
|
||||
"""Test extraction from XRechnung PDFs with non-standard filenames."""
|
||||
|
||||
def test_xrechnung_by_xml_extension_fallback(self):
|
||||
"""Test XRechnung with 'xrechnung.xml' filename is detected via fallback.
|
||||
"""Test XRechnung with 'xrechnung.xml' filename is extracted correctly.
|
||||
|
||||
This test verifies the fallback mechanism when factur-x library
|
||||
doesn't recognize non-standard filenames like 'xrechnung.xml'.
|
||||
We create a synthetic PDF with a non-standard attachment name to
|
||||
exercise the fallback code path.
|
||||
Depending on the factur-x version, the library recognizes non-standard
|
||||
attachment names itself or our fallback kicks in — both paths must
|
||||
yield the same result, so we only assert our own behavior here.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
@@ -337,20 +336,14 @@ class TestXRechnungExtraction:
|
||||
pdf_writer.write(output)
|
||||
pdf_bytes = output.getvalue()
|
||||
|
||||
# Verify factur-x does NOT recognize it (returns False/None)
|
||||
from facturx import get_xml_from_pdf
|
||||
|
||||
facturx_result = get_xml_from_pdf(pdf_bytes, check_xsd=False)
|
||||
assert facturx_result[0] is False, "factur-x should not recognize xrechnung.xml"
|
||||
|
||||
# Verify our fallback function DOES find it
|
||||
# Verify our fallback function finds it on its own
|
||||
fallback_result = _find_xml_attachment_fallback(pdf_bytes)
|
||||
assert fallback_result[0] == "xrechnung.xml", (
|
||||
"Fallback should find xrechnung.xml"
|
||||
)
|
||||
assert fallback_result[1] is not None, "Fallback should return XML content"
|
||||
|
||||
# Verify full extraction works via fallback
|
||||
# Verify full extraction works, whichever path finds the XML
|
||||
result = extract_zugferd(pdf_bytes)
|
||||
|
||||
assert result.is_zugferd is True
|
||||
@@ -358,3 +351,170 @@ class TestXRechnungExtraction:
|
||||
assert result.xml_data is not None
|
||||
assert result.xml_data.invoice_number is not None
|
||||
assert result.extraction_meta.xml_attachment_name == "xrechnung.xml"
|
||||
|
||||
|
||||
TAX_REGISTRATION_XML_TEMPLATE = """<rsm:CrossIndustryInvoice
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:SellerTradeParty>
|
||||
<ram:Name>Test Seller GmbH</ram:Name>
|
||||
{seller_tax_registrations}
|
||||
{seller_contact}
|
||||
</ram:SellerTradeParty>
|
||||
<ram:BuyerTradeParty>
|
||||
<ram:Name>Test Buyer AG</ram:Name>
|
||||
{buyer_tax_registrations}
|
||||
</ram:BuyerTradeParty>
|
||||
</ram:ApplicableHeaderTradeAgreement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>"""
|
||||
|
||||
VA_REGISTRATION = (
|
||||
"<ram:SpecifiedTaxRegistration>"
|
||||
'<ram:ID schemeID="VA">DE123456789</ram:ID>'
|
||||
"</ram:SpecifiedTaxRegistration>"
|
||||
)
|
||||
FC_REGISTRATION = (
|
||||
"<ram:SpecifiedTaxRegistration>"
|
||||
'<ram:ID schemeID="FC">2281410000697</ram:ID>'
|
||||
"</ram:SpecifiedTaxRegistration>"
|
||||
)
|
||||
BUYER_FC_REGISTRATION = (
|
||||
"<ram:SpecifiedTaxRegistration>"
|
||||
'<ram:ID schemeID="FC">9876543210</ram:ID>'
|
||||
"</ram:SpecifiedTaxRegistration>"
|
||||
)
|
||||
CONTACT_WITH_EMAIL = (
|
||||
"<ram:DefinedTradeContact>"
|
||||
"<ram:EmailURIUniversalCommunication>"
|
||||
"<ram:URIID>seller@example.de</ram:URIID>"
|
||||
"</ram:EmailURIUniversalCommunication>"
|
||||
"</ram:DefinedTradeContact>"
|
||||
)
|
||||
|
||||
|
||||
class TestTaxRegistrationExtraction:
|
||||
"""Test extraction of tax registrations (VAT ID vs. tax number)."""
|
||||
|
||||
def _parse(self, seller_regs: str, buyer_regs: str, seller_contact: str = ""):
|
||||
from lxml import etree
|
||||
|
||||
from src.extractor import parse_buyer, parse_supplier
|
||||
|
||||
xml = TAX_REGISTRATION_XML_TEMPLATE.format(
|
||||
seller_tax_registrations=seller_regs,
|
||||
buyer_tax_registrations=buyer_regs,
|
||||
seller_contact=seller_contact,
|
||||
)
|
||||
root = etree.fromstring(xml.encode("utf-8"))
|
||||
return parse_supplier(root), parse_buyer(root)
|
||||
|
||||
def test_seller_vat_id_and_tax_number(self):
|
||||
"""Seller with VA and FC registrations extracts both fields."""
|
||||
supplier, _ = self._parse(VA_REGISTRATION + FC_REGISTRATION, "")
|
||||
|
||||
assert supplier.vat_id == "DE123456789"
|
||||
assert supplier.tax_number == "2281410000697"
|
||||
|
||||
def test_seller_tax_number_only(self):
|
||||
"""Seller with only FC registration (e.g. Kleinunternehmer/Werkstatt)."""
|
||||
supplier, _ = self._parse(FC_REGISTRATION, "")
|
||||
|
||||
assert supplier.vat_id is None
|
||||
assert supplier.tax_number == "2281410000697"
|
||||
|
||||
def test_seller_vat_id_only(self):
|
||||
"""Seller with only VA registration leaves tax_number empty."""
|
||||
supplier, _ = self._parse(VA_REGISTRATION, "")
|
||||
|
||||
assert supplier.vat_id == "DE123456789"
|
||||
assert supplier.tax_number is None
|
||||
|
||||
def test_buyer_tax_number(self):
|
||||
"""Buyer with FC registration extracts tax_number."""
|
||||
_, buyer = self._parse(VA_REGISTRATION, BUYER_FC_REGISTRATION)
|
||||
|
||||
assert buyer.vat_id is None
|
||||
assert buyer.tax_number == "9876543210"
|
||||
|
||||
def test_seller_email_from_trade_contact(self):
|
||||
"""Seller email is found in DefinedTradeContact (BT-42)."""
|
||||
supplier, _ = self._parse(
|
||||
VA_REGISTRATION, "", seller_contact=CONTACT_WITH_EMAIL
|
||||
)
|
||||
|
||||
assert supplier.email == "seller@example.de"
|
||||
|
||||
def test_seller_email_absent(self):
|
||||
"""No email elements leaves email as None."""
|
||||
supplier, _ = self._parse(VA_REGISTRATION, "")
|
||||
|
||||
assert supplier.email is None
|
||||
|
||||
|
||||
LINE_ITEM_XML_TEMPLATE = """<rsm:CrossIndustryInvoice
|
||||
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:SpecifiedTradeProduct>
|
||||
<ram:Name>Flanschsicherungsset</ram:Name>
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>{charge_amount}</ram:ChargeAmount>
|
||||
{basis_quantity}
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
<ram:BilledQuantity unitCode="H87">{billed_quantity}</ram:BilledQuantity>
|
||||
</ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>{line_total}</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>"""
|
||||
|
||||
|
||||
class TestBasisQuantityExtraction:
|
||||
"""Test unit price normalization via BasisQuantity (BT-149)."""
|
||||
|
||||
def _parse_items(self, basis_quantity: str):
|
||||
from lxml import etree
|
||||
|
||||
from src.extractor import parse_line_items
|
||||
|
||||
xml = LINE_ITEM_XML_TEMPLATE.format(
|
||||
charge_amount="75.85",
|
||||
basis_quantity=basis_quantity,
|
||||
billed_quantity="140",
|
||||
line_total="106.19",
|
||||
)
|
||||
root = etree.fromstring(xml.encode("utf-8"))
|
||||
return parse_line_items(root)
|
||||
|
||||
def test_unit_price_divided_by_basis_quantity(self):
|
||||
"""Charge amount per 100 units is normalized to per-unit price."""
|
||||
items = self._parse_items("<ram:BasisQuantity>100</ram:BasisQuantity>")
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].unit_price == pytest.approx(0.7585)
|
||||
|
||||
def test_unit_price_without_basis_quantity(self):
|
||||
"""Without BasisQuantity the charge amount stays as-is."""
|
||||
items = self._parse_items("")
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].unit_price == pytest.approx(75.85)
|
||||
|
||||
def test_unit_price_with_zero_basis_quantity(self):
|
||||
"""Zero BasisQuantity is ignored (division guard)."""
|
||||
items = self._parse_items("<ram:BasisQuantity>0</ram:BasisQuantity>")
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].unit_price == pytest.approx(75.85)
|
||||
|
||||
Reference in New Issue
Block a user