267 lines
9.1 KiB
Python
267 lines
9.1 KiB
Python
"""
|
||
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",
|
||
)
|