Skip to content

Commit 68051b4

Browse files
author
Florian Wunderlich
committed
fix: normalize Factur-X preview data
1 parent 00c4dc5 commit 68051b4

6 files changed

Lines changed: 184 additions & 36 deletions

File tree

models/ai_auto_apply.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ class AccountMove(models.Model):
2323
def _ai_can_auto_apply(self, data):
2424
"""Check if extraction can be auto-applied (skip preview).
2525
26-
Conditions: enabled in settings, vendor matched + reliable,
27-
all confidence scores >= threshold, no warnings, valid doc type.
26+
Conditions: enabled in settings, not explicitly preview-only,
27+
vendor matched + reliable, all confidence scores >= threshold,
28+
no warnings, valid doc type.
2829
"""
2930
if not self._ai_get_bool_param('ai_auto_apply_enabled'):
3031
return False
32+
if data.get('_require_preview'):
33+
return False
3134

3235
# Quick disqualifiers
3336
doc_type = data.get('document_type', '')

models/ai_extraction_engine.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ def _ai_trigger_extraction(self, api_key, attachment, preview=False):
9898
data = self._ai_run_pipeline(api_key, cfg, raw_data, mimetype)
9999
if data is None:
100100
return
101+
if preview and isinstance(data, dict) and data.get('_facturx'):
102+
# Preview/cache/UI code expects the normal extraction schema, not
103+
# the internal Factur-X wrapper with raw XML bytes. Keep explicit
104+
# metadata so preview-mode Factur-X results cannot bypass review.
105+
parsed_data = self._ai_parse_facturx(data['_xml'])
106+
if parsed_data is None:
107+
return
108+
data = {
109+
**parsed_data,
110+
'_source': 'facturx',
111+
'_require_preview': True,
112+
}
101113
if preview:
102114
return data
103115
if isinstance(data, dict) and data.get('_facturx'):

models/ai_field_mapper.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,26 @@ def _ai_get_invoice_attachment(self):
3939
# Factur-X application
4040
# ===================================================================
4141

42+
def _ai_parse_facturx(self, xml_data):
43+
"""Parse Factur-X XML into the standard extraction dict."""
44+
self.ensure_one()
45+
try:
46+
return ai_facturx_parser.parse_facturx_xml(xml_data)
47+
except Exception as exc:
48+
_logger.warning('Factur-X XML parsing failed: %s', exc)
49+
self.ai_extraction_status = 'failed'
50+
self.ai_confidence = json.dumps({'source': 'facturx', 'overall': 0.0})
51+
return None
52+
4253
def _ai_apply_facturx(self, xml_data):
4354
"""Apply Factur-X structured data directly to the invoice (zero AI cost).
4455
4556
Parses the CII XML into the same dict format as Claude's response,
4657
then feeds it through the standard extraction pipeline.
4758
"""
4859
self.ensure_one()
49-
try:
50-
data = ai_facturx_parser.parse_facturx_xml(xml_data)
51-
except Exception as exc:
52-
_logger.warning('Factur-X XML parsing failed: %s', exc)
53-
self.ai_extraction_status = 'failed'
54-
self.ai_confidence = json.dumps({'source': 'facturx', 'overall': 0.0})
60+
data = self._ai_parse_facturx(xml_data)
61+
if data is None:
5562
return
5663

5764
try:

tests/test_auto_apply.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ def test_no_auto_apply_low_confidence(self):
7979
result = self.move._ai_can_auto_apply(data)
8080
self.assertFalse(result)
8181

82+
def test_no_auto_apply_when_preview_review_required(self):
83+
"""Preview-only results must not bypass the manual review flow."""
84+
data = self._make_high_confidence_data()
85+
data['_source'] = 'facturx'
86+
data['_require_preview'] = True
87+
result = self.move._ai_can_auto_apply(data)
88+
self.assertFalse(result)
89+
self.assertNotIn('_force_partner_id', data)
90+
8291
def test_no_auto_apply_unknown_vendor(self):
8392
"""Unknown vendor (no VAT match) → auto-apply returns False."""
8493
data = self._make_high_confidence_data()

tests/test_facturx_pipeline.py

Lines changed: 143 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,60 @@
1-
"""Factur-X pipeline integration tests.
2-
3-
Covers the Factur-X shortcut in the extraction pipeline:
4-
1. Factur-X detection skips AI API call
5-
2. Returns correct wrapper dict
6-
3. Non-PDF and unavailable cases
7-
4. Apply and preview modes
8-
"""
1+
"""Factur-X pipeline integration tests."""
92

103
import base64
4+
import json
115
from unittest.mock import MagicMock, patch
126

137
from odoo.tests.common import TransactionCase, tagged
148

159
_MODULE = 'odoo.addons.account_invoice_digitize_ai'
1610

