Initial commit
This commit is contained in:
5
fusion_accounts/__init__.py
Normal file
5
fusion_accounts/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import models
|
||||
42
fusion_accounts/__manifest__.py
Normal file
42
fusion_accounts/__manifest__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- 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',
|
||||
],
|
||||
'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,
|
||||
}
|
||||
36
fusion_accounts/data/ir_config_parameter_data.xml
Normal file
36
fusion_accounts/data/ir_config_parameter_data.xml
Normal 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>
|
||||
9
fusion_accounts/models/__init__.py
Normal file
9
fusion_accounts/models/__init__.py
Normal 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
|
||||
265
fusion_accounts/models/account_move.py
Normal file
265
fusion_accounts/models/account_move.py
Normal 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
|
||||
614
fusion_accounts/models/ai_bill_extractor.py
Normal file
614
fusion_accounts/models/ai_bill_extractor.py
Normal 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 ''
|
||||
140
fusion_accounts/models/fusion_accounts_log.py
Normal file
140
fusion_accounts/models/fusion_accounts_log.py
Normal 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),
|
||||
]),
|
||||
}
|
||||
84
fusion_accounts/models/res_config_settings.py
Normal file
84
fusion_accounts/models/res_config_settings.py
Normal 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)
|
||||
47
fusion_accounts/models/res_partner.py
Normal file
47
fusion_accounts/models/res_partner.py
Normal 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'},
|
||||
}
|
||||
}
|
||||
3
fusion_accounts/security/ir.model.access.csv
Normal file
3
fusion_accounts/security/ir.model.access.csv
Normal 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
|
||||
|
25
fusion_accounts/security/security.xml
Normal file
25
fusion_accounts/security/security.xml
Normal 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>
|
||||
BIN
fusion_accounts/static/description/icon.png
Normal file
BIN
fusion_accounts/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
63
fusion_accounts/views/account_move_views.xml
Normal file
63
fusion_accounts/views/account_move_views.xml
Normal 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>
|
||||
92
fusion_accounts/views/fusion_accounts_dashboard.xml
Normal file
92
fusion_accounts/views/fusion_accounts_dashboard.xml
Normal 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>
|
||||
130
fusion_accounts/views/fusion_accounts_log_views.xml
Normal file
130
fusion_accounts/views/fusion_accounts_log_views.xml
Normal 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>
|
||||
190
fusion_accounts/views/fusion_accounts_menus.xml
Normal file
190
fusion_accounts/views/fusion_accounts_menus.xml
Normal 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>
|
||||
73
fusion_accounts/views/res_config_settings_views.xml
Normal file
73
fusion_accounts/views/res_config_settings_views.xml
Normal 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>
|
||||
59
fusion_accounts/views/res_partner_views.xml
Normal file
59
fusion_accounts/views/res_partner_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user