Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View 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",
)