Files
Odoo-Modules/fusion_accounting_ocr/tests/test_invoice_ocr_flow.py
gsinghpal 125f48377a feat(fusion_accounting_ocr): pluggable OCR for vendor bills
Replaces Enterprise's account_invoice_extract with a Fusion-native pipeline:

Stage 1 (text extraction): Tesseract OCRs the bill attachment via
pytesseract + pdf2image. Pluggable OCRProvider adapter pattern allows
future Mindee / Google Document AI / Ollama-vision backends.

Stage 2 (field parsing): The fusion_accounting_ai LLMProvider reads the
raw OCR text and returns structured invoice fields (vendor, invoice
number, dates, amounts, line items) as JSON.

Draft invoice fields are auto-populated for empty-only fields (never
overwriting user-entered data). Vendor matching by name against
res.partner with supplier_rank > 0.

Adds:
- account.move.ocr_state (selection: not_requested/pending/processing/
  done/failed/manual)
- account.move.ocr_raw_text, ocr_extracted_data (Json), ocr_backend,
  ocr_confidence
- fusion.ocr.log (audit trail per OCR run)
- res.company.fusion_ocr_enabled / fusion_ocr_default_backend / auto_run
- /fusion/ocr/request_for_invoice JSON-RPC endpoint

Backend availability detected at runtime via OCRProvider.is_available()
classmethods. Tesseract 5.3.4 + pytesseract 0.3.13 + pdf2image 1.17.0
are installed in the container.

Tests: 13 (TesseractAdapter availability + image OCR; flow tests for
draft autofill, no-attachment guard, customer-invoice guard, ref-not-
overwritten; field parser empty/clean-json/markdown-fence/bad-JSON/
provider-exception). All pass on westin-v19 OrbStack VM.

Made-with: Cursor
2026-04-20 00:32:50 -04:00

118 lines
4.1 KiB
Python

import base64
import io
from unittest.mock import patch
from PIL import Image, ImageDraw
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestInvoiceOcrFlow(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Vendor',
'supplier_rank': 1,
})
self.move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner.id,
})
def test_ocr_state_default(self):
self.assertEqual(self.move.ocr_state, 'not_requested')
def test_action_request_ocr_no_attachment_raises(self):
with self.assertRaises(UserError):
self.move.action_request_ocr()
def test_action_request_ocr_with_image(self):
img = Image.new('RGB', (800, 120), color='white')
draw = ImageDraw.Draw(img)
try:
from PIL import ImageFont
font = ImageFont.truetype(
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36,
)
except Exception:
font = None
draw.text((20, 30), "TOTAL $50.00 INV-9999", fill='black', font=font)
buf = io.BytesIO()
img.save(buf, format='PNG')
self.env['ir.attachment'].create({
'name': 'test_invoice.png',
'datas': base64.b64encode(buf.getvalue()),
'res_model': 'account.move',
'res_id': self.move.id,
'mimetype': 'image/png',
})
# Mock the LLM call to avoid a real API roundtrip.
with patch(
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
return_value={
'vendor_name': None,
'invoice_number': 'INV-9999',
'invoice_date': None,
'due_date': None,
'currency': None,
'subtotal': None,
'tax_total': None,
'total': 50.0,
'line_items': [],
},
):
self.move.action_request_ocr()
self.assertEqual(self.move.ocr_state, 'done')
self.assertEqual(self.move.ocr_backend, 'tesseract')
self.assertGreater(self.move.ocr_confidence, 0)
self.assertIsNotNone(self.move.ocr_extracted_data)
# Parsed invoice_number should land on the invoice's ref field.
self.assertEqual(self.move.ref, 'INV-9999')
# OCR log row was created.
self.assertEqual(len(self.move.ocr_log_ids), 1)
log = self.move.ocr_log_ids
self.assertEqual(log.backend, 'tesseract')
self.assertGreater(log.raw_text_length, 0)
def test_apply_does_not_overwrite_user_entered_ref(self):
self.move.ref = 'USER-SET-REF'
img = Image.new('RGB', (400, 80), color='white')
ImageDraw.Draw(img).text((10, 30), "INV-7777", fill='black')
buf = io.BytesIO()
img.save(buf, format='PNG')
self.env['ir.attachment'].create({
'name': 't.png',
'datas': base64.b64encode(buf.getvalue()),
'res_model': 'account.move',
'res_id': self.move.id,
'mimetype': 'image/png',
})
with patch(
'odoo.addons.fusion_accounting_ocr.models.account_move.parse_invoice_fields',
return_value={
'vendor_name': None, 'invoice_number': 'INV-7777',
'invoice_date': None, 'due_date': None, 'currency': None,
'subtotal': None, 'tax_total': None, 'total': None,
'line_items': [],
},
):
self.move.action_request_ocr()
# User-entered ref must not be overwritten.
self.assertEqual(self.move.ref, 'USER-SET-REF')
def test_only_vendor_bills_supported(self):
customer_invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
})
with self.assertRaises(UserError):
customer_invoice.action_request_ocr()