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:
@@ -109,6 +109,7 @@ Extract ZUGFeRD data from a base64-encoded PDF file.
|
|||||||
"city": "Berlin",
|
"city": "Berlin",
|
||||||
"country": "DE",
|
"country": "DE",
|
||||||
"vat_id": "DE123456789",
|
"vat_id": "DE123456789",
|
||||||
|
"tax_number": null,
|
||||||
"email": "billing@acme-corp.de"
|
"email": "billing@acme-corp.de"
|
||||||
},
|
},
|
||||||
"buyer": {
|
"buyer": {
|
||||||
@@ -276,7 +277,7 @@ The service supports four validation checks:
|
|||||||
|
|
||||||
Validates that all critical invoice fields are present and non-empty:
|
Validates that all critical invoice fields are present and non-empty:
|
||||||
|
|
||||||
- **Critical errors:** invoice_number, invoice_date, supplier.name, supplier.vat_id, buyer.name, totals.net, totals.gross, totals.vat_total, line_items array, line item fields
|
- **Critical errors:** invoice_number, invoice_date, supplier.name, supplier.vat_id (or supplier.tax_number), buyer.name, totals.net, totals.gross, totals.vat_total, line_items array, line item fields
|
||||||
- **Warnings:** due_date, payment_terms.iban
|
- **Warnings:** due_date, payment_terms.iban
|
||||||
|
|
||||||
### 2. betraege (Amount Calculations)
|
### 2. betraege (Amount Calculations)
|
||||||
@@ -470,7 +471,7 @@ PORT=8000 python -m src.main
|
|||||||
Check that all required fields are present:
|
Check that all required fields are present:
|
||||||
- invoice_number
|
- invoice_number
|
||||||
- invoice_date (YYYY-MM-DD format)
|
- invoice_date (YYYY-MM-DD format)
|
||||||
- supplier.name and supplier.vat_id
|
- supplier.name and supplier.vat_id (or supplier.tax_number — EN 16931 BR-CO-26 requires one of both)
|
||||||
- buyer.name
|
- buyer.name
|
||||||
- Non-zero totals (net, gross, vat_total)
|
- Non-zero totals (net, gross, vat_total)
|
||||||
- At least one line item with description, quantity, and unit_price
|
- At least one line item with description, quantity, and unit_price
|
||||||
|
|||||||
+32
-1
@@ -104,11 +104,23 @@ def parse_supplier(xml_root: etree._Element) -> Supplier:
|
|||||||
"ram:ID[@schemeID='VA']/text()",
|
"ram:ID[@schemeID='VA']/text()",
|
||||||
namespaces=NAMESPACES,
|
namespaces=NAMESPACES,
|
||||||
)
|
)
|
||||||
|
tax_number = xml_root.xpath(
|
||||||
|
"//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/"
|
||||||
|
"ram:ID[@schemeID='FC']/text()",
|
||||||
|
namespaces=NAMESPACES,
|
||||||
|
)
|
||||||
email = xml_root.xpath(
|
email = xml_root.xpath(
|
||||||
"//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/"
|
"//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/"
|
||||||
"ram:URIUniversalCommunication/ram:URIID/text()",
|
"ram:URIUniversalCommunication/ram:URIID/text()",
|
||||||
namespaces=NAMESPACES,
|
namespaces=NAMESPACES,
|
||||||
)
|
)
|
||||||
|
# Fallback: email address from the seller's defined trade contact (BT-42)
|
||||||
|
if not email:
|
||||||
|
email = xml_root.xpath(
|
||||||
|
"//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/"
|
||||||
|
"ram:DefinedTradeContact/ram:EmailURIUniversalCommunication/ram:URIID/text()",
|
||||||
|
namespaces=NAMESPACES,
|
||||||
|
)
|
||||||
|
|
||||||
return Supplier(
|
return Supplier(
|
||||||
name=name[0] if name else "",
|
name=name[0] if name else "",
|
||||||
@@ -117,6 +129,7 @@ def parse_supplier(xml_root: etree._Element) -> Supplier:
|
|||||||
city=city[0] if city else None,
|
city=city[0] if city else None,
|
||||||
country=country[0] if country else None,
|
country=country[0] if country else None,
|
||||||
vat_id=vat_id[0] if vat_id else None,
|
vat_id=vat_id[0] if vat_id else None,
|
||||||
|
tax_number=tax_number[0] if tax_number else None,
|
||||||
email=email[0] if email else None,
|
email=email[0] if email else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,6 +172,11 @@ def parse_buyer(xml_root: etree._Element) -> Buyer:
|
|||||||
"ram:ID[@schemeID='VA']/text()",
|
"ram:ID[@schemeID='VA']/text()",
|
||||||
namespaces=NAMESPACES,
|
namespaces=NAMESPACES,
|
||||||
)
|
)
|
||||||
|
tax_number = xml_root.xpath(
|
||||||
|
"//ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/"
|
||||||
|
"ram:ID[@schemeID='FC']/text()",
|
||||||
|
namespaces=NAMESPACES,
|
||||||
|
)
|
||||||
|
|
||||||
return Buyer(
|
return Buyer(
|
||||||
name=name[0] if name else "",
|
name=name[0] if name else "",
|
||||||
@@ -167,6 +185,7 @@ def parse_buyer(xml_root: etree._Element) -> Buyer:
|
|||||||
city=city[0] if city else None,
|
city=city[0] if city else None,
|
||||||
country=country[0] if country else None,
|
country=country[0] if country else None,
|
||||||
vat_id=vat_id[0] if vat_id else None,
|
vat_id=vat_id[0] if vat_id else None,
|
||||||
|
tax_number=tax_number[0] if tax_number else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +228,11 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]:
|
|||||||
"./ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount/text()",
|
"./ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount/text()",
|
||||||
namespaces=NAMESPACES,
|
namespaces=NAMESPACES,
|
||||||
)
|
)
|
||||||
|
basis_quantity = item_node.xpath(
|
||||||
|
"./ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/"
|
||||||
|
"ram:BasisQuantity/text()",
|
||||||
|
namespaces=NAMESPACES,
|
||||||
|
)
|
||||||
line_total = item_node.xpath(
|
line_total = item_node.xpath(
|
||||||
"./ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/"
|
"./ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/"
|
||||||
"ram:LineTotalAmount/text()",
|
"ram:LineTotalAmount/text()",
|
||||||
@@ -225,6 +249,13 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]:
|
|||||||
|
|
||||||
unit = unit_code[0] if unit_code else "Stück"
|
unit = unit_code[0] if unit_code else "Stück"
|
||||||
|
|
||||||
|
# Basis quantity (BT-149): charge amount applies to N units (e.g. price per 100)
|
||||||
|
effective_unit_price = float(unit_price[0]) if unit_price else None
|
||||||
|
if effective_unit_price is not None and basis_quantity:
|
||||||
|
base = float(basis_quantity[0])
|
||||||
|
if base > 0:
|
||||||
|
effective_unit_price = effective_unit_price / base
|
||||||
|
|
||||||
items.append(
|
items.append(
|
||||||
LineItem(
|
LineItem(
|
||||||
position=position,
|
position=position,
|
||||||
@@ -235,7 +266,7 @@ def parse_line_items(xml_root: etree._Element) -> list[LineItem]:
|
|||||||
description=description[0] if description else "",
|
description=description[0] if description else "",
|
||||||
quantity=float(quantity[0]) if quantity else 0.0,
|
quantity=float(quantity[0]) if quantity else 0.0,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
unit_price=float(unit_price[0]) if unit_price else None,
|
unit_price=effective_unit_price,
|
||||||
line_total=float(line_total[0]) if line_total else None,
|
line_total=float(line_total[0]) if line_total else None,
|
||||||
vat_rate=float(vat_rate[0]) if vat_rate else None,
|
vat_rate=float(vat_rate[0]) if vat_rate else None,
|
||||||
vat_amount=float(vat_amount[0]) if vat_amount else None,
|
vat_amount=float(vat_amount[0]) if vat_amount else None,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class Supplier(BaseModel):
|
|||||||
city: str | None = Field(default=None, description="City")
|
city: str | None = Field(default=None, description="City")
|
||||||
country: str | None = Field(default=None, description="Country code")
|
country: str | None = Field(default=None, description="Country code")
|
||||||
vat_id: str | None = Field(default=None, description="VAT ID")
|
vat_id: str | None = Field(default=None, description="VAT ID")
|
||||||
|
tax_number: str | None = Field(
|
||||||
|
default=None, description="Tax number (Steuernummer, BT-32)"
|
||||||
|
)
|
||||||
email: str | None = Field(default=None, description="Email address")
|
email: str | None = Field(default=None, description="Email address")
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +39,9 @@ class Buyer(BaseModel):
|
|||||||
city: str | None = Field(default=None, description="City")
|
city: str | None = Field(default=None, description="City")
|
||||||
country: str | None = Field(default=None, description="Country code")
|
country: str | None = Field(default=None, description="Country code")
|
||||||
vat_id: str | None = Field(default=None, description="VAT ID")
|
vat_id: str | None = Field(default=None, description="VAT ID")
|
||||||
|
tax_number: str | None = Field(
|
||||||
|
default=None, description="Tax number (Steuernummer, BT-49)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VatBreakdown(BaseModel):
|
class VatBreakdown(BaseModel):
|
||||||
|
|||||||
+18
-5
@@ -19,13 +19,13 @@ def validate_pflichtfelder(xml_data: XmlData) -> list[ErrorDetail]:
|
|||||||
"""Check required fields are present."""
|
"""Check required fields are present."""
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
def add_error(field: str, severity: str) -> None:
|
def add_error(field: str, severity: str, message: str | None = None) -> None:
|
||||||
errors.append(
|
errors.append(
|
||||||
ErrorDetail(
|
ErrorDetail(
|
||||||
check="pflichtfelder",
|
check="pflichtfelder",
|
||||||
field=field,
|
field=field,
|
||||||
error_code="missing_required",
|
error_code="missing_required",
|
||||||
message=f"Required field '{field}' is missing or empty",
|
message=message or f"Required field '{field}' is missing or empty",
|
||||||
severity=severity,
|
severity=severity,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -40,8 +40,20 @@ def validate_pflichtfelder(xml_data: XmlData) -> list[ErrorDetail]:
|
|||||||
if not xml_data.supplier.name or not xml_data.supplier.name.strip():
|
if not xml_data.supplier.name or not xml_data.supplier.name.strip():
|
||||||
add_error("supplier.name", "critical")
|
add_error("supplier.name", "critical")
|
||||||
|
|
||||||
if not xml_data.supplier.vat_id or not xml_data.supplier.vat_id.strip():
|
# EN 16931 BR-CO-26: seller identified by VAT ID (BT-31) OR tax number (BT-32)
|
||||||
add_error("supplier.vat_id", "critical")
|
has_vat_id = bool(xml_data.supplier.vat_id and xml_data.supplier.vat_id.strip())
|
||||||
|
has_tax_number = bool(
|
||||||
|
xml_data.supplier.tax_number and xml_data.supplier.tax_number.strip()
|
||||||
|
)
|
||||||
|
if not has_vat_id and not has_tax_number:
|
||||||
|
add_error(
|
||||||
|
"supplier.vat_id",
|
||||||
|
"critical",
|
||||||
|
message=(
|
||||||
|
"Supplier must have a VAT ID (supplier.vat_id) "
|
||||||
|
"or a tax number (supplier.tax_number)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if not xml_data.buyer.name or not xml_data.buyer.name.strip():
|
if not xml_data.buyer.name or not xml_data.buyer.name.strip():
|
||||||
add_error("buyer.name", "critical")
|
add_error("buyer.name", "critical")
|
||||||
@@ -163,7 +175,8 @@ def validate_ustid(vat_id: str) -> ErrorDetail | None:
|
|||||||
severity="critical",
|
severity="critical",
|
||||||
)
|
)
|
||||||
|
|
||||||
vat_id = vat_id.strip()
|
# Whitespace inside VAT IDs is formatting only (e.g. "DE 140 978 617")
|
||||||
|
vat_id = re.sub(r"\s+", "", vat_id)
|
||||||
|
|
||||||
# German VAT ID: DE followed by 9 digits
|
# German VAT ID: DE followed by 9 digits
|
||||||
if vat_id.startswith("DE"):
|
if vat_id.startswith("DE"):
|
||||||
|
|||||||
+173
-13
@@ -307,12 +307,11 @@ class TestXRechnungExtraction:
|
|||||||
"""Test extraction from XRechnung PDFs with non-standard filenames."""
|
"""Test extraction from XRechnung PDFs with non-standard filenames."""
|
||||||
|
|
||||||
def test_xrechnung_by_xml_extension_fallback(self):
|
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
|
Depending on the factur-x version, the library recognizes non-standard
|
||||||
doesn't recognize non-standard filenames like 'xrechnung.xml'.
|
attachment names itself or our fallback kicks in — both paths must
|
||||||
We create a synthetic PDF with a non-standard attachment name to
|
yield the same result, so we only assert our own behavior here.
|
||||||
exercise the fallback code path.
|
|
||||||
"""
|
"""
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
@@ -337,20 +336,14 @@ class TestXRechnungExtraction:
|
|||||||
pdf_writer.write(output)
|
pdf_writer.write(output)
|
||||||
pdf_bytes = output.getvalue()
|
pdf_bytes = output.getvalue()
|
||||||
|
|
||||||
# Verify factur-x does NOT recognize it (returns False/None)
|
# Verify our fallback function finds it on its own
|
||||||
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
|
|
||||||
fallback_result = _find_xml_attachment_fallback(pdf_bytes)
|
fallback_result = _find_xml_attachment_fallback(pdf_bytes)
|
||||||
assert fallback_result[0] == "xrechnung.xml", (
|
assert fallback_result[0] == "xrechnung.xml", (
|
||||||
"Fallback should find xrechnung.xml"
|
"Fallback should find xrechnung.xml"
|
||||||
)
|
)
|
||||||
assert fallback_result[1] is not None, "Fallback should return XML content"
|
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)
|
result = extract_zugferd(pdf_bytes)
|
||||||
|
|
||||||
assert result.is_zugferd is True
|
assert result.is_zugferd is True
|
||||||
@@ -358,3 +351,170 @@ class TestXRechnungExtraction:
|
|||||||
assert result.xml_data is not None
|
assert result.xml_data is not None
|
||||||
assert result.xml_data.invoice_number is not None
|
assert result.xml_data.invoice_number is not None
|
||||||
assert result.extraction_meta.xml_attachment_name == "xrechnung.xml"
|
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)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class TestSupplier:
|
|||||||
assert supplier.name == "ACME GmbH"
|
assert supplier.name == "ACME GmbH"
|
||||||
assert supplier.street is None
|
assert supplier.street is None
|
||||||
assert supplier.vat_id is None
|
assert supplier.vat_id is None
|
||||||
|
assert supplier.tax_number is None
|
||||||
|
|
||||||
def test_full_supplier(self):
|
def test_full_supplier(self):
|
||||||
"""Test Supplier with all fields."""
|
"""Test Supplier with all fields."""
|
||||||
@@ -52,6 +53,7 @@ class TestSupplier:
|
|||||||
city="Musterstadt",
|
city="Musterstadt",
|
||||||
country="DE",
|
country="DE",
|
||||||
vat_id="DE123456789",
|
vat_id="DE123456789",
|
||||||
|
tax_number="2281410000697",
|
||||||
email="info@acme.de",
|
email="info@acme.de",
|
||||||
)
|
)
|
||||||
assert supplier.name == "ACME GmbH"
|
assert supplier.name == "ACME GmbH"
|
||||||
@@ -60,6 +62,7 @@ class TestSupplier:
|
|||||||
assert supplier.city == "Musterstadt"
|
assert supplier.city == "Musterstadt"
|
||||||
assert supplier.country == "DE"
|
assert supplier.country == "DE"
|
||||||
assert supplier.vat_id == "DE123456789"
|
assert supplier.vat_id == "DE123456789"
|
||||||
|
assert supplier.tax_number == "2281410000697"
|
||||||
assert supplier.email == "info@acme.de"
|
assert supplier.email == "info@acme.de"
|
||||||
|
|
||||||
|
|
||||||
@@ -85,10 +88,12 @@ class TestBuyer:
|
|||||||
city="Kundenstadt",
|
city="Kundenstadt",
|
||||||
country="DE",
|
country="DE",
|
||||||
vat_id="DE987654321",
|
vat_id="DE987654321",
|
||||||
|
tax_number="1234567890",
|
||||||
)
|
)
|
||||||
assert buyer.name == "Customer AG"
|
assert buyer.name == "Customer AG"
|
||||||
assert buyer.street == "Kundenweg 7"
|
assert buyer.street == "Kundenweg 7"
|
||||||
assert buyer.vat_id == "DE987654321"
|
assert buyer.vat_id == "DE987654321"
|
||||||
|
assert buyer.tax_number == "1234567890"
|
||||||
|
|
||||||
|
|
||||||
class TestVatBreakdown:
|
class TestVatBreakdown:
|
||||||
|
|||||||
@@ -149,6 +149,58 @@ class TestValidatePflichtfelder:
|
|||||||
assert any(e.field == "supplier.vat_id" for e in errors)
|
assert any(e.field == "supplier.vat_id" for e in errors)
|
||||||
assert any(e.severity == "critical" for e in errors)
|
assert any(e.severity == "critical" for e in errors)
|
||||||
|
|
||||||
|
def test_tax_number_only_supplier_no_critical_error(self):
|
||||||
|
"""Supplier with only a tax number (no VAT ID) should pass BR-CO-26."""
|
||||||
|
xml_data = XmlData(
|
||||||
|
invoice_number="INV001",
|
||||||
|
invoice_date="2024-01-15",
|
||||||
|
supplier=Supplier(
|
||||||
|
name="Werkstatt gGmbH",
|
||||||
|
vat_id=None,
|
||||||
|
tax_number="2281410000697",
|
||||||
|
),
|
||||||
|
buyer=Buyer(name="Test Buyer AG"),
|
||||||
|
line_items=[
|
||||||
|
LineItem(
|
||||||
|
position=1,
|
||||||
|
description="Test Product",
|
||||||
|
quantity=2.0,
|
||||||
|
unit="HUR",
|
||||||
|
unit_price=5.0,
|
||||||
|
line_total=10.0,
|
||||||
|
vat_rate=7.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.7, gross=10.7),
|
||||||
|
)
|
||||||
|
errors = validate_pflichtfelder(xml_data)
|
||||||
|
assert not any(e.field == "supplier.vat_id" for e in errors)
|
||||||
|
|
||||||
|
def test_missing_vat_id_and_tax_number_critical(self):
|
||||||
|
"""Supplier with neither VAT ID nor tax number stays critical."""
|
||||||
|
xml_data = XmlData(
|
||||||
|
invoice_number="INV001",
|
||||||
|
invoice_date="2024-01-15",
|
||||||
|
supplier=Supplier(name="Test Supplier GmbH", vat_id="", tax_number=""),
|
||||||
|
buyer=Buyer(name="Test Buyer AG"),
|
||||||
|
line_items=[
|
||||||
|
LineItem(
|
||||||
|
position=1,
|
||||||
|
description="Test Product",
|
||||||
|
quantity=2.0,
|
||||||
|
unit="HUR",
|
||||||
|
unit_price=5.0,
|
||||||
|
line_total=10.0,
|
||||||
|
vat_rate=7.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
totals=Totals(line_total_sum=10.0, net=10.0, vat_total=0.7, gross=10.7),
|
||||||
|
)
|
||||||
|
errors = validate_pflichtfelder(xml_data)
|
||||||
|
assert any(e.field == "supplier.vat_id" for e in errors)
|
||||||
|
assert any(e.severity == "critical" for e in errors)
|
||||||
|
assert any("tax number" in e.message for e in errors)
|
||||||
|
|
||||||
def test_missing_buyer_name_critical(self):
|
def test_missing_buyer_name_critical(self):
|
||||||
"""Missing buyer.name should produce critical error."""
|
"""Missing buyer.name should produce critical error."""
|
||||||
xml_data = XmlData(
|
xml_data = XmlData(
|
||||||
@@ -720,6 +772,11 @@ class TestValidateUstid:
|
|||||||
error = validate_ustid("DE123456789")
|
error = validate_ustid("DE123456789")
|
||||||
assert error is None
|
assert error is None
|
||||||
|
|
||||||
|
def test_valid_german_vat_id_with_spaces(self):
|
||||||
|
"""German VAT ID with formatting spaces (e.g. 'DE 140 978 617') passes."""
|
||||||
|
error = validate_ustid("DE 140 978 617")
|
||||||
|
assert error is None
|
||||||
|
|
||||||
def test_valid_austrian_vat_id(self):
|
def test_valid_austrian_vat_id(self):
|
||||||
"""Valid Austrian VAT ID should pass."""
|
"""Valid Austrian VAT ID should pass."""
|
||||||
error = validate_ustid("ATU12345678")
|
error = validate_ustid("ATU12345678")
|
||||||
|
|||||||
Reference in New Issue
Block a user