17-
SAMPLE_FACTURX_XML = '<xml>facturx</xml>'
11+
SAMPLE_FACTURX_XML = b'<xml>facturx</xml>'
12+
SAMPLE_FACTURX_DATA = {
13+
'document_type': 'invoice',
14+
'is_marked_paid': False,
15+
'vendor': {'name': 'Factur-X Vendor', 'vat': 'DE123456789', 'confidence': 1.0},
16+
'buyer': {'name': 'Factor 3', 'vat': 'DE999999999', 'confidence': 1.0},
17+
'invoice': {
18+
'reference': 'FX-001',
19+
'invoice_date': '2024-01-15',
20+
'currency': 'EUR',
21+
'confidence': 1.0,
22+
},
23+
'totals': {
24+
'untaxed_amount': 1000.0,
25+
'tax_amount': 200.0,
26+
'total_amount': 1200.0,
27+
'confidence': 1.0,
28+
},
29+
'tax_lines': [
30+
{
31+
'tax_label': 'VAT 20%',
32+
'tax_rate': 20.0,
33+
'base_amount': 1000.0,
34+
'tax_amount': 200.0,
35+
'confidence': 1.0,
36+
}
37+
],
38+
'lines': [
39+
{
40+
'description': 'Consulting services',
41+
'quantity': 1.0,
42+
'unit_price': 1000.0,
43+
'subtotal_untaxed': 1000.0,
44+
'tax_rate': 20.0,
45+
}
46+
],
47+
'table_analysis': {
48+
'number_format': 'dot_decimal',
49+
'complexity': 'simple',
50+
'line_count': 1,
51+
},
52+
}
53+
SAMPLE_FACTURX_PREVIEW_DATA = {
54+
**SAMPLE_FACTURX_DATA,
55+
'_source': 'facturx',
56+
'_require_preview': True,
57+
}
1858

1959

2060
@tagged('post_install', '-at_install')
@@ -91,39 +131,116 @@ def test_facturx_unavailable_skipped(self):
91131
@patch(f'{_MODULE}.models.ai_facturx_parser.parse_facturx_xml')
92132
def test_facturx_apply_sets_done(self, mock_parse, _is_pdf, _detect):
93133
"""Successful Factur-X extraction should set status to 'done'."""
94-
mock_parse.return_value = {
95-
'vendor': {'name': 'Test Vendor', 'confidence': 1.0},
96-
'invoice': {'number': 'FX-001', 'date': '2024-01-15', 'confidence': 1.0},
97-
'totals': {'total_amount': 1200, 'confidence': 1.0},
98-
'document_type': 'invoice',
99-
}
134+
mock_parse.return_value = SAMPLE_FACTURX_DATA
135+
move = self.env['account.move'].create({
136+
'move_type': 'in_invoice',
137+
'company_id': self.company.id,
138+
})
100139
attachment = self.env['ir.attachment'].create({
101140
'name': 'test.pdf',
102141
'datas': base64.b64encode(b'fake-pdf'),
103142
'res_model': 'account.move',
104-
'res_id': self.move.id,
143+
'res_id': move.id,
105144
})
106-
self.move._ai_trigger_extraction('test-key-123', attachment)
107-
self.assertEqual(self.move.ai_extraction_status, 'done')
145+
move._ai_trigger_extraction('test-key-123', attachment)
146+
self.assertEqual(move.ai_extraction_status, 'done')
108147

109-
# --- 6. Preview returns data without applying ---
148+
# --- 6. Preview returns normalized data without applying ---
110149

