Compare commits
33 Commits
d6bac8e623
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f3766c2898 | |||
| 431052920e | |||
| 1f79cdcaaf | |||
| 8761d0e7c7 | |||
| 0053576cc2 | |||
| 7bd7b8f7c4 | |||
| 3342b57469 | |||
| 1bfa50aa5f | |||
| 85367747a6 | |||
| d7657bb356 | |||
| 9dac39853f | |||
| c1a3b02ac5 | |||
| 1f750a6db4 | |||
| ffcc83d7bd | |||
| 6c3c565440 | |||
| 1c191a54e1 | |||
| 512aedce69 | |||
| f362fbd915 | |||
|
|
35399170b3 | ||
|
|
3b3c57205a | ||
|
|
b649246e81 | ||
|
|
14fe9ab716 | ||
|
|
3c8f83b8e6 | ||
|
|
4384987b82 | ||
|
|
de8e3a83bb | ||
|
|
3e59f9d5f6 | ||
|
|
34e5b46025 | ||
|
|
e71bc503f9 | ||
|
|
0e1aebe60b | ||
|
|
84c009416e | ||
|
|
9d9453b5c8 | ||
|
|
f85658c03a | ||
|
|
e8e554de95 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -237,6 +237,7 @@ class FusionFollowupLine(models.Model):
|
|||||||
('account_id.account_type', '=', 'asset_receivable'),
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
('parent_state', '=', 'posted'),
|
('parent_state', '=', 'posted'),
|
||||||
('reconciled', '=', False),
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
('date_maturity', '<', today),
|
('date_maturity', '<', today),
|
||||||
])
|
])
|
||||||
line.overdue_amount = sum(overdue_lines.mapped('amount_residual'))
|
line.overdue_amount = sum(overdue_lines.mapped('amount_residual'))
|
||||||
@@ -281,6 +282,8 @@ class FusionFollowupLine(models.Model):
|
|||||||
of the current follow-up level, then advances the partner to
|
of the current follow-up level, then advances the partner to
|
||||||
the next level.
|
the next level.
|
||||||
|
|
||||||
|
Skips sending if the partner no longer has any overdue balance.
|
||||||
|
|
||||||
:raises UserError: If no follow-up level is set.
|
:raises UserError: If no follow-up level is set.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
@@ -291,6 +294,11 @@ class FusionFollowupLine(models.Model):
|
|||||||
self.partner_id.display_name,
|
self.partner_id.display_name,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
self._compute_overdue_values()
|
||||||
|
if self.overdue_amount <= 0:
|
||||||
|
self.followup_status = 'no_action_needed'
|
||||||
|
return True
|
||||||
|
|
||||||
level = self.followup_level_id
|
level = self.followup_level_id
|
||||||
partner = self.partner_id
|
partner = self.partner_id
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ class FusionPartnerFollowup(models.Model):
|
|||||||
"""Return unpaid receivable move lines that are past due.
|
"""Return unpaid receivable move lines that are past due.
|
||||||
|
|
||||||
Searches for posted, unreconciled journal items on receivable
|
Searches for posted, unreconciled journal items on receivable
|
||||||
accounts where the maturity date is earlier than today.
|
accounts where the maturity date is earlier than today and
|
||||||
|
there is still an outstanding balance.
|
||||||
|
|
||||||
:returns: An ``account.move.line`` recordset.
|
:returns: An ``account.move.line`` recordset.
|
||||||
"""
|
"""
|
||||||
@@ -88,6 +89,7 @@ class FusionPartnerFollowup(models.Model):
|
|||||||
('account_id.account_type', '=', 'asset_receivable'),
|
('account_id.account_type', '=', 'asset_receivable'),
|
||||||
('parent_state', '=', 'posted'),
|
('parent_state', '=', 'posted'),
|
||||||
('reconciled', '=', False),
|
('reconciled', '=', False),
|
||||||
|
('amount_residual', '>', 0),
|
||||||
('date_maturity', '<', today),
|
('date_maturity', '<', today),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -188,6 +188,15 @@ class FusionFollowupSendWizard(models.TransientModel):
|
|||||||
if not line:
|
if not line:
|
||||||
raise UserError(_("No follow-up record is linked to this wizard."))
|
raise UserError(_("No follow-up record is linked to this wizard."))
|
||||||
|
|
||||||
|
line._compute_overdue_values()
|
||||||
|
if line.overdue_amount <= 0:
|
||||||
|
line.followup_status = 'no_action_needed'
|
||||||
|
raise UserError(_(
|
||||||
|
"Partner '%s' no longer has any overdue balance. "
|
||||||
|
"Follow-up cancelled.",
|
||||||
|
line.partner_id.display_name,
|
||||||
|
))
|
||||||
|
|
||||||
partner = line.partner_id
|
partner = line.partner_id
|
||||||
|
|
||||||
# ---- Email ----
|
# ---- Email ----
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# -*- 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,
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
# -*- 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 ''
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# -*- 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),
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# -*- 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'},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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,25 +0,0 @@
|
|||||||
<?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.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,63 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?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>
|
|
||||||
BIN
fusion_authorizer_portal/.DS_Store
vendored
Normal file
BIN
fusion_authorizer_portal/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -2,3 +2,29 @@
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
|
||||||
|
|
||||||
|
def _reactivate_views(env):
|
||||||
|
"""Ensure all module views are active after install/update.
|
||||||
|
|
||||||
|
Odoo silently deactivates inherited views when an xpath fails
|
||||||
|
validation (e.g. parent view structure changed between versions).
|
||||||
|
Once deactivated, subsequent -u runs never reactivate them.
|
||||||
|
This hook prevents that from silently breaking the portal.
|
||||||
|
"""
|
||||||
|
views = env['ir.ui.view'].sudo().search([
|
||||||
|
('key', 'like', 'fusion_authorizer_portal.%'),
|
||||||
|
('active', '=', False),
|
||||||
|
])
|
||||||
|
if views:
|
||||||
|
views.write({'active': True})
|
||||||
|
env.cr.execute("""
|
||||||
|
SELECT key FROM ir_ui_view
|
||||||
|
WHERE key LIKE 'fusion_authorizer_portal.%%'
|
||||||
|
AND id = ANY(%s)
|
||||||
|
""", [views.ids])
|
||||||
|
keys = [r[0] for r in env.cr.fetchall()]
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Reactivated %d deactivated views: %s", len(keys), keys
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'Fusion Authorizer & Sales Portal',
|
'name': 'Fusion Authorizer & Sales Portal',
|
||||||
'version': '19.0.2.0.9',
|
'version': '19.0.2.5.0',
|
||||||
'category': 'Sales/Portal',
|
'category': 'Sales/Portal',
|
||||||
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -50,8 +50,10 @@ This module provides external portal access for:
|
|||||||
'website',
|
'website',
|
||||||
'mail',
|
'mail',
|
||||||
'calendar',
|
'calendar',
|
||||||
|
'appointment',
|
||||||
'knowledge',
|
'knowledge',
|
||||||
'fusion_claims',
|
'fusion_claims',
|
||||||
|
'fusion_tasks',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
# Security
|
# Security
|
||||||
@@ -62,10 +64,12 @@ This module provides external portal access for:
|
|||||||
'data/portal_menu_data.xml',
|
'data/portal_menu_data.xml',
|
||||||
'data/ir_actions_server_data.xml',
|
'data/ir_actions_server_data.xml',
|
||||||
'data/welcome_articles.xml',
|
'data/welcome_articles.xml',
|
||||||
|
'data/appointment_invite_data.xml',
|
||||||
# Views
|
# Views
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/assessment_views.xml',
|
'views/assessment_views.xml',
|
||||||
|
'views/loaner_checkout_views.xml',
|
||||||
'views/pdf_template_views.xml',
|
'views/pdf_template_views.xml',
|
||||||
# Portal Templates
|
# Portal Templates
|
||||||
'views/portal_templates.xml',
|
'views/portal_templates.xml',
|
||||||
@@ -75,6 +79,8 @@ This module provides external portal access for:
|
|||||||
'views/portal_accessibility_forms.xml',
|
'views/portal_accessibility_forms.xml',
|
||||||
'views/portal_technician_templates.xml',
|
'views/portal_technician_templates.xml',
|
||||||
'views/portal_book_assessment.xml',
|
'views/portal_book_assessment.xml',
|
||||||
|
'views/portal_schedule.xml',
|
||||||
|
'views/portal_page11_sign_templates.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
@@ -91,9 +97,11 @@ This module provides external portal access for:
|
|||||||
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
|
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
|
||||||
'fusion_authorizer_portal/static/src/js/technician_push.js',
|
'fusion_authorizer_portal/static/src/js/technician_push.js',
|
||||||
'fusion_authorizer_portal/static/src/js/technician_location.js',
|
'fusion_authorizer_portal/static/src/js/technician_location.js',
|
||||||
|
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'images': ['static/description/icon.png'],
|
'images': ['static/description/icon.png'],
|
||||||
|
'post_init_hook': '_reactivate_views',
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
from . import portal_main
|
from . import portal_main
|
||||||
from . import portal_assessment
|
from . import portal_assessment
|
||||||
from . import pdf_editor
|
from . import pdf_editor
|
||||||
|
from . import portal_schedule
|
||||||
|
from . import portal_page11_sign
|
||||||
@@ -35,6 +35,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
fields = template.field_ids.read([
|
fields = template.field_ids.read([
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
||||||
|
'text_align',
|
||||||
])
|
])
|
||||||
|
|
||||||
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
|
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
|
||||||
@@ -56,6 +57,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
return template.field_ids.read([
|
return template.field_ids.read([
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
||||||
|
'text_align',
|
||||||
])
|
])
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -73,6 +75,7 @@ class FusionPdfEditorController(http.Controller):
|
|||||||
allowed = {
|
allowed = {
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
||||||
|
'text_align',
|
||||||
}
|
}
|
||||||
safe_values = {k: v for k, v in values.items() if k in allowed}
|
safe_values = {k: v for k, v in values.items() if k in allowed}
|
||||||
if safe_values:
|
if safe_values:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
|
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
|
||||||
posting_info = self._get_adp_posting_info()
|
posting_info = self._get_adp_posting_info()
|
||||||
response.qcontext.update(posting_info)
|
response.qcontext.update(posting_info)
|
||||||
|
response.qcontext.update(self._get_clock_status_data())
|
||||||
|
|
||||||
# Add signature count (documents to sign) - only if Sign module is installed
|
# Add signature count (documents to sign) - only if Sign module is installed
|
||||||
sign_count = 0
|
sign_count = 0
|
||||||
@@ -37,6 +38,14 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
])
|
])
|
||||||
response.qcontext['sign_count'] = sign_count
|
response.qcontext['sign_count'] = sign_count
|
||||||
response.qcontext['sign_module_available'] = sign_module_available
|
response.qcontext['sign_module_available'] = sign_module_available
|
||||||
|
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
|
||||||
|
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
|
||||||
|
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
|
||||||
|
response.qcontext['portal_gradient'] = (
|
||||||
|
'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -716,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
'sale_type_filter': sale_type,
|
'sale_type_filter': sale_type,
|
||||||
'status_filter': status,
|
'status_filter': status,
|
||||||
}
|
}
|
||||||
|
values.update(self._get_clock_status_data())
|
||||||
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
|
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
|
||||||
|
|
||||||
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
|
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
|
||||||
@@ -1082,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
_logger.error(f"Error downloading proof of delivery: {e}")
|
_logger.error(f"Error downloading proof of delivery: {e}")
|
||||||
return request.redirect('/my/funding-claims')
|
return request.redirect('/my/funding-claims')
|
||||||
|
|
||||||
|
# ==================== CLOCK STATUS HELPER ====================
|
||||||
|
|
||||||
|
def _get_clock_status_data(self):
|
||||||
|
"""Get clock in/out status for the current portal user."""
|
||||||
|
try:
|
||||||
|
user = request.env.user
|
||||||
|
Employee = request.env['hr.employee'].sudo()
|
||||||
|
employee = Employee.search([('user_id', '=', user.id)], limit=1)
|
||||||
|
if not employee:
|
||||||
|
employee = Employee.search([
|
||||||
|
('name', '=', user.partner_id.name),
|
||||||
|
('user_id', '=', False),
|
||||||
|
], limit=1)
|
||||||
|
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
|
||||||
|
return {'clock_enabled': False}
|
||||||
|
|
||||||
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
|
check_in_time = ''
|
||||||
|
location_name = ''
|
||||||
|
if is_checked_in:
|
||||||
|
att = request.env['hr.attendance'].sudo().search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('check_out', '=', False),
|
||||||
|
], limit=1)
|
||||||
|
if att:
|
||||||
|
check_in_time = att.check_in.isoformat() if att.check_in else ''
|
||||||
|
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
'clock_enabled': True,
|
||||||
|
'clock_checked_in': is_checked_in,
|
||||||
|
'clock_check_in_time': check_in_time,
|
||||||
|
'clock_location_name': location_name,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("Clock status check failed: %s", e)
|
||||||
|
return {'clock_enabled': False}
|
||||||
|
|
||||||
# ==================== TECHNICIAN PORTAL ====================
|
# ==================== TECHNICIAN PORTAL ====================
|
||||||
|
|
||||||
def _check_technician_access(self):
|
def _check_technician_access(self):
|
||||||
"""Check if current user is a technician portal user."""
|
"""Check if current user is a technician portal user."""
|
||||||
partner = request.env.user.partner_id
|
partner = request.env.user.partner_id
|
||||||
if not partner.is_technician_portal:
|
if partner.is_technician_portal:
|
||||||
return False
|
return True
|
||||||
return True
|
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
|
||||||
|
'|',
|
||||||
|
('technician_id', '=', request.env.user.id),
|
||||||
|
('additional_technician_ids', 'in', [request.env.user.id]),
|
||||||
|
], limit=1)
|
||||||
|
if has_tasks:
|
||||||
|
partner.sudo().write({'is_technician_portal': True})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
|
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
|
||||||
def technician_dashboard(self, **kw):
|
def technician_dashboard(self, **kw):
|
||||||
@@ -1103,9 +1158,11 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
SaleOrder = request.env['sale.order'].sudo()
|
SaleOrder = request.env['sale.order'].sudo()
|
||||||
today = fields.Date.context_today(request.env['fusion.technician.task'])
|
today = fields.Date.context_today(request.env['fusion.technician.task'])
|
||||||
|
|
||||||
# Today's tasks
|
# Today's tasks (lead or additional technician)
|
||||||
today_tasks = Task.search([
|
today_tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', today),
|
('scheduled_date', '=', today),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1135,7 +1192,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
tomorrow_count = Task.search_count([
|
tomorrow_count = Task.search_count([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', tomorrow),
|
('scheduled_date', '=', tomorrow),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
])
|
])
|
||||||
@@ -1147,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||||
|
|
||||||
|
clock_data = self._get_clock_status_data()
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
'today_tasks': today_tasks,
|
'today_tasks': today_tasks,
|
||||||
'current_task': current_task,
|
'current_task': current_task,
|
||||||
@@ -1162,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
'google_maps_api_key': google_maps_api_key,
|
'google_maps_api_key': google_maps_api_key,
|
||||||
'page_name': 'technician_dashboard',
|
'page_name': 'technician_dashboard',
|
||||||
}
|
}
|
||||||
|
values.update(clock_data)
|
||||||
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
|
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
|
||||||
|
|
||||||
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
|
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
|
||||||
@@ -1173,7 +1235,7 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
user = request.env.user
|
user = request.env.user
|
||||||
Task = request.env['fusion.technician.task'].sudo()
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
|
|
||||||
domain = [('technician_id', '=', user.id)]
|
domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])]
|
||||||
|
|
||||||
if filter_status == 'scheduled':
|
if filter_status == 'scheduled':
|
||||||
domain.append(('status', '=', 'scheduled'))
|
domain.append(('status', '=', 'scheduled'))
|
||||||
@@ -1229,14 +1291,19 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
raise AccessError(_('You do not have access to this task.'))
|
raise AccessError(_('You do not have access to this task.'))
|
||||||
except (AccessError, MissingError):
|
except (AccessError, MissingError):
|
||||||
return request.redirect('/my/technician/tasks')
|
return request.redirect('/my/technician/tasks')
|
||||||
|
|
||||||
# Check for earlier uncompleted tasks (sequential enforcement)
|
# Check for earlier uncompleted tasks (sequential enforcement)
|
||||||
earlier_incomplete = Task.search([
|
earlier_incomplete = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('time_start', '<', task.time_start),
|
('time_start', '<', task.time_start),
|
||||||
('status', 'not in', ['completed', 'cancelled']),
|
('status', 'not in', ['completed', 'cancelled']),
|
||||||
@@ -1276,7 +1343,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
Attachment = request.env['ir.attachment'].sudo()
|
Attachment = request.env['ir.attachment'].sudo()
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
@@ -1403,34 +1473,61 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
|
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
|
||||||
def technician_task_action(self, task_id, action, **kw):
|
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
|
||||||
"""Handle task status changes (start, complete, en_route, cancel)."""
|
"""Handle task status changes (start, complete, en_route, cancel).
|
||||||
|
Location is mandatory -- the client must send GPS coordinates."""
|
||||||
if not self._check_technician_access():
|
if not self._check_technician_access():
|
||||||
return {'success': False, 'error': 'Access denied'}
|
return {'success': False, 'error': 'Access denied'}
|
||||||
|
|
||||||
|
if not latitude or not longitude:
|
||||||
|
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
||||||
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||||
|
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
||||||
|
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
Task = request.env['fusion.technician.task'].sudo()
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
||||||
|
|
||||||
|
request.env['fusion.technician.location'].sudo().log_location(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
accuracy=accuracy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Push location to remote instances for cross-instance visibility
|
||||||
|
try:
|
||||||
|
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
|
||||||
|
user.id, latitude, longitude, accuracy or 0)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-blocking: sync failure should not block task action
|
||||||
|
|
||||||
|
location_ctx = {
|
||||||
|
'action_latitude': latitude,
|
||||||
|
'action_longitude': longitude,
|
||||||
|
'action_accuracy': accuracy or 0,
|
||||||
|
}
|
||||||
|
|
||||||
if action == 'en_route':
|
if action == 'en_route':
|
||||||
task.action_start_en_route()
|
task.with_context(**location_ctx).action_start_en_route()
|
||||||
elif action == 'start':
|
elif action == 'start':
|
||||||
task.action_start_task()
|
task.with_context(**location_ctx).action_start_task()
|
||||||
elif action == 'complete':
|
elif action == 'complete':
|
||||||
completion_notes = kw.get('completion_notes', '')
|
completion_notes = kw.get('completion_notes', '')
|
||||||
if completion_notes:
|
if completion_notes:
|
||||||
task.completion_notes = completion_notes
|
task.completion_notes = completion_notes
|
||||||
task.action_complete_task()
|
task.with_context(**location_ctx).action_complete_task()
|
||||||
elif action == 'cancel':
|
elif action == 'cancel':
|
||||||
task.action_cancel_task()
|
task.with_context(**location_ctx).action_cancel_task()
|
||||||
else:
|
else:
|
||||||
return {'success': False, 'error': f'Unknown action: {action}'}
|
return {'success': False, 'error': f'Unknown action: {action}'}
|
||||||
|
|
||||||
# For completion, also return next task info
|
|
||||||
result = {
|
result = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'status': task.status,
|
'status': task.status,
|
||||||
@@ -1462,7 +1559,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1524,7 +1624,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1571,17 +1674,24 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
|
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
|
||||||
def technician_voice_complete(self, task_id, transcription, **kw):
|
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
|
||||||
"""Format transcription with GPT and complete the task."""
|
"""Format transcription with GPT and complete the task."""
|
||||||
if not self._check_technician_access():
|
if not self._check_technician_access():
|
||||||
return {'success': False, 'error': 'Access denied'}
|
return {'success': False, 'error': 'Access denied'}
|
||||||
|
if not latitude or not longitude:
|
||||||
|
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
||||||
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||||
|
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
||||||
|
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
Task = request.env['fusion.technician.task'].sudo()
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1643,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
'completion_notes': completion_html,
|
'completion_notes': completion_html,
|
||||||
'voice_note_transcription': transcription,
|
'voice_note_transcription': transcription,
|
||||||
})
|
})
|
||||||
task.action_complete_task()
|
|
||||||
|
request.env['fusion.technician.location'].sudo().log_location(
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
accuracy=accuracy,
|
||||||
|
)
|
||||||
|
location_ctx = {
|
||||||
|
'action_latitude': latitude,
|
||||||
|
'action_longitude': longitude,
|
||||||
|
'action_accuracy': accuracy or 0,
|
||||||
|
}
|
||||||
|
task.with_context(**location_ctx).action_complete_task()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -1664,7 +1785,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
tomorrow_tasks = Task.search([
|
tomorrow_tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', tomorrow),
|
('scheduled_date', '=', tomorrow),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1703,7 +1826,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
return request.redirect('/my/technician')
|
return request.redirect('/my/technician')
|
||||||
|
|
||||||
tasks = Task.search([
|
tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', schedule_date),
|
('scheduled_date', '=', schedule_date),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1752,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
_logger.warning(f"Location log error: {e}")
|
_logger.warning(f"Location log error: {e}")
|
||||||
return {'success': False}
|
return {'success': False}
|
||||||
|
|
||||||
|
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
|
||||||
|
def technician_clock_status(self, **kw):
|
||||||
|
"""Check if the current technician is clocked in.
|
||||||
|
|
||||||
|
Returns {clocked_in: bool} so the JS background logger can decide
|
||||||
|
whether to track location. Replaces the fixed 9-6 hour window.
|
||||||
|
"""
|
||||||
|
if not self._check_technician_access():
|
||||||
|
return {'clocked_in': False}
|
||||||
|
try:
|
||||||
|
emp = request.env['hr.employee'].sudo().search([
|
||||||
|
('user_id', '=', request.env.user.id),
|
||||||
|
], limit=1)
|
||||||
|
if emp and emp.attendance_state == 'checked_in':
|
||||||
|
return {'clocked_in': True}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {'clocked_in': False}
|
||||||
|
|
||||||
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
|
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
|
||||||
def technician_save_start_location(self, address='', **kw):
|
def technician_save_start_location(self, address='', **kw):
|
||||||
"""Save the technician's personal start location."""
|
"""Save the technician's personal start location."""
|
||||||
@@ -1827,7 +1971,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
if not has_access and partner.is_technician_portal:
|
if not has_access and partner.is_technician_portal:
|
||||||
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
||||||
('sale_order_id', '=', order.id),
|
('sale_order_id', '=', order.id),
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
])
|
])
|
||||||
if task_count:
|
if task_count:
|
||||||
has_access = True
|
has_access = True
|
||||||
@@ -2017,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
_logger.error(f"Error saving POD signature: {e}")
|
_logger.error(f"Error saving POD signature: {e}")
|
||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
# ==================== TASK-LEVEL POD SIGNATURE ====================
|
||||||
|
|
||||||
|
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
|
||||||
|
def task_pod_signature_page(self, task_id, **kw):
|
||||||
|
"""Task-level POD signature capture page (works for all tasks including shadow)."""
|
||||||
|
if not self._check_technician_access():
|
||||||
|
return request.redirect('/my')
|
||||||
|
|
||||||
|
user = request.env.user
|
||||||
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = Task.browse(task_id)
|
||||||
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
|
raise AccessError(_('You do not have access to this task.'))
|
||||||
|
except (AccessError, MissingError):
|
||||||
|
return request.redirect('/my/technician/tasks')
|
||||||
|
|
||||||
|
values = {
|
||||||
|
'task': task,
|
||||||
|
'has_existing_signature': bool(task.pod_signature),
|
||||||
|
'page_name': 'task_pod_signature',
|
||||||
|
}
|
||||||
|
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
|
||||||
|
|
||||||
|
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
|
||||||
|
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
|
||||||
|
"""Save POD signature directly on a task."""
|
||||||
|
if not self._check_technician_access():
|
||||||
|
return {'success': False, 'error': 'Access denied'}
|
||||||
|
|
||||||
|
user = request.env.user
|
||||||
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = Task.browse(task_id)
|
||||||
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
|
if not client_name or not client_name.strip():
|
||||||
|
return {'success': False, 'error': 'Client name is required'}
|
||||||
|
if not signature_data:
|
||||||
|
return {'success': False, 'error': 'Signature is required'}
|
||||||
|
|
||||||
|
if ',' in signature_data:
|
||||||
|
signature_data = signature_data.split(',')[1]
|
||||||
|
|
||||||
|
from datetime import datetime as dt_datetime
|
||||||
|
sig_date = None
|
||||||
|
if signature_date:
|
||||||
|
try:
|
||||||
|
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
task.write({
|
||||||
|
'pod_signature': signature_data,
|
||||||
|
'pod_client_name': client_name.strip(),
|
||||||
|
'pod_signature_date': sig_date,
|
||||||
|
'pod_signed_by_user_id': user.id,
|
||||||
|
'pod_signed_datetime': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if task.sale_order_id:
|
||||||
|
task.sale_order_id.write({
|
||||||
|
'x_fc_pod_signature': signature_data,
|
||||||
|
'x_fc_pod_client_name': client_name.strip(),
|
||||||
|
'x_fc_pod_signature_date': sig_date,
|
||||||
|
'x_fc_pod_signed_by_user_id': user.id,
|
||||||
|
'x_fc_pod_signed_datetime': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Signature saved successfully',
|
||||||
|
'redirect_url': f'/my/technician/task/{task_id}',
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Error saving task POD signature: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
def _generate_signed_pod_pdf(self, order, save_to_field=True):
|
def _generate_signed_pod_pdf(self, order, save_to_field=True):
|
||||||
"""Generate a signed POD PDF with the signature embedded.
|
"""Generate a signed POD PDF with the signature embedded.
|
||||||
|
|
||||||
@@ -2466,3 +2700,71 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
_logger.info(f"Attached video to assessment {assessment.reference}")
|
_logger.info(f"Attached video to assessment {assessment.reference}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
|
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# RENTAL PICKUP INSPECTION (added by fusion_rental)
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/my/technician/rental-inspection/<int:task_id>',
|
||||||
|
type='http', auth='user', website=True,
|
||||||
|
)
|
||||||
|
def rental_inspection_page(self, task_id, **kw):
|
||||||
|
"""Render the rental pickup inspection form for the technician."""
|
||||||
|
user = request.env.user
|
||||||
|
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not task.exists()
|
||||||
|
or (task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids)
|
||||||
|
or task.task_type != 'pickup'
|
||||||
|
):
|
||||||
|
return request.redirect('/my')
|
||||||
|
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.portal_rental_inspection',
|
||||||
|
{
|
||||||
|
'task': task,
|
||||||
|
'order': task.sale_order_id,
|
||||||
|
'page_name': 'rental_inspection',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/my/technician/rental-inspection/<int:task_id>/submit',
|
||||||
|
type='json', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def rental_inspection_submit(self, task_id, **kwargs):
|
||||||
|
"""Save the rental inspection results."""
|
||||||
|
user = request.env.user
|
||||||
|
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not task.exists()
|
||||||
|
or (task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids)
|
||||||
|
or task.task_type != 'pickup'
|
||||||
|
):
|
||||||
|
return {'success': False, 'error': 'Access denied.'}
|
||||||
|
|
||||||
|
condition = kwargs.get('condition', '')
|
||||||
|
notes = kwargs.get('notes', '')
|
||||||
|
photo_ids = kwargs.get('photo_ids', [])
|
||||||
|
|
||||||
|
if not condition:
|
||||||
|
return {'success': False, 'error': 'Please select a condition.'}
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'rental_inspection_condition': condition,
|
||||||
|
'rental_inspection_notes': notes,
|
||||||
|
'rental_inspection_completed': True,
|
||||||
|
}
|
||||||
|
if photo_ids:
|
||||||
|
vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)]
|
||||||
|
task.write(vals)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Inspection saved. You can now complete the task.',
|
||||||
|
}
|
||||||
|
|||||||
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal file
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http, fields, _
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Page11PublicSignController(http.Controller):
|
||||||
|
|
||||||
|
def _get_sign_request(self, token):
|
||||||
|
"""Look up and validate a signing request by token."""
|
||||||
|
req = request.env['fusion.page11.sign.request'].sudo().search([
|
||||||
|
('access_token', '=', token),
|
||||||
|
], limit=1)
|
||||||
|
if not req:
|
||||||
|
return None, 'not_found'
|
||||||
|
if req.state == 'signed':
|
||||||
|
return req, 'already_signed'
|
||||||
|
if req.state == 'cancelled':
|
||||||
|
return req, 'cancelled'
|
||||||
|
if req.state == 'expired' or (
|
||||||
|
req.expiry_date and req.expiry_date < fields.Datetime.now()
|
||||||
|
):
|
||||||
|
if req.state != 'expired':
|
||||||
|
req.state = 'expired'
|
||||||
|
return req, 'expired'
|
||||||
|
return req, 'ok'
|
||||||
|
|
||||||
|
@http.route('/page11/sign/<string:token>', type='http', auth='public',
|
||||||
|
website=True, sitemap=False)
|
||||||
|
def page11_sign_form(self, token, **kw):
|
||||||
|
"""Display the Page 11 signing form."""
|
||||||
|
sign_req, status = self._get_sign_request(token)
|
||||||
|
|
||||||
|
if status == 'not_found':
|
||||||
|
return request.render(
|
||||||
|
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
|
||||||
|
)
|
||||||
|
|
||||||
|
if status in ('expired', 'cancelled'):
|
||||||
|
return request.render(
|
||||||
|
'fusion_authorizer_portal.portal_page11_sign_expired',
|
||||||
|
{'sign_request': sign_req},
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 'already_signed':
|
||||||
|
return request.render(
|
||||||
|
'fusion_authorizer_portal.portal_page11_sign_success',
|
||||||
|
{'sign_request': sign_req, 'token': token},
|
||||||
|
)
|
||||||
|
|
||||||
|
order = sign_req.sale_order_id
|
||||||
|
partner = order.partner_id
|
||||||
|
|
||||||
|
assessment = request.env['fusion.assessment'].sudo().search([
|
||||||
|
('sale_order_id', '=', order.id),
|
||||||
|
], limit=1, order='create_date desc')
|
||||||
|
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||||
|
|
||||||
|
client_first_name = ''
|
||||||
|
client_last_name = ''
|
||||||
|
client_middle_name = ''
|
||||||
|
client_health_card = ''
|
||||||
|
client_health_card_version = ''
|
||||||
|
|
||||||
|
if assessment:
|
||||||
|
client_first_name = assessment.client_first_name or ''
|
||||||
|
client_last_name = assessment.client_last_name or ''
|
||||||
|
client_middle_name = assessment.client_middle_name or ''
|
||||||
|
client_health_card = assessment.client_health_card or ''
|
||||||
|
client_health_card_version = assessment.client_health_card_version or ''
|
||||||
|
else:
|
||||||
|
first, last = order._get_client_name_parts()
|
||||||
|
client_first_name = first
|
||||||
|
client_last_name = last
|
||||||
|
|
||||||
|
values = {
|
||||||
|
'sign_request': sign_req,
|
||||||
|
'order': order,
|
||||||
|
'partner': partner,
|
||||||
|
'assessment': assessment,
|
||||||
|
'company': order.company_id,
|
||||||
|
'token': token,
|
||||||
|
'signer_type': sign_req.signer_type,
|
||||||
|
'is_agent': sign_req.signer_type != 'client',
|
||||||
|
'google_maps_api_key': google_maps_api_key,
|
||||||
|
'client_first_name': client_first_name,
|
||||||
|
'client_last_name': client_last_name,
|
||||||
|
'client_middle_name': client_middle_name,
|
||||||
|
'client_health_card': client_health_card,
|
||||||
|
'client_health_card_version': client_health_card_version,
|
||||||
|
}
|
||||||
|
return request.render(
|
||||||
|
'fusion_authorizer_portal.portal_page11_public_sign', values,
|
||||||
|
)
|
||||||
|
|
||||||
|
@http.route('/page11/sign/<string:token>/submit', type='http',
|
||||||
|
auth='public', methods=['POST'], website=True,
|
||||||
|
csrf=True, sitemap=False)
|
||||||
|
def page11_sign_submit(self, token, **post):
|
||||||
|
"""Process the submitted Page 11 signature."""
|
||||||
|
sign_req, status = self._get_sign_request(token)
|
||||||
|
|
||||||
|
if status != 'ok':
|
||||||
|
return request.redirect(f'/page11/sign/{token}')
|
||||||
|
|
||||||
|
signature_data = post.get('signature_data', '')
|
||||||
|
if not signature_data:
|
||||||
|
return request.redirect(f'/page11/sign/{token}?error=no_signature')
|
||||||
|
|
||||||
|
if signature_data.startswith('data:image'):
|
||||||
|
signature_data = signature_data.split(',', 1)[1]
|
||||||
|
|
||||||
|
consent_accepted = post.get('consent_declaration', '') == 'on'
|
||||||
|
if not consent_accepted:
|
||||||
|
return request.redirect(f'/page11/sign/{token}?error=no_consent')
|
||||||
|
|
||||||
|
signer_name = post.get('signer_name', sign_req.signer_name or '')
|
||||||
|
chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
|
||||||
|
consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
|
||||||
|
|
||||||
|
signer_type_labels = {
|
||||||
|
'spouse': 'Spouse', 'parent': 'Parent',
|
||||||
|
'legal_guardian': 'Legal Guardian',
|
||||||
|
'poa': 'Power of Attorney',
|
||||||
|
'public_trustee': 'Public Trustee',
|
||||||
|
}
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'signature_data': signature_data,
|
||||||
|
'signer_name': signer_name,
|
||||||
|
'signer_type': chosen_signer_type,
|
||||||
|
'consent_declaration_accepted': True,
|
||||||
|
'consent_signed_by': consent_signed_by,
|
||||||
|
'signed_date': fields.Datetime.now(),
|
||||||
|
'state': 'signed',
|
||||||
|
'client_first_name': post.get('client_first_name', ''),
|
||||||
|
'client_last_name': post.get('client_last_name', ''),
|
||||||
|
'client_health_card': post.get('client_health_card', ''),
|
||||||
|
'client_health_card_version': post.get('client_health_card_version', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
if consent_signed_by == 'agent':
|
||||||
|
vals.update({
|
||||||
|
'agent_first_name': post.get('agent_first_name', ''),
|
||||||
|
'agent_last_name': post.get('agent_last_name', ''),
|
||||||
|
'agent_middle_initial': post.get('agent_middle_initial', ''),
|
||||||
|
'agent_phone': post.get('agent_phone', ''),
|
||||||
|
'agent_unit': post.get('agent_unit', ''),
|
||||||
|
'agent_street_number': post.get('agent_street_number', ''),
|
||||||
|
'agent_street': post.get('agent_street', ''),
|
||||||
|
'agent_city': post.get('agent_city', ''),
|
||||||
|
'agent_province': post.get('agent_province', 'Ontario'),
|
||||||
|
'agent_postal_code': post.get('agent_postal_code', ''),
|
||||||
|
'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
|
||||||
|
})
|
||||||
|
|
||||||
|
sign_req.sudo().write(vals)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sign_req.sudo()._generate_signed_pdf()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sign_req.sudo()._update_sale_order()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
|
||||||
|
|
||||||
|
return request.render(
|
||||||
|
'fusion_authorizer_portal.portal_page11_sign_success',
|
||||||
|
{'sign_request': sign_req, 'token': token},
|
||||||
|
)
|
||||||
|
|
||||||
|
@http.route('/page11/sign/<string:token>/download', type='http',
|
||||||
|
auth='public', website=True, sitemap=False)
|
||||||
|
def page11_download_pdf(self, token, **kw):
|
||||||
|
"""Download the signed Page 11 PDF."""
|
||||||
|
sign_req = request.env['fusion.page11.sign.request'].sudo().search([
|
||||||
|
('access_token', '=', token),
|
||||||
|
('state', '=', 'signed'),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not sign_req or not sign_req.signed_pdf:
|
||||||
|
return request.redirect(f'/page11/sign/{token}')
|
||||||
|
|
||||||
|
pdf_content = base64.b64decode(sign_req.signed_pdf)
|
||||||
|
filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
|
||||||
|
|
||||||
|
return request.make_response(
|
||||||
|
pdf_content,
|
||||||
|
headers=[
|
||||||
|
('Content-Type', 'application/pdf'),
|
||||||
|
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||||
|
('Content-Length', str(len(pdf_content))),
|
||||||
|
],
|
||||||
|
)
|
||||||
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import http, _, fields
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||||
|
from odoo.exceptions import AccessError, ValidationError
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalSchedule(CustomerPortal):
|
||||||
|
"""Portal controller for appointment scheduling and calendar management."""
|
||||||
|
|
||||||
|
def _get_schedule_values(self):
|
||||||
|
"""Common values for schedule pages."""
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
|
||||||
|
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
|
||||||
|
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
|
||||||
|
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
|
||||||
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'portal_gradient': gradient,
|
||||||
|
'google_maps_api_key': google_maps_api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_user_timezone(self):
|
||||||
|
tz_name = request.env.user.tz or 'America/Toronto'
|
||||||
|
try:
|
||||||
|
return pytz.timezone(tz_name)
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
return pytz.timezone('America/Toronto')
|
||||||
|
|
||||||
|
def _get_appointment_types(self):
|
||||||
|
"""Get appointment types available to the current user."""
|
||||||
|
return request.env['appointment.type'].sudo().search([
|
||||||
|
('staff_user_ids', 'in', [request.env.user.id]),
|
||||||
|
])
|
||||||
|
|
||||||
|
@http.route(['/my/schedule'], type='http', auth='user', website=True)
|
||||||
|
def schedule_page(self, **kw):
|
||||||
|
"""Schedule overview: upcoming appointments and shareable link."""
|
||||||
|
partner = request.env.user.partner_id
|
||||||
|
user = request.env.user
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
|
||||||
|
upcoming_events = request.env['calendar.event'].sudo().search([
|
||||||
|
('partner_ids', 'in', [partner.id]),
|
||||||
|
('start', '>=', now),
|
||||||
|
], order='start asc', limit=20)
|
||||||
|
|
||||||
|
today_events = request.env['calendar.event'].sudo().search([
|
||||||
|
('partner_ids', 'in', [partner.id]),
|
||||||
|
('start', '>=', now.replace(hour=0, minute=0, second=0)),
|
||||||
|
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
|
||||||
|
], order='start asc')
|
||||||
|
|
||||||
|
invite = request.env['appointment.invite'].sudo().search([
|
||||||
|
('staff_user_ids', 'in', [user.id]),
|
||||||
|
], limit=1)
|
||||||
|
share_url = invite.book_url if invite else ''
|
||||||
|
|
||||||
|
appointment_types = self._get_appointment_types()
|
||||||
|
tz = self._get_user_timezone()
|
||||||
|
|
||||||
|
values = self._get_schedule_values()
|
||||||
|
values.update({
|
||||||
|
'page_name': 'schedule',
|
||||||
|
'upcoming_events': upcoming_events,
|
||||||
|
'today_events': today_events,
|
||||||
|
'share_url': share_url,
|
||||||
|
'appointment_types': appointment_types,
|
||||||
|
'user_tz': tz,
|
||||||
|
'now': now,
|
||||||
|
})
|
||||||
|
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
|
||||||
|
|
||||||
|
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
|
||||||
|
def schedule_book(self, appointment_type_id=None, **kw):
|
||||||
|
"""Booking form for a new appointment."""
|
||||||
|
appointment_types = self._get_appointment_types()
|
||||||
|
if not appointment_types:
|
||||||
|
return request.redirect('/my/schedule')
|
||||||
|
|
||||||
|
if appointment_type_id:
|
||||||
|
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
|
||||||
|
if not selected_type.exists():
|
||||||
|
selected_type = appointment_types[0]
|
||||||
|
else:
|
||||||
|
selected_type = appointment_types[0]
|
||||||
|
|
||||||
|
values = self._get_schedule_values()
|
||||||
|
values.update({
|
||||||
|
'page_name': 'schedule_book',
|
||||||
|
'appointment_types': appointment_types,
|
||||||
|
'selected_type': selected_type,
|
||||||
|
'now': fields.Datetime.now(),
|
||||||
|
'error': kw.get('error'),
|
||||||
|
'success': kw.get('success'),
|
||||||
|
})
|
||||||
|
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
|
||||||
|
|
||||||
|
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
|
||||||
|
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
|
||||||
|
"""JSON-RPC endpoint: return available time slots for a date."""
|
||||||
|
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
|
||||||
|
if not appointment_type.exists():
|
||||||
|
return {'error': 'Appointment type not found', 'slots': []}
|
||||||
|
|
||||||
|
user = request.env.user
|
||||||
|
tz_name = user.tz or 'America/Toronto'
|
||||||
|
tz = self._get_user_timezone()
|
||||||
|
|
||||||
|
ref_date = fields.Datetime.now()
|
||||||
|
slot_data = appointment_type._get_appointment_slots(
|
||||||
|
timezone=tz_name,
|
||||||
|
filter_users=request.env['res.users'].sudo().browse(user.id),
|
||||||
|
asked_capacity=1,
|
||||||
|
reference_date=ref_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered_slots = []
|
||||||
|
target_date = None
|
||||||
|
if selected_date:
|
||||||
|
try:
|
||||||
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return {'error': 'Invalid date format', 'slots': []}
|
||||||
|
|
||||||
|
for month_data in slot_data:
|
||||||
|
for week in month_data.get('weeks', []):
|
||||||
|
for day_info in week:
|
||||||
|
if not day_info:
|
||||||
|
continue
|
||||||
|
day = day_info.get('day')
|
||||||
|
if target_date and day != target_date:
|
||||||
|
continue
|
||||||
|
for slot in day_info.get('slots', []):
|
||||||
|
slot_dt_str = slot.get('datetime')
|
||||||
|
if not slot_dt_str:
|
||||||
|
continue
|
||||||
|
filtered_slots.append({
|
||||||
|
'datetime': slot_dt_str,
|
||||||
|
'start_hour': slot.get('start_hour', ''),
|
||||||
|
'end_hour': slot.get('end_hour', ''),
|
||||||
|
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
|
||||||
|
'staff_user_id': slot.get('staff_user_id', user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
available_dates = []
|
||||||
|
if not target_date:
|
||||||
|
seen = set()
|
||||||
|
for month_data in slot_data:
|
||||||
|
for week in month_data.get('weeks', []):
|
||||||
|
for day_info in week:
|
||||||
|
if not day_info:
|
||||||
|
continue
|
||||||
|
day = day_info.get('day')
|
||||||
|
if day and day_info.get('slots') and str(day) not in seen:
|
||||||
|
seen.add(str(day))
|
||||||
|
available_dates.append(str(day))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'slots': filtered_slots,
|
||||||
|
'available_dates': sorted(available_dates),
|
||||||
|
'duration': appointment_type.appointment_duration,
|
||||||
|
'timezone': tz_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
|
||||||
|
def schedule_week_events(self, selected_date, **kw):
|
||||||
|
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
|
||||||
|
try:
|
||||||
|
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
|
||||||
|
|
||||||
|
monday = target - timedelta(days=target.weekday())
|
||||||
|
sunday = monday + timedelta(days=6)
|
||||||
|
|
||||||
|
partner = request.env.user.partner_id
|
||||||
|
tz = self._get_user_timezone()
|
||||||
|
|
||||||
|
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
|
||||||
|
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
|
||||||
|
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
events = request.env['calendar.event'].sudo().search([
|
||||||
|
('partner_ids', 'in', [partner.id]),
|
||||||
|
('start', '>=', monday_start_utc),
|
||||||
|
('start', '<=', sunday_end_utc),
|
||||||
|
], order='start asc')
|
||||||
|
|
||||||
|
event_list = []
|
||||||
|
for ev in events:
|
||||||
|
start_utc = ev.start
|
||||||
|
stop_utc = ev.stop
|
||||||
|
start_local = pytz.utc.localize(start_utc).astimezone(tz)
|
||||||
|
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
|
||||||
|
event_list.append({
|
||||||
|
'name': ev.name or '',
|
||||||
|
'start': start_local.strftime('%Y-%m-%d %H:%M'),
|
||||||
|
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
|
||||||
|
'start_time': start_local.strftime('%I:%M %p'),
|
||||||
|
'end_time': stop_local.strftime('%I:%M %p'),
|
||||||
|
'day_of_week': start_local.weekday(),
|
||||||
|
'date': start_local.strftime('%Y-%m-%d'),
|
||||||
|
'location': ev.location or '',
|
||||||
|
'duration': ev.duration,
|
||||||
|
})
|
||||||
|
|
||||||
|
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
week_days = []
|
||||||
|
for i in range(7):
|
||||||
|
day = monday + timedelta(days=i)
|
||||||
|
week_days.append({
|
||||||
|
'label': day_labels[i],
|
||||||
|
'date': day.strftime('%Y-%m-%d'),
|
||||||
|
'day_num': day.day,
|
||||||
|
'is_selected': day == target,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': event_list,
|
||||||
|
'week_days': week_days,
|
||||||
|
'selected_date': selected_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
|
||||||
|
def schedule_book_submit(self, **post):
|
||||||
|
"""Process the booking form submission."""
|
||||||
|
appointment_type_id = int(post.get('appointment_type_id', 0))
|
||||||
|
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
|
||||||
|
if not appointment_type.exists():
|
||||||
|
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
|
||||||
|
|
||||||
|
client_name = (post.get('client_name') or '').strip()
|
||||||
|
client_street = (post.get('client_street') or '').strip()
|
||||||
|
client_city = (post.get('client_city') or '').strip()
|
||||||
|
client_province = (post.get('client_province') or '').strip()
|
||||||
|
client_postal = (post.get('client_postal') or '').strip()
|
||||||
|
notes = (post.get('notes') or '').strip()
|
||||||
|
slot_datetime = (post.get('slot_datetime') or '').strip()
|
||||||
|
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
|
||||||
|
|
||||||
|
if not client_name or not slot_datetime:
|
||||||
|
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
|
||||||
|
|
||||||
|
user = request.env.user
|
||||||
|
tz = self._get_user_timezone()
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||||
|
start_dt_local = tz.localize(start_dt_naive)
|
||||||
|
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
|
||||||
|
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
|
||||||
|
|
||||||
|
duration = float(slot_duration)
|
||||||
|
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
|
||||||
|
|
||||||
|
is_valid = appointment_type._check_appointment_is_valid_slot(
|
||||||
|
staff_user=user,
|
||||||
|
resources=request.env['appointment.resource'],
|
||||||
|
asked_capacity=1,
|
||||||
|
timezone=str(tz),
|
||||||
|
start_dt=start_dt_utc,
|
||||||
|
duration=duration,
|
||||||
|
allday=False,
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
|
||||||
|
|
||||||
|
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
|
||||||
|
location = ', '.join(address_parts)
|
||||||
|
|
||||||
|
description_lines = []
|
||||||
|
if client_name:
|
||||||
|
description_lines.append(f"Client: {client_name}")
|
||||||
|
if location:
|
||||||
|
description_lines.append(f"Address: {location}")
|
||||||
|
if notes:
|
||||||
|
description_lines.append(f"Notes: {notes}")
|
||||||
|
description = '\n'.join(description_lines)
|
||||||
|
|
||||||
|
event_name = f"{client_name} - {appointment_type.name}"
|
||||||
|
|
||||||
|
booking_line_values = [{
|
||||||
|
'appointment_user_id': user.id,
|
||||||
|
'capacity_reserved': 1,
|
||||||
|
'capacity_used': 1,
|
||||||
|
}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_vals = appointment_type._prepare_calendar_event_values(
|
||||||
|
asked_capacity=1,
|
||||||
|
booking_line_values=booking_line_values,
|
||||||
|
description=description,
|
||||||
|
duration=duration,
|
||||||
|
allday=False,
|
||||||
|
appointment_invite=request.env['appointment.invite'],
|
||||||
|
guests=request.env['res.partner'],
|
||||||
|
name=event_name,
|
||||||
|
customer=user.partner_id,
|
||||||
|
staff_user=user,
|
||||||
|
start=start_dt_utc,
|
||||||
|
stop=stop_dt_utc,
|
||||||
|
)
|
||||||
|
event_vals['location'] = location
|
||||||
|
event = request.env['calendar.event'].sudo().create(event_vals)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"Appointment booked: %s at %s (event ID: %s)",
|
||||||
|
event_name, start_dt_utc, event.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Failed to create appointment: %s", e)
|
||||||
|
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
|
||||||
|
|
||||||
|
return request.redirect('/my/schedule?success=Appointment+booked+successfully')
|
||||||
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal file
13
fusion_authorizer_portal/data/appointment_invite_data.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- Auto-create a shareable booking link for staff members.
|
||||||
|
URL: /book/book-appointment
|
||||||
|
Filtered to appointment type "Assessment" and staff users configured on that type. -->
|
||||||
|
|
||||||
|
<record id="default_appointment_invite" model="appointment.invite">
|
||||||
|
<field name="short_code">book-appointment</field>
|
||||||
|
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
# Fusion Authorizer & Sales Portal
|
|
||||||
|
|
||||||
**Version:** 19.0.1.0.0
|
|
||||||
**License:** LGPL-3
|
|
||||||
**Category:** Sales/Portal
|
|
||||||
**Author:** Fusion Claims
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Overview](#overview)
|
|
||||||
2. [Features](#features)
|
|
||||||
3. [Installation](#installation)
|
|
||||||
4. [Configuration](#configuration)
|
|
||||||
5. [Models](#models)
|
|
||||||
6. [Controllers & Routes](#controllers--routes)
|
|
||||||
7. [Security](#security)
|
|
||||||
8. [Frontend Assets](#frontend-assets)
|
|
||||||
9. [Email Templates](#email-templates)
|
|
||||||
10. [User Guide](#user-guide)
|
|
||||||
11. [API Reference](#api-reference)
|
|
||||||
12. [Troubleshooting](#troubleshooting)
|
|
||||||
13. [Changelog](#changelog)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The **Fusion Authorizer & Sales Portal** module extends Odoo's portal functionality to provide external access for two key user types:
|
|
||||||
|
|
||||||
- **Authorizers (Occupational Therapists/OTs):** Healthcare professionals who authorize ADP (Assistive Devices Program) claims
|
|
||||||
- **Sales Representatives:** Field sales staff who conduct client assessments and manage orders
|
|
||||||
|
|
||||||
This module integrates with the `fusion_claims` module to provide a seamless workflow for ADP claims management, from initial client assessment through to order completion.
|
|
||||||
|
|
||||||
### Target Platform
|
|
||||||
- **Odoo Enterprise v19**
|
|
||||||
- Requires: `base`, `sale`, `portal`, `website`, `mail`, `fusion_claims`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Authorizer Portal
|
|
||||||
- View all assigned ADP cases with full details (excluding internal costs)
|
|
||||||
- Real-time search by client name, reference numbers, or claim number
|
|
||||||
- Upload ADP application documents with revision tracking
|
|
||||||
- Add comments/notes to cases
|
|
||||||
- Download submitted ADP applications
|
|
||||||
- Receive email notifications for new assignments and status changes
|
|
||||||
|
|
||||||
### Sales Rep Portal
|
|
||||||
- View sales cases linked to the logged-in user
|
|
||||||
- Start and manage client assessments
|
|
||||||
- Record detailed wheelchair specifications and measurements
|
|
||||||
- Capture digital signatures for ADP pages 11 & 12
|
|
||||||
- Track assessment progress through workflow states
|
|
||||||
|
|
||||||
### Assessment System
|
|
||||||
- Comprehensive client information collection
|
|
||||||
- Wheelchair specifications (seat width, depth, height, cushion type, etc.)
|
|
||||||
- Accessibility and mobility needs documentation
|
|
||||||
- Touch-friendly digital signature capture
|
|
||||||
- Automatic draft Sale Order creation upon completion
|
|
||||||
- Document distribution to authorizers, sales reps, and internal records
|
|
||||||
- Automated email notifications
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
1. Odoo Enterprise v19 installed and running
|
|
||||||
2. The `fusion_claims` module installed and configured
|
|
||||||
3. Portal module enabled
|
|
||||||
4. Website module enabled
|
|
||||||
5. Mail module configured with outgoing email server
|
|
||||||
|
|
||||||
### Installation Steps
|
|
||||||
|
|
||||||
1. **Copy the module** to your Odoo addons directory:
|
|
||||||
```bash
|
|
||||||
cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update the apps list** in Odoo:
|
|
||||||
- Go to Apps menu
|
|
||||||
- Click "Update Apps List"
|
|
||||||
- Search for "Fusion Authorizer"
|
|
||||||
|
|
||||||
3. **Install the module**:
|
|
||||||
- Click Install on "Fusion Authorizer & Sales Portal"
|
|
||||||
- Wait for installation to complete
|
|
||||||
|
|
||||||
4. **Restart Odoo** (recommended):
|
|
||||||
```bash
|
|
||||||
docker restart odoo-app # For Docker installations
|
|
||||||
# OR
|
|
||||||
sudo systemctl restart odoo # For systemd installations
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Granting Portal Access to Users
|
|
||||||
|
|
||||||
1. Navigate to **Contacts** in Odoo backend
|
|
||||||
2. Open the contact record for the authorizer or sales rep
|
|
||||||
3. Go to the **Portal Access** tab
|
|
||||||
4. Check the appropriate role:
|
|
||||||
- `Is Authorizer` - For Occupational Therapists
|
|
||||||
- `Is Sales Rep (Portal)` - For Sales Representatives
|
|
||||||
5. Click the **Grant Portal Access** button
|
|
||||||
6. An invitation email will be sent to the contact's email address
|
|
||||||
|
|
||||||
### Setting Up Authorizers on Sales Orders
|
|
||||||
|
|
||||||
1. Open a Sales Order
|
|
||||||
2. In the order details, set the **Authorizer** field (`x_fc_authorizer_id`)
|
|
||||||
3. The authorizer will receive an email notification about the assignment
|
|
||||||
4. The case will appear in their portal dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Models
|
|
||||||
|
|
||||||
### New Models
|
|
||||||
|
|
||||||
#### `fusion.assessment`
|
|
||||||
**Wheelchair Assessment Record**
|
|
||||||
|
|
||||||
Captures comprehensive client assessment data including:
|
|
||||||
|
|
||||||
| Field Group | Fields |
|
|
||||||
|-------------|--------|
|
|
||||||
| **Client Info** | `client_name`, `client_first_name`, `client_last_name`, `client_street`, `client_city`, `client_state`, `client_postal_code`, `client_country_id`, `client_phone`, `client_mobile`, `client_email`, `client_dob`, `client_health_card`, `client_reference_1`, `client_reference_2` |
|
|
||||||
| **Participants** | `sales_rep_id` (res.users), `authorizer_id` (res.partner) |
|
|
||||||
| **Assessment Details** | `assessment_date`, `assessment_location`, `assessment_location_notes` |
|
|
||||||
| **Measurements** | `seat_width`, `seat_depth`, `seat_to_floor_height`, `back_height`, `armrest_height`, `footrest_length`, `overall_width`, `overall_length`, `overall_height`, `seat_angle`, `back_angle`, `client_weight`, `client_height` |
|
|
||||||
| **Product Types** | `cushion_type`, `cushion_notes`, `backrest_type`, `backrest_notes`, `frame_type`, `frame_notes`, `wheel_type`, `wheel_notes` |
|
|
||||||
| **Needs** | `mobility_notes`, `accessibility_notes`, `special_requirements`, `diagnosis` |
|
|
||||||
| **Signatures** | `signature_page_11`, `signature_page_11_name`, `signature_page_11_date`, `signature_page_12`, `signature_page_12_name`, `signature_page_12_date` |
|
|
||||||
| **Status** | `state` (draft, pending_signature, completed, cancelled) |
|
|
||||||
| **References** | `reference` (auto-generated ASM-XXXXX), `sale_order_id`, `partner_id` |
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `action_complete()` - Completes assessment, creates draft Sale Order, sends notifications
|
|
||||||
- `_ensure_partner()` - Creates or links res.partner for the client
|
|
||||||
- `_create_draft_sale_order()` - Generates Sale Order with specifications
|
|
||||||
- `_generate_signed_documents()` - Creates document records for signatures
|
|
||||||
- `_send_completion_notifications()` - Sends emails to authorizer and client
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `fusion.adp.document`
|
|
||||||
**ADP Document Management with Revision Tracking**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `sale_order_id` | Many2one | Link to Sale Order |
|
|
||||||
| `assessment_id` | Many2one | Link to Assessment |
|
|
||||||
| `document_type` | Selection | full_application, page_11, page_12, pages_11_12, final_submission, other |
|
|
||||||
| `file` | Binary | Document file content |
|
|
||||||
| `filename` | Char | Original filename |
|
|
||||||
| `file_size` | Integer | File size in bytes |
|
|
||||||
| `mimetype` | Char | MIME type |
|
|
||||||
| `revision` | Integer | Revision number (auto-incremented) |
|
|
||||||
| `revision_note` | Text | Notes about this revision |
|
|
||||||
| `is_current` | Boolean | Whether this is the current version |
|
|
||||||
| `uploaded_by` | Many2one | User who uploaded |
|
|
||||||
| `upload_date` | Datetime | Upload timestamp |
|
|
||||||
| `source` | Selection | portal, internal, assessment |
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `action_download()` - Download the document
|
|
||||||
- `get_documents_for_order()` - Get all documents for a sale order
|
|
||||||
- `get_revision_history()` - Get all revisions of a document type
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `fusion.authorizer.comment`
|
|
||||||
**Portal Comments System**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `sale_order_id` | Many2one | Link to Sale Order |
|
|
||||||
| `assessment_id` | Many2one | Link to Assessment |
|
|
||||||
| `author_id` | Many2one | res.partner who authored |
|
|
||||||
| `author_user_id` | Many2one | res.users who authored |
|
|
||||||
| `comment` | Text | Comment content |
|
|
||||||
| `comment_type` | Selection | general, question, update, approval |
|
|
||||||
| `is_internal` | Boolean | Internal-only comment |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Extended Models
|
|
||||||
|
|
||||||
#### `res.partner` (Extended)
|
|
||||||
|
|
||||||
| New Field | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `is_authorizer` | Boolean | Partner is an Authorizer/OT |
|
|
||||||
| `is_sales_rep_portal` | Boolean | Partner is a Sales Rep with portal access |
|
|
||||||
| `authorizer_portal_user_id` | Many2one | Linked portal user account |
|
|
||||||
| `assigned_case_count` | Integer | Computed count of assigned cases |
|
|
||||||
| `assessment_count` | Integer | Computed count of assessments |
|
|
||||||
|
|
||||||
**New Methods:**
|
|
||||||
- `action_grant_portal_access()` - Creates portal user and sends invitation
|
|
||||||
- `action_view_assigned_cases()` - Opens list of assigned Sale Orders
|
|
||||||
- `action_view_assessments()` - Opens list of assessments
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `sale.order` (Extended)
|
|
||||||
|
|
||||||
| New Field | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `portal_comment_ids` | One2many | Comments from portal users |
|
|
||||||
| `portal_comment_count` | Integer | Computed comment count |
|
|
||||||
| `portal_document_ids` | One2many | Documents uploaded via portal |
|
|
||||||
| `portal_document_count` | Integer | Computed document count |
|
|
||||||
| `assessment_id` | Many2one | Source assessment that created this order |
|
|
||||||
| `portal_authorizer_id` | Many2one | Authorizer reference (computed from x_fc_authorizer_id) |
|
|
||||||
|
|
||||||
**New Methods:**
|
|
||||||
- `_send_authorizer_assignment_notification()` - Email on authorizer assignment
|
|
||||||
- `_send_status_change_notification()` - Email on status change
|
|
||||||
- `get_portal_display_data()` - Safe data for portal display (excludes costs)
|
|
||||||
- `get_authorizer_portal_cases()` - Search cases for authorizer portal
|
|
||||||
- `get_sales_rep_portal_cases()` - Search cases for sales rep portal
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Controllers & Routes
|
|
||||||
|
|
||||||
### Authorizer Portal Routes
|
|
||||||
|
|
||||||
| Route | Method | Auth | Description |
|
|
||||||
|-------|--------|------|-------------|
|
|
||||||
| `/my/authorizer` | GET | user | Authorizer dashboard |
|
|
||||||
| `/my/authorizer/cases` | GET | user | List of assigned cases |
|
|
||||||
| `/my/authorizer/cases/search` | POST | user | AJAX search (jsonrpc) |
|
|
||||||
| `/my/authorizer/case/<id>` | GET | user | Case detail view |
|
|
||||||
| `/my/authorizer/case/<id>/comment` | POST | user | Add comment to case |
|
|
||||||
| `/my/authorizer/case/<id>/upload` | POST | user | Upload document |
|
|
||||||
| `/my/authorizer/document/<id>/download` | GET | user | Download document |
|
|
||||||
|
|
||||||
### Sales Rep Portal Routes
|
|
||||||
|
|
||||||
| Route | Method | Auth | Description |
|
|
||||||
|-------|--------|------|-------------|
|
|
||||||
| `/my/sales` | GET | user | Sales rep dashboard |
|
|
||||||
| `/my/sales/cases` | GET | user | List of sales cases |
|
|
||||||
| `/my/sales/cases/search` | POST | user | AJAX search (jsonrpc) |
|
|
||||||
| `/my/sales/case/<id>` | GET | user | Case detail view |
|
|
||||||
|
|
||||||
### Assessment Routes
|
|
||||||
|
|
||||||
| Route | Method | Auth | Description |
|
|
||||||
|-------|--------|------|-------------|
|
|
||||||
| `/my/assessments` | GET | user | List of assessments |
|
|
||||||
| `/my/assessment/new` | GET | user | New assessment form |
|
|
||||||
| `/my/assessment/<id>` | GET | user | View/edit assessment |
|
|
||||||
| `/my/assessment/save` | POST | user | Save assessment data |
|
|
||||||
| `/my/assessment/<id>/signatures` | GET | user | Signature capture page |
|
|
||||||
| `/my/assessment/<id>/save_signature` | POST | user | Save signature (jsonrpc) |
|
|
||||||
| `/my/assessment/<id>/complete` | POST | user | Complete assessment |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Security Groups
|
|
||||||
|
|
||||||
| Group | XML ID | Description |
|
|
||||||
|-------|--------|-------------|
|
|
||||||
| Authorizer Portal | `group_authorizer_portal` | Access to authorizer portal features |
|
|
||||||
| Sales Rep Portal | `group_sales_rep_portal` | Access to sales rep portal features |
|
|
||||||
|
|
||||||
### Record Rules
|
|
||||||
|
|
||||||
| Model | Rule | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `fusion.authorizer.comment` | Portal Read | Users can read non-internal comments on their cases |
|
|
||||||
| `fusion.authorizer.comment` | Portal Create | Users can create comments on their cases |
|
|
||||||
| `fusion.adp.document` | Portal Read | Users can read documents on their cases |
|
|
||||||
| `fusion.adp.document` | Portal Create | Users can upload documents to their cases |
|
|
||||||
| `fusion.assessment` | Portal Access | Users can access assessments they're linked to |
|
|
||||||
| `sale.order` | Portal Authorizer | Authorizers can view their assigned orders |
|
|
||||||
|
|
||||||
### Access Rights (ir.model.access.csv)
|
|
||||||
|
|
||||||
| Model | Group | Read | Write | Create | Unlink |
|
|
||||||
|-------|-------|------|-------|--------|--------|
|
|
||||||
| `fusion.authorizer.comment` | base.group_user | 1 | 1 | 1 | 1 |
|
|
||||||
| `fusion.authorizer.comment` | base.group_portal | 1 | 0 | 1 | 0 |
|
|
||||||
| `fusion.adp.document` | base.group_user | 1 | 1 | 1 | 1 |
|
|
||||||
| `fusion.adp.document` | base.group_portal | 1 | 0 | 1 | 0 |
|
|
||||||
| `fusion.assessment` | base.group_user | 1 | 1 | 1 | 1 |
|
|
||||||
| `fusion.assessment` | base.group_portal | 1 | 1 | 1 | 0 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Assets
|
|
||||||
|
|
||||||
### CSS (`static/src/css/portal_style.css`)
|
|
||||||
|
|
||||||
Custom portal styling with a dark blue and green color scheme:
|
|
||||||
|
|
||||||
- **Primary Color:** Dark blue (#1e3a5f)
|
|
||||||
- **Secondary Color:** Medium blue (#2c5282)
|
|
||||||
- **Accent Color:** Green (#38a169)
|
|
||||||
- **Background:** Light gray (#f7fafc)
|
|
||||||
|
|
||||||
Styled components:
|
|
||||||
- Portal cards with shadow effects
|
|
||||||
- Status badges with color coding
|
|
||||||
- Custom buttons with hover effects
|
|
||||||
- Responsive tables
|
|
||||||
- Form inputs with focus states
|
|
||||||
|
|
||||||
### JavaScript
|
|
||||||
|
|
||||||
#### `portal_search.js`
|
|
||||||
Real-time search functionality:
|
|
||||||
- Debounced input handling (300ms delay)
|
|
||||||
- AJAX calls to search endpoints
|
|
||||||
- Dynamic table updates
|
|
||||||
- Search result highlighting
|
|
||||||
|
|
||||||
#### `assessment_form.js`
|
|
||||||
Assessment form enhancements:
|
|
||||||
- Unsaved changes warning
|
|
||||||
- Auto-fill client name from first/last name
|
|
||||||
- Number input validation
|
|
||||||
- Form state tracking
|
|
||||||
|
|
||||||
#### `signature_pad.js`
|
|
||||||
Digital signature capture:
|
|
||||||
- HTML5 Canvas-based drawing
|
|
||||||
- Touch and mouse event support
|
|
||||||
- Clear signature functionality
|
|
||||||
- Export to base64 PNG
|
|
||||||
- AJAX save to server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Email Templates
|
|
||||||
|
|
||||||
### Case Assignment (`mail_template_case_assigned`)
|
|
||||||
**Trigger:** Authorizer assigned to a Sale Order
|
|
||||||
**Recipient:** Authorizer email
|
|
||||||
**Content:** Case details, client information, link to portal
|
|
||||||
|
|
||||||
### Status Change (`mail_template_status_changed`)
|
|
||||||
**Trigger:** Sale Order state changes
|
|
||||||
**Recipient:** Assigned authorizer
|
|
||||||
**Content:** Previous and new status, case details
|
|
||||||
|
|
||||||
### Assessment Complete - Authorizer (`mail_template_assessment_complete_authorizer`)
|
|
||||||
**Trigger:** Assessment completed
|
|
||||||
**Recipient:** Assigned authorizer
|
|
||||||
**Content:** Assessment details, measurements, signed documents
|
|
||||||
|
|
||||||
### Assessment Complete - Client (`mail_template_assessment_complete_client`)
|
|
||||||
**Trigger:** Assessment completed
|
|
||||||
**Recipient:** Client email
|
|
||||||
**Content:** Confirmation, next steps, measurements summary
|
|
||||||
|
|
||||||
### Document Uploaded (`mail_template_document_uploaded`)
|
|
||||||
**Trigger:** Document uploaded via portal
|
|
||||||
**Recipient:** Internal team
|
|
||||||
**Content:** Document details, revision info, download link
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Guide
|
|
||||||
|
|
||||||
### For Administrators
|
|
||||||
|
|
||||||
#### Granting Portal Access
|
|
||||||
|
|
||||||
1. Go to **Contacts** > Select the contact
|
|
||||||
2. Navigate to the **Portal Access** tab
|
|
||||||
3. Enable the appropriate role:
|
|
||||||
- Check `Is Authorizer` for OTs/Therapists
|
|
||||||
- Check `Is Sales Rep (Portal)` for Sales Reps
|
|
||||||
4. Click **Grant Portal Access**
|
|
||||||
5. The user receives an email with login instructions
|
|
||||||
|
|
||||||
#### Assigning Cases to Authorizers
|
|
||||||
|
|
||||||
1. Open a **Sale Order**
|
|
||||||
2. Set the **Authorizer** field to the appropriate contact
|
|
||||||
3. Save the order
|
|
||||||
4. The authorizer receives a notification email
|
|
||||||
5. The case appears in their portal dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### For Authorizers
|
|
||||||
|
|
||||||
#### Accessing the Portal
|
|
||||||
|
|
||||||
1. Visit `https://your-domain.com/my`
|
|
||||||
2. Log in with your portal credentials
|
|
||||||
3. Click **Authorizer Portal** in the menu
|
|
||||||
|
|
||||||
#### Viewing Cases
|
|
||||||
|
|
||||||
1. From the dashboard, view recent cases and statistics
|
|
||||||
2. Click **View All Cases** or **My Cases** for the full list
|
|
||||||
3. Use the search bar to find specific cases by:
|
|
||||||
- Client name
|
|
||||||
- Client reference 1 or 2
|
|
||||||
- Claim number
|
|
||||||
|
|
||||||
#### Adding Comments
|
|
||||||
|
|
||||||
1. Open a case detail view
|
|
||||||
2. Scroll to the Comments section
|
|
||||||
3. Enter your comment
|
|
||||||
4. Select comment type (General, Question, Update, Approval)
|
|
||||||
5. Click **Add Comment**
|
|
||||||
|
|
||||||
#### Uploading Documents
|
|
||||||
|
|
||||||
1. Open a case detail view
|
|
||||||
2. Go to the Documents section
|
|
||||||
3. Click **Upload Document**
|
|
||||||
4. Select document type (Full Application, Page 11, Page 12, etc.)
|
|
||||||
5. Choose the file and add revision notes
|
|
||||||
6. Click **Upload**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### For Sales Representatives
|
|
||||||
|
|
||||||
#### Starting a New Assessment
|
|
||||||
|
|
||||||
1. Log in to the portal
|
|
||||||
2. Click **New Assessment**
|
|
||||||
3. Fill in client information:
|
|
||||||
- Name, address, contact details
|
|
||||||
- Client references
|
|
||||||
4. Record wheelchair specifications:
|
|
||||||
- Measurements (seat width, depth, height)
|
|
||||||
- Product types (cushion, backrest, frame, wheels)
|
|
||||||
5. Document accessibility and mobility needs
|
|
||||||
6. Click **Save & Continue**
|
|
||||||
|
|
||||||
#### Capturing Signatures
|
|
||||||
|
|
||||||
1. After saving assessment data, click **Proceed to Signatures**
|
|
||||||
2. **Page 11 (Authorizer):**
|
|
||||||
- Have the OT sign on the canvas
|
|
||||||
- Enter their printed name
|
|
||||||
- Click **Save Signature**
|
|
||||||
3. **Page 12 (Client):**
|
|
||||||
- Have the client sign on the canvas
|
|
||||||
- Enter their printed name
|
|
||||||
- Click **Save Signature**
|
|
||||||
|
|
||||||
#### Completing the Assessment
|
|
||||||
|
|
||||||
1. Once both signatures are captured, click **Complete Assessment**
|
|
||||||
2. The system will:
|
|
||||||
- Create a new customer record (if needed)
|
|
||||||
- Generate a draft Sale Order
|
|
||||||
- Attach signed documents
|
|
||||||
- Send notification emails
|
|
||||||
3. The assessment moves to "Completed" status
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Assessment Model Methods
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Complete an assessment and create Sale Order
|
|
||||||
assessment.action_complete()
|
|
||||||
|
|
||||||
# Get formatted specifications for order notes
|
|
||||||
specs = assessment._format_specifications_for_order()
|
|
||||||
|
|
||||||
# Ensure partner exists or create new
|
|
||||||
partner = assessment._ensure_partner()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sale Order Portal Methods
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get safe data for portal display (no costs)
|
|
||||||
data = order.get_portal_display_data()
|
|
||||||
|
|
||||||
# Search cases for authorizer
|
|
||||||
cases = SaleOrder.get_authorizer_portal_cases(
|
|
||||||
partner_id=123,
|
|
||||||
search_query='Smith',
|
|
||||||
limit=50,
|
|
||||||
offset=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Search cases for sales rep
|
|
||||||
cases = SaleOrder.get_sales_rep_portal_cases(
|
|
||||||
user_id=456,
|
|
||||||
search_query='wheelchair',
|
|
||||||
limit=50,
|
|
||||||
offset=0
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Partner Methods
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Grant portal access programmatically
|
|
||||||
partner.action_grant_portal_access()
|
|
||||||
|
|
||||||
# Check if partner is an authorizer
|
|
||||||
if partner.is_authorizer:
|
|
||||||
cases = partner.assigned_case_count
|
|
||||||
```
|
|
||||||
|
|
||||||
### Document Methods
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get all documents for an order
|
|
||||||
docs = ADPDocument.get_documents_for_order(sale_order_id)
|
|
||||||
|
|
||||||
# Get revision history
|
|
||||||
history = document.get_revision_history()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Errors
|
|
||||||
|
|
||||||
#### Error: `Invalid field 'in_portal' in 'portal.wizard.user'`
|
|
||||||
|
|
||||||
**Cause:** Odoo 19 changed the portal wizard API, removing the `in_portal` field.
|
|
||||||
|
|
||||||
**Solution:** The `action_grant_portal_access` method has been updated to:
|
|
||||||
1. First attempt using the standard portal wizard
|
|
||||||
2. If that fails, fall back to direct user creation with portal group assignment
|
|
||||||
|
|
||||||
```python
|
|
||||||
# The fallback code creates the user directly:
|
|
||||||
portal_group = self.env.ref('base.group_portal')
|
|
||||||
portal_user = self.env['res.users'].sudo().create({
|
|
||||||
'name': self.name,
|
|
||||||
'login': self.email,
|
|
||||||
'email': self.email,
|
|
||||||
'partner_id': self.id,
|
|
||||||
'groups_id': [(6, 0, [portal_group.id])],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Error: `Invalid view type: 'tree'`
|
|
||||||
|
|
||||||
**Cause:** Odoo 19 renamed `<tree>` views to `<list>`.
|
|
||||||
|
|
||||||
**Solution:** Replace all `<tree>` tags with `<list>` in XML view definitions:
|
|
||||||
```xml
|
|
||||||
<!-- Old (Odoo 18 and earlier) -->
|
|
||||||
<tree>...</tree>
|
|
||||||
|
|
||||||
<!-- New (Odoo 19) -->
|
|
||||||
<list>...</list>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Error: `Invalid field 'category_id' in 'res.groups'`
|
|
||||||
|
|
||||||
**Cause:** Odoo 19 no longer supports `category_id` in `res.groups` XML definitions.
|
|
||||||
|
|
||||||
**Solution:** Remove the `<field name="category_id">` element from security group definitions:
|
|
||||||
```xml
|
|
||||||
<!-- Remove this line -->
|
|
||||||
<field name="category_id" ref="base.module_category_sales"/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Error: `DeprecationWarning: @route(type='json') is deprecated`
|
|
||||||
|
|
||||||
**Cause:** Odoo 19 uses `type='jsonrpc'` instead of `type='json'`.
|
|
||||||
|
|
||||||
**Solution:** Update route decorators:
|
|
||||||
```python
|
|
||||||
# Old
|
|
||||||
@http.route('/my/endpoint', type='json', auth='user')
|
|
||||||
|
|
||||||
# New
|
|
||||||
@http.route('/my/endpoint', type='jsonrpc', auth='user')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Portal Access Issues
|
|
||||||
|
|
||||||
#### User can't see cases in portal
|
|
||||||
|
|
||||||
1. Verify the partner has `is_authorizer` or `is_sales_rep_portal` checked
|
|
||||||
2. Verify the `authorizer_portal_user_id` is set
|
|
||||||
3. For authorizers, verify the Sale Order has `x_fc_authorizer_id` set to their partner ID
|
|
||||||
4. For sales reps, verify the Sale Order has `user_id` set to their user ID
|
|
||||||
|
|
||||||
#### Email notifications not sending
|
|
||||||
|
|
||||||
1. Check that the outgoing mail server is configured in Odoo
|
|
||||||
2. Verify the email templates exist and are active
|
|
||||||
3. Check the mail queue (Settings > Technical > Email > Emails)
|
|
||||||
4. Review the Odoo logs for mail errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Debug Logging
|
|
||||||
|
|
||||||
Enable debug logging for this module:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
_logger = logging.getLogger('fusion_authorizer_portal')
|
|
||||||
_logger.setLevel(logging.DEBUG)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or in Odoo configuration:
|
|
||||||
```ini
|
|
||||||
[options]
|
|
||||||
log_handler = fusion_authorizer_portal:DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### Version 19.0.1.0.0 (Initial Release)
|
|
||||||
|
|
||||||
**New Features:**
|
|
||||||
- Authorizer Portal with case management
|
|
||||||
- Sales Rep Portal with assessment forms
|
|
||||||
- Wheelchair Assessment model with 50+ fields
|
|
||||||
- Digital signature capture (Pages 11 & 12)
|
|
||||||
- Document management with revision tracking
|
|
||||||
- Real-time search functionality
|
|
||||||
- Email notifications for key events
|
|
||||||
- Portal access management from partner form
|
|
||||||
|
|
||||||
**Technical:**
|
|
||||||
- Compatible with Odoo Enterprise v19
|
|
||||||
- Integrates with fusion_claims module
|
|
||||||
- Mobile-responsive portal design
|
|
||||||
- Touch-friendly signature pad
|
|
||||||
- AJAX-powered search
|
|
||||||
|
|
||||||
**Bug Fixes:**
|
|
||||||
- Fixed `in_portal` field error in Odoo 19 portal wizard
|
|
||||||
- Fixed `tree` to `list` view type for Odoo 19
|
|
||||||
- Fixed `category_id` error in security groups
|
|
||||||
- Fixed `type='json'` deprecation warning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
fusion_authorizer_portal/
|
|
||||||
├── __init__.py
|
|
||||||
├── __manifest__.py
|
|
||||||
├── README.md
|
|
||||||
├── controllers/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── portal_main.py # Authorizer & Sales Rep portal routes
|
|
||||||
│ └── portal_assessment.py # Assessment routes
|
|
||||||
├── data/
|
|
||||||
│ ├── mail_template_data.xml # Email templates & sequences
|
|
||||||
│ └── portal_menu_data.xml # Portal menu items
|
|
||||||
├── models/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── adp_document.py # Document management model
|
|
||||||
│ ├── assessment.py # Assessment model
|
|
||||||
│ ├── authorizer_comment.py # Comments model
|
|
||||||
│ ├── res_partner.py # Partner extensions
|
|
||||||
│ └── sale_order.py # Sale Order extensions
|
|
||||||
├── security/
|
|
||||||
│ ├── ir.model.access.csv # Access rights
|
|
||||||
│ └── portal_security.xml # Groups & record rules
|
|
||||||
├── static/
|
|
||||||
│ └── src/
|
|
||||||
│ ├── css/
|
|
||||||
│ │ └── portal_style.css # Portal styling
|
|
||||||
│ └── js/
|
|
||||||
│ ├── assessment_form.js # Form enhancements
|
|
||||||
│ ├── portal_search.js # Real-time search
|
|
||||||
│ └── signature_pad.js # Signature capture
|
|
||||||
└── views/
|
|
||||||
├── assessment_views.xml # Assessment backend views
|
|
||||||
├── portal_templates.xml # Portal QWeb templates
|
|
||||||
├── res_partner_views.xml # Partner form extensions
|
|
||||||
└── sale_order_views.xml # Sale Order extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For support or feature requests, contact:
|
|
||||||
|
|
||||||
- **Email:** support@fusionclaims.com
|
|
||||||
- **Website:** https://fusionclaims.com
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: January 2026*
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
{
|
|
||||||
'name': 'Fusion Authorizer & Sales Portal',
|
|
||||||
'version': '19.0.2.0.9',
|
|
||||||
'category': 'Sales/Portal',
|
|
||||||
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
|
|
||||||
'description': """
|
|
||||||
Fusion Authorizer & Sales Rep Portal
|
|
||||||
=====================================
|
|
||||||
|
|
||||||
This module provides external portal access for:
|
|
||||||
|
|
||||||
**Authorizers (Occupational Therapists)**
|
|
||||||
- View assigned ADP cases
|
|
||||||
- Upload documents (ADP applications, signed pages)
|
|
||||||
- Add comments to cases
|
|
||||||
- Complete assessments with clients
|
|
||||||
- Capture digital signatures for ADP pages 11 & 12
|
|
||||||
|
|
||||||
**Sales Representatives**
|
|
||||||
- View their sales cases
|
|
||||||
- Start new client assessments
|
|
||||||
- Record wheelchair specifications and measurements
|
|
||||||
- Capture client signatures
|
|
||||||
- Track assessment progress
|
|
||||||
|
|
||||||
**Assessment System**
|
|
||||||
- Client information collection
|
|
||||||
- Wheelchair specifications (seat width, depth, height, etc.)
|
|
||||||
- Accessibility and mobility needs documentation
|
|
||||||
- Digital signature capture for ADP pages 11 & 12
|
|
||||||
- Automatic draft Sale Order creation
|
|
||||||
- Document distribution to all parties
|
|
||||||
- Automated email notifications
|
|
||||||
|
|
||||||
**Features**
|
|
||||||
- Real-time client search
|
|
||||||
- Document version tracking
|
|
||||||
- Mobile-friendly signature capture
|
|
||||||
- Email notifications for status changes
|
|
||||||
- Secure portal access with role-based permissions
|
|
||||||
""",
|
|
||||||
'author': 'Fusion Claims',
|
|
||||||
'website': 'https://fusionclaims.com',
|
|
||||||
'license': 'LGPL-3',
|
|
||||||
'depends': [
|
|
||||||
'base',
|
|
||||||
'sale',
|
|
||||||
'portal',
|
|
||||||
'website',
|
|
||||||
'mail',
|
|
||||||
'calendar',
|
|
||||||
'knowledge',
|
|
||||||
'fusion_claims',
|
|
||||||
],
|
|
||||||
'data': [
|
|
||||||
# Security
|
|
||||||
'security/portal_security.xml',
|
|
||||||
'security/ir.model.access.csv',
|
|
||||||
# Data
|
|
||||||
'data/mail_template_data.xml',
|
|
||||||
'data/portal_menu_data.xml',
|
|
||||||
'data/ir_actions_server_data.xml',
|
|
||||||
'data/welcome_articles.xml',
|
|
||||||
# Views
|
|
||||||
'views/res_partner_views.xml',
|
|
||||||
'views/sale_order_views.xml',
|
|
||||||
'views/assessment_views.xml',
|
|
||||||
'views/pdf_template_views.xml',
|
|
||||||
# Portal Templates
|
|
||||||
'views/portal_templates.xml',
|
|
||||||
'views/portal_assessment_express.xml',
|
|
||||||
'views/portal_pdf_editor.xml',
|
|
||||||
'views/portal_accessibility_templates.xml',
|
|
||||||
'views/portal_accessibility_forms.xml',
|
|
||||||
'views/portal_technician_templates.xml',
|
|
||||||
'views/portal_book_assessment.xml',
|
|
||||||
],
|
|
||||||
'assets': {
|
|
||||||
'web.assets_backend': [
|
|
||||||
'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml',
|
|
||||||
'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js',
|
|
||||||
],
|
|
||||||
'web.assets_frontend': [
|
|
||||||
'fusion_authorizer_portal/static/src/css/portal_style.css',
|
|
||||||
'fusion_authorizer_portal/static/src/css/technician_portal.css',
|
|
||||||
'fusion_authorizer_portal/static/src/js/portal_search.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/assessment_form.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/signature_pad.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/loaner_portal.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/technician_push.js',
|
|
||||||
'fusion_authorizer_portal/static/src/js/technician_location.js',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'images': ['static/description/icon.png'],
|
|
||||||
'installable': True,
|
|
||||||
'application': False,
|
|
||||||
'auto_install': False,
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from . import portal_main
|
|
||||||
from . import portal_assessment
|
|
||||||
from . import pdf_editor
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Fusion PDF Field Editor Controller
|
|
||||||
# Provides routes for the visual drag-and-drop field position editor
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from odoo import http
|
|
||||||
from odoo.http import request
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class FusionPdfEditorController(http.Controller):
|
|
||||||
"""Controller for the PDF field position visual editor."""
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Editor Page
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/<int:template_id>', type='http', auth='user', website=True)
|
|
||||||
def pdf_field_editor(self, template_id, **kw):
|
|
||||||
"""Render the visual field editor for a PDF template."""
|
|
||||||
template = request.env['fusion.pdf.template'].browse(template_id)
|
|
||||||
if not template.exists():
|
|
||||||
return request.redirect('/web')
|
|
||||||
|
|
||||||
# Get preview image for page 1
|
|
||||||
preview_url = ''
|
|
||||||
preview = template.preview_ids.filtered(lambda p: p.page == 1)
|
|
||||||
if preview and preview[0].image:
|
|
||||||
preview_url = f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'
|
|
||||||
|
|
||||||
fields = template.field_ids.read([
|
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
|
||||||
'text_align',
|
|
||||||
])
|
|
||||||
|
|
||||||
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
|
|
||||||
'template': template,
|
|
||||||
'fields': fields,
|
|
||||||
'preview_url': preview_url,
|
|
||||||
})
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# JSONRPC: Get fields for template
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/fields', type='json', auth='user')
|
|
||||||
def get_fields(self, template_id, **kw):
|
|
||||||
"""Return all fields for a template."""
|
|
||||||
template = request.env['fusion.pdf.template'].browse(template_id)
|
|
||||||
if not template.exists():
|
|
||||||
return []
|
|
||||||
return template.field_ids.read([
|
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
|
||||||
'text_align',
|
|
||||||
])
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# JSONRPC: Update field position/properties
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/update-field', type='json', auth='user')
|
|
||||||
def update_field(self, field_id, values, **kw):
|
|
||||||
"""Update a field's position or properties."""
|
|
||||||
field = request.env['fusion.pdf.template.field'].browse(field_id)
|
|
||||||
if not field.exists():
|
|
||||||
return {'error': 'Field not found'}
|
|
||||||
|
|
||||||
# Filter to allowed fields only
|
|
||||||
allowed = {
|
|
||||||
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
|
|
||||||
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
|
|
||||||
'text_align',
|
|
||||||
}
|
|
||||||
safe_values = {k: v for k, v in values.items() if k in allowed}
|
|
||||||
if safe_values:
|
|
||||||
field.write(safe_values)
|
|
||||||
return {'success': True}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# JSONRPC: Create new field
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/create-field', type='json', auth='user')
|
|
||||||
def create_field(self, **kw):
|
|
||||||
"""Create a new field on a template."""
|
|
||||||
template_id = kw.get('template_id')
|
|
||||||
if not template_id:
|
|
||||||
return {'error': 'Missing template_id'}
|
|
||||||
|
|
||||||
vals = {
|
|
||||||
'template_id': int(template_id),
|
|
||||||
'name': kw.get('name', 'new_field'),
|
|
||||||
'label': kw.get('label', 'New Field'),
|
|
||||||
'field_type': kw.get('field_type', 'text'),
|
|
||||||
'field_key': kw.get('field_key', kw.get('name', '')),
|
|
||||||
'page': int(kw.get('page', 1)),
|
|
||||||
'pos_x': float(kw.get('pos_x', 0.3)),
|
|
||||||
'pos_y': float(kw.get('pos_y', 0.3)),
|
|
||||||
'width': float(kw.get('width', 0.150)),
|
|
||||||
'height': float(kw.get('height', 0.015)),
|
|
||||||
'font_size': float(kw.get('font_size', 10)),
|
|
||||||
}
|
|
||||||
|
|
||||||
field = request.env['fusion.pdf.template.field'].create(vals)
|
|
||||||
return {'id': field.id, 'success': True}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# JSONRPC: Delete field
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/delete-field', type='json', auth='user')
|
|
||||||
def delete_field(self, field_id, **kw):
|
|
||||||
"""Delete a field from a template."""
|
|
||||||
field = request.env['fusion.pdf.template.field'].browse(field_id)
|
|
||||||
if field.exists():
|
|
||||||
field.unlink()
|
|
||||||
return {'success': True}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# JSONRPC: Get page preview image URL
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/page-image', type='json', auth='user')
|
|
||||||
def get_page_image(self, template_id, page, **kw):
|
|
||||||
"""Return the preview image URL for a specific page."""
|
|
||||||
template = request.env['fusion.pdf.template'].browse(template_id)
|
|
||||||
if not template.exists():
|
|
||||||
return {'image_url': ''}
|
|
||||||
|
|
||||||
preview = template.preview_ids.filtered(lambda p: p.page == page)
|
|
||||||
if preview and preview[0].image:
|
|
||||||
return {'image_url': f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'}
|
|
||||||
return {'image_url': ''}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Upload page preview image (from editor)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/upload-preview', type='http', auth='user',
|
|
||||||
methods=['POST'], csrf=True, website=True)
|
|
||||||
def upload_preview_image(self, **kw):
|
|
||||||
"""Upload a preview image for a template page directly from the editor."""
|
|
||||||
template_id = int(kw.get('template_id', 0))
|
|
||||||
page = int(kw.get('page', 1))
|
|
||||||
template = request.env['fusion.pdf.template'].browse(template_id)
|
|
||||||
if not template.exists():
|
|
||||||
return json.dumps({'error': 'Template not found'})
|
|
||||||
|
|
||||||
image_file = request.httprequest.files.get('preview_image')
|
|
||||||
if not image_file:
|
|
||||||
return json.dumps({'error': 'No image uploaded'})
|
|
||||||
|
|
||||||
image_data = base64.b64encode(image_file.read())
|
|
||||||
|
|
||||||
# Find or create preview for this page
|
|
||||||
preview = template.preview_ids.filtered(lambda p: p.page == page)
|
|
||||||
if preview:
|
|
||||||
preview[0].write({'image': image_data, 'image_filename': image_file.filename})
|
|
||||||
else:
|
|
||||||
request.env['fusion.pdf.template.preview'].create({
|
|
||||||
'template_id': template_id,
|
|
||||||
'page': page,
|
|
||||||
'image': image_data,
|
|
||||||
'image_filename': image_file.filename,
|
|
||||||
})
|
|
||||||
|
|
||||||
_logger.info("Uploaded preview image for template %s page %d", template.name, page)
|
|
||||||
return request.redirect(f'/fusion/pdf-editor/{template_id}')
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Preview: Generate sample filled PDF
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@http.route('/fusion/pdf-editor/preview/<int:template_id>', type='http', auth='user')
|
|
||||||
def preview_pdf(self, template_id, **kw):
|
|
||||||
"""Generate a preview filled PDF with sample data."""
|
|
||||||
template = request.env['fusion.pdf.template'].browse(template_id)
|
|
||||||
if not template.exists() or not template.pdf_file:
|
|
||||||
return request.redirect('/web')
|
|
||||||
|
|
||||||
# Build sample data for preview
|
|
||||||
sample_context = {
|
|
||||||
'client_last_name': 'Smith',
|
|
||||||
'client_first_name': 'John',
|
|
||||||
'client_middle_name': 'A',
|
|
||||||
'client_health_card': '1234-567-890',
|
|
||||||
'client_health_card_version': 'AB',
|
|
||||||
'client_street': '123 Main Street',
|
|
||||||
'client_unit': 'Unit 4B',
|
|
||||||
'client_city': 'Toronto',
|
|
||||||
'client_state': 'Ontario',
|
|
||||||
'client_postal_code': 'M5V 2T6',
|
|
||||||
'client_phone': '(416) 555-0123',
|
|
||||||
'client_email': 'john.smith@example.com',
|
|
||||||
'client_weight': '185',
|
|
||||||
'consent_applicant': True,
|
|
||||||
'consent_agent': False,
|
|
||||||
'consent_date': '2026-02-08',
|
|
||||||
'agent_last_name': '',
|
|
||||||
'agent_first_name': '',
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
pdf_bytes = template.generate_filled_pdf(sample_context)
|
|
||||||
headers = [
|
|
||||||
('Content-Type', 'application/pdf'),
|
|
||||||
('Content-Disposition', f'inline; filename="preview_{template.name}.pdf"'),
|
|
||||||
]
|
|
||||||
return request.make_response(pdf_bytes, headers=headers)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error("PDF preview generation failed: %s", e)
|
|
||||||
return request.redirect(f'/fusion/pdf-editor/{template_id}?error=preview_failed')
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- ==================== BATCH SERVER ACTIONS FOR RES.PARTNER ==================== -->
|
|
||||||
|
|
||||||
<!-- Mark as Authorizer - Batch Action (Gear Menu) -->
|
|
||||||
<record id="action_mark_as_authorizer" model="ir.actions.server">
|
|
||||||
<field name="name">Mark as Authorizer</field>
|
|
||||||
<field name="model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_view_types">list</field>
|
|
||||||
<field name="state">code</field>
|
|
||||||
<field name="code">
|
|
||||||
action = records.action_mark_as_authorizer()
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Send Portal Invitation - Batch Action (Gear Menu) -->
|
|
||||||
<record id="action_batch_send_invitation" model="ir.actions.server">
|
|
||||||
<field name="name">Send Portal Invitation</field>
|
|
||||||
<field name="model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_view_types">list</field>
|
|
||||||
<field name="state">code</field>
|
|
||||||
<field name="code">
|
|
||||||
action = records.action_batch_send_portal_invitation()
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Mark as Authorizer & Send Invitation (Combined) - Batch Action (Gear Menu) -->
|
|
||||||
<record id="action_mark_and_invite" model="ir.actions.server">
|
|
||||||
<field name="name">Mark as Authorizer & Send Invitation</field>
|
|
||||||
<field name="model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_model_id" ref="base.model_res_partner"/>
|
|
||||||
<field name="binding_view_types">list</field>
|
|
||||||
<field name="state">code</field>
|
|
||||||
<field name="code">
|
|
||||||
action = records.action_mark_and_send_invitation()
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- Sequence for Assessment Reference - noupdate=1 prevents reset on module upgrade -->
|
|
||||||
<data noupdate="1">
|
|
||||||
<record id="seq_fusion_assessment" model="ir.sequence">
|
|
||||||
<field name="name">Assessment Sequence</field>
|
|
||||||
<field name="code">fusion.assessment</field>
|
|
||||||
<field name="prefix">ASM-</field>
|
|
||||||
<field name="padding">5</field>
|
|
||||||
<field name="number_next">1</field>
|
|
||||||
<field name="number_increment">1</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Sequence for Accessibility Assessment Reference -->
|
|
||||||
<record id="seq_fusion_accessibility_assessment" model="ir.sequence">
|
|
||||||
<field name="name">Accessibility Assessment Sequence</field>
|
|
||||||
<field name="code">fusion.accessibility.assessment</field>
|
|
||||||
<field name="prefix">ACC-</field>
|
|
||||||
<field name="padding">5</field>
|
|
||||||
<field name="number_next">1</field>
|
|
||||||
<field name="number_increment">1</field>
|
|
||||||
</record>
|
|
||||||
</data>
|
|
||||||
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<!-- Email Template: Case Assigned to Authorizer -->
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<record id="mail_template_case_assigned" model="mail.template">
|
|
||||||
<field name="name">Authorizer Portal: Case Assigned</field>
|
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
|
||||||
<field name="subject">New Case Assigned: {{ object.name }} - {{ object.partner_id.name }}</field>
|
|
||||||
<field name="email_from">{{ (object.company_id.email or object.user_id.email or 'noreply@example.com') }}</field>
|
|
||||||
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
|
|
||||||
<field name="body_html" type="html">
|
|
||||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
|
||||||
<div style="height:4px;background-color:#2B6CB0;"></div>
|
|
||||||
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
|
||||||
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
|
|
||||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Case Assigned</h2>
|
|
||||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new ADP case has been assigned to you.</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
|
||||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Case Details</td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Case</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.partner_id.name"/></td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options='{"widget": "date"}'/></td></tr>
|
|
||||||
</table>
|
|
||||||
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
|
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/><span style="color:#718096;"><t t-out="object.company_id.name"/></span></p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
|
|
||||||
</div>
|
|
||||||
</field>
|
|
||||||
<field name="auto_delete" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Status Changed template removed - redundant.
|
|
||||||
Each workflow transition sends its own detailed email from fusion_claims.
|
|
||||||
Record kept to avoid XML ID reference errors on upgrade. -->
|
|
||||||
<record id="mail_template_status_changed" model="mail.template">
|
|
||||||
<field name="name">Authorizer Portal: Status Changed (Disabled)</field>
|
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
|
||||||
<field name="subject">Case Update: {{ object.name }}</field>
|
|
||||||
<field name="body_html" type="html"><p>This template is no longer in use.</p></field>
|
|
||||||
<field name="auto_delete" eval="True"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<!-- Email Template: Assessment Complete - To Authorizer -->
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<record id="mail_template_assessment_complete_authorizer" model="mail.template">
|
|
||||||
<field name="name">Assessment Complete - Authorizer Notification</field>
|
|
||||||
<field name="model_id" ref="model_fusion_assessment"/>
|
|
||||||
<field name="subject">Assessment Complete: {{ object.reference }} - {{ object.client_name }}</field>
|
|
||||||
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
|
|
||||||
<field name="email_to">{{ object.authorizer_id.email }}</field>
|
|
||||||
<field name="body_html" type="html">
|
|
||||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
|
||||||
<div style="height:4px;background-color:#38a169;"></div>
|
|
||||||
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
|
||||||
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
|
|
||||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
|
|
||||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">The assessment for <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong> has been completed and a sale order has been created.</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
|
||||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Assessment Details</td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.client_name"/></td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
|
|
||||||
<t t-if="object.sale_order_id">
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Sale Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.sale_order_id.name"/></td></tr>
|
|
||||||
</t>
|
|
||||||
</table>
|
|
||||||
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
|
||||||
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;"><strong>Next steps:</strong> Please submit the ADP application (including pages 11-12 signed by the client) so we can proceed with the claim submission.</p>
|
|
||||||
</div>
|
|
||||||
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#38a169;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
|
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
|
|
||||||
</div>
|
|
||||||
</field>
|
|
||||||
<field name="auto_delete" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<!-- Email Template: Assessment Complete - To Client -->
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<record id="mail_template_assessment_complete_client" model="mail.template">
|
|
||||||
<field name="name">Assessment Complete - Client Notification</field>
|
|
||||||
<field name="model_id" ref="model_fusion_assessment"/>
|
|
||||||
<field name="subject">Your Assessment is Complete - {{ object.reference }}</field>
|
|
||||||
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
|
|
||||||
<field name="email_to">{{ object.client_email }}</field>
|
|
||||||
<field name="body_html" type="html">
|
|
||||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
|
||||||
<div style="height:4px;background-color:#2B6CB0;"></div>
|
|
||||||
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
|
||||||
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
|
|
||||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
|
|
||||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">Dear <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong>, thank you for completing your assessment with us.</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
|
||||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Summary</td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
|
|
||||||
<t t-if="object.authorizer_id">
|
|
||||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Therapist</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.authorizer_id.name"/></td></tr>
|
|
||||||
</t>
|
|
||||||
</table>
|
|
||||||
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
|
||||||
<p style="margin:0 0 8px 0;font-size:14px;color:#2d3748;font-weight:600;">What happens next:</p>
|
|
||||||
<ol style="margin:0;padding-left:20px;font-size:14px;line-height:1.6;color:#2d3748;">
|
|
||||||
<li>Your assessment will be reviewed by our team</li>
|
|
||||||
<li>We will submit the ADP application on your behalf</li>
|
|
||||||
<li>You will be notified once approval is received</li>
|
|
||||||
<li>Your equipment will be ordered and delivered</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.5;">If you have any questions, please do not hesitate to contact us.</p>
|
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
|
|
||||||
</div>
|
|
||||||
</field>
|
|
||||||
<field name="auto_delete" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<!-- Email Template: Document Uploaded -->
|
|
||||||
<!-- ================================================================= -->
|
|
||||||
<record id="mail_template_document_uploaded" model="mail.template">
|
|
||||||
<field name="name">Authorizer Portal: Document Uploaded</field>
|
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
|
||||||
<field name="subject">New Document Uploaded: {{ object.name }}</field>
|
|
||||||
<field name="email_from">{{ (object.company_id.email or 'noreply@example.com') }}</field>
|
|
||||||
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
|
|
||||||
<field name="body_html" type="html">
|
|
||||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
|
||||||
<div style="height:4px;background-color:#2B6CB0;"></div>
|
|
||||||
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
|
||||||
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
|
|
||||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Document Available</h2>
|
|
||||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new document has been uploaded for case <strong style="color:#2d3748;"><t t-out="object.name"/></strong> (<t t-out="object.partner_id.name"/>).</p>
|
|
||||||
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
|
|
||||||
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.company_id.name"/></strong></p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
|
|
||||||
</div>
|
|
||||||
</field>
|
|
||||||
<field name="auto_delete" eval="False"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- This file can be used for portal menu customizations if needed -->
|
|
||||||
<!-- Currently, portal menus are handled by the templates -->
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<data noupdate="1">
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- AUTHORIZER WELCOME ARTICLE TEMPLATE -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="welcome_article_authorizer">
|
|
||||||
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Authorizer Portal</h1>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
|
|
||||||
<t t-out="company_name"/> - Assistive Devices Program
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Dear <strong><t t-out="user_name"/></strong>,
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Welcome to the <strong><t t-out="company_name"/></strong> Authorizer Portal.
|
|
||||||
This portal is designed to streamline the ADP (Assistive Devices Program) process
|
|
||||||
and keep you connected with your assigned cases.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; width: 40px; text-align: center; font-size: 20px;">1</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>View Assigned Cases</strong><br/>
|
|
||||||
Access all ADP cases assigned to you with real-time status updates.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">2</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Complete Assessments</strong><br/>
|
|
||||||
Fill out assessments online with measurements, photos, and specifications.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">3</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Track Application Status</strong><br/>
|
|
||||||
Monitor the progress of ADP applications from submission to approval.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">4</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Upload Documents</strong><br/>
|
|
||||||
Upload ADP applications, signed pages 11 and 12, and supporting documentation.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
|
|
||||||
<ol style="font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Navigate to <strong>My Cases</strong> from the portal menu to see your assigned ADP cases.</li>
|
|
||||||
<li>Click on any case to view details, upload documents, or add comments.</li>
|
|
||||||
<li>To start a new assessment, go to <strong>Assessments</strong> and click <strong>New Assessment</strong>.</li>
|
|
||||||
<li>Complete the assessment form with all required measurements and photos.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
|
|
||||||
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
|
|
||||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
||||||
<li><strong>Assessment Validity:</strong> Assessments are valid for 3 months from the completion date.</li>
|
|
||||||
<li><strong>Application Submission:</strong> Please submit the ADP application promptly after the assessment is completed.</li>
|
|
||||||
<li><strong>Page 11:</strong> Must be signed by the applicant (or authorized agent: spouse, parent, legal guardian, public trustee, or power of attorney).</li>
|
|
||||||
<li><strong>Page 12:</strong> Must be signed by the authorizer and the vendor.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
|
|
||||||
<p style="font-size: 14px; line-height: 1.7;">
|
|
||||||
If you have any questions or need assistance, please contact our office:
|
|
||||||
</p>
|
|
||||||
<div style="background: #f0f4ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
|
|
||||||
<strong><t t-out="company_name"/></strong><br/>
|
|
||||||
Email: <t t-out="company_email"/><br/>
|
|
||||||
Phone: <t t-out="company_phone"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- SALES REP WELCOME ARTICLE TEMPLATE -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="welcome_article_sales_rep">
|
|
||||||
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Sales Portal</h1>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
|
|
||||||
<t t-out="company_name"/> - Sales Dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Dear <strong><t t-out="user_name"/></strong>,
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Welcome to the <strong><t t-out="company_name"/></strong> Sales Portal.
|
|
||||||
This is your hub for managing sales orders, completing assessments, and tracking ADP cases.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">What You Can Do</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; width: 40px; text-align: center; font-size: 20px;">1</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Sales Dashboard</strong><br/>
|
|
||||||
View all your sales orders, filter by status, sale type, and search by client name or order number.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">2</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Complete Assessments</strong><br/>
|
|
||||||
Start ADP Express Assessments and Accessibility Assessments (stair lifts, platform lifts, ceiling lifts, ramps, bathroom modifications).
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">3</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Proof of Delivery</strong><br/>
|
|
||||||
Get proof of delivery signed by clients directly from your phone or tablet.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">4</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Loaner Equipment</strong><br/>
|
|
||||||
Track loaner equipment checkouts and returns.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">5</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Track ADP Cases</strong><br/>
|
|
||||||
Monitor ADP application status from assessment through approval to billing.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Getting Started</h2>
|
|
||||||
<ol style="font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Go to <strong>Sales Dashboard</strong> to see all your cases at a glance.</li>
|
|
||||||
<li>Use the <strong>search bar</strong> and <strong>filters</strong> to quickly find cases by client name, order number, sale type, or status.</li>
|
|
||||||
<li>Click on any case to view full details and take action.</li>
|
|
||||||
<li>To start a new assessment, go to <strong>Assessments</strong> and select the appropriate assessment type.</li>
|
|
||||||
<li>For deliveries, navigate to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips for Success</h2>
|
|
||||||
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
|
|
||||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Take clear photos during assessments - they will be attached to the case automatically.</li>
|
|
||||||
<li>Complete all required measurements before submitting an assessment.</li>
|
|
||||||
<li>Follow up on cases in <strong>Waiting for Application</strong> status to keep the process moving.</li>
|
|
||||||
<li>Always collect proof of delivery signatures at the time of delivery.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Need Help?</h2>
|
|
||||||
<p style="font-size: 14px; line-height: 1.7;">
|
|
||||||
If you have any questions or need assistance, please contact the office:
|
|
||||||
</p>
|
|
||||||
<div style="background: #f0fff4; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
|
|
||||||
<strong><t t-out="company_name"/></strong><br/>
|
|
||||||
Email: <t t-out="company_email"/><br/>
|
|
||||||
Phone: <t t-out="company_phone"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- TECHNICIAN WELCOME ARTICLE TEMPLATE -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="welcome_article_technician">
|
|
||||||
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="background: linear-gradient(135deg, #3a8fb7 0%, #2e7aad 60%, #1a6b9a 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Technician Portal</h1>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
|
|
||||||
<t t-out="company_name"/> - Delivery and Service
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Dear <strong><t t-out="user_name"/></strong>,
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Welcome to the <strong><t t-out="company_name"/></strong> Technician Portal.
|
|
||||||
This portal helps you manage your assigned deliveries and collect proof of delivery signatures.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; width: 40px; text-align: center; font-size: 20px;">1</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>View Assigned Deliveries</strong><br/>
|
|
||||||
See all deliveries assigned to you with client details, addresses, and product information.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">2</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Collect Proof of Delivery</strong><br/>
|
|
||||||
Get the client's signature on the proof of delivery document directly from your device.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">3</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Track Delivery Status</strong><br/>
|
|
||||||
Monitor which deliveries are pending, in progress, or completed.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
|
|
||||||
<ol style="font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Navigate to <strong>My Deliveries</strong> to see all deliveries assigned to you.</li>
|
|
||||||
<li>Click on a delivery to view the client details and delivery address.</li>
|
|
||||||
<li>Go to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
|
|
||||||
<li>After collecting the signature, the signed POD is automatically attached to the order.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
|
|
||||||
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
|
|
||||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
||||||
<li><strong>Always get POD signed before leaving.</strong> The proof of delivery is required for billing.</li>
|
|
||||||
<li>Ensure the client's <strong>name</strong> and <strong>date</strong> are filled in on the delivery form.</li>
|
|
||||||
<li>If the client is unavailable, contact the office immediately.</li>
|
|
||||||
<li>Report any product issues or damages to the office right away.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
|
|
||||||
<p style="font-size: 14px; line-height: 1.7;">
|
|
||||||
If you have any questions or need assistance, please contact the office:
|
|
||||||
</p>
|
|
||||||
<div style="background: #fff0f3; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
|
|
||||||
<strong><t t-out="company_name"/></strong><br/>
|
|
||||||
Email: <t t-out="company_email"/><br/>
|
|
||||||
Phone: <t t-out="company_phone"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- GENERAL PORTAL USER (CLIENT) WELCOME ARTICLE TEMPLATE -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="welcome_article_client">
|
|
||||||
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to Your Portal</h1>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
|
|
||||||
<t t-out="company_name"/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Dear <strong><t t-out="user_name"/></strong>,
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Welcome to the <strong><t t-out="company_name"/></strong> Portal.
|
|
||||||
Here you can view your orders, track their status, and access your documents.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">What You Can Do</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; width: 40px; text-align: center; font-size: 20px;">1</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>View Your Orders</strong><br/>
|
|
||||||
Access all your orders and track their current status.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">2</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Track Status</strong><br/>
|
|
||||||
See real-time updates on your application and delivery status.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">3</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Access Documents</strong><br/>
|
|
||||||
Download invoices, delivery receipts, and other important documents.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Getting Started</h2>
|
|
||||||
<ol style="font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Click on <strong>My Orders</strong> from the portal menu to see your orders.</li>
|
|
||||||
<li>Click on any order to view its full details and status.</li>
|
|
||||||
<li>Download documents by clicking the download button next to each file.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Need Help?</h2>
|
|
||||||
<p style="font-size: 14px; line-height: 1.7;">
|
|
||||||
If you have any questions, please don't hesitate to reach out:
|
|
||||||
</p>
|
|
||||||
<div style="background: #f0f8ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
|
|
||||||
<strong><t t-out="company_name"/></strong><br/>
|
|
||||||
Email: <t t-out="company_email"/><br/>
|
|
||||||
Phone: <t t-out="company_phone"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- INTERNAL STAFF WELCOME ARTICLE TEMPLATE -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="welcome_article_internal">
|
|
||||||
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to <t t-out="company_name"/></h1>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
|
|
||||||
Fusion Claims - Internal Operations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
Welcome, <strong><t t-out="user_name"/></strong>!
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 15px; line-height: 1.7;">
|
|
||||||
This is your quick-start guide to the <strong><t t-out="company_name"/></strong> system.
|
|
||||||
Below you'll find an overview of the key areas and how to navigate the system.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">System Overview</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; width: 40px; text-align: center; font-size: 20px;">1</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>ADP Case Management</strong><br/>
|
|
||||||
Process ADP claims through the full workflow: Assessment, Application, Submission, Approval, Billing, and Case Close.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">2</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Sales Orders</strong><br/>
|
|
||||||
Manage quotations, sales orders, invoicing, and delivery for all sale types (ADP, ODSP, Private, Insurance, etc.).
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">3</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Device Codes</strong><br/>
|
|
||||||
Look up and manage ADP device codes, prices, and serial number requirements.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">4</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Inventory and Loaner Tracking</strong><br/>
|
|
||||||
Manage product inventory, track loaner equipment checkouts and returns.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">5</td>
|
|
||||||
<td style="padding: 12px; border: 1px solid #e0e0e0;">
|
|
||||||
<strong>Delivery Management</strong><br/>
|
|
||||||
Assign technicians, track deliveries, and manage proof of delivery documents.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">ADP Workflow Quick Reference</h2>
|
|
||||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 15px 0;">
|
|
||||||
<ol style="font-size: 14px; line-height: 2.0; margin: 0;">
|
|
||||||
<li><strong>Quotation</strong> - Create the sales order</li>
|
|
||||||
<li><strong>Assessment Scheduled</strong> - Schedule the client assessment</li>
|
|
||||||
<li><strong>Assessment Completed</strong> - Complete the on-site assessment</li>
|
|
||||||
<li><strong>Waiting for Application</strong> - Wait for the authorizer to submit the application</li>
|
|
||||||
<li><strong>Application Received</strong> - Upload the ADP application and signed pages</li>
|
|
||||||
<li><strong>Ready for Submission</strong> - Verify all documents are ready</li>
|
|
||||||
<li><strong>Application Submitted</strong> - Submit to ADP</li>
|
|
||||||
<li><strong>Accepted / Approved</strong> - ADP accepts and approves the case</li>
|
|
||||||
<li><strong>Ready for Delivery</strong> - Prepare and deliver the product</li>
|
|
||||||
<li><strong>Billed to ADP</strong> - Submit the claim for payment</li>
|
|
||||||
<li><strong>Case Closed</strong> - Finalize the case</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Key Menu Locations</h2>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px;">
|
|
||||||
<tr style="background: #eaf4fd;">
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">ADP Claims</td>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - ADP Claims - filter by workflow stage</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Device Codes</td>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - Configuration - ADP Device Codes</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="background: #eaf4fd;">
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Fusion Claims Settings</td>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Settings - Fusion Claims (scroll down)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Contacts (Authorizers)</td>
|
|
||||||
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Contacts - filter by "Authorizers"</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips</h2>
|
|
||||||
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
|
|
||||||
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
||||||
<li>Use <strong>filters and search</strong> to quickly find cases by status, client, or authorizer.</li>
|
|
||||||
<li>Check the <strong>chatter</strong> (message log) on each case for the full history of changes and communications.</li>
|
|
||||||
<li>Use the <strong>status bar</strong> at the top of each case to see where it is in the workflow.</li>
|
|
||||||
<li>All automated emails are logged in the chatter for audit purposes.</li>
|
|
||||||
<li>Use <strong>scheduled activities</strong> to set reminders and follow-ups.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</data>
|
|
||||||
</odoo>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from . import res_partner
|
|
||||||
from . import res_users
|
|
||||||
from . import authorizer_comment
|
|
||||||
from . import adp_document
|
|
||||||
from . import assessment
|
|
||||||
from . import accessibility_assessment
|
|
||||||
from . import sale_order
|
|
||||||
from . import pdf_template
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user