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,9 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import res_partner
from . import fusion_accounts_log
from . import ai_bill_extractor
from . import account_move
from . import res_config_settings

View File

@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from email.utils import parseaddr
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
x_fa_created_from_email = fields.Boolean(
string='Created from Email',
default=False,
readonly=True,
copy=False,
help='This bill was automatically created from an incoming email.',
)
x_fa_match_level = fields.Selection(
selection=[
('exact_email', 'Exact Email'),
('domain', 'Domain Match'),
('name', 'Name Match'),
('no_match', 'No Match'),
],
string='Vendor Match Level',
readonly=True,
copy=False,
help='How the vendor was matched from the sender email.',
)
x_fa_ai_extracted = fields.Boolean(
string='AI Extracted',
default=False,
readonly=True,
copy=False,
help='Bill data was extracted using AI.',
)
x_fa_original_sender = fields.Char(
string='Original Email Sender',
readonly=True,
copy=False,
help='The original sender email address that triggered bill creation.',
)
# =========================================================================
# VENDOR MATCHING
# =========================================================================
@api.model
def _fa_match_vendor_from_email(self, email_from):
"""Multi-level vendor matching from sender email.
Tries three levels:
1. Exact email match
2. Domain match (email domain or website)
3. Name match (sender display name)
Returns: (partner_record, match_level) or (False, 'no_match')
"""
if not email_from:
return False, 'no_match'
# Parse "Display Name <email@example.com>" format
display_name, email_address = parseaddr(email_from)
if not email_address:
return False, 'no_match'
email_address = email_address.strip().lower()
Partner = self.env['res.partner'].sudo()
# Check settings for which match levels are enabled
ICP = self.env['ir.config_parameter'].sudo()
enable_domain = ICP.get_param('fusion_accounts.enable_domain_match', 'True') == 'True'
enable_name = ICP.get_param('fusion_accounts.enable_name_match', 'True') == 'True'
# ----- Level 1: Exact email match -----
partner = Partner.search([
('email', '=ilike', email_address),
('supplier_rank', '>', 0),
], limit=1)
if not partner:
# Also check without supplier_rank filter (contact might not be flagged as vendor)
partner = Partner.search([
('email', '=ilike', email_address),
], limit=1)
if partner:
_logger.info("Vendor match Level 1 (exact email): %s -> %s",
email_address, partner.name)
return partner, 'exact_email'
# ----- Level 2: Domain match -----
if enable_domain and '@' in email_address:
domain = email_address.split('@')[1]
# Skip common email providers
common_domains = {
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
'live.com', 'aol.com', 'icloud.com', 'mail.com',
'protonmail.com', 'zoho.com',
}
if domain not in common_domains:
# Search by email domain
partners = Partner.search([
'|',
('email', '=ilike', f'%@{domain}'),
('website', 'ilike', domain),
])
if partners:
# Prefer is_company=True (the parent company)
company_partner = partners.filtered(lambda p: p.is_company)
partner = company_partner[0] if company_partner else partners[0]
_logger.info("Vendor match Level 2 (domain): %s -> %s (from %d candidates)",
domain, partner.name, len(partners))
return partner, 'domain'
# ----- Level 3: Name match -----
if enable_name and display_name:
clean_name = display_name.strip().strip('"').strip("'")
if len(clean_name) >= 3: # Only match names with 3+ characters
partners = Partner.search([
'|',
('name', 'ilike', clean_name),
('commercial_company_name', 'ilike', clean_name),
])
if len(partners) == 1:
_logger.info("Vendor match Level 3 (name): '%s' -> %s",
clean_name, partners.name)
return partners, 'name'
elif len(partners) > 1:
_logger.info("Vendor match Level 3 skipped: '%s' matched %d partners (ambiguous)",
clean_name, len(partners))
_logger.info("No vendor match found for: %s (%s)", display_name, email_address)
return False, 'no_match'
# =========================================================================
# MESSAGE_NEW OVERRIDE
# =========================================================================
@api.model
def message_new(self, msg_dict, custom_values=None):
"""Override to add vendor matching and blocking for incoming bills.
When an email arrives via the accounts alias:
1. Match sender to a vendor
2. If vendor is blocked -> log to Discuss, don't create bill
3. If not blocked -> create draft bill, run AI extraction
"""
email_from = msg_dict.get('email_from', '') or msg_dict.get('from', '')
subject = msg_dict.get('subject', '')
_logger.info("Fusion Accounts: Processing incoming email from '%s' subject '%s'",
email_from, subject)
# Match vendor
partner, match_level = self._fa_match_vendor_from_email(email_from)
# Check if vendor is blocked
if partner and partner.x_fa_block_email_bill:
_logger.info("Vendor '%s' is blocked for email bill creation. Skipping bill.",
partner.name)
# Log the blocked action
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id,
'match_level': match_level,
'action_taken': 'blocked',
'notes': f'Vendor "{partner.name}" has email bill creation blocked.',
})
# Post note to vendor's chatter
try:
partner.message_post(
body=f'<p><strong>Blocked bill email:</strong> {subject}</p>'
f'<p><strong>From:</strong> {email_from}</p>',
message_type='comment',
subtype_xmlid='mail.mt_note',
)
except Exception as e:
_logger.warning("Failed to post blocked email to partner chatter: %s", e)
# Don't create a bill -- just let fetchmail mark the email as handled
# by raising a controlled exception that fetchmail catches gracefully
_logger.info("Skipping bill creation for blocked vendor %s", partner.name)
raise ValueError(
f"Fusion Accounts: Bill creation blocked for vendor '{partner.name}'. "
f"Email from {email_from} logged to activity log."
)
# Not blocked -- create the bill
custom_values = custom_values or {}
custom_values['move_type'] = 'in_invoice'
if partner:
custom_values['partner_id'] = partner.id
# Create the bill via standard Odoo mechanism
try:
move = super().message_new(msg_dict, custom_values=custom_values)
# Write FA fields after creation (Odoo may strip unknown fields from custom_values)
move.sudo().write({
'x_fa_created_from_email': True,
'x_fa_match_level': match_level,
'x_fa_original_sender': email_from,
})
except Exception as e:
_logger.error("Failed to create bill from email: %s", e)
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id if partner else False,
'match_level': match_level,
'action_taken': 'failed',
'notes': str(e),
})
raise
# Run AI extraction using attachments from msg_dict
# (ir.attachment records don't exist yet at this point - they're created by message_post later)
ai_extracted = False
ai_result = ''
try:
ICP = self.env['ir.config_parameter'].sudo()
ai_enabled = ICP.get_param('fusion_accounts.ai_enabled', 'True') == 'True'
if ai_enabled:
extractor = self.env['fusion.accounts.ai.extractor']
email_body = msg_dict.get('body', '')
raw_attachments = msg_dict.get('attachments', [])
extracted_data = extractor.extract_bill_data_from_raw(
email_body, raw_attachments
)
if extracted_data:
extractor.apply_extracted_data(move, extracted_data)
ai_extracted = True
ai_result = str(extracted_data)
move.sudo().write({'x_fa_ai_extracted': True})
except Exception as e:
_logger.warning("AI extraction failed for bill %s: %s", move.id, e)
ai_result = f'Error: {e}'
# Log the successful creation
self.env['fusion.accounts.log'].sudo().create({
'email_from': email_from,
'email_subject': subject,
'email_date': msg_dict.get('date'),
'vendor_id': partner.id if partner else False,
'match_level': match_level,
'action_taken': 'bill_created',
'bill_id': move.id,
'ai_extracted': ai_extracted,
'ai_result': ai_result,
})
_logger.info("Fusion Accounts: Created bill %s from email (vendor=%s, match=%s, ai=%s)",
move.name, partner.name if partner else 'None', match_level, ai_extracted)
return move

