Files
Odoo-Modules/Fusion Accounting/wizard/extraction_review_wizard.py
2026-02-22 01:22:18 -05:00

267 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 = [_("<b>OCR Extraction Manual Corrections</b><ul>")]
for field_name, change in corrections.items():
body_lines.append(
_("<li><b>%s</b>: %s%s</li>",
field_name, change["original"], change["corrected"])
)
body_lines.append("</ul>")
self.move_id.message_post(
body="".join(str(l) for l in body_lines),
message_type="comment",
subtype_xmlid="mail.mt_note",
)