Add review stage coverage and fix XML tax deduplication

- Reviewer now sends all pages to the review VLM alongside extracted JSON.

- XML builder deduplicates account.tax records by rate.

- XML builder raises on confidence below min_confidence_threshold.

- Adds fractional tax-rate support with stable external IDs.

- Adds expected_invoice.xml fixture, reviewer tests, and XML regression tests.
This commit is contained in:
2026-08-21 16:44:25 +02:00
parent 570c8585a0
commit e2becd6219
6 changed files with 270 additions and 45 deletions
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="0">
<record id="vendor_acme_supplies_078953f5" model="res.partner">
<field name="name">Acme Supplies</field>
<field name="supplier_rank">1</field>
<field name="vat">GB123456789</field>
</record>
<record id="purchase_vat_20" model="account.tax">
<field name="name">Purchase VAT 20%</field>
<field name="amount">20</field>
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
</record>
<record id="invoice_inv_2024_001_20240501_8047d436" model="account.move">
<field name="move_type">in_invoice</field>
<field name="partner_id" ref="vendor_acme_supplies_078953f5"/>
<field name="invoice_date">2024-05-01</field>
<field name="date">2024-05-01</field>
<field name="invoice_date_due">2024-06-01</field>
<field name="ref">INV-2024-001</field>
<field name="currency_id" ref="base.EUR"/>
<field name="narration">Net 30</field>
</record>
<record id="invoice_inv_2024_001_20240501_8047d436_line_1" model="account.move.line">
<field name="move_id" ref="invoice_inv_2024_001_20240501_8047d436"/>
<field name="name">Consulting services</field>
<field name="quantity">10.00</field>
<field name="price_unit">100.00</field>
<field name="tax_ids" eval="[(6, 0, [ref('purchase_vat_20')])]"/>
</record>
</data>
</odoo>
+85
View File
@@ -0,0 +1,85 @@
"""Tests for the vision review stage."""
from __future__ import annotations
import pytest
from PIL import Image
from odoo_ocr.config import Settings
from odoo_ocr.pipeline.reviewer import review_invoice
from odoo_ocr.schemas import ExtractedInvoice, ReviewResult
from tests.conftest import FakeVLMClient
def make_invoice() -> ExtractedInvoice:
return ExtractedInvoice(
vendor_name="Acme",
invoice_number="INV-1",
invoice_date="2024-01-01",
)
@pytest.mark.asyncio
async def test_review_returns_parsed_result() -> None:
client = FakeVLMClient(
responses={
"invoice review assistant": {
"valid": True,
"confidence": 0.95,
"issues": [],
"corrected_invoice": None,
}
}
)
result = await review_invoice(
make_invoice(), [Image.new("RGB", (10, 10))], client, Settings()
)
assert isinstance(result, ReviewResult)
assert result.valid is True
assert result.confidence == 0.95
@pytest.mark.asyncio
async def test_review_sends_all_pages() -> None:
client = FakeVLMClient(
responses={
"invoice review assistant": {
"valid": True,
"confidence": 0.90,
"issues": [],
"corrected_invoice": None,
}
}
)
pages = [Image.new("RGB", (10, 10)) for _ in range(3)]
await review_invoice(make_invoice(), pages, client, Settings())
assert client.calls[-1]["image_count"] == len(pages)
@pytest.mark.asyncio
async def test_review_preserves_corrected_invoice() -> None:
corrected = {
"vendor_name": "Acme",
"invoice_number": "INV-1",
"invoice_date": "2024-01-01",
"line_items": [],
"subtotal": 100.0,
"tax_total": 0.0,
"total": 100.0,
}
client = FakeVLMClient(
responses={
"invoice review assistant": {
"valid": True,
"confidence": 0.95,
"issues": [{"field": "total", "severity": "warning", "message": "rounded"}],
"corrected_invoice": corrected,
}
}
)
result = await review_invoice(
make_invoice(), [Image.new("RGB", (10, 10))], client, Settings()
)
assert result.corrected_invoice is not None
assert result.corrected_invoice.total == 100.0
assert result.issues[0].severity == "warning"
+103 -5
View File
@@ -1,15 +1,20 @@
"""Tests for the Odoo XML builder."""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import pytest
from lxml import etree
from odoo_ocr.config import Settings
from odoo_ocr.pipeline.xml_builder import build_odoo_xml
from odoo_ocr.schemas import ExtractedInvoice, InvoiceLineItem, ReviewResult
def test_build_odoo_xml() -> None:
settings = Settings()
invoice = ExtractedInvoice(
def _fixture_invoice() -> ExtractedInvoice:
return ExtractedInvoice(
vendor_name="Acme Supplies",
vendor_vat="GB123456789",
invoice_number="INV-2024-001",
@@ -30,9 +35,12 @@ def test_build_odoo_xml() -> None:
tax_total=Decimal("200.00"),
total=Decimal("1200.00"),
)
review = ReviewResult(valid=True, confidence=0.95)
xml = build_odoo_xml(invoice, review, settings)
def test_build_odoo_xml() -> None:
settings = Settings()
invoice = _fixture_invoice()
xml = build_odoo_xml(invoice, ReviewResult(valid=True, confidence=0.95), settings)
assert xml.startswith("<?xml version=")
assert 'model="res.partner"' in xml
@@ -43,6 +51,60 @@ def test_build_odoo_xml() -> None:
assert "in_invoice" in xml
def test_xml_parses_with_lxml() -> None:
invoice = _fixture_invoice()
xml = build_odoo_xml(invoice, ReviewResult(valid=True, confidence=0.95), Settings())
root = etree.fromstring(xml.encode("utf-8"))
models = {record.get("model") for record in root.iter("record")}
assert models == {"res.partner", "account.tax", "account.move", "account.move.line"}
def test_xml_matches_expected_fixture() -> None:
invoice = _fixture_invoice()
xml = build_odoo_xml(invoice, ReviewResult(valid=True, confidence=0.95), Settings())
expected = (Path(__file__).parent / "fixtures" / "expected_invoice.xml").read_text(
encoding="utf-8"
)
def _normalize(doc: str) -> dict:
root = etree.fromstring(doc.encode("utf-8"))
def _el(element) -> dict:
return {
"tag": element.tag,
"attrs": dict(element.attrib.items()),
"text": (element.text or "").strip(),
"children": [_el(child) for child in element],
}
return _el(root)
assert _normalize(xml) == _normalize(expected)
def test_fractional_tax_rate_ids_are_distinct() -> None:
invoice = ExtractedInvoice(
vendor_name="X",
invoice_number="1",
invoice_date="2024-01-01",
line_items=[
InvoiceLineItem(description="A", tax_rate=Decimal("19.79")),
InvoiceLineItem(description="B", tax_rate=Decimal("15.2")),
InvoiceLineItem(description="C", tax_rate=Decimal("15.7")),
],
)
xml = build_odoo_xml(invoice, ReviewResult(valid=True, confidence=0.95), Settings())
assert 'id="purchase_vat_19_79"' in xml
assert 'id="purchase_vat_15_2"' in xml
assert 'id="purchase_vat_15_7"' in xml
assert xml.count('model="account.tax"') == 3
assert "Purchase VAT 19.79%" in xml
assert "<field name=\"amount\">19.79</field>" in xml
def test_xml_adds_review_comment_for_low_confidence() -> None:
settings = Settings()
invoice = ExtractedInvoice(
@@ -58,3 +120,39 @@ def test_xml_adds_review_comment_for_low_confidence() -> None:
xml = build_odoo_xml(invoice, review, settings)
assert "HUMAN REVIEW ADVISED" in xml
def test_duplicate_tax_rates_emit_one_record() -> None:
"""Multiple lines sharing the same tax rate must produce a single account.tax record."""
invoice = ExtractedInvoice(
vendor_name="X",
invoice_number="1",
invoice_date="2024-01-01",
line_items=[
InvoiceLineItem(description="A", tax_rate=Decimal("20")),
InvoiceLineItem(description="B", tax_rate=Decimal("20")),
InvoiceLineItem(description="C", tax_rate=Decimal("0")),
],
)
xml = build_odoo_xml(invoice, ReviewResult(valid=True, confidence=0.95), Settings())
assert xml.count('id="purchase_vat_20"') == 1
assert xml.count('id="purchase_vat_0"') == 1
assert xml.count('model="account.tax"') == 2
def test_xml_refuses_low_confidence() -> None:
settings = Settings()
invoice = ExtractedInvoice(
vendor_name="X",
invoice_number="1",
invoice_date="2024-01-01",
line_items=[],
subtotal=Decimal("0"),
tax_total=Decimal("0"),
total=Decimal("0"),
)
review = ReviewResult(valid=False, confidence=0.70)
with pytest.raises(ValueError, match="below the minimum threshold"):
build_odoo_xml(invoice, review, settings)