This commit is contained in:
gsinghpal
2026-02-22 01:37:50 -05:00
parent 5200d5baf0
commit d6bac8e623
1550 changed files with 263540 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Accounts',
'version': '19.0.1.0.0',
'category': 'Accounting',
'summary': 'Smart vendor bill creation from email with AI extraction and vendor matching',
'description': """
Fusion Accounts - Smart Vendor Bill Management
===============================================
Automatically creates vendor bills from incoming emails with:
- Multi-level vendor matching (email, domain, name)
- Vendor blocking for PO-tracked vendors
- AI-powered data extraction from email body and PDF attachments
- Full activity logging and dashboard
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'account',
'mail',
'purchase',
],
'external_dependencies': {
'python': ['fitz'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/fusion_accounts_log_views.xml',
'views/fusion_accounts_dashboard.xml',
'views/res_partner_views.xml',
'views/res_config_settings_views.xml',
'views/account_move_views.xml',
'views/fusion_accounts_menus.xml',
],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Default configuration parameters for Fusion Accounts.
noupdate="1" ensures these are ONLY set on first install,
never overwritten during module upgrades.
-->
<data noupdate="1">
<record id="config_ai_enabled" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_enabled</field>
<field name="value">True</field>
</record>
<record id="config_ai_model" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="config_ai_max_pages" model="ir.config_parameter">
<field name="key">fusion_accounts.ai_max_pages</field>
<field name="value">2</field>
</record>
<record id="config_enable_domain_match" model="ir.config_parameter">
<field name="key">fusion_accounts.enable_domain_match</field>
<field name="value">True</field>
</record>
<record id="config_enable_name_match" model="ir.config_parameter">
<field name="key">fusion_accounts.enable_name_match</field>
<field name="value">True</field>
</record>
<record id="config_log_retention_days" model="ir.config_parameter">
<field name="key">fusion_accounts.log_retention_days</field>
<field name="value">90</field>
</record>
</data>
</odoo>

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'},
}
}

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_accounts_log_user,fusion.accounts.log user,model_fusion_accounts_log,group_fusion_accounts_user,1,0,0,0
access_fusion_accounts_log_manager,fusion.accounts.log manager,model_fusion_accounts_log,group_fusion_accounts_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_accounts_log_user fusion.accounts.log user model_fusion_accounts_log group_fusion_accounts_user 1 0 0 0
3 access_fusion_accounts_log_manager fusion.accounts.log manager model_fusion_accounts_log group_fusion_accounts_manager 1 1 1 1

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Privilege (replaces module_category in Odoo 19) -->
<record id="res_groups_privilege_fusion_accounts" model="res.groups.privilege">
<field name="name">Fusion Accounts</field>
<field name="sequence">50</field>
</record>
<!-- User Group -->
<record id="group_fusion_accounts_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounts"/>
</record>
<!-- Manager Group -->
<record id="group_fusion_accounts_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">20</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounts"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounts_user'))]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- VENDOR BILL FORM: Email creation info -->
<!-- ================================================================= -->
<record id="view_move_form_fusion_accounts" model="ir.ui.view">
<field name="name">account.move.form.fusion.accounts</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="priority">80</field>
<field name="arch" type="xml">
<!-- Add email creation badge -->
<xpath expr="//div[hasclass('oe_title')]" position="before">
<field name="x_fa_created_from_email" invisible="1"/>
<div class="float-end" invisible="not x_fa_created_from_email or move_type != 'in_invoice'">
<span class="badge text-bg-info">
<i class="fa fa-envelope me-1"/>Created from Email
</span>
<field name="x_fa_ai_extracted" invisible="1"/>
<span class="badge text-bg-primary ms-1" invisible="not x_fa_ai_extracted">
<i class="fa fa-magic me-1"/>AI Extracted
</span>
</div>
</xpath>
<!-- Add email origin info in notebook -->
<xpath expr="//notebook" position="inside">
<field name="x_fa_created_from_email" invisible="1"/>
<page string="Email Origin" name="fa_email_origin"
invisible="not x_fa_created_from_email or move_type != 'in_invoice'">
<group>
<group string="Email Details">
<field name="x_fa_original_sender" readonly="1"/>
<field name="x_fa_match_level" widget="badge" readonly="1"/>
</group>
<group string="Processing">
<field name="x_fa_ai_extracted" readonly="1"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ================================================================= -->
<!-- VENDOR BILL SEARCH: Add email filter -->
<!-- ================================================================= -->
<record id="view_move_search_fusion_accounts" model="ir.ui.view">
<field name="name">account.move.search.fusion.accounts</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_account_invoice_filter"/>
<field name="priority">80</field>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<separator/>
<filter string="From Email" name="from_email"
domain="[('x_fa_created_from_email', '=', True)]"/>
<filter string="AI Extracted" name="ai_extracted"
domain="[('x_fa_ai_extracted', '=', True)]"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- DASHBOARD ACTION -->
<!-- ================================================================= -->
<record id="action_fusion_accounts_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">fusion.accounts.log</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">{'search_default_filter_date': 1, 'search_default_group_action': 1}</field>
<field name="search_view_id" ref="view_fusion_accounts_log_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Fusion Accounts Dashboard
</p>
<p>
Email processing activity will appear here.
Configure your email aliases and AI settings under Configuration.
</p>
</field>
</record>
<!-- ================================================================= -->
<!-- DASHBOARD KANBAN VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_kanban" model="ir.ui.view">
<field name="name">fusion.accounts.log.kanban</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard" create="0" edit="0"
group_create="0" group_delete="0" group_edit="0"
default_group_by="action_taken">
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<field name="match_level"/>
<field name="action_taken"/>
<field name="bill_id"/>
<field name="ai_extracted"/>
<field name="create_date"/>
<field name="vendor_blocked"/>
<templates>
<t t-name="card">
<div class="d-flex flex-column">
<strong class="fs-5 mb-1">
<field name="email_subject"/>
</strong>
<div class="text-muted small mb-1">
<i class="fa fa-envelope-o me-1"/>
<field name="email_from"/>
</div>
<div class="d-flex align-items-center gap-2 mb-1">
<field name="match_level" widget="badge"
decoration-info="match_level == 'exact_email'"
decoration-success="match_level == 'domain'"
decoration-warning="match_level == 'name'"
decoration-danger="match_level == 'no_match'"/>
<span t-if="record.ai_extracted.raw_value" class="badge text-bg-primary">
<i class="fa fa-magic me-1"/>AI
</span>
</div>
<div t-if="record.vendor_id.value" class="text-muted small">
<i class="fa fa-building-o me-1"/>
<field name="vendor_id"/>
</div>
<div t-if="record.bill_id.value" class="small mt-1">
<i class="fa fa-file-text-o me-1"/>
<field name="bill_id"/>
</div>
<div class="text-muted small mt-1">
<field name="create_date" widget="datetime"/>
</div>
<!-- Block/Enable buttons -->
<div t-if="record.vendor_id.value" class="mt-2 d-flex gap-1">
<button t-if="!record.vendor_blocked.raw_value"
name="action_block_vendor" type="object"
class="btn btn-sm btn-outline-danger">
<i class="fa fa-ban me-1"/>Block Vendor
</button>
<button t-if="record.vendor_blocked.raw_value"
name="action_enable_vendor" type="object"
class="btn btn-sm btn-outline-success">
<i class="fa fa-check me-1"/>Enable Vendor
</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - LIST VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_list" model="ir.ui.view">
<field name="name">fusion.accounts.log.list</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<list string="Email Processing Log" create="0" edit="0"
decoration-success="action_taken == 'bill_created'"
decoration-warning="action_taken == 'blocked'"
decoration-danger="action_taken == 'failed'"
decoration-muted="action_taken == 'no_vendor'">
<header>
<button name="action_block_vendor" type="object"
string="Block Vendor" class="btn-secondary"
icon="fa-ban"/>
<button name="action_enable_vendor" type="object"
string="Enable Vendor" class="btn-secondary"
icon="fa-check"/>
</header>
<field name="create_date" string="Date"/>
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<field name="match_level" widget="badge"
decoration-info="match_level == 'exact_email'"
decoration-success="match_level == 'domain'"
decoration-warning="match_level == 'name'"
decoration-danger="match_level == 'no_match'"/>
<field name="action_taken" widget="badge"
decoration-success="action_taken == 'bill_created'"
decoration-warning="action_taken == 'blocked'"
decoration-danger="action_taken == 'failed'"/>
<field name="bill_id"/>
<field name="ai_extracted" widget="boolean"/>
<field name="vendor_blocked" string="Blocked" widget="boolean"/>
</list>
</field>
</record>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - FORM VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_form" model="ir.ui.view">
<field name="name">fusion.accounts.log.form</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<form string="Email Processing Log" create="0" edit="0">
<header>
<button name="action_block_vendor" type="object"
string="Block Vendor" class="btn-secondary"
icon="fa-ban"
invisible="not vendor_id or vendor_blocked"/>
<button name="action_enable_vendor" type="object"
string="Enable Vendor" class="btn-secondary"
icon="fa-check"
invisible="not vendor_id or not vendor_blocked"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="email_subject" readonly="1"/></h1>
</div>
<group>
<group string="Email Details">
<field name="email_from"/>
<field name="email_date"/>
<field name="create_date" string="Processed At"/>
</group>
<group string="Processing Result">
<field name="vendor_id"/>
<field name="vendor_blocked" string="Vendor Blocked"/>
<field name="match_level" widget="badge"/>
<field name="action_taken" widget="badge"/>
<field name="bill_id"/>
<field name="ai_extracted"/>
</group>
</group>
<group string="AI Extraction Result" invisible="not ai_extracted">
<field name="ai_result" widget="text" nolabel="1" colspan="2"/>
</group>
<group string="Notes" invisible="not notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================= -->
<!-- ACTIVITY LOG - SEARCH VIEW -->
<!-- ================================================================= -->
<record id="view_fusion_accounts_log_search" model="ir.ui.view">
<field name="name">fusion.accounts.log.search</field>
<field name="model">fusion.accounts.log</field>
<field name="arch" type="xml">
<search string="Activity Log">
<field name="email_from"/>
<field name="email_subject"/>
<field name="vendor_id"/>
<separator/>
<!-- Action Filters -->
<filter string="Bills Created" name="bill_created" domain="[('action_taken', '=', 'bill_created')]"/>
<filter string="Blocked" name="blocked" domain="[('action_taken', '=', 'blocked')]"/>
<filter string="Failed" name="failed" domain="[('action_taken', '=', 'failed')]"/>
<separator/>
<filter string="AI Extracted" name="ai_extracted" domain="[('ai_extracted', '=', True)]"/>
<separator/>
<!-- Time Period Filter (Odoo date filter with period selector) -->
<filter string="Date" name="filter_date" date="create_date"/>
<separator/>
<!-- Match Level Filters -->
<filter string="Exact Email Match" name="exact" domain="[('match_level', '=', 'exact_email')]"/>
<filter string="Domain Match" name="domain_match" domain="[('match_level', '=', 'domain')]"/>
<filter string="Name Match" name="name_match" domain="[('match_level', '=', 'name')]"/>
<filter string="No Match" name="no_match" domain="[('match_level', '=', 'no_match')]"/>
<group>
<filter string="Action" name="group_action" context="{'group_by': 'action_taken'}"/>
<filter string="Match Level" name="group_match" context="{'group_by': 'match_level'}"/>
<filter string="Vendor" name="group_vendor" context="{'group_by': 'vendor_id'}"/>
<filter string="Day" name="group_day" context="{'group_by': 'create_date:day'}"/>
<filter string="Week" name="group_week" context="{'group_by': 'create_date:week'}"/>
<filter string="Month" name="group_month" context="{'group_by': 'create_date:month'}"/>
<filter string="Year" name="group_year" context="{'group_by': 'create_date:year'}"/>
</group>
</search>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- WINDOW ACTIONS (must be before menus) -->
<!-- ================================================================= -->
<!-- Bills from Email -->
<record id="action_bills_from_email" model="ir.actions.act_window">
<field name="name">Bills from Email</field>
<field name="res_model">account.move</field>
<field name="view_mode">list,form</field>
<field name="domain">[('move_type', '=', 'in_invoice'), ('x_fa_created_from_email', '=', True)]</field>
<field name="context">{'default_move_type': 'in_invoice'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No bills from email yet
</p>
<p>
Bills will appear here when incoming emails create vendor bills automatically.
</p>
</field>
</record>
<!-- All Vendor Bills -->
<record id="action_all_vendor_bills" model="ir.actions.act_window">
<field name="name">All Vendor Bills</field>
<field name="res_model">account.move</field>
<field name="view_mode">list,form</field>
<field name="domain">[('move_type', '=', 'in_invoice')]</field>
<field name="context">{'default_move_type': 'in_invoice'}</field>
</record>
<!-- Blocked Vendors -->
<record id="action_blocked_vendors" model="ir.actions.act_window">
<field name="name">Blocked Vendors</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('x_fa_block_email_bill', '=', True)]</field>
<field name="context">{'default_x_fa_block_email_bill': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No blocked vendors
</p>
<p>
Vendors blocked from automatic email bill creation will appear here.
Block vendors whose bills should be created through Purchase Orders instead.
</p>
</field>
</record>
<!-- Vendors with Active POs -->
<record id="action_vendors_with_po" model="ir.actions.act_window">
<field name="name">Vendors with Active POs</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('purchase_line_ids', '!=', False), ('supplier_rank', '>', 0)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No vendors with purchase orders
</p>
<p>
Vendors with Purchase Orders appear here.
Consider blocking these vendors from automatic email bill creation.
</p>
</field>
</record>
<!-- All Vendors -->
<record id="action_all_vendors" model="ir.actions.act_window">
<field name="name">All Vendors</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_partner_list_fusion_accounts"/>
<field name="domain">[('supplier_rank', '>', 0)]</field>
</record>
<!-- Activity Log -->
<record id="action_fusion_accounts_log" model="ir.actions.act_window">
<field name="name">Activity Log</field>
<field name="res_model">fusion.accounts.log</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No activity logged yet
</p>
<p>
Email processing activity will be logged here automatically.
</p>
</field>
</record>
<!-- Settings -->
<record id="action_fusion_accounts_settings" model="ir.actions.act_window">
<field name="name">Fusion Accounts Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">current</field>
<field name="context">{'module': 'fusion_accounts'}</field>
</record>
<!-- ================================================================= -->
<!-- TOP-LEVEL APP MENU -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_root"
name="Fusion Accounts"
web_icon="fusion_accounts,static/description/icon.png"
sequence="35"
groups="group_fusion_accounts_user"/>
<!-- ================================================================= -->
<!-- DASHBOARD -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_dashboard"
name="Dashboard"
parent="menu_fusion_accounts_root"
action="action_fusion_accounts_dashboard"
sequence="10"/>
<!-- ================================================================= -->
<!-- BILLS -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_bills"
name="Bills"
parent="menu_fusion_accounts_root"
sequence="20"/>
<menuitem id="menu_fusion_accounts_bills_email"
name="Bills from Email"
parent="menu_fusion_accounts_bills"
action="action_bills_from_email"
sequence="10"/>
<menuitem id="menu_fusion_accounts_bills_all"
name="All Vendor Bills"
parent="menu_fusion_accounts_bills"
action="action_all_vendor_bills"
sequence="20"/>
<!-- ================================================================= -->
<!-- VENDORS -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_vendors"
name="Vendors"
parent="menu_fusion_accounts_root"
sequence="30"/>
<menuitem id="menu_fusion_accounts_vendors_blocked"
name="Blocked Vendors"
parent="menu_fusion_accounts_vendors"
action="action_blocked_vendors"
sequence="10"/>
<menuitem id="menu_fusion_accounts_vendors_with_po"
name="Vendors with Active POs"
parent="menu_fusion_accounts_vendors"
action="action_vendors_with_po"
sequence="15"/>
<menuitem id="menu_fusion_accounts_vendors_all"
name="All Vendors"
parent="menu_fusion_accounts_vendors"
action="action_all_vendors"
sequence="20"/>
<!-- ================================================================= -->
<!-- ACTIVITY LOG -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_logs"
name="Activity Log"
parent="menu_fusion_accounts_root"
action="action_fusion_accounts_log"
sequence="40"/>
<!-- ================================================================= -->
<!-- CONFIGURATION -->
<!-- ================================================================= -->
<menuitem id="menu_fusion_accounts_config"
name="Configuration"
parent="menu_fusion_accounts_root"
sequence="90"
groups="group_fusion_accounts_manager"/>
<menuitem id="menu_fusion_accounts_settings"
name="Settings"
parent="menu_fusion_accounts_config"
action="action_fusion_accounts_settings"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- SETTINGS PAGE -->
<!-- ================================================================= -->
<record id="view_res_config_settings_fusion_accounts" model="ir.ui.view">
<field name="name">res.config.settings.fusion.accounts</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Accounts" string="Fusion Accounts"
name="fusion_accounts"
groups="fusion_accounts.group_fusion_accounts_manager">
<!-- AI SETTINGS -->
<block title="AI Data Extraction" name="fa_ai_settings">
<setting id="fa_ai_enabled" string="Enable AI Extraction"
help="Use OpenAI to automatically extract bill data from emails and PDF attachments.">
<field name="x_fa_ai_enabled"/>
</setting>
<setting id="fa_openai_key" string="OpenAI API Key"
help="Your OpenAI API key for bill data extraction."
invisible="not x_fa_ai_enabled">
<field name="x_fa_openai_api_key" password="True"/>
</setting>
<setting id="fa_ai_model" string="AI Model"
help="Select the OpenAI model. GPT-4o Mini is faster and cheaper, GPT-4o is more accurate."
invisible="not x_fa_ai_enabled">
<field name="x_fa_ai_model"/>
</setting>
<setting id="fa_ai_max_pages" string="Max PDF Pages"
help="Maximum number of PDF pages to send to AI for extraction. More pages = higher cost."
invisible="not x_fa_ai_enabled">
<field name="x_fa_ai_max_pages"/>
</setting>
</block>
<!-- VENDOR MATCHING SETTINGS -->
<block title="Vendor Matching" name="fa_matching_settings">
<setting id="fa_domain_match" string="Domain Matching (Level 2)"
help="Match vendors by email domain when exact email is not found.">
<field name="x_fa_enable_domain_match"/>
</setting>
<setting id="fa_name_match" string="Name Matching (Level 3)"
help="Match vendors by sender display name when email and domain don't match.">
<field name="x_fa_enable_name_match"/>
</setting>
<setting id="fa_auto_block" string="Auto-Block PO Vendors"
help="Automatically block email bill creation for vendors that have active Purchase Orders.">
<field name="x_fa_auto_block_po_vendors"/>
</setting>
</block>
<!-- GENERAL SETTINGS -->
<block title="General" name="fa_general_settings">
<setting id="fa_log_retention" string="Log Retention"
help="Number of days to keep activity logs. Set to 0 to keep forever.">
<div class="content-group">
<div class="row mt8">
<label for="x_fa_log_retention_days" string="Keep logs for" class="col-3"/>
<field name="x_fa_log_retention_days" class="col-1"/>
<span class="col-2"> days</span>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================================================= -->
<!-- VENDOR FORM: Add Block Email Bill checkbox -->
<!-- ================================================================= -->
<record id="view_partner_form_fusion_accounts" model="ir.ui.view">
<field name="name">res.partner.form.fusion.accounts</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//page[@name='internal_notes']" position="before">
<page string="Fusion Accounts" name="fusion_accounts">
<group>
<group string="Email Bill Settings">
<field name="x_fa_block_email_bill" widget="boolean_toggle"/>
<div class="alert alert-info" role="alert" colspan="2"
invisible="not x_fa_block_email_bill">
<i class="fa fa-info-circle"/>
Emails from this vendor will <strong>not</strong> create vendor bills automatically.
Bills for this vendor should be created through Purchase Orders.
</div>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ================================================================= -->
<!-- FUSION ACCOUNTS: Custom Vendor List View (clean actions) -->
<!-- ================================================================= -->
<record id="view_partner_list_fusion_accounts" model="ir.ui.view">
<field name="name">res.partner.list.fusion.accounts</field>
<field name="model">res.partner</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<list string="Vendors" multi_edit="1">
<header>
<button name="action_fa_block_vendors" type="object"
string="Block Email Bills" class="btn-secondary"
icon="fa-ban"/>
<button name="action_fa_enable_vendors" type="object"
string="Enable Email Bills" class="btn-secondary"
icon="fa-check"/>
</header>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="x_fa_block_email_bill" string="Blocked" widget="boolean_toggle"/>
<field name="supplier_rank" column_invisible="True"/>
</list>
</field>
</record>
<!-- ================================================================= -->
<!-- Add block/enable methods to res.partner -->
<!-- ================================================================= -->
</odoo>