111150
@patch(f'{_MODULE}.models.ai_document.detect_facturx', return_value=SAMPLE_FACTURX_XML)
112151
@patch(f'{_MODULE}.models.ai_document.FACTURX_AVAILABLE', True)
113152
@patch(f'{_MODULE}.models.ai_document.is_pdf', return_value=True)
114-
def test_facturx_preview_returns_data(self, _is_pdf, _detect):
115-
"""In preview mode, Factur-X data should be returned without applying."""
153+
@patch(f'{_MODULE}.models.ai_facturx_parser.parse_facturx_xml', return_value=SAMPLE_FACTURX_DATA)
154+
def test_facturx_preview_returns_normalized_data(self, mock_parse, _is_pdf, _detect):
155+
"""Preview mode should normalize Factur-X XML into JSON-safe extraction data."""
156+
move = self.env['account.move'].create({
157+
'move_type': 'in_invoice',
158+
'company_id': self.company.id,
159+
})
116160
attachment = self.env['ir.attachment'].create({
117161
'name': 'test.pdf',
118162
'datas': base64.b64encode(b'fake-pdf'),
119163
'res_model': 'account.move',
120-
'res_id': self.move.id,
164+
'res_id': move.id,
121165
})
122-
result = self.move._ai_trigger_extraction(
166+
result = move._ai_trigger_extraction(
123167
'test-key-123', attachment, preview=True,
124168
)
169+
mock_parse.assert_called_once_with(SAMPLE_FACTURX_XML)
125170
self.assertIsNotNone(result)
126-
self.assertTrue(result.get('_facturx'))
127-
self.assertEqual(result['_xml'], SAMPLE_FACTURX_XML)
171+
self.assertEqual(result, SAMPLE_FACTURX_PREVIEW_DATA)
128172
# Status should NOT be 'done' (preview mode)
129-
self.assertNotEqual(self.move.ai_extraction_status, 'done')
173+
self.assertNotEqual(move.ai_extraction_status, 'done')
174+
175+
# --- 7. Sync button uses normalized Factur-X preview data ---
176+
177+
@patch(f'{_MODULE}.models.ai_document.detect_facturx', return_value=SAMPLE_FACTURX_XML)
178+
@patch(f'{_MODULE}.models.ai_document.FACTURX_AVAILABLE', True)
179+
@patch(f'{_MODULE}.models.ai_document.is_pdf', return_value=True)
180+
@patch(f'{_MODULE}.models.ai_facturx_parser.parse_facturx_xml', return_value=SAMPLE_FACTURX_DATA)
181+
def test_facturx_sync_button_opens_preview_wizard(self, mock_parse, _is_pdf, _detect):
182+
"""The normal button path must open the preview wizard for Factur-X PDFs."""
183+
move = self.env['account.move'].create({
184+
'move_type': 'in_invoice',
185+
'company_id': self.company.id,
186+
})
187+
self.env['ir.attachment'].create({
188+
'name': 'test.pdf',
189+
'datas': base64.b64encode(b'fake-pdf'),
190+
'mimetype': 'application/pdf',
191+
'res_model': 'account.move',
192+
'res_id': move.id,
193+
})
194+
195+
result = move.action_ai_extract()
196+
197+
mock_parse.assert_called_once_with(SAMPLE_FACTURX_XML)
198+
self.assertEqual(result.get('res_model'), 'ai.preview.wizard')
199+
self.assertEqual(move.ai_extraction_status, 'pending')
200+
self.assertEqual(json.loads(move.ai_last_extraction_data), SAMPLE_FACTURX_PREVIEW_DATA)
201+
202+
wizard = self.env['ai.preview.wizard'].browse(result['res_id'])
203+
self.assertEqual(wizard.invoice_ref, 'FX-001')
204+
self.assertEqual(wizard.vendor_name, 'Factur-X Vendor')
205+
206+
@patch(f'{_MODULE}.models.ai_document.detect_facturx', return_value=SAMPLE_FACTURX_XML)
207+
@patch(f'{_MODULE}.models.ai_document.FACTURX_AVAILABLE', True)
208+
@patch(f'{_MODULE}.models.ai_document.is_pdf', return_value=True)
209+
@patch(f'{_MODULE}.models.ai_facturx_parser.parse_facturx_xml', return_value=SAMPLE_FACTURX_DATA)
210+
def test_facturx_sync_button_skips_auto_apply_and_keeps_preview(self, mock_parse, _is_pdf, _detect):
211+
"""Factur-X preview data must not be auto-applied even for reliable vendors."""
212+
self.env['ir.config_parameter'].sudo().set_param(
213+
'account_invoice_digitize_ai.ai_auto_apply_enabled',
214+
'True',
215+
)
216+
partner = self.env['res.partner'].create({
217+
'name': 'Factur-X Vendor',
218+
'is_company': True,
219+
'vat': 'DE123456789',
220+
})
221+
self.env['ai.vendor.score'].create({
222+
'partner_id': partner.id,
223+
'company_id': self.company.id,
224+
'total_extractions': 10,
225+
'correct_extractions': 9,
226+
})
227+
move = self.env['account.move'].create({
228+
'move_type': 'in_invoice',
229+
'company_id': self.company.id,
230+
})
231+
self.env['ir.attachment'].create({
232+
'name': 'test.pdf',
233+
'datas': base64.b64encode(b'fake-pdf'),
234+
'mimetype': 'application/pdf',
235+
'res_model': 'account.move',
236+
'res_id': move.id,
237+
})
238+
239+
result = move.action_ai_extract()
240+
241+
mock_parse.assert_called_once_with(SAMPLE_FACTURX_XML)
242+
self.assertEqual(result.get('res_model'), 'ai.preview.wizard')
243+
self.assertEqual(move.ai_extraction_status, 'pending')
244+
self.assertFalse(move.ref)
245+
self.assertFalse(move.partner_id)
246+
self.assertEqual(json.loads(move.ai_last_extraction_data), SAMPLE_FACTURX_PREVIEW_DATA)

tests/test_preprocessing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,11 +543,11 @@ def test_facturx_takes_priority(self):
543543
ICP.set_param('account_invoice_digitize_ai.ai_azure_api_key', 'test-azure-key')
544544

545545
move = self._create_move()
546-
facturx_data = {'vendor': {}, 'invoice': {}, 'totals': {}}
546+
facturx_xml = b'<xml>facturx</xml>'
547547

548548
AccountMove = type(move)
549549
with (
550-
patch.object(AccountMove, '_ai_try_facturx', return_value=facturx_data),
550+
patch.object(AccountMove, '_ai_try_facturx', return_value=facturx_xml),
551551
patch.object(AccountMove, '_ai_apply_facturx') as mock_fx,
552552
patch.object(AccountMove, '_ai_try_preprocess') as mock_pp,
553553
):

0 commit comments

Comments
 (0)