Initial commit
This commit is contained in:
266
Fusion Accounting/wizard/extraction_review_wizard.py
Normal file
266
Fusion Accounting/wizard/extraction_review_wizard.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
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",
|
||||
)
|
||||
Reference in New Issue
Block a user