""" Fusion Accounting - Extraction Review Wizard Transient model that presents the OCR-extracted invoice fields alongside the original scan so the user can verify, correct, and then apply them to the parent ``account.move`` record. Corrections are tracked so they can later feed back into extraction quality metrics or fine-tuning data sets. Original implementation by Nexa Systems Inc. """ import json import logging from odoo import api, fields, models, _ from odoo.exceptions import UserError _log = logging.getLogger(__name__) class FusionExtractionReviewWizard(models.TransientModel): """ Review and optionally correct OCR-extracted invoice fields before they are committed to the journal entry. """ _name = "fusion.extraction.review.wizard" _description = "Review Extracted Invoice Fields" # ------------------------------------------------------------------ # Relationship # ------------------------------------------------------------------ move_id = fields.Many2one( comodel_name="account.move", string="Invoice / Bill", required=True, ondelete="cascade", help="The journal entry whose extracted data is being reviewed.", ) # ------------------------------------------------------------------ # Extracted header fields (editable) # ------------------------------------------------------------------ vendor_name = fields.Char( string="Vendor / Supplier Name", help="Name of the vendor as read by the OCR engine.", ) invoice_number = fields.Char( string="Invoice Number", help="The supplier's invoice reference.", ) invoice_date = fields.Date( string="Invoice Date", ) due_date = fields.Date( string="Due Date", ) total_amount = fields.Float( string="Total Amount", digits=(16, 2), ) tax_amount = fields.Float( string="Tax Amount", digits=(16, 2), ) subtotal = fields.Float( string="Subtotal", digits=(16, 2), ) currency_code = fields.Char( string="Currency", help="ISO 4217 currency code (e.g. USD, EUR, CAD).", ) # ------------------------------------------------------------------ # Read-only context # ------------------------------------------------------------------ raw_text = fields.Text( string="Raw OCR Text", readonly=True, help="Full OCR output – shown for reference while reviewing fields.", ) confidence = fields.Float( string="Confidence (%)", digits=(5, 2), readonly=True, ) # ------------------------------------------------------------------ # Line items (JSON text field – editable as raw JSON for power users) # ------------------------------------------------------------------ line_items_json = fields.Text( string="Line Items (JSON)", help=( "Extracted line items in JSON format. Each item should " "have: description, quantity, unit_price, amount." ), ) # ------------------------------------------------------------------ # Correction tracking # ------------------------------------------------------------------ corrections_json = fields.Text( string="Corrections Log (JSON)", readonly=True, help="Records which fields the user changed during review.", ) # ------------------------------------------------------------------ # Computed: original attachment preview # ------------------------------------------------------------------ attachment_preview = fields.Binary( string="Original Scan", compute="_compute_attachment_preview", ) attachment_filename = fields.Char( string="Attachment Filename", compute="_compute_attachment_preview", ) @api.depends("move_id") def _compute_attachment_preview(self): """Fetch the first image / PDF attachment for inline preview.""" for wiz in self: att = wiz.move_id._find_extractable_attachment() if wiz.move_id else None if att: wiz.attachment_preview = att.datas wiz.attachment_filename = att.name else: wiz.attachment_preview = False wiz.attachment_filename = False # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_apply(self): """Validate the (possibly corrected) fields and apply them to the invoice. Returns: dict: A window action returning to the updated invoice form. """ self.ensure_one() # Build the fields dict from the wizard form fields_dict = { "vendor_name": self.vendor_name or "", "invoice_number": self.invoice_number or "", "invoice_date": str(self.invoice_date) if self.invoice_date else "", "due_date": str(self.due_date) if self.due_date else "", "total_amount": self.total_amount, "tax_amount": self.tax_amount, "subtotal": self.subtotal, "currency": self.currency_code or "", } # Parse line items line_items = [] if self.line_items_json: try: line_items = json.loads(self.line_items_json) if not isinstance(line_items, list): raise ValueError("Expected a JSON array.") except (json.JSONDecodeError, ValueError) as exc: raise UserError( _("The line items JSON is invalid: %s", str(exc)) ) from exc fields_dict["line_items"] = line_items # Track corrections self._track_corrections(fields_dict) # Apply to the invoice self.move_id._apply_extracted_fields(fields_dict) # Update the stored JSON on the move for audit purposes self.move_id.fusion_extracted_fields_json = json.dumps( fields_dict, default=str, indent=2, ) return { "type": "ir.actions.act_window", "name": _("Invoice"), "res_model": "account.move", "res_id": self.move_id.id, "view_mode": "form", "target": "current", } def action_discard(self): """Close the wizard without applying any changes. Returns: dict: An action that closes the wizard window. """ return {"type": "ir.actions.act_window_close"} def action_re_extract(self): """Re-run the OCR extraction pipeline from scratch. Useful when the user has attached a better-quality scan. Returns: dict: The action returned by :meth:`~FusionInvoiceExtractor.action_extract_from_attachment`. """ self.ensure_one() return self.move_id.action_extract_from_attachment() # ------------------------------------------------------------------ # Correction tracking # ------------------------------------------------------------------ def _track_corrections(self, final_fields): """Compare the wizard values against the original extraction and record any user edits. The result is stored in :attr:`corrections_json` on the wizard and logged for future reference. Args: final_fields (dict): The fields the user is about to apply. """ self.ensure_one() original = {} if self.move_id.fusion_extracted_fields_json: try: original = json.loads(self.move_id.fusion_extracted_fields_json) except (json.JSONDecodeError, TypeError): original = {} corrections = {} compare_keys = [ "vendor_name", "invoice_number", "invoice_date", "due_date", "total_amount", "tax_amount", "subtotal", "currency", ] for key in compare_keys: orig_val = str(original.get(key, "") or "") new_val = str(final_fields.get(key, "") or "") if orig_val != new_val: corrections[key] = { "original": orig_val, "corrected": new_val, } if corrections: self.corrections_json = json.dumps(corrections, default=str, indent=2) _log.info( "Fusion OCR: user corrected %d field(s) on move %s: %s", len(corrections), self.move_id.id, list(corrections.keys()), ) # Store corrections on the move as a note for audit trail body_lines = [_("OCR Extraction – Manual Corrections