View File

@@ -0,0 +1,614 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
import re
from odoo import models
_logger = logging.getLogger(__name__)
EXTRACTION_PROMPT = """You are an accounts payable assistant. Extract billing information from the attached invoice/bill document and email.
IMPORTANT RULES:
- The PDF attachment is the PRIMARY source of truth. Always prefer data from the PDF over the email body.
- "vendor_name" = the company that ISSUED the invoice/bill (the seller/supplier name on the document), NOT the email sender.
- "invoice_number" = the Invoice Number, Bill Number, Reference Number, or Sales Order Number printed on the document.
- "invoice_date" = the date the invoice was issued (not the email date).
- "due_date" = the payment due date on the invoice.
- For line items, extract each product/service line with description, quantity, unit price, and line total.
Return ONLY valid JSON with this exact structure (use null for missing values):
{
"vendor_name": "string - the company name that issued the bill",
"invoice_number": "string - invoice/bill/reference number",
"invoice_date": "YYYY-MM-DD",
"due_date": "YYYY-MM-DD",
"currency": "CAD or USD",
"subtotal": 0.00,
"tax_amount": 0.00,
"total_amount": 0.00,
"po_reference": "string or null - any PO reference on the document",
"lines": [
{
"description": "string",
"quantity": 1.0,
"unit_price": 0.00,
"amount": 0.00
}
]
}
If you cannot determine a value, use null. For lines, include as many as you can find.
Do NOT include any text outside the JSON object."""
class AIBillExtractor(models.AbstractModel):
_name = 'fusion.accounts.ai.extractor'
_description = 'AI Bill Data Extractor'
def _get_api_key(self):
"""Get the OpenAI API key from settings."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.openai_api_key', ''
)
def _get_ai_model(self):
"""Get the configured AI model."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_model', 'gpt-4o-mini'
)
def _get_max_pages(self):
"""Get the max PDF pages to process."""
try:
return int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_max_pages', '2'
))
except (ValueError, TypeError):
return 2
def _is_ai_enabled(self):
"""Check if AI extraction is enabled."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_accounts.ai_enabled', 'True'
) == 'True'
def extract_bill_data_from_raw(self, email_body, raw_attachments=None):
"""Extract bill data using raw attachments from msg_dict.
Raw attachments come as a list that can contain:
- tuples: (filename, content_bytes, info_dict)
- ir.attachment records (if already created)
Args:
email_body: HTML email body
raw_attachments: list from msg_dict['attachments']
Returns:
dict with extracted data, or empty dict on failure
"""
if not self._is_ai_enabled():
_logger.info("AI extraction is disabled")
return {}
api_key = self._get_api_key()
if not api_key:
_logger.warning("No OpenAI API key configured")
return {}
try:
import requests as req_lib
except ImportError:
_logger.error("requests library not available")
return {}
clean_body = self._strip_html(email_body or '')
content_parts = []
has_pdf_content = False
# Process raw attachments from msg_dict
if raw_attachments:
for att in raw_attachments[:3]:
fname = ''
content = None
if hasattr(att, 'datas'):
# ir.attachment record
fname = att.name or ''
content = base64.b64decode(att.datas) if att.datas else None
mimetype = att.mimetype or ''
elif hasattr(att, 'fname') and hasattr(att, 'content'):
# Odoo Attachment namedtuple (fname, content, info)
fname = att.fname or ''
content = att.content if isinstance(att.content, bytes) else None
mimetype = getattr(att, 'info', {}).get('content_type', '') if hasattr(att, 'info') and att.info else ''
elif isinstance(att, (tuple, list)) and len(att) >= 2:
# (filename, content_bytes, ...) tuple
fname = att[0] or ''
content = att[1] if isinstance(att[1], bytes) else None
mimetype = ''
else:
continue
# Determine mimetype from filename if not set
if not mimetype:
if fname.lower().endswith('.pdf'):
mimetype = 'application/pdf'
elif fname.lower().endswith(('.png', '.jpg', '.jpeg')):
mimetype = 'image/' + fname.rsplit('.', 1)[-1].lower()
if not content:
continue
_logger.info("Processing attachment: %s (%d bytes)", fname, len(content))
if fname.lower().endswith('.pdf') or mimetype == 'application/pdf':
# Convert PDF to images
pdf_images = self._pdf_bytes_to_images(content)
if pdf_images:
has_pdf_content = True
for img_data in pdf_images:
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_data}",
"detail": "high",
}
})
else:
# Fallback: text extraction
pdf_text = self._pdf_bytes_to_text(content)
if pdf_text:
has_pdf_content = True
content_parts.append({
"type": "text",
"text": f"INVOICE/BILL DOCUMENT:\n{pdf_text[:8000]}"
})
elif mimetype.startswith('image/'):
has_pdf_content = True
img_b64 = base64.b64encode(content).decode()
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{mimetype};base64,{img_b64}",
"detail": "high",
}
})
# Email body as secondary context
if clean_body and not has_pdf_content:
content_parts.append({
"type": "text",
"text": f"EMAIL BODY (no invoice attachment):\n{clean_body[:5000]}"
})
elif clean_body and has_pdf_content:
content_parts.append({
"type": "text",
"text": f"ADDITIONAL CONTEXT FROM EMAIL:\n{clean_body[:2000]}"
})
if not content_parts:
_logger.info("No content to extract from")
return {}
# Call OpenAI API
model = self._get_ai_model()
messages = [
{"role": "system", "content": EXTRACTION_PROMPT},
{"role": "user", "content": content_parts},
]
try:
response = req_lib.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': messages,
'max_tokens': 2000,
'temperature': 0.1,
},
timeout=60,
)
response.raise_for_status()
result = response.json()
content = result['choices'][0]['message']['content']
content = content.strip()
if content.startswith('```'):
lines = content.split('\n')
content = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
content = content.strip()
if not content:
_logger.warning("AI returned empty response")
return {}
extracted = json.loads(content)
_logger.info("AI extraction successful: %s", json.dumps(extracted, indent=2)[:500])
return extracted
except Exception as e:
_logger.error("AI extraction failed: %s", e)
return {}
def _pdf_bytes_to_images(self, pdf_bytes):
"""Convert raw PDF bytes to base64 PNG images."""
max_pages = self._get_max_pages()
images = []
try:
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img_data = base64.b64encode(pix.tobytes("png")).decode()
images.append(img_data)
_logger.info("Converted PDF page %d to image (%d bytes)", page_num + 1, len(img_data))
doc.close()
except ImportError:
_logger.warning("PyMuPDF not available")
except Exception as e:
_logger.warning("PDF to image failed: %s", e)
return images
def _pdf_bytes_to_text(self, pdf_bytes):
"""Extract text from raw PDF bytes."""
max_pages = self._get_max_pages()
try:
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
parts = []
for page_num in range(min(len(doc), max_pages)):
parts.append(doc[page_num].get_text())
doc.close()
return '\n'.join(parts)
except Exception:
return ''
def extract_bill_data(self, email_body, attachments=None):
"""Extract bill data from email body and attachments using OpenAI.
Args:
email_body: Plain text or HTML email body
attachments: List of ir.attachment records
Returns:
dict with extracted data, or empty dict on failure
"""
if not self._is_ai_enabled():
_logger.info("AI extraction is disabled")
return {}
api_key = self._get_api_key()
if not api_key:
_logger.warning("No OpenAI API key configured for Fusion Accounts")
return {}
try:
import requests
except ImportError:
_logger.error("requests library not available")
return {}
# Clean HTML from email body
clean_body = self._strip_html(email_body or '')
# Build messages for OpenAI
messages = [
{"role": "system", "content": EXTRACTION_PROMPT},
]
# Build content -- PDF attachments FIRST (primary source), email body second
content_parts = []
has_pdf_content = False
# Add PDF/image attachments first (these are the invoice documents)
if attachments:
for attachment in attachments[:3]: # Max 3 attachments
if attachment.mimetype == 'application/pdf':
# Try image conversion first (best for AI vision)
pdf_images = self._pdf_to_images(attachment)
if pdf_images:
has_pdf_content = True
for img_data in pdf_images:
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_data}",
"detail": "high",
}
})
else:
# Fallback: extract text from PDF
pdf_text = self._pdf_to_text(attachment)
if pdf_text:
has_pdf_content = True
content_parts.append({
"type": "text",
"text": f"INVOICE/BILL DOCUMENT:\n{pdf_text[:8000]}"
})
elif attachment.mimetype in ('image/png', 'image/jpeg', 'image/jpg'):
has_pdf_content = True
img_b64 = base64.b64encode(base64.b64decode(attachment.datas)).decode()
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{attachment.mimetype};base64,{img_b64}",
"detail": "high",
}
})
# Add email body as secondary context (only if no PDF content found)
if clean_body and not has_pdf_content:
content_parts.append({
"type": "text",
"text": f"EMAIL BODY (no invoice attachment found):\n{clean_body[:5000]}"
})
elif clean_body and has_pdf_content:
content_parts.append({
"type": "text",
"text": f"ADDITIONAL CONTEXT FROM EMAIL:\n{clean_body[:2000]}"
})
if not content_parts:
_logger.info("No content to extract from")
return {}
messages.append({"role": "user", "content": content_parts})
# Call OpenAI API
model = self._get_ai_model()
try:
response = requests.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': messages,
'max_tokens': 2000,
'temperature': 0.1,
},
timeout=60,
)
response.raise_for_status()
result = response.json()
content = result['choices'][0]['message']['content']
# Parse JSON from response -- handle markdown code fences
content = content.strip()
if content.startswith('```'):
# Remove ```json ... ``` wrapper
lines = content.split('\n')
content = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
content = content.strip()
if not content:
_logger.warning("AI returned empty response")
return {}
extracted = json.loads(content)
_logger.info("AI extraction successful: %s", json.dumps(extracted, indent=2)[:500])
return extracted
except requests.exceptions.RequestException as e:
_logger.error("OpenAI API request failed: %s", e)
return {}
except (json.JSONDecodeError, KeyError, IndexError) as e:
_logger.warning("Failed to parse AI response: %s (content: %s)", e, content[:200] if content else 'empty')
return {}
def apply_extracted_data(self, move, extracted_data):
"""Apply AI-extracted data to a draft vendor bill.
The PDF/invoice is the source of truth for:
- Vendor name (matched to Odoo contact)
- Invoice/bill number (ref)
- Invoice date, due date
- Line items
Args:
move: account.move record (draft vendor bill)
extracted_data: dict from extract_bill_data()
"""
if not extracted_data:
return
vals = {}
# --- Vendor matching from AI-extracted vendor name ---
# This overrides the email sender match because the PDF
# shows the actual billing company (e.g., "Canada Computers Inc.")
ai_vendor_name = extracted_data.get('vendor_name')
if ai_vendor_name:
partner = self._match_vendor_by_name(ai_vendor_name)
if partner:
vals['partner_id'] = partner.id
_logger.info("AI vendor match: '%s' -> %s (id=%d)",
ai_vendor_name, partner.name, partner.id)
# Invoice reference (vendor's invoice/bill/SO number)
if extracted_data.get('invoice_number'):
vals['ref'] = extracted_data['invoice_number']
# Invoice date
if extracted_data.get('invoice_date'):
try:
from datetime import datetime
vals['invoice_date'] = datetime.strptime(
extracted_data['invoice_date'], '%Y-%m-%d'
).date()
except (ValueError, TypeError):
pass
# Due date
if extracted_data.get('due_date'):
try:
from datetime import datetime
vals['invoice_date_due'] = datetime.strptime(
extracted_data['due_date'], '%Y-%m-%d'
).date()
except (ValueError, TypeError):
pass
if vals:
try:
move.write(vals)
_logger.info("Applied AI data to bill %s: %s", move.id, vals)
except Exception as e:
_logger.error("Failed to apply AI data to bill %s: %s", move.id, e)
# Add invoice lines if extracted
lines = extracted_data.get('lines', [])
if lines and not move.invoice_line_ids:
line_vals_list = []
for line in lines[:20]: # Max 20 lines
line_vals = {
'move_id': move.id,
'name': line.get('description', 'Extracted line'),
'quantity': line.get('quantity', 1.0),
'price_unit': line.get('unit_price', 0.0),
}
line_vals_list.append(line_vals)
if line_vals_list:
try:
move.write({
'invoice_line_ids': [(0, 0, lv) for lv in line_vals_list]
})
_logger.info("Added %d AI-extracted lines to bill %s",
len(line_vals_list), move.id)
except Exception as e:
_logger.error("Failed to add lines to bill %s: %s", move.id, e)
def _match_vendor_by_name(self, vendor_name):
"""Match AI-extracted vendor name to an Odoo partner.
Tries multiple strategies:
1. Exact name match
2. Commercial company name match
3. Partial/contains match (only if single result)
Returns: res.partner record or False
"""
if not vendor_name or len(vendor_name) < 3:
return False
Partner = self.env['res.partner'].sudo()
vendor_name = vendor_name.strip()
# Level 1: Exact name match
partner = Partner.search([
('name', '=ilike', vendor_name),
('supplier_rank', '>', 0),
], limit=1)
if partner:
return partner
# Level 2: Exact name match without supplier_rank filter
partner = Partner.search([
('name', '=ilike', vendor_name),
], limit=1)
if partner:
return partner
# Level 3: Commercial company name match
partner = Partner.search([
('commercial_company_name', '=ilike', vendor_name),
], limit=1)
if partner:
return partner
# Level 4: Contains match (only accept single result to avoid false positives)
partners = Partner.search([
'|',
('name', 'ilike', vendor_name),
('commercial_company_name', 'ilike', vendor_name),
])
if len(partners) == 1:
return partners
# Level 5: Try without common suffixes (Inc, Ltd, Corp, etc.)
clean_name = vendor_name
for suffix in [' Inc', ' Inc.', ' Ltd', ' Ltd.', ' Corp', ' Corp.',
' Co', ' Co.', ' LLC', ' Company', ' Limited']:
if clean_name.lower().endswith(suffix.lower()):
clean_name = clean_name[:len(clean_name) - len(suffix)].strip()
break
if clean_name != vendor_name and len(clean_name) >= 3:
partners = Partner.search([
'|',
('name', 'ilike', clean_name),
('commercial_company_name', 'ilike', clean_name),
])
if len(partners) == 1:
return partners
_logger.info("No vendor match for AI-extracted name: '%s'", vendor_name)
return False
def _strip_html(self, html):
"""Strip HTML tags from text."""
clean = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL)
clean = re.sub(r'<script[^>]*>.*?</script>', '', clean, flags=re.DOTALL)
clean = re.sub(r'<[^>]+>', ' ', clean)
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def _pdf_to_images(self, attachment):
"""Convert PDF attachment pages to base64 PNG images using PyMuPDF."""
max_pages = self._get_max_pages()
images = []
try:
import fitz # PyMuPDF
pdf_data = base64.b64decode(attachment.datas)
doc = fitz.open(stream=pdf_data, filetype="pdf")
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x zoom for readability
img_data = base64.b64encode(pix.tobytes("png")).decode()
images.append(img_data)
_logger.info("Converted PDF page %d to image (%d bytes)", page_num + 1, len(img_data))
doc.close()
except ImportError:
_logger.warning("PyMuPDF not available, will try text extraction fallback")
except Exception as e:
_logger.warning("PDF to image conversion failed: %s", e)
return images
def _pdf_to_text(self, attachment):
"""Extract text content from PDF as fallback when image conversion fails."""
max_pages = self._get_max_pages()
try:
import fitz # PyMuPDF
pdf_data = base64.b64decode(attachment.datas)
doc = fitz.open(stream=pdf_data, filetype="pdf")
text_parts = []
for page_num in range(min(len(doc), max_pages)):
page = doc[page_num]
text_parts.append(page.get_text())
doc.close()
full_text = '\n'.join(text_parts)
if full_text.strip():
_logger.info("Extracted %d chars of text from PDF", len(full_text))
return full_text
except ImportError:
pass
except Exception as e:
_logger.warning("PDF text extraction failed: %s", e)
return ''

View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
class FusionAccountsLog(models.Model):
_name = 'fusion.accounts.log'
_description = 'Fusion Accounts - Email Processing Log'
_order = 'create_date desc'
_rec_name = 'email_subject'
email_from = fields.Char(
string='From',
readonly=True,
help='Sender email address',
)
vendor_blocked = fields.Boolean(
related='vendor_id.x_fa_block_email_bill',
string='Vendor Blocked',
readonly=True,
)
email_subject = fields.Char(
string='Subject',
readonly=True,
)
email_date = fields.Datetime(
string='Email Date',
readonly=True,
)
vendor_id = fields.Many2one(
'res.partner',
string='Matched Vendor',
readonly=True,
help='Vendor matched from sender email',
)
match_level = fields.Selection(
selection=[
('exact_email', 'Exact Email'),
('domain', 'Domain Match'),
('name', 'Name Match'),
('no_match', 'No Match'),
],
string='Match Level',
readonly=True,
help='How the vendor was identified',
)
action_taken = fields.Selection(
selection=[
('bill_created', 'Bill Created'),
('blocked', 'Blocked (Vendor)'),
('failed', 'Failed'),
('no_vendor', 'No Vendor Match'),
],
string='Action',
readonly=True,
)
bill_id = fields.Many2one(
'account.move',
string='Created Bill',
readonly=True,
help='The vendor bill created from this email',
)
ai_extracted = fields.Boolean(
string='AI Extracted',
readonly=True,
default=False,
help='Whether AI data extraction was performed',
)
ai_result = fields.Text(
string='AI Extraction Result',
readonly=True,
help='JSON output from AI extraction',
)
notes = fields.Text(
string='Notes',
readonly=True,
help='Error messages or additional details',
)
def action_block_vendor(self):
"""Block the vendor from this log entry from email bill creation."""
for log in self:
if log.vendor_id and not log.vendor_id.x_fa_block_email_bill:
log.vendor_id.write({'x_fa_block_email_bill': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendor Blocked'),
'message': _('Vendor blocked from email bill creation.'),
'type': 'success',
'sticky': False,
}
}
def action_enable_vendor(self):
"""Enable the vendor from this log entry for email bill creation."""
for log in self:
if log.vendor_id and log.vendor_id.x_fa_block_email_bill:
log.vendor_id.write({'x_fa_block_email_bill': False})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendor Enabled'),
'message': _('Vendor enabled for email bill creation.'),
'type': 'success',
'sticky': False,
}
}
# Stat fields for dashboard
@api.model
def get_dashboard_data(self):
"""Return statistics for the dashboard."""
today = fields.Date.today()
return {
'bills_pending': self.env['account.move'].search_count([
('move_type', '=', 'in_invoice'),
('state', '=', 'draft'),
('x_fa_created_from_email', '=', True),
]),
'bills_today': self.search_count([
('action_taken', '=', 'bill_created'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'blocked_today': self.search_count([
('action_taken', '=', 'blocked'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'failed_today': self.search_count([
('action_taken', '=', 'failed'),
('create_date', '>=', fields.Datetime.to_string(today)),
]),
'total_blocked_vendors': self.env['res.partner'].search_count([
('x_fa_block_email_bill', '=', True),
]),
}

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# =========================================================================
# AI SETTINGS
# =========================================================================
x_fa_ai_enabled = fields.Boolean(
string='Enable AI Extraction',
config_parameter='fusion_accounts.ai_enabled',
help='Enable AI-powered data extraction from email body and attachments.',
)
x_fa_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_accounts.openai_api_key',
help='Your OpenAI API key for bill data extraction.',
)
x_fa_ai_model = fields.Selection(
selection=[
('gpt-4o-mini', 'GPT-4o Mini (Fast, Low Cost)'),
('gpt-4o', 'GPT-4o (Best Quality)'),
],
string='AI Model',
config_parameter='fusion_accounts.ai_model',
help='OpenAI model to use for extraction.',
)
x_fa_ai_max_pages = fields.Integer(
string='Max PDF Pages',
config_parameter='fusion_accounts.ai_max_pages',
help='Maximum number of PDF pages to send to AI for extraction.',
)
# =========================================================================
# MATCHING SETTINGS
# =========================================================================
x_fa_enable_domain_match = fields.Boolean(
string='Enable Domain Matching',
config_parameter='fusion_accounts.enable_domain_match',
help='Match vendors by email domain (Level 2 matching).',
)
x_fa_enable_name_match = fields.Boolean(
string='Enable Name Matching',
config_parameter='fusion_accounts.enable_name_match',
help='Match vendors by sender display name (Level 3 matching).',
)
x_fa_auto_block_po_vendors = fields.Boolean(
string='Auto-Block PO Vendors',
config_parameter='fusion_accounts.auto_block_po_vendors',
help='Automatically block email bill creation for vendors with active Purchase Orders.',
)
# =========================================================================
# GENERAL SETTINGS
# =========================================================================
x_fa_log_retention_days = fields.Integer(
string='Log Retention (Days)',
config_parameter='fusion_accounts.log_retention_days',
help='Number of days to keep activity logs. Set 0 to keep forever.',
)
def set_values(self):
ICP = self.env['ir.config_parameter'].sudo()
# Protect API key and customized settings from accidental blanking
_protected = {
'fusion_accounts.openai_api_key': ICP.get_param('fusion_accounts.openai_api_key', ''),
'fusion_accounts.ai_model': ICP.get_param('fusion_accounts.ai_model', ''),
'fusion_accounts.ai_max_pages': ICP.get_param('fusion_accounts.ai_max_pages', ''),
}
super().set_values()
for key, old_val in _protected.items():
new_val = ICP.get_param(key, '')
if not new_val and old_val:
ICP.set_param(key, old_val)
_logger.warning("Settings protection: restored %s", key)

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, _
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fa_block_email_bill = fields.Boolean(
string='Block Email Bill Creation',
default=False,
help='When enabled, incoming emails from this vendor will NOT '
'automatically create vendor bills. Use this for vendors '
'whose bills should be created through Purchase Orders instead.',
)
def action_fa_block_vendors(self):
"""Block selected vendors from email bill creation."""
self.write({'x_fa_block_email_bill': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendors Blocked'),
'message': _('%d vendor(s) blocked from email bill creation.') % len(self),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}
def action_fa_enable_vendors(self):
"""Enable selected vendors for email bill creation."""
self.write({'x_fa_block_email_bill': False})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Vendors Enabled'),
'message': _('%d vendor(s) enabled for email bill creation.') % len(self